/** * Two-level encryption system for key management * * Level 1: Private key encrypted with KEK (Key Encryption Key) * Level 2: KEK encrypted with 4-word recovery phrase * * Flow: * - KEK is generated randomly and stored encrypted in Credentials API * - Private key is encrypted with KEK and stored in IndexedDB * - Recovery phrase (4 words) is used to encrypt/decrypt KEK */ import type { EncryptedPayload } from './keyManagementEncryption' import { generateRecoveryPhrase } from './keyManagementBIP39' const PBKDF2_ITERATIONS = 100000 const PBKDF2_HASH = 'SHA-256' /** * Generate a random KEK (Key Encryption Key) */ async function generateKEK(): Promise { return await crypto.subtle.generateKey( { name: 'AES-GCM', length: 256 }, true, // extractable ['encrypt', 'decrypt'] ) } /** * Derive encryption key from recovery phrase using PBKDF2 */ async function deriveKeyFromPhrase(phrase: string[]): Promise { const phraseString = phrase.join(' ') const encoder = new TextEncoder() const password = encoder.encode(phraseString) // Generate deterministic salt from phrase const saltBuffer = await crypto.subtle.digest('SHA-256', password) const saltArray = new Uint8Array(saltBuffer) const salt = saltArray.slice(0, 32) // Import password as key material const keyMaterial = await crypto.subtle.importKey( 'raw', password, 'PBKDF2', false, ['deriveBits', 'deriveKey'] ) // Derive key using PBKDF2 const derivedKey = await crypto.subtle.deriveKey( { name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: PBKDF2_HASH, }, keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] ) return derivedKey } /** * Export KEK to raw bytes (for storage) */ async function exportKEK(kek: CryptoKey): Promise { const exported = await crypto.subtle.exportKey('raw', kek) return new Uint8Array(exported) } /** * Import KEK from raw bytes */ async function importKEK(keyBytes: Uint8Array): Promise { // Create a new ArrayBuffer from the Uint8Array const buffer = new ArrayBuffer(keyBytes.length) const view = new Uint8Array(buffer) view.set(keyBytes) return await crypto.subtle.importKey( 'raw', buffer, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'] ) } /** * Encrypt KEK with recovery phrase */ async function encryptKEK(kek: CryptoKey, recoveryPhrase: string[]): Promise { const phraseKey = await deriveKeyFromPhrase(recoveryPhrase) const kekBytes = await exportKEK(kek) const encoder = new TextEncoder() const data = encoder.encode(Array.from(kekBytes).map(b => b.toString(16).padStart(2, '0')).join('')) const iv = crypto.getRandomValues(new Uint8Array(12)) const encrypted = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, phraseKey, data ) const encryptedArray = new Uint8Array(encrypted) function toBase64(bytes: Uint8Array): string { let binary = '' bytes.forEach((b) => { binary += String.fromCharCode(b) }) return btoa(binary) } return { iv: toBase64(iv), ciphertext: toBase64(encryptedArray), } } /** * Decrypt KEK with recovery phrase */ async function decryptKEK(encryptedKEK: EncryptedPayload, recoveryPhrase: string[]): Promise { const phraseKey = await deriveKeyFromPhrase(recoveryPhrase) function fromBase64(value: string): Uint8Array { const binary = atob(value) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i += 1) { bytes[i] = binary.charCodeAt(i) } return bytes } const iv = fromBase64(encryptedKEK.iv) const ciphertext = fromBase64(encryptedKEK.ciphertext) // Create ArrayBuffer views for decrypt const ivBuffer = new ArrayBuffer(iv.length) const ivView = new Uint8Array(ivBuffer) ivView.set(iv) const cipherBuffer = new ArrayBuffer(ciphertext.length) const cipherView = new Uint8Array(cipherBuffer) cipherView.set(ciphertext) const decrypted = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: ivView }, phraseKey, cipherBuffer ) const decoder = new TextDecoder() const hexString = decoder.decode(decrypted) // Convert hex string back to bytes const kekBytes = new Uint8Array(hexString.match(/.{1,2}/g)?.map(byte => parseInt(byte, 16)) || []) return await importKEK(kekBytes) } /** * Encrypt private key with KEK */ async function encryptPrivateKeyWithKEK(privateKey: string, kek: CryptoKey): Promise { const encoder = new TextEncoder() const data = encoder.encode(privateKey) const iv = crypto.getRandomValues(new Uint8Array(12)) const encrypted = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, kek, data ) const encryptedArray = new Uint8Array(encrypted) function toBase64(bytes: Uint8Array): string { let binary = '' bytes.forEach((b) => { binary += String.fromCharCode(b) }) return btoa(binary) } return { iv: toBase64(iv), ciphertext: toBase64(encryptedArray), } } /** * Decrypt private key with KEK */ async function decryptPrivateKeyWithKEK(encryptedPrivateKey: EncryptedPayload, kek: CryptoKey): Promise { function fromBase64(value: string): Uint8Array { const binary = atob(value) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i += 1) { bytes[i] = binary.charCodeAt(i) } return bytes } const iv = fromBase64(encryptedPrivateKey.iv) const ciphertext = fromBase64(encryptedPrivateKey.ciphertext) // Create ArrayBuffer views for decrypt const ivBuffer = new ArrayBuffer(iv.length) const ivView = new Uint8Array(ivBuffer) ivView.set(iv) const cipherBuffer = new ArrayBuffer(ciphertext.length) const cipherView = new Uint8Array(cipherBuffer) cipherView.set(ciphertext) const decrypted = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: ivView }, kek, cipherBuffer ) const decoder = new TextDecoder() return decoder.decode(decrypted) } /** * Store encrypted KEK in Credentials API */ async function storeEncryptedKEK(encryptedKEK: EncryptedPayload): Promise { if (typeof window === 'undefined') { throw new Error('Window not available') } // Type definition for PasswordCredential interface PasswordCredentialData { id: string name: string password: string iconURL?: string } type PasswordCredentialConstructorType = new (data: PasswordCredentialData) => Credential & { id: string; password: string } const PasswordCredentialConstructor = (window as unknown as { PasswordCredential?: PasswordCredentialConstructorType }).PasswordCredential if (!PasswordCredentialConstructor || !navigator.credentials || !navigator.credentials.store) { throw new Error('PasswordCredential API not available') } // Store encrypted KEK as password in credential // Type assertion for PasswordCredential interface PasswordCredentialData { id: string name: string password: string iconURL?: string } type PasswordCredentialType = new (data: PasswordCredentialData) => Credential & { id: string; password: string } const PasswordCredentialClass = PasswordCredentialConstructor as PasswordCredentialType const credential = new PasswordCredentialClass({ id: 'nostr_kek', name: 'Nostr KEK', password: JSON.stringify(encryptedKEK), iconURL: window.location.origin + '/favicon.ico', }) await navigator.credentials.store(credential) } /** * Retrieve encrypted KEK from Credentials API */ async function getEncryptedKEK(): Promise { if (typeof window === 'undefined' || !navigator.credentials || !navigator.credentials.get) { return null } try { const credential = await navigator.credentials.get({ password: true, } as CredentialRequestOptions) if (credential && 'password' in credential && typeof credential.password === 'string' && credential.id === 'nostr_kek') { return JSON.parse(credential.password) as EncryptedPayload } return null } catch (e) { console.error('Error retrieving encrypted KEK:', e) return null } } export interface CreateAccountResult { recoveryPhrase: string[] npub: string publicKey: string } export interface UnlockAccountResult { privateKey: string publicKey: string npub: string } /** * Create account with two-level encryption */ export async function createAccountTwoLevel( privateKeyHex: string, getPublicKey: (secretKey: Uint8Array) => string, encodeNpub: (publicKey: string) => string ): Promise { // Step 1: Generate recovery phrase (4 words from BIP39) const recoveryPhrase = generateRecoveryPhrase() // Step 2: Generate KEK (in memory) const kek = await generateKEK() // Step 3: Encrypt private key with KEK const encryptedPrivateKey = await encryptPrivateKeyWithKEK(privateKeyHex, kek) // Step 4: Encrypt KEK with recovery phrase const encryptedKEK = await encryptKEK(kek, recoveryPhrase) // Step 5: Store encrypted KEK in Credentials API await storeEncryptedKEK(encryptedKEK) // Step 6: Store encrypted private key in IndexedDB (via storage service) const { storageService } = await import('./storage/indexedDB') await storageService.set('nostr_encrypted_key', encryptedPrivateKey, 'nostr_key_storage') // Step 7: Compute public key and npub const { hexToBytes } = await import('nostr-tools/utils') const secretKey = hexToBytes(privateKeyHex) const publicKey = getPublicKey(secretKey) const npub = encodeNpub(publicKey) // Step 8: Store public keys in IndexedDB await storageService.set('nostr_public_key', { publicKey, npub }, 'nostr_key_storage') // Step 9: Store account flag in IndexedDB (not localStorage) await storageService.set('nostr_account_exists', true, 'nostr_key_storage') // Step 10: Clear KEK from memory (it's now encrypted and stored) // Note: In JavaScript, we can't force garbage collection, but we can null the reference // The KEK will be garbage collected automatically return { recoveryPhrase, npub, publicKey, } } /** * Unlock account with two-level decryption */ export async function unlockAccountTwoLevel( recoveryPhrase: string[], getPublicKey: (secretKey: Uint8Array) => string, encodeNpub: (publicKey: string) => string ): Promise { // Step 1: Get encrypted KEK from Credentials API const encryptedKEK = await getEncryptedKEK() if (!encryptedKEK) { throw new Error('No encrypted KEK found in Credentials API') } // Step 2: Decrypt KEK with recovery phrase (in memory) const kek = await decryptKEK(encryptedKEK, recoveryPhrase) // Step 3: Clear recovery phrase from memory (set to empty) // Note: In JavaScript, we can't force memory clearing, but we can overwrite recoveryPhrase.fill('') // Step 4: Get encrypted private key from IndexedDB const { storageService } = await import('./storage/indexedDB') const encryptedPrivateKey = await storageService.get('nostr_encrypted_key', 'nostr_key_storage') if (!encryptedPrivateKey) { throw new Error('No encrypted private key found in IndexedDB') } // Step 5: Decrypt private key with KEK (in memory) const privateKeyHex = await decryptPrivateKeyWithKEK(encryptedPrivateKey, kek) // Step 6: Clear KEK from memory // Note: In JavaScript, we can't force memory clearing, but we can null the reference // Step 7: Verify by computing public key const { hexToBytes } = await import('nostr-tools/utils') const secretKey = hexToBytes(privateKeyHex) const publicKey = getPublicKey(secretKey) const npub = encodeNpub(publicKey) return { privateKey: privateKeyHex, publicKey, npub, } } /** * Check if account exists */ export async function accountExistsTwoLevel(): Promise { try { const { storageService } = await import('./storage/indexedDB') const exists = await storageService.get('nostr_account_exists', 'nostr_key_storage') return exists === true } catch { return false } } /** * Get public keys if account exists */ export async function getPublicKeysTwoLevel(): Promise<{ publicKey: string; npub: string } | null> { try { const { storageService } = await import('./storage/indexedDB') return await storageService.get<{ publicKey: string; npub: string }>('nostr_public_key', 'nostr_key_storage') } catch { return null } } /** * Delete account (remove all stored data) */ export async function deleteAccountTwoLevel(): Promise { const { storageService } = await import('./storage/indexedDB') await storageService.delete('nostr_encrypted_key') await storageService.delete('nostr_public_key') await storageService.delete('nostr_account_exists') // Try to remove credential (may not be possible via API) if (navigator.credentials && navigator.credentials.preventSilentAccess) { navigator.credentials.preventSilentAccess() } }