]> git.codecow.com Git - libnemo.git/commitdiff
Use wallet ID in additional info to ensure only targeted wallet is encrypted or decry...
authorChris Duncan <chris@codecow.com>
Sun, 2 Aug 2026 21:28:33 +0000 (14:28 -0700)
committerChris Duncan <chris@codecow.com>
Sun, 2 Aug 2026 21:28:33 +0000 (14:28 -0700)
src/lib/convert/utf8.ts
src/lib/crypto/wallet-aes-gcm.ts
src/lib/vault/parsers.ts
src/lib/vault/vault-worker.ts
src/lib/wallet/backup.ts
src/lib/wallet/create.ts
src/lib/wallet/get.ts
src/lib/wallet/index.ts
src/lib/wallet/load.ts
src/lib/wallet/unlock.ts

index 0c7dc364c351e625d07c344e6f4f3263770ec0cf..2a95e02f359242c5093e75312a756571a6bfd713 100644 (file)
@@ -1,11 +1,21 @@
 //! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@codecow.com>
 //! 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.
         *
index 1fcca162b99694698daaa4da3cd83753eaef77fd..069578c8abf58085bb406cb2a51bc86e348de8f4 100644 (file)
@@ -3,12 +3,14 @@
 //! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@codecow.com>
 //! 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<Record<string, ArrayBuffer>> {
+       static decrypt (type: WalletType, id: UUID, key: CryptoKey, iv: ArrayBuffer, encrypted: ArrayBuffer): Promise<Record<string, ArrayBuffer>> {
                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<Record<string, ArrayBuffer>> {
+       static encrypt (type: WalletType, id: UUID, key: CryptoKey, seed: ArrayBuffer, mnemonic?: ArrayBuffer): Promise<Record<string, ArrayBuffer>> {
                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)
index 5c0639cb2ddf99308b930cada4516e478c15ff8c..ce333fe65d67756a6157dfdaad4f15cc35e781da 100644 (file)
@@ -1,6 +1,9 @@
 //! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@codecow.com>
 //! 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<string, unknown>): Ar
        }
 }
 
+/**
+ * UUID assigned in Wallet constructor.
+ */
+export function parseId (action: string, data: Record<string, unknown>): 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
index e4b954f11429f54b8ebd9845e40eced4cd58ac8d..6e825fe4f9f1f6c0b387268b794379274a99865e 100644 (file)
@@ -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<any>): void => {
        Passkey.create(action, keySalt, data)
                .then((key: CryptoKey | undefined): Promise<Record<string, boolean | number | ArrayBuffer> | 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<any>): 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<any>): 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<void> {
  * 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<Record<string, ArrayBuffer>> {
+function create (type?: WalletType, id?: UUID, key?: CryptoKey, keySalt?: ArrayBuffer, mnemonicSalt?: string): Promise<Record<string, ArrayBuffer>> {
        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<Record<string, number | ArrayBuffer>>
  * 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<Record<string, ArrayBuffer>> {
+function load (type?: WalletType, id?: UUID, key?: CryptoKey, keySalt?: ArrayBuffer, secret?: string | ArrayBuffer, mnemonicSalt?: string): Promise<Record<string, ArrayBuffer>> {
        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<Record<string, Array
 /**
  * Decrypts the input and sets the seed and, if it is included, the mnemonic.
  */
-function unlock (type?: WalletType, key?: CryptoKey, iv?: ArrayBuffer, encrypted?: ArrayBuffer): Promise<Record<string, boolean>> {
+function unlock (type?: WalletType, id?: UUID, key?: CryptoKey, iv?: ArrayBuffer, encrypted?: ArrayBuffer): Promise<Record<string, boolean>> {
        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<Record<string, Ar
                if (_type == null) {
                        throw new Error('Wallet type not found')
                }
+               if (_id == null) {
+                       throw new Error('Wallet ID not found')
+               }
                if (key == null || salt == null) {
                        throw new TypeError('Wallet password is required')
                }
-               return WalletAesGcm.encrypt(_type, key, _seed, _mnemonic)
+               return WalletAesGcm.encrypt(_type, _id, key, _seed, _mnemonic)
                        .then(({ iv, encrypted }) => {
                                _timer = new VaultTimer(_autolock, _timeout)
                                return { iv, salt, encrypted }
@@ -425,7 +435,7 @@ function _ckd (index: number): Promise<ArrayBuffer> {
  * 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<Record<string, ArrayBuffer>> {
+function _load (type?: 'BIP-44' | 'BLAKE2b' | 'Exodus', id?: UUID, key?: CryptoKey, keySalt?: ArrayBuffer, secret?: string | ArrayBuffer, mnemonicSalt?: string): Promise<Record<string, ArrayBuffer>> {
        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<ArrayBuffer>
                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) {
index f590d0a9900a188e9953a6f4541d690ecb5e0f17..92333327138d96fb7dcedc5b30b90a7967e9e2f6 100644 (file)
@@ -1,6 +1,7 @@
 //! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@codecow.com>
 //! 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<Record<string, string | ArrayBuffer>>(Wallet.DB_NAME)
                return Object.values(records).map((record: Record<string, unknown>) => {
                        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') {
index 6bf1b2a19e65a34c19e9de9ce26e1a226ecacf62..d9d237636fdb52aaa8a0c93457c19a1e3b09056b 100644 (file)
@@ -28,6 +28,7 @@ export async function _create (wallet: Wallet, vault: Vault, password: unknown,
                        const pending = vault.request<ArrayBuffer>({
                                action: 'create',
                                type: wallet.type,
+                               id: wallet.id,
                                password: utf8.toBuffer(password),
                                mnemonicSalt: mnemonicSalt ?? ''
                        })
index 878be77e82207bff37d1aaaf41b0d413c0f5f1b6..bdcaacccc63a8a5d763cd4fbf79304bd21cb10a0 100644 (file)
@@ -1,6 +1,7 @@
 //! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@codecow.com>
 //! 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<Record<string, string | ArrayBuffer>>(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') {
index 2ee4f44a160353deb3d0c76f12ad21d45c917105..181174f3268244b9780e504f71b17086151c02f4 100644 (file)
@@ -1,10 +1,11 @@
 //! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@codecow.com>\r
 //! SPDX-License-Identifier: GPL-3.0-or-later\r
 \r
+import { UUID } from 'crypto'\r
 import { Account } from '../account'\r
 import { Block } from '../block'\r
 import { ADDRESS_GAP } from '../constants'\r
-import { bytes } from '../convert'\r
+import { bytes, utf8 } from '../convert'\r
 import { Ledger } from '../ledger'\r
 import { Rpc } from '../rpc'\r
 import { Vault } from '../vault'\r
@@ -137,21 +138,24 @@ export class Wallet {
 \r
        #accounts: Map<number, Account> = new Map<number, Account>()\r
        #eventTarget: EventTarget = new EventTarget()\r
-       #id: string = crypto.randomUUID()\r
+       #id: UUID = crypto.randomUUID()\r
        #vault: Vault = new Vault()\r
 \r
        #mnemonic?: ArrayBuffer\r
        #seed?: ArrayBuffer\r
        #type: WalletType\r
 \r
-       constructor (type: WalletType, id?: string)\r
-       constructor (type: unknown, id?: string) {\r
+       constructor (type: WalletType, id?: UUID)\r
+       constructor (type: unknown, id: unknown) {\r
                if (!(this.constructor as typeof Wallet).isInternal) {\r
                        throw new Error(`Wallet cannot be instantiated directly. Use 'await Wallet.create()' instead.`)\r
                }\r
                if (type !== 'BIP-44' && type !== 'BLAKE2b' && type !== 'Exodus' && type !== 'Ledger') {\r
                        throw new TypeError('Invalid wallet type', { cause: type })\r
                }\r
+               if (id !== undefined && !(utf8.isUuid(id))) {\r
+                       throw new TypeError('Invalid wallet ID', { cause: id })\r
+               }\r
                this.#id = id ?? this.#id\r
                this.#type = type\r
                this.#vault.addEventListener('locked', () => this.dispatchEvent(new Event('locked')))\r
index e905ce9546a7661943c046b2e8e243c0ef3d687f..bedb522338c6319be7ee0937923fb7d1e76c921d 100644 (file)
@@ -38,6 +38,7 @@ export async function _load (wallet: Wallet, vault: Vault, password: unknown, se
                        const data: Record<string, string | ArrayBuffer> = {
                                action: 'load',
                                type: wallet.type,
+                               id: wallet.id,
                                password: utf8.toBuffer(password)
                        }
                        password = undefined
index e2d70d518dbad8ada84f98be2c5248191a78ff83..471b83fc1034c4d39d7aa4d9952e1b79b780d126 100644 (file)
@@ -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),