]> git.codecow.com Git - libnemo.git/commitdiff
Adjust password-to-key conversion to ease worker integration. Extract wallet encrypt...
authorChris Duncan <chris@zoso.dev>
Wed, 3 Sep 2025 14:48:37 +0000 (07:48 -0700)
committerChris Duncan <chris@zoso.dev>
Wed, 3 Sep 2025 14:48:37 +0000 (07:48 -0700)
src/lib/crypto/index.ts
src/lib/crypto/wallet-aes-gcm.ts [new file with mode: 0644]
src/lib/vault/index.ts
src/lib/vault/passkey.ts
src/lib/vault/vault-worker.ts

index 3c5223be2a28283fb387531ad7f579f8f0a59cc1..6c61c9fe145c235ce8030047cf8fd006dd423d1e 100644 (file)
@@ -1,9 +1,10 @@
 //! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>
 //! SPDX-License-Identifier: GPL-3.0-or-later
 
-import { Bip39 } from "./bip39"
-import { Bip44 } from "./bip44"
-import { Blake2b } from "./blake2b"
-import { NanoNaCl } from "./nano-nacl"
+import { Bip39 } from './bip39'
+import { Bip44 } from './bip44'
+import { Blake2b } from './blake2b'
+import { NanoNaCl } from './nano-nacl'
+import { WalletAesGcm } from './wallet-aes-gcm'
 
-export { Bip39, Bip44, Blake2b, NanoNaCl }
+export { Bip39, Bip44, Blake2b, NanoNaCl, WalletAesGcm }
diff --git a/src/lib/crypto/wallet-aes-gcm.ts b/src/lib/crypto/wallet-aes-gcm.ts
new file mode 100644 (file)
index 0000000..90e6a13
--- /dev/null
@@ -0,0 +1,44 @@
+
+
+//! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>
+//! SPDX-License-Identifier: GPL-3.0-or-later
+
+import { NamedData } from "#types"
+import { utf8 } from "../convert"
+
+export class WalletAesGcm {
+       static async decrypt (type: string, key: CryptoKey, iv: ArrayBuffer, encrypted: ArrayBuffer): Promise<NamedData<ArrayBuffer>> {
+               const seedLength = type === 'BIP-44' ? 64 : 32
+               const additionalData = utf8.toBytes(type)
+               return crypto.subtle
+                       .decrypt({ name: 'AES-GCM', iv, additionalData }, key, encrypted)
+                       .then(decrypted => {
+                               const seed = decrypted.slice(0, seedLength)
+                               const mnemonic = decrypted.slice(seedLength)
+                               new Uint8Array(decrypted).fill(0)
+                               return { mnemonic, seed }
+                       })
+       }
+
+       static async encrypt (type: string, key: CryptoKey, seed: ArrayBuffer, mnemonic?: ArrayBuffer): Promise<NamedData<ArrayBuffer>> {
+               if (type == null) {
+                       throw new Error('Wallet type missing')
+               }
+               if (key == null) {
+                       throw new Error('Wallet key missing')
+               }
+               if (seed == null) {
+                       throw new Error('Wallet seed missing')
+               }
+               // restrict iv to 96 bits per GCM best practice
+               const iv = crypto.getRandomValues(new Uint8Array(12)).buffer
+               const additionalData = utf8.toBytes(type)
+               const encoded = new Uint8Array([...new Uint8Array(seed), ...new Uint8Array(mnemonic ?? [])])
+               return crypto.subtle
+                       .encrypt({ name: 'AES-GCM', iv, additionalData }, key, encoded)
+                       .then(encrypted => {
+                               encoded.fill(0)
+                               return { iv, encrypted }
+                       })
+       }
+}
index 3a2e7b66f5e2fab662c2bbca6b1ef87c3440989a..819809a8cb7afd344d91d88d1000617b3c7c3f24 100644 (file)
@@ -5,7 +5,8 @@ import { Worker as NodeWorker } from 'node:worker_threads'
 import { Data, NamedData } from '#types'
 import { default as Constants } from '../constants'
 import { default as Convert } from '../convert'
-import { Bip39, Bip44, Blake2b, NanoNaCl } from '../crypto'
+import { Bip39, Bip44, Blake2b, NanoNaCl, WalletAesGcm } from '../crypto'
+import { Passkey } from './passkey'
 import { VaultTimer } from './vault-timer'
 import { VaultWorker } from './vault-worker'
 
@@ -28,6 +29,8 @@ export class Vault {
                        const Bip44 = ${Bip44}
                        const Blake2b = ${Blake2b}
                        const NanoNaCl = ${NanoNaCl}
+                       const WalletAesGcm = ${WalletAesGcm}
+                       const Passkey = ${Passkey}
                        const VaultTimer = ${VaultTimer}
                        const VaultWorker = ${VaultWorker}
                        const v = new VaultWorker()
index fdcfe0721db22cf44df55230242be63be56ec08c..2d967705af8ebc6f2fbd7dacbcb2880afcbac42f 100644 (file)
@@ -1,47 +1,49 @@
 //! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>
 //! SPDX-License-Identifier: GPL-3.0-or-later
 
-export async function createKeyFromPassword (action: string, salt: ArrayBuffer, data: { [key: string]: unknown }): Promise<CryptoKey | undefined> {
-       // Allowlisted wallet actions
-       if (['create', 'load', 'unlock', 'update'].includes(action)) {
+export class Passkey {
+       static async create (action: string, salt: ArrayBuffer, data: { [key: string]: unknown }): Promise<CryptoKey | undefined> {
+               // Allowlisted wallet actions
+               if (['create', 'load', 'unlock', 'update'].includes(action)) {
 
-               // Create local copy of password ASAP, then clear bytes from original buffer
-               if (!(data.password instanceof ArrayBuffer)) {
-                       throw new TypeError('Password must be ArrayBuffer')
-               }
+                       // Create local copy of password ASAP, then clear bytes from original buffer
+                       if (!(data.password instanceof ArrayBuffer)) {
+                               throw new TypeError('Password must be ArrayBuffer')
+                       }
 
-               const password = data.password.slice()
-               new Uint8Array(data.password).fill(0)
-               delete data.password
+                       const password = data.password.slice()
+                       new Uint8Array(data.password).fill(0)
+                       delete data.password
 
-               // Only unlocking should decrypt the vault; other sensitive actions should
-               // throw if the vault is still locked and encrypted
-               const purpose = action === 'unlock' ? 'decrypt' : 'encrypt'
+                       // Only unlocking should decrypt the vault; other sensitive actions should
+                       // throw if the vault is still locked and encrypted
+                       const purpose = action === 'unlock' ? 'decrypt' : 'encrypt'
 
-               return crypto.subtle
-                       .importKey('raw', password, 'PBKDF2', false, ['deriveKey'])
-                       .then(derivationKey => {
-                               new Uint8Array(password).fill(0).buffer.transfer?.()
-                               const derivationAlgorithm: Pbkdf2Params = {
-                                       name: 'PBKDF2',
-                                       hash: 'SHA-512',
-                                       iterations: 210000,
-                                       salt
-                               }
-                               const derivedKeyType: AesKeyGenParams = {
-                                       name: 'AES-GCM',
-                                       length: 256
-                               }
-                               return crypto.subtle
-                                       .deriveKey(derivationAlgorithm, derivationKey, derivedKeyType, false, [purpose])
-                       })
-                       .catch(err => {
-                               console.error(err)
-                               throw new Error('Failed to derive CryptoKey from password', { cause: err })
-                       })
-       } else if (data.password !== undefined) {
-               throw new Error('Password is not allowed for this action', { cause: action })
-       } else {
-               return Promise.resolve(undefined)
+                       return crypto.subtle
+                               .importKey('raw', password, 'PBKDF2', false, ['deriveKey'])
+                               .then(derivationKey => {
+                                       new Uint8Array(password).fill(0).buffer.transfer?.()
+                                       const derivationAlgorithm: Pbkdf2Params = {
+                                               name: 'PBKDF2',
+                                               hash: 'SHA-512',
+                                               iterations: 210000,
+                                               salt
+                                       }
+                                       const derivedKeyType: AesKeyGenParams = {
+                                               name: 'AES-GCM',
+                                               length: 256
+                                       }
+                                       return crypto.subtle
+                                               .deriveKey(derivationAlgorithm, derivationKey, derivedKeyType, false, [purpose])
+                               })
+                               .catch(err => {
+                                       console.error(err)
+                                       throw new Error('Failed to derive CryptoKey from password', { cause: err })
+                               })
+               } else if (data.password !== undefined) {
+                       throw new Error('Password is not allowed for this action', { cause: action })
+               } else {
+                       return Promise.resolve(undefined)
+               }
        }
 }
index c50ad33d96ed2496581cafe475c00b98061c6c43..e4b7c0ae532c48c471d50a20607b3e82ba2dd9f0 100644 (file)
@@ -5,8 +5,8 @@ import { parentPort } from 'node:worker_threads'
 import { NamedData } from '#types'
 import { BIP44_COIN_NANO } from '../constants'
 import { utf8 } from '../convert'
-import { Bip39, Bip44, Blake2b, NanoNaCl } from '../crypto'
-import { createKeyFromPassword } from './passkey'
+import { Bip39, Bip44, Blake2b, NanoNaCl, WalletAesGcm } from '../crypto'
+import { Passkey } from './passkey'
 import { VaultTimer } from './vault-timer'
 
 /**
@@ -32,7 +32,7 @@ export class VaultWorker {
                        const data = this.#parseData(event.data)
                        const action = this.#parseAction(data)
                        const keySalt = this.#parseKeySalt(action, data)
-                       createKeyFromPassword(action, keySalt, data)
+                       Passkey.create(action, keySalt, data)
                                .then((key: CryptoKey | undefined): Promise<NamedData> => {
                                        const type = this.#parseType(action, data)
                                        const iv = this.#parseIv(action, data)
@@ -49,7 +49,7 @@ export class VaultWorker {
                                                        return this.derive(index)
                                                }
                                                case 'load': {
-                                                       return this.#load(type, key, keySalt, mnemonicPhrase ?? seed, mnemonicSalt)
+                                                       return this.load(type, key, keySalt, mnemonicPhrase ?? seed, mnemonicSalt)
                                                }
                                                case 'lock': {
                                                        return Promise.resolve(this.lock())
@@ -236,14 +236,16 @@ export class VaultWorker {
                        throw new TypeError('Wallet encrypted data is required')
                }
                this.#timeout?.pause()
-               return this.#decryptWallet(type, key, iv, encrypted)
-                       .then(() => {
-                               if (!(this.#seed instanceof ArrayBuffer)) {
+               return WalletAesGcm.decrypt(type, key, iv, encrypted)
+                       .then(({ mnemonic, seed }) => {
+                               if (!(seed instanceof ArrayBuffer)) {
                                        throw new TypeError('Invalid seed')
                                }
-                               if (this.#mnemonic != null && !(this.#mnemonic instanceof ArrayBuffer)) {
+                               if (mnemonic != null && !(mnemonic instanceof ArrayBuffer)) {
                                        throw new TypeError('Invalid mnemonic')
                                }
+                               this.#seed = seed
+                               this.#mnemonic = mnemonic
                                this.#locked = false
                                this.#timeout = new VaultTimer(() => this.lock(), 120000)
                                return { isUnlocked: !this.#locked }
@@ -267,10 +269,13 @@ export class VaultWorker {
                        if (this.#seed == null) {
                                throw new Error('Wallet seed not found')
                        }
+                       if (this.#type == null) {
+                               throw new Error('Wallet type not found')
+                       }
                        if (key == null || salt == null) {
                                throw new TypeError('Wallet password is required')
                        }
-                       return this.#encryptWallet(key)
+                       return WalletAesGcm.encrypt(this.#type, key, this.#seed, this.#mnemonic)
                                .then(({ iv, encrypted }) => {
                                        this.#timeout = new VaultTimer(() => this.lock(), 120000)
                                        return { iv, salt, encrypted }
@@ -326,39 +331,6 @@ export class VaultWorker {
                }
        }
 
-       #decryptWallet (type: string, key: CryptoKey, iv: ArrayBuffer, encrypted: ArrayBuffer): Promise<void> {
-               const seedLength = type === 'BIP-44' ? 64 : 32
-               const additionalData = utf8.toBytes(type)
-               return crypto.subtle
-                       .decrypt({ name: 'AES-GCM', iv, additionalData }, key, encrypted)
-                       .then(decrypted => {
-                               this.#seed = decrypted.slice(0, seedLength)
-                               this.#mnemonic = decrypted.slice(seedLength)
-                               new Uint8Array(decrypted).fill(0)
-                       })
-       }
-
-       #encryptWallet (key: CryptoKey): Promise<NamedData<ArrayBuffer>> {
-               if (this.#type == null) {
-                       throw new Error('Invalid wallet type')
-               }
-               if (this.#seed == null) {
-                       throw new Error('Wallet seed not found')
-               }
-               const seed = new Uint8Array(this.#seed)
-               const mnemonic = new Uint8Array(this.#mnemonic ?? [])
-               // restrict iv to 96 bits per GCM best practice
-               const iv = crypto.getRandomValues(new Uint8Array(12)).buffer
-               const additionalData = utf8.toBytes(this.#type)
-               const encoded = new Uint8Array([...seed, ...mnemonic])
-               return crypto.subtle
-                       .encrypt({ name: 'AES-GCM', iv, additionalData }, key, encoded)
-                       .then(encrypted => {
-                               encoded.fill(0)
-                               return { iv, encrypted }
-                       })
-       }
-
        /**
        * Parse inbound message from main thread into typechecked variables.
        */
@@ -503,7 +475,7 @@ export class VaultWorker {
                        }
                        return seed.then(seed => {
                                this.#seed = seed
-                               return this.#encryptWallet(key)
+                               return WalletAesGcm.encrypt(type, key, this.#seed, this.#mnemonic)
                                        .then(({ iv, encrypted }) => ({ iv, salt: keySalt, encrypted }))
                        })
                } catch (err) {