/** * 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 { 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.buffer as ArrayBuffer, { 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 { // 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): Promise { 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> { 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();