116 lines
3.0 KiB
TypeScript
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)
|
|
}
|