/** * Crypto Service for Stack.Gallery Registry * Handles AES-256-GCM encryption/decryption of secrets */ export class CryptoService { private masterKey: CryptoKey | null = null; private initialized = false; /** * Initialize the crypto service with the master key * The key should be a 64-character hex string (32 bytes = 256 bits) */ public async initialize(): Promise { if (this.initialized) return; const keyHex = Deno.env.get('AUTH_ENCRYPTION_KEY'); if (!keyHex) { console.warn( '[CryptoService] AUTH_ENCRYPTION_KEY not set. Generating ephemeral key (NOT for production!)' ); const randomBytes = crypto.getRandomValues(new Uint8Array(32)); this.masterKey = await this.importKey(this.bytesToHex(randomBytes)); } else { if (keyHex.length !== 64) { throw new Error('AUTH_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)'); } this.masterKey = await this.importKey(keyHex); } this.initialized = true; } /** * Encrypt a plaintext string * Returns format: base64(iv):base64(ciphertext) */ public async encrypt(plaintext: string): Promise { await this.initialize(); if (!this.masterKey) { throw new Error('CryptoService not initialized'); } // Generate random IV (12 bytes for AES-GCM) const iv = crypto.getRandomValues(new Uint8Array(12)); // Encode plaintext to bytes const encoded = new TextEncoder().encode(plaintext); // Encrypt const encrypted = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, this.masterKey, encoded ); // Format: iv:ciphertext (both base64) const ivBase64 = this.bytesToBase64(iv); const ciphertextBase64 = this.bytesToBase64(new Uint8Array(encrypted)); return `${ivBase64}:${ciphertextBase64}`; } /** * Decrypt an encrypted string * Expects format: base64(iv):base64(ciphertext) */ public async decrypt(ciphertext: string): Promise { await this.initialize(); if (!this.masterKey) { throw new Error('CryptoService not initialized'); } const parts = ciphertext.split(':'); if (parts.length !== 2) { throw new Error('Invalid ciphertext format'); } const [ivBase64, encryptedBase64] = parts; // Decode from base64 const iv = this.base64ToBytes(ivBase64); const encrypted = this.base64ToBytes(encryptedBase64); // Decrypt const decrypted = await crypto.subtle.decrypt( { name: 'AES-GCM', iv }, this.masterKey, encrypted ); // Decode to string return new TextDecoder().decode(decrypted); } /** * Check if a string is already encrypted (contains the iv:ciphertext format) */ public isEncrypted(value: string): boolean { if (!value || typeof value !== 'string') return false; const parts = value.split(':'); if (parts.length !== 2) return false; // Check if both parts look like base64 try { this.base64ToBytes(parts[0]); this.base64ToBytes(parts[1]); return true; } catch { return false; } } /** * Import a hex key as CryptoKey */ private async importKey(keyHex: string): Promise { const keyBytes = this.hexToBytes(keyHex); return await crypto.subtle.importKey( 'raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'] ); } /** * Convert bytes to hex string */ private bytesToHex(bytes: Uint8Array): string { return Array.from(bytes) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } /** * Convert hex string to bytes */ private hexToBytes(hex: string): Uint8Array { const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); } return bytes; } /** * Convert bytes to base64 */ private bytesToBase64(bytes: Uint8Array): string { return btoa(String.fromCharCode(...bytes)); } /** * Convert base64 to bytes */ private base64ToBytes(base64: string): Uint8Array { const binary = atob(base64); return Uint8Array.from(binary, (c) => c.charCodeAt(0)); } /** * Generate a new encryption key (for setup) * Returns a 64-character hex string */ public static generateKey(): string { const bytes = crypto.getRandomValues(new Uint8Array(32)); return Array.from(bytes) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } } // Singleton instance export const cryptoService = new CryptoService();