446 lines
13 KiB
TypeScript
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()
|
|
}
|
|
}
|