204 lines
5.7 KiB
TypeScript
204 lines
5.7 KiB
TypeScript
|
|
/**
|
||
|
|
* AES-256-GCM encryption for credential storage
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { logger } from '../logging.ts';
|
||
|
|
|
||
|
|
export class CredentialEncryption {
|
||
|
|
private key: CryptoKey | null = null;
|
||
|
|
private readonly algorithm = 'AES-GCM';
|
||
|
|
private readonly keyLength = 256;
|
||
|
|
private readonly ivLength = 12; // 96 bits for GCM
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Initialize encryption with a key from environment or generate machine-specific key
|
||
|
|
*/
|
||
|
|
async init(): Promise<void> {
|
||
|
|
const envKey = Deno.env.get('ONEBOX_ENCRYPTION_KEY');
|
||
|
|
|
||
|
|
if (envKey) {
|
||
|
|
// Use provided key (should be 32 bytes base64 encoded)
|
||
|
|
const keyBytes = this.base64ToBytes(envKey);
|
||
|
|
if (keyBytes.length !== 32) {
|
||
|
|
throw new Error('ONEBOX_ENCRYPTION_KEY must be 32 bytes (256 bits) base64 encoded');
|
||
|
|
}
|
||
|
|
this.key = await crypto.subtle.importKey(
|
||
|
|
'raw',
|
||
|
|
keyBytes,
|
||
|
|
{ name: this.algorithm },
|
||
|
|
false,
|
||
|
|
['encrypt', 'decrypt']
|
||
|
|
);
|
||
|
|
logger.log('info', 'Encryption key loaded from environment', 'CredentialEncryption');
|
||
|
|
} else {
|
||
|
|
// Derive key from machine-specific data
|
||
|
|
this.key = await this.deriveKeyFromMachine();
|
||
|
|
logger.log('info', 'Encryption key derived from machine identity', 'CredentialEncryption');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Derive a key from machine-specific information
|
||
|
|
* This ensures the key is consistent across restarts on the same machine
|
||
|
|
*/
|
||
|
|
private async deriveKeyFromMachine(): Promise<CryptoKey> {
|
||
|
|
// Collect machine-specific data
|
||
|
|
const machineData: string[] = [];
|
||
|
|
|
||
|
|
// Hostname
|
||
|
|
try {
|
||
|
|
machineData.push(Deno.hostname());
|
||
|
|
} catch {
|
||
|
|
machineData.push('unknown-host');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Machine ID from /etc/machine-id (Linux) or generate consistent fallback
|
||
|
|
try {
|
||
|
|
const machineId = await Deno.readTextFile('/etc/machine-id');
|
||
|
|
machineData.push(machineId.trim());
|
||
|
|
} catch {
|
||
|
|
// Fallback: use a combination of other identifiers
|
||
|
|
machineData.push('onebox-default-machine-id');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Add a salt
|
||
|
|
machineData.push('onebox-credential-encryption-v1');
|
||
|
|
|
||
|
|
// Create seed from machine data
|
||
|
|
const seed = machineData.join(':');
|
||
|
|
const encoder = new TextEncoder();
|
||
|
|
const seedBytes = encoder.encode(seed);
|
||
|
|
|
||
|
|
// Use PBKDF2 to derive key
|
||
|
|
const baseKey = await crypto.subtle.importKey(
|
||
|
|
'raw',
|
||
|
|
seedBytes,
|
||
|
|
'PBKDF2',
|
||
|
|
false,
|
||
|
|
['deriveKey']
|
||
|
|
);
|
||
|
|
|
||
|
|
return await crypto.subtle.deriveKey(
|
||
|
|
{
|
||
|
|
name: 'PBKDF2',
|
||
|
|
salt: encoder.encode('onebox-salt-v1'),
|
||
|
|
iterations: 100000,
|
||
|
|
hash: 'SHA-256',
|
||
|
|
},
|
||
|
|
baseKey,
|
||
|
|
{ name: this.algorithm, length: this.keyLength },
|
||
|
|
false,
|
||
|
|
['encrypt', 'decrypt']
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Encrypt a credentials object to a base64 string
|
||
|
|
*/
|
||
|
|
async encrypt(data: Record<string, string>): Promise<string> {
|
||
|
|
if (!this.key) {
|
||
|
|
throw new Error('Encryption not initialized. Call init() first.');
|
||
|
|
}
|
||
|
|
|
||
|
|
const iv = crypto.getRandomValues(new Uint8Array(this.ivLength));
|
||
|
|
const encoded = new TextEncoder().encode(JSON.stringify(data));
|
||
|
|
|
||
|
|
const ciphertext = await crypto.subtle.encrypt(
|
||
|
|
{ name: this.algorithm, iv },
|
||
|
|
this.key,
|
||
|
|
encoded
|
||
|
|
);
|
||
|
|
|
||
|
|
// Combine IV + ciphertext and encode as base64
|
||
|
|
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
|
||
|
|
combined.set(iv, 0);
|
||
|
|
combined.set(new Uint8Array(ciphertext), iv.length);
|
||
|
|
|
||
|
|
return this.bytesToBase64(combined);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Decrypt a base64 string back to credentials object
|
||
|
|
*/
|
||
|
|
async decrypt(encrypted: string): Promise<Record<string, string>> {
|
||
|
|
if (!this.key) {
|
||
|
|
throw new Error('Encryption not initialized. Call init() first.');
|
||
|
|
}
|
||
|
|
|
||
|
|
const combined = this.base64ToBytes(encrypted);
|
||
|
|
|
||
|
|
// Extract IV and ciphertext
|
||
|
|
const iv = combined.slice(0, this.ivLength);
|
||
|
|
const ciphertext = combined.slice(this.ivLength);
|
||
|
|
|
||
|
|
const decrypted = await crypto.subtle.decrypt(
|
||
|
|
{ name: this.algorithm, iv },
|
||
|
|
this.key,
|
||
|
|
ciphertext
|
||
|
|
);
|
||
|
|
|
||
|
|
const decoded = new TextDecoder().decode(decrypted);
|
||
|
|
return JSON.parse(decoded);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generate a secure random password
|
||
|
|
*/
|
||
|
|
generatePassword(length: number = 32): string {
|
||
|
|
// Exclude ambiguous characters (0, O, l, 1, I)
|
||
|
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
|
||
|
|
const randomBytes = crypto.getRandomValues(new Uint8Array(length));
|
||
|
|
let result = '';
|
||
|
|
for (const byte of randomBytes) {
|
||
|
|
result += chars[byte % chars.length];
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generate an access key (alphanumeric, uppercase)
|
||
|
|
*/
|
||
|
|
generateAccessKey(length: number = 20): string {
|
||
|
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||
|
|
const randomBytes = crypto.getRandomValues(new Uint8Array(length));
|
||
|
|
let result = '';
|
||
|
|
for (const byte of randomBytes) {
|
||
|
|
result += chars[byte % chars.length];
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generate a secret key (alphanumeric, mixed case)
|
||
|
|
*/
|
||
|
|
generateSecretKey(length: number = 40): string {
|
||
|
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||
|
|
const randomBytes = crypto.getRandomValues(new Uint8Array(length));
|
||
|
|
let result = '';
|
||
|
|
for (const byte of randomBytes) {
|
||
|
|
result += chars[byte % chars.length];
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
private bytesToBase64(bytes: Uint8Array): string {
|
||
|
|
let binary = '';
|
||
|
|
for (const byte of bytes) {
|
||
|
|
binary += String.fromCharCode(byte);
|
||
|
|
}
|
||
|
|
return btoa(binary);
|
||
|
|
}
|
||
|
|
|
||
|
|
private base64ToBytes(base64: string): Uint8Array {
|
||
|
|
const binary = atob(base64);
|
||
|
|
const bytes = new Uint8Array(binary.length);
|
||
|
|
for (let i = 0; i < binary.length; i++) {
|
||
|
|
bytes[i] = binary.charCodeAt(i);
|
||
|
|
}
|
||
|
|
return bytes;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Singleton instance
|
||
|
|
export const credentialEncryption = new CredentialEncryption();
|