From: Chris Duncan Date: Wed, 3 Sep 2025 14:48:37 +0000 (-0700) Subject: Adjust password-to-key conversion to ease worker integration. Extract wallet encrypt... X-Git-Tag: v0.10.5~35^2 X-Git-Url: https://git.codecow.com/?a=commitdiff_plain;h=ceb99b777cdaa9be9f2a4d755a6ef9edabde70a6;p=libnemo.git Adjust password-to-key conversion to ease worker integration. Extract wallet encrypt/decrypt to separate crypto file. --- diff --git a/src/lib/crypto/index.ts b/src/lib/crypto/index.ts index 3c5223b..6c61c9f 100644 --- a/src/lib/crypto/index.ts +++ b/src/lib/crypto/index.ts @@ -1,9 +1,10 @@ //! SPDX-FileCopyrightText: 2025 Chris Duncan //! 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 index 0000000..90e6a13 --- /dev/null +++ b/src/lib/crypto/wallet-aes-gcm.ts @@ -0,0 +1,44 @@ + + +//! SPDX-FileCopyrightText: 2025 Chris Duncan +//! 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> { + 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> { + 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 } + }) + } +} diff --git a/src/lib/vault/index.ts b/src/lib/vault/index.ts index 3a2e7b6..819809a 100644 --- a/src/lib/vault/index.ts +++ b/src/lib/vault/index.ts @@ -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() diff --git a/src/lib/vault/passkey.ts b/src/lib/vault/passkey.ts index fdcfe07..2d96770 100644 --- a/src/lib/vault/passkey.ts +++ b/src/lib/vault/passkey.ts @@ -1,47 +1,49 @@ //! SPDX-FileCopyrightText: 2025 Chris Duncan //! SPDX-License-Identifier: GPL-3.0-or-later -export async function createKeyFromPassword (action: string, salt: ArrayBuffer, data: { [key: string]: unknown }): Promise { - // 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 { + // 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) + } } } diff --git a/src/lib/vault/vault-worker.ts b/src/lib/vault/vault-worker.ts index c50ad33..e4b7c0a 100644 --- a/src/lib/vault/vault-worker.ts +++ b/src/lib/vault/vault-worker.ts @@ -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 => { 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 { - 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> { - 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) {