From ff8f55db648e00c2a82e1e240d77660170e17254 Mon Sep 17 00:00:00 2001 From: Chris Duncan Date: Sun, 2 Aug 2026 14:28:33 -0700 Subject: [PATCH] Use wallet ID in additional info to ensure only targeted wallet is encrypted or decrypted. --- src/lib/convert/utf8.ts | 10 ++++++++ src/lib/crypto/wallet-aes-gcm.ts | 13 +++++++--- src/lib/vault/parsers.ts | 22 ++++++++++++++++ src/lib/vault/vault-worker.ts | 43 ++++++++++++++++++++++---------- src/lib/wallet/backup.ts | 3 ++- src/lib/wallet/create.ts | 1 + src/lib/wallet/get.ts | 3 ++- src/lib/wallet/index.ts | 12 ++++++--- src/lib/wallet/load.ts | 1 + src/lib/wallet/unlock.ts | 1 + 10 files changed, 86 insertions(+), 23 deletions(-) diff --git a/src/lib/convert/utf8.ts b/src/lib/convert/utf8.ts index 0c7dc36..2a95e02 100644 --- a/src/lib/convert/utf8.ts +++ b/src/lib/convert/utf8.ts @@ -1,11 +1,21 @@ //! SPDX-FileCopyrightText: 2025 Chris Duncan //! SPDX-License-Identifier: GPL-3.0-or-later +import { UUID } from 'node:crypto' import { bytes } from './bytes' const encoder = new TextEncoder() export const utf8 = Object.freeze({ + /** + * Type guard to check if input is a valid UUID. + * + * @param {unknown} input - Variable to check + * @returns True if input is UUID-formatted string, else false + */ + isUuid (input: unknown): input is UUID { + return typeof input === 'string' && /[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/.test(input) + }, /** * Convert a UTF-8 text string to an ArrayBuffer. * diff --git a/src/lib/crypto/wallet-aes-gcm.ts b/src/lib/crypto/wallet-aes-gcm.ts index 1fcca16..069578c 100644 --- a/src/lib/crypto/wallet-aes-gcm.ts +++ b/src/lib/crypto/wallet-aes-gcm.ts @@ -3,12 +3,14 @@ //! SPDX-FileCopyrightText: 2025 Chris Duncan //! SPDX-License-Identifier: GPL-3.0-or-later +import { UUID } from 'crypto' import { utf8 } from "../convert" +import { WalletType } from "../wallet" export class WalletAesGcm { - static decrypt (type: string, key: CryptoKey, iv: ArrayBuffer, encrypted: ArrayBuffer): Promise> { + static decrypt (type: WalletType, id: UUID, key: CryptoKey, iv: ArrayBuffer, encrypted: ArrayBuffer): Promise> { const seedLength = type === 'BLAKE2b' ? 32 : 64 - const additionalData = utf8.toBuffer(type) + const additionalData = utf8.toBuffer(`${type};${id}`) return crypto.subtle .decrypt({ name: 'AES-GCM', iv, additionalData }, key, encrypted) .then(decrypted => { @@ -19,10 +21,13 @@ export class WalletAesGcm { }) } - static encrypt (type: string, key: CryptoKey, seed: ArrayBuffer, mnemonic?: ArrayBuffer): Promise> { + static encrypt (type: WalletType, id: UUID, key: CryptoKey, seed: ArrayBuffer, mnemonic?: ArrayBuffer): Promise> { if (type == null) { throw new Error('Wallet type missing') } + if (id == null) { + throw new Error('Wallet ID missing') + } if (key == null) { throw new Error('Wallet key missing') } @@ -31,7 +36,7 @@ export class WalletAesGcm { } // restrict iv to 96 bits per GCM best practice const iv = crypto.getRandomValues(new Uint8Array(12)).buffer - const additionalData = utf8.toBuffer(type) + const additionalData = utf8.toBuffer(`${type};${id}`) 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/parsers.ts b/src/lib/vault/parsers.ts index 5c0639c..ce333fe 100644 --- a/src/lib/vault/parsers.ts +++ b/src/lib/vault/parsers.ts @@ -1,6 +1,9 @@ //! SPDX-FileCopyrightText: 2025 Chris Duncan //! SPDX-License-Identifier: GPL-3.0-or-later +import { UUID } from 'crypto' +import { utf8 } from "../convert" + /** * Action for selecting method execution */ @@ -138,6 +141,25 @@ export function parseKeySalt (action: string, data: Record): Ar } } +/** + * UUID assigned in Wallet constructor. + */ +export function parseId (action: string, data: Record): UUID | undefined { + if (['create', 'load', 'unlock'].includes(action)) { + if (typeof data.id !== 'string') { + throw new TypeError(`ID is required to ${action} wallet`) + } else { + data.id = data.id.toLowerCase() + if (!utf8.isUuid(data.id)) { + throw new TypeError('Invalid wallet ID', { cause: action }) + } + } + } else if (data.id !== undefined) { + throw new Error('ID is not allowed for this action', { cause: action }) + } + return data.id +} + /** * Initialization vector created to encrypt and lock the vault; subsequently * required to decrypt and unlock the vault diff --git a/src/lib/vault/vault-worker.ts b/src/lib/vault/vault-worker.ts index e4b954f..6e825fe 100644 --- a/src/lib/vault/vault-worker.ts +++ b/src/lib/vault/vault-worker.ts @@ -2,12 +2,13 @@ //! SPDX-License-Identifier: GPL-3.0-or-later import { derive as nano25519_derive, sign as nano25519_sign } from 'nano25519/sync' +import { UUID } from 'node:crypto' import { parentPort, threadId } from 'node:worker_threads' import { BIP44_COIN_NANO } from '../constants' import { dec, utf8 } from '../convert' import { Bip39, Bip44, Blake2b, WalletAesGcm } from '../crypto' import { WalletType } from '../wallet' -import { parseAction, parseData, parseIv, parseKeySalt, parseType } from './parsers' +import { parseAction, parseData, parseId, parseIv, parseKeySalt, parseType } from './parsers' import { Passkey } from './passkey' import { VaultTimer } from './vault-timer' @@ -15,6 +16,7 @@ let _locked: boolean = true let _timeout: number = 120_000 let _timer: VaultTimer = new VaultTimer(() => { }, 0) let _type: 'BIP-44' | 'BLAKE2b' | 'Exodus' | undefined = undefined +let _id: UUID | undefined = undefined let _seed: ArrayBuffer | undefined = undefined let _mnemonic: ArrayBuffer | undefined = undefined @@ -37,6 +39,7 @@ const listener = (event: MessageEvent): void => { Passkey.create(action, keySalt, data) .then((key: CryptoKey | undefined): Promise | void> => { const type = parseType(action, data) + const id = parseId(action, data) const iv = parseIv(action, data) const { seed, mnemonicPhrase, mnemonicSalt, index, encrypted, message, timeout } = parseData(action, data) switch (action) { @@ -48,13 +51,13 @@ const listener = (event: MessageEvent): void => { return config(timeout) } case 'create': { - return create(type, key, keySalt, mnemonicSalt) + return create(type, id, key, keySalt, mnemonicSalt) } case 'derive': { return derive(index) } case 'load': { - return load(type, key, keySalt, mnemonicPhrase ?? seed, mnemonicSalt) + return load(type, id, key, keySalt, mnemonicPhrase ?? seed, mnemonicSalt) } case 'lock': { return lock() @@ -63,7 +66,7 @@ const listener = (event: MessageEvent): void => { return sign(index, message) } case 'unlock': { - return unlock(type, key, iv, encrypted) + return unlock(type, id, key, iv, encrypted) } case 'update': { return update(key, keySalt) @@ -140,14 +143,14 @@ function config (timeout?: number): Promise { * Generates a new mnemonic and seed and then returns the initialization vector * vector, salt, and encrypted data representing the wallet in a locked state. */ -function create (type?: WalletType, key?: CryptoKey, keySalt?: ArrayBuffer, mnemonicSalt?: string): Promise> { +function create (type?: WalletType, id?: UUID, key?: CryptoKey, keySalt?: ArrayBuffer, mnemonicSalt?: string): Promise> { if (type !== 'BIP-44' && type !== 'BLAKE2b') { throw new TypeError('Unsupported software wallet algorithm', { cause: type }) } try { const entropy = crypto.getRandomValues(new Uint8Array(32)) return Bip39.fromEntropy(entropy) - .then(bip39 => _load(type, key, keySalt, bip39.phrase, mnemonicSalt)) + .then(bip39 => _load(type, id, key, keySalt, bip39.phrase, mnemonicSalt)) .then(({ iv, salt, encrypted }) => { entropy.fill(0) if (_seed == null || _mnemonic == null) { @@ -201,11 +204,11 @@ function derive (index?: number): Promise> * Encrypts an existing seed or mnemonic+salt and returns the initialization * vector, salt, and encrypted data representing the wallet in a locked state. */ -function load (type?: WalletType, key?: CryptoKey, keySalt?: ArrayBuffer, secret?: string | ArrayBuffer, mnemonicSalt?: string): Promise> { +function load (type?: WalletType, id?: UUID, key?: CryptoKey, keySalt?: ArrayBuffer, secret?: string | ArrayBuffer, mnemonicSalt?: string): Promise> { if (type !== 'BIP-44' && type !== 'BLAKE2b' && type !== 'Exodus') { throw new TypeError('Unsupported software wallet algorithm', { cause: type }) } - return _load(type, key, keySalt, secret, mnemonicSalt) + return _load(type, id, key, keySalt, secret, mnemonicSalt) .then(record => { if (_seed == null) { throw new Error('Wallet seed not found') @@ -264,10 +267,13 @@ function sign (index?: number, data?: ArrayBuffer): Promise> { +function unlock (type?: WalletType, id?: UUID, key?: CryptoKey, iv?: ArrayBuffer, encrypted?: ArrayBuffer): Promise> { if (type == null) { throw new TypeError('Wallet type is required') } + if (id == null) { + throw new TypeError('Wallet ID is required') + } if (type === 'Ledger') { _locked = false return Promise.resolve({ isLocked: false }) @@ -282,7 +288,7 @@ function unlock (type?: WalletType, key?: CryptoKey, iv?: ArrayBuffer, encrypted throw new TypeError('Wallet encrypted data is required') } _timer?.pause() - return WalletAesGcm.decrypt(type, key, iv, encrypted) + return WalletAesGcm.decrypt(type, id, key, iv, encrypted) .then(({ mnemonic, seed }) => { if (!(seed instanceof ArrayBuffer)) { throw new TypeError('Invalid seed') @@ -291,6 +297,7 @@ function unlock (type?: WalletType, key?: CryptoKey, iv?: ArrayBuffer, encrypted throw new TypeError('Invalid mnemonic') } _type = type + _id = id _seed = seed _mnemonic = mnemonic _locked = false @@ -319,10 +326,13 @@ function update (key?: CryptoKey, salt?: ArrayBuffer): Promise { _timer = new VaultTimer(_autolock, _timeout) return { iv, salt, encrypted } @@ -425,7 +435,7 @@ function _ckd (index: number): Promise { * Encrypts an existing seed or mnemonic+salt and returns the initialization * vector, salt, and encrypted data representing the wallet in a locked state. */ -function _load (type?: 'BIP-44' | 'BLAKE2b' | 'Exodus', key?: CryptoKey, keySalt?: ArrayBuffer, secret?: string | ArrayBuffer, mnemonicSalt?: string): Promise> { +function _load (type?: 'BIP-44' | 'BLAKE2b' | 'Exodus', id?: UUID, key?: CryptoKey, keySalt?: ArrayBuffer, secret?: string | ArrayBuffer, mnemonicSalt?: string): Promise> { try { if (!_locked) { throw new Error('Wallet is in use') @@ -436,6 +446,12 @@ function _load (type?: 'BIP-44' | 'BLAKE2b' | 'Exodus', key?: CryptoKey, keySalt if (type == null) { throw new TypeError('Wallet type is required') } + if (id == null) { + throw new TypeError('Wallet ID is required') + } + if (!utf8.isUuid(id)) { + throw new TypeError('Invalid wallet ID') + } if (type !== 'BIP-44' && type !== 'BLAKE2b' && type !== 'Exodus') { throw new TypeError('Invalid wallet type') } @@ -464,6 +480,7 @@ function _load (type?: 'BIP-44' | 'BLAKE2b' | 'Exodus', key?: CryptoKey, keySalt } } _type = type + _id = id let seed: Promise if (secret instanceof ArrayBuffer) { if (type === 'BLAKE2b') { @@ -488,7 +505,7 @@ function _load (type?: 'BIP-44' | 'BLAKE2b' | 'Exodus', key?: CryptoKey, keySalt return seed.then(seed => { _seed = seed return WalletAesGcm - .encrypt(type, key, _seed, _mnemonic) + .encrypt(type, id, key, _seed, _mnemonic) .then(({ iv, encrypted }) => ({ iv, salt: keySalt, encrypted })) }) } catch (err) { diff --git a/src/lib/wallet/backup.ts b/src/lib/wallet/backup.ts index f590d0a..9233332 100644 --- a/src/lib/wallet/backup.ts +++ b/src/lib/wallet/backup.ts @@ -1,6 +1,7 @@ //! SPDX-FileCopyrightText: 2025 Chris Duncan //! SPDX-License-Identifier: GPL-3.0-or-later +import { utf8 } from '../convert' import { Database } from '../database' import { Wallet } from '../wallet' @@ -9,7 +10,7 @@ export async function _backup () { const records = await Database.getAll>(Wallet.DB_NAME) return Object.values(records).map((record: Record) => { const { id, type, iv, salt, encrypted } = record - if (typeof id !== 'string') { + if (!utf8.isUuid(id)) { throw new TypeError('Retrieved invalid ID', { cause: id }) } if (type !== 'BIP-44' && type !== 'BLAKE2b' && type !== 'Exodus' && type !== 'Ledger') { diff --git a/src/lib/wallet/create.ts b/src/lib/wallet/create.ts index 6bf1b2a..d9d2376 100644 --- a/src/lib/wallet/create.ts +++ b/src/lib/wallet/create.ts @@ -28,6 +28,7 @@ export async function _create (wallet: Wallet, vault: Vault, password: unknown, const pending = vault.request({ action: 'create', type: wallet.type, + id: wallet.id, password: utf8.toBuffer(password), mnemonicSalt: mnemonicSalt ?? '' }) diff --git a/src/lib/wallet/get.ts b/src/lib/wallet/get.ts index 878be77..bdcaacc 100644 --- a/src/lib/wallet/get.ts +++ b/src/lib/wallet/get.ts @@ -1,6 +1,7 @@ //! SPDX-FileCopyrightText: 2025 Chris Duncan //! SPDX-License-Identifier: GPL-3.0-or-later +import { utf8 } from '../convert' import { Database } from '../database' import { Wallet } from '../wallet' @@ -8,7 +9,7 @@ export async function _get (recordId: string) { try { const record = await Database.get>(recordId, Wallet.DB_NAME) const { id, type, iv, salt, encrypted } = record[recordId] - if (typeof id !== 'string') { + if (!utf8.isUuid(id)) { throw new TypeError('Retrieved invalid ID', { cause: id }) } if (type !== 'BIP-44' && type !== 'BLAKE2b' && type !== 'Exodus' && type !== 'Ledger') { diff --git a/src/lib/wallet/index.ts b/src/lib/wallet/index.ts index 2ee4f44..181174f 100644 --- a/src/lib/wallet/index.ts +++ b/src/lib/wallet/index.ts @@ -1,10 +1,11 @@ //! SPDX-FileCopyrightText: 2025 Chris Duncan //! SPDX-License-Identifier: GPL-3.0-or-later +import { UUID } from 'crypto' import { Account } from '../account' import { Block } from '../block' import { ADDRESS_GAP } from '../constants' -import { bytes } from '../convert' +import { bytes, utf8 } from '../convert' import { Ledger } from '../ledger' import { Rpc } from '../rpc' import { Vault } from '../vault' @@ -137,21 +138,24 @@ export class Wallet { #accounts: Map = new Map() #eventTarget: EventTarget = new EventTarget() - #id: string = crypto.randomUUID() + #id: UUID = crypto.randomUUID() #vault: Vault = new Vault() #mnemonic?: ArrayBuffer #seed?: ArrayBuffer #type: WalletType - constructor (type: WalletType, id?: string) - constructor (type: unknown, id?: string) { + constructor (type: WalletType, id?: UUID) + constructor (type: unknown, id: unknown) { if (!(this.constructor as typeof Wallet).isInternal) { throw new Error(`Wallet cannot be instantiated directly. Use 'await Wallet.create()' instead.`) } if (type !== 'BIP-44' && type !== 'BLAKE2b' && type !== 'Exodus' && type !== 'Ledger') { throw new TypeError('Invalid wallet type', { cause: type }) } + if (id !== undefined && !(utf8.isUuid(id))) { + throw new TypeError('Invalid wallet ID', { cause: id }) + } this.#id = id ?? this.#id this.#type = type this.#vault.addEventListener('locked', () => this.dispatchEvent(new Event('locked'))) diff --git a/src/lib/wallet/load.ts b/src/lib/wallet/load.ts index e905ce9..bedb522 100644 --- a/src/lib/wallet/load.ts +++ b/src/lib/wallet/load.ts @@ -38,6 +38,7 @@ export async function _load (wallet: Wallet, vault: Vault, password: unknown, se const data: Record = { action: 'load', type: wallet.type, + id: wallet.id, password: utf8.toBuffer(password) } password = undefined diff --git a/src/lib/wallet/unlock.ts b/src/lib/wallet/unlock.ts index e2d70d5..471b83f 100644 --- a/src/lib/wallet/unlock.ts +++ b/src/lib/wallet/unlock.ts @@ -17,6 +17,7 @@ export async function _unlock (wallet: Wallet, vault: Vault, password: unknown): const data = { action: 'unlock', type: wallet.type, + id: wallet.id, password: utf8.toBuffer(password), iv: new ArrayBuffer(0), keySalt: new ArrayBuffer(0), -- 2.52.0