story-research-zapwall/lib/keyManagementEncryption.ts

116 lines
3.0 KiB
TypeScript

export interface EncryptedPayload {
iv: string
ciphertext: string
}
const SALT_LENGTH = 32
const PBKDF2_ITERATIONS = 100000
const PBKDF2_HASH = 'SHA-256'
const KEY_LENGTH = 32
/**
* Derive an encryption key from a recovery phrase using PBKDF2
*/
export async function deriveKeyFromPhrase(phrase: string[]): Promise<CryptoKey> {
const phraseString = phrase.join(' ')
const encoder = new TextEncoder()
const password = encoder.encode(phraseString)
// Generate a deterministic salt from the phrase itself
// This ensures the same phrase always generates the same key
const saltBuffer = await crypto.subtle.digest('SHA-256', password)
const saltArray = new Uint8Array(saltBuffer)
const salt = saltArray.slice(0, SALT_LENGTH)
// 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: KEY_LENGTH * 8 },
false,
['encrypt', 'decrypt']
)
return derivedKey
}
/**
* Encrypt nsec with derived key
*/
export async function encryptNsec(derivedKey: CryptoKey, nsecHex: string): Promise<EncryptedPayload> {
const encoder = new TextEncoder()
const data = encoder.encode(nsecHex)
const iv = crypto.getRandomValues(new Uint8Array(12))
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
derivedKey,
data
)
const encryptedArray = new Uint8Array(encrypted)
// Convert to base64 for storage
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 nsec with derived key
*/
export async function decryptNsec(derivedKey: CryptoKey, payload: EncryptedPayload): 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(payload.iv)
const ciphertext = fromBase64(payload.ciphertext)
// Ensure iv and ciphertext are proper ArrayBuffer views
const ivBuffer = iv.buffer instanceof ArrayBuffer ? iv.buffer : new ArrayBuffer(iv.byteLength)
const ivView = new Uint8Array(ivBuffer, 0, iv.byteLength)
ivView.set(iv)
const cipherBuffer = ciphertext.buffer instanceof ArrayBuffer ? ciphertext.buffer : new ArrayBuffer(ciphertext.byteLength)
const cipherView = new Uint8Array(cipherBuffer, 0, ciphertext.byteLength)
cipherView.set(ciphertext)
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: ivView },
derivedKey,
cipherView
)
const decoder = new TextDecoder()
return decoder.decode(decrypted)
}