story-research-zapwall/lib/keyManagementTwoLevel.ts

446 lines
13 KiB
TypeScript

/**
* 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<CryptoKey> {
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<CryptoKey> {
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<Uint8Array> {
const exported = await crypto.subtle.exportKey('raw', kek)
return new Uint8Array(exported)
}
/**
* Import KEK from raw bytes
*/
async function importKEK(keyBytes: Uint8Array): Promise<CryptoKey> {
// 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<EncryptedPayload> {
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<CryptoKey> {
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<EncryptedPayload> {
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<string> {
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<void> {
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<EncryptedPayload | null> {
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<CreateAccountResult> {
// 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<UnlockAccountResult> {
// 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<EncryptedPayload>('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<boolean> {
try {
const { storageService } = await import('./storage/indexedDB')
const exists = await storageService.get<boolean>('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<void> {
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()
}
}