From b02e50be82c28b12054e521e23d7e120883925be Mon Sep 17 00:00:00 2001 From: Chris Duncan Date: Thu, 4 Sep 2025 11:45:15 -0700 Subject: [PATCH] Refactor vault for minification. To support name mangling, the stringified Vault worker now redefines the names of its own imports based on each class's mangled name. In the same vein, convert functions and constants have been inlined where used in the Vault. Finally, the worker now dynamically imports NodeJS worker threads when instantiated so that it can be excluded from static imports and avoid mangling. --- esbuild-prod.mjs | 2 -- esbuild.mjs | 1 + src/lib/constants.ts | 2 -- src/lib/crypto/bip39.ts | 19 +++++++++++-------- src/lib/crypto/bip44.ts | 12 +++++++----- src/lib/crypto/wallet-aes-gcm.ts | 7 ++++--- src/lib/vault/index.ts | 17 +++++++++-------- src/lib/vault/vault-worker.ts | 29 +++++++++++++++++++---------- 8 files changed, 51 insertions(+), 38 deletions(-) diff --git a/esbuild-prod.mjs b/esbuild-prod.mjs index 50ff359..f29b71d 100644 --- a/esbuild-prod.mjs +++ b/esbuild-prod.mjs @@ -5,8 +5,6 @@ import { build } from 'esbuild' import { browserOptions, nodeOptions } from './esbuild.mjs' browserOptions.drop = nodeOptions.drop = ['console', 'debugger'] -browserOptions.minifySyntax = nodeOptions.minifySyntax = true -browserOptions.minifyWhitespace = nodeOptions.minifyWhitespace = true // Browser build await build(browserOptions) diff --git a/esbuild.mjs b/esbuild.mjs index b886fb5..2732f64 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -8,6 +8,7 @@ import { build } from 'esbuild' */ const sharedOptions = { bundle: true, + minify: true, platform: 'browser', loader: { '.d.ts': 'copy' diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 31bbe04..d215a7d 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -19,7 +19,6 @@ export const PREFIX = 'nano_' export const PREFIX_LEGACY = 'xrb_' export const SEED_LENGTH_BIP44 = 128 export const SEED_LENGTH_BLAKE2B = 64 -export const SLIP10_ED25519 = 'ed25519 seed' export const DIFFICULTY_RECEIVE = 0xfffffe0000000000n export const DIFFICULTY_SEND = 0xfffffff800000000n export const XNO = 'Ó¾' @@ -55,7 +54,6 @@ export default ` const PREFIX_LEGACY = '${PREFIX_LEGACY}' const SEED_LENGTH_BIP44 = ${SEED_LENGTH_BIP44} const SEED_LENGTH_BLAKE2B = ${SEED_LENGTH_BLAKE2B} - const SLIP10_ED25519 = '${SLIP10_ED25519}' const DIFFICULTY_RECEIVE = ${DIFFICULTY_RECEIVE} const DIFFICULTY_SEND = ${DIFFICULTY_SEND} const XNO = '${XNO}' diff --git a/src/lib/crypto/bip39.ts b/src/lib/crypto/bip39.ts index 545cb71..2843821 100644 --- a/src/lib/crypto/bip39.ts +++ b/src/lib/crypto/bip39.ts @@ -1,12 +1,12 @@ //! SPDX-FileCopyrightText: 2025 Chris Duncan //! SPDX-License-Identifier: GPL-3.0-or-later -import { bytes, utf8 } from '../convert' - /** * Represents a mnemonic phrase that identifies a wallet as defined by BIP-39. */ export class Bip39 { + encoder: TextEncoder = new TextEncoder() + /** * SHA-256 hash of entropy that is appended to the entropy and subsequently * used to generate the mnemonic phrase. @@ -157,8 +157,8 @@ export class Bip39 { * Erases seed bytes and releases variable references. */ destroy () { - bytes.erase(this.#bip39Seed) - bytes.erase(this.#blake2bSeed) + this.#bip39Seed?.fill(0).buffer.transfer?.() + this.#blake2bSeed?.fill(0).buffer.transfer?.() this.#bip39Seed = undefined this.#blake2bSeed = undefined this.#phrase = undefined @@ -189,16 +189,19 @@ export class Bip39 { throw new Error('BIP-39 mnemonic phrase not found') } if (this.#bip39Seed != null) { - return Promise.resolve(format === 'hex' ? bytes.toHex(this.#bip39Seed) : this.#bip39Seed) + const seed = format === 'hex' + ? [...this.#bip39Seed].map(b => b.toString(16).padStart(2, '0')).join('').toUpperCase() + : this.#bip39Seed + return Promise.resolve(seed) } else { const salt = (typeof passphrase === 'string') ? passphrase : '' - const keyData = utf8.toBytes(this.phrase) + const keyData = this.encoder.encode(this.phrase) return crypto.subtle.importKey('raw', keyData, 'PBKDF2', false, ['deriveBits', 'deriveKey']) .then(phraseKey => { const algorithm: Pbkdf2Params = { name: 'PBKDF2', hash: 'SHA-512', - salt: utf8.toBytes(`mnemonic${salt.normalize('NFKD')}`), + salt: this.encoder.encode(`mnemonic${salt.normalize('NFKD')}`), iterations: 2048 } return crypto.subtle.deriveBits(algorithm, phraseKey, 512) @@ -244,7 +247,7 @@ export class Bip39 { } } return format === 'hex' - ? bytes.toHex(this.#blake2bSeed) + ? [...this.#blake2bSeed].map(b => b.toString(16).padStart(2, '0')).join('').toUpperCase() : this.#blake2bSeed } diff --git a/src/lib/crypto/bip44.ts b/src/lib/crypto/bip44.ts index 05958cd..b0989bd 100644 --- a/src/lib/crypto/bip44.ts +++ b/src/lib/crypto/bip44.ts @@ -1,14 +1,16 @@ //! SPDX-FileCopyrightText: 2025 Chris Duncan //! SPDX-License-Identifier: GPL-3.0-or-later -import { BIP44_PURPOSE, HARDENED_OFFSET, SLIP10_ED25519 } from '../constants' - type ExtendedKey = { privateKey: ArrayBuffer chainCode: ArrayBuffer } export class Bip44 { + static get BIP44_PURPOSE (): 44 { return 44 } + static get HARDENED_OFFSET (): 0x80000000 { return 0x80000000 } + static get SLIP10_ED25519 (): 'ed25519 seed' { return 'ed25519 seed' } + /** * Derives a private child key for a coin by following the specified BIP-32 and * BIP-44 derivation path. Purpose is always 44'. Only hardened child keys are @@ -37,8 +39,8 @@ export class Bip44 { if (address !== undefined && (!Number.isSafeInteger(address) || address < 0 || address > 0x7fffffff)) { throw new RangeError(`Invalid address index 0x${account.toString(16)}`) } - return this.slip10(SLIP10_ED25519, seed) - .then(masterKey => this.CKDpriv(masterKey, BIP44_PURPOSE)) + return this.slip10(this.SLIP10_ED25519, seed) + .then(masterKey => this.CKDpriv(masterKey, this.BIP44_PURPOSE)) .then(purposeKey => this.CKDpriv(purposeKey, coin)) .then(coinKey => this.CKDpriv(coinKey, account)) .then(accountKey => this.CKDpriv(accountKey, change)) @@ -61,7 +63,7 @@ export class Bip44 { if (index === undefined) { return Promise.resolve({ privateKey, chainCode }) } - index += HARDENED_OFFSET + index += this.HARDENED_OFFSET const key = new Uint8Array(chainCode) const data = new Uint8Array(37) data.set([0]) diff --git a/src/lib/crypto/wallet-aes-gcm.ts b/src/lib/crypto/wallet-aes-gcm.ts index be08c9d..b2e1013 100644 --- a/src/lib/crypto/wallet-aes-gcm.ts +++ b/src/lib/crypto/wallet-aes-gcm.ts @@ -4,12 +4,13 @@ //! SPDX-License-Identifier: GPL-3.0-or-later import { NamedData } from "#types" -import { utf8 } from "../convert" export class WalletAesGcm { + static encoder: TextEncoder = new TextEncoder() + static decrypt (type: string, key: CryptoKey, iv: ArrayBuffer, encrypted: ArrayBuffer): Promise> { const seedLength = type === 'BIP-44' ? 64 : 32 - const additionalData = utf8.toBytes(type) + const additionalData = this.encoder.encode(type) return crypto.subtle .decrypt({ name: 'AES-GCM', iv, additionalData }, key, encrypted) .then(decrypted => { @@ -32,7 +33,7 @@ export class WalletAesGcm { } // restrict iv to 96 bits per GCM best practice const iv = crypto.getRandomValues(new Uint8Array(12)).buffer - const additionalData = utf8.toBytes(type) + const additionalData = this.encoder.encode(type) const encoded = new Uint8Array([...new Uint8Array(seed), ...new Uint8Array(mnemonic ?? [])]) return crypto.subtle .encrypt({ name: 'AES-GCM', iv, additionalData }, key, encoded) diff --git a/src/lib/vault/index.ts b/src/lib/vault/index.ts index 2944d27..1030966 100644 --- a/src/lib/vault/index.ts +++ b/src/lib/vault/index.ts @@ -3,8 +3,6 @@ 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, WalletAesGcm } from '../crypto' import { Passkey } from './passkey' import { VaultTimer } from './vault-timer' @@ -19,12 +17,7 @@ type Task = { export class Vault { static get #blob () { - let importWorkerThreads = '' - NODE: importWorkerThreads = `import { parentPort } from 'node:worker_threads'` return ` - ${importWorkerThreads} - ${Convert} - ${Constants} const ${Bip39.name} = ${Bip39} const ${Bip44.name} = ${Bip44} const ${Blake2b.name} = ${Blake2b} @@ -33,7 +26,15 @@ export class Vault { const ${Passkey.name} = ${Passkey} const ${VaultTimer.name} = ${VaultTimer} const ${VaultWorker.name} = ${VaultWorker} - const v = new ${VaultWorker.name}() + ${Bip39.name === 'Bip39' ? '' : `const Bip39 = ${Bip39.name}`} + ${Bip44.name === 'Bip44' ? '' : `const Bip44 = ${Bip44.name}`} + ${Blake2b.name === 'Blake2b' ? '' : `const Blake2b = ${Blake2b.name}`} + ${NanoNaCl.name === 'NanoNaCl' ? '' : `const NanoNaCl = ${NanoNaCl.name}`} + ${WalletAesGcm.name === 'WalletAesGcm' ? '' : `const WalletAesGcm = ${WalletAesGcm.name}`} + ${Passkey.name === 'Passkey' ? '' : `const Passkey = ${Passkey.name}`} + ${VaultTimer.name === 'VaultTimer' ? '' : `const VaultTimer = ${VaultTimer.name}`} + ${VaultWorker.name === 'VaultWorker' ? '' : `const VaultWorker = ${VaultWorker.name}`} + const v = new VaultWorker() ` } static #instances: Vault[] = [] diff --git a/src/lib/vault/vault-worker.ts b/src/lib/vault/vault-worker.ts index e4b7c0a..363ede0 100644 --- a/src/lib/vault/vault-worker.ts +++ b/src/lib/vault/vault-worker.ts @@ -1,10 +1,8 @@ //! SPDX-FileCopyrightText: 2025 Chris Duncan //! SPDX-License-Identifier: GPL-3.0-or-later -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, WalletAesGcm } from '../crypto' import { Passkey } from './passkey' import { VaultTimer } from './vault-timer' @@ -13,6 +11,7 @@ import { VaultTimer } from './vault-timer' * Cross-platform worker for managing wallet secrets. */ export class VaultWorker { + #encoder: TextEncoder = new TextEncoder() #locked: boolean #timeout: VaultTimer #type?: 'BIP-44' | 'BLAKE2b' @@ -26,9 +25,9 @@ export class VaultWorker { this.#type = undefined this.#seed = undefined this.#mnemonic = undefined - NODE: this.#parentPort = parentPort const listener = (event: MessageEvent): void => { + NODE: if (this.#parentPort == null) setTimeout(() => listener(event), 0) const data = this.#parseData(event.data) const action = this.#parseAction(data) const keySalt = this.#parseKeySalt(action, data) @@ -79,9 +78,9 @@ export class VaultWorker { } } //@ts-expect-error + // postMessage(result, transfer) BROWSER: postMessage(result, transfer) - //@ts-expect-error - NODE: parentPort?.postMessage(result, transfer) + NODE: this.#parentPort?.postMessage(result, transfer) }) .catch((err: any) => { console.error(err) @@ -91,12 +90,22 @@ export class VaultWorker { } event.data[key] = undefined } + // postMessage({ error: 'Failed to process Vault request', cause: err }) BROWSER: postMessage({ error: 'Failed to process Vault request', cause: err }) - NODE: parentPort?.postMessage({ error: 'Failed to process Vault request', cause: err }) + NODE: this.#parentPort?.postMessage({ error: 'Failed to process Vault request', cause: err }) }) } + // addEventListener('message', listener) BROWSER: addEventListener('message', listener) - NODE: this.#parentPort?.on('message', listener) + NODE: { + if (this.#parentPort == null) { + import('node:worker_threads') + .then(({ parentPort }) => { + this.#parentPort = parentPort + this.#parentPort.on('message', listener) + }) + } + } } /** @@ -317,7 +326,7 @@ export class VaultWorker { } if (mnemonicPhrase != null) { let diff = 0 - const userMnemonic = utf8.toBytes(mnemonicPhrase) + const userMnemonic = this.#encoder.encode(mnemonicPhrase) const thisMnemonic = new Uint8Array(this.#mnemonic ?? []) for (let i = 0; i < userMnemonic.byteLength; i++) { diff |= userMnemonic[i] ^ thisMnemonic[i] @@ -457,7 +466,7 @@ export class VaultWorker { if (type === 'BLAKE2b') { seed = Bip39.fromEntropy(new Uint8Array(secret)) .then(bip39 => { - this.#mnemonic = utf8.toBuffer(bip39.phrase ?? '') + this.#mnemonic = this.#encoder.encode(bip39.phrase ?? '').buffer return secret }) } else { @@ -466,7 +475,7 @@ export class VaultWorker { } else { seed = Bip39.fromPhrase(secret) .then(bip39 => { - this.#mnemonic = utf8.toBuffer(bip39.phrase ?? '') + this.#mnemonic = this.#encoder.encode(bip39.phrase ?? '').buffer const derive = type === 'BIP-44' ? bip39.toBip39Seed(mnemonicSalt ?? '') : Promise.resolve(bip39.toBlake2bSeed()) -- 2.47.3