From: Chris Duncan Date: Tue, 29 Jul 2025 07:32:49 +0000 (-0700) Subject: Continue building out passkey file which will likely just become wallet worker. X-Git-Tag: v0.10.5~47^2~43 X-Git-Url: https://git.codecow.com/?a=commitdiff_plain;h=9f95b7015ac50b9fc3ea073bda19c5e932df1d42;p=libnemo.git Continue building out passkey file which will likely just become wallet worker. --- diff --git a/src/lib/safe/passkey.ts b/src/lib/safe/passkey.ts index a886f44..196d6b4 100644 --- a/src/lib/safe/passkey.ts +++ b/src/lib/safe/passkey.ts @@ -3,41 +3,154 @@ 'use strict' +import { bytes } from '#src/lib/convert.js' +import { NamedData } from '#src/types.js' import { parentPort } from 'node:worker_threads' +import { Bip39Words } from '../bip39-wordlist' /** -* Converts a user password to a `CryptoKey`. +* Cross-platform worker for managing wallet secrets. */ export class Passkey { - static #parentPort: any + static #salt: Uint8Array = crypto.getRandomValues(new Uint8Array(32)) + static #type?: 'BIP-44' | 'BLAKE2b' + static #seed?: Uint8Array + static #mnemonic?: string + static #parentPort?: any static { NODE: this.#parentPort = parentPort const listener = async (message: MessageEvent): Promise => { - const { data } = message - if (data === 'STOP') { - BROWSER: close() - NODE: process.exit() - } else { - try { - const { purpose, password, salt } = this.#extractData(data) - const key = await this.#createAesKey(purpose, password, salt) - new Uint8Array(password).fill(0).buffer.transfer() - //@ts-expect-error - BROWSER: postMessage({ key, salt }, [key, salt]) - //@ts-expect-error - NODE: parentPort?.postMessage({ key, salt }, [key, salt]) - } catch (err) { - console.error(err) - BROWSER: postMessage({ error: 'Failed to derive key from password', cause: err }) - NODE: parentPort?.postMessage({ error: 'Failed to derive key from password', cause: err }) + const { action, type, password, iv, salt, seed, mnemonic, index, data } = this.#extractData(message.data) + try { + let result: NamedData + switch (action) { + case 'STOP': { + BROWSER: close() + NODE: process.exit() + } + case 'create': { + result = await this.create(type, password) + break + } + case 'derive': { + result = await this.derive(index) + break + } + case 'backup': { + result = await this.backup() + break + } + case 'lock': { + result = await this.lock() + break + } + case 'sign': { + result = await this sign(data) + } + case 'unlock': { + result = await this.unlock(password, iv, salt) + break + } + default: { + throw new Error(`Unknown wallet action '${action}'`) + } } + const transfer = [] + for (const k of Object.keys(result)) { + if (result[k] instanceof ArrayBuffer || result[k] instanceof CryptoKey) { + transfer.push(result[k]) + } + } + //@ts-expect-error + BROWSER: postMessage(result, transfer) + //@ts-expect-error + NODE: parentPort?.postMessage(result, transfer) + } catch (err) { + BROWSER: postMessage({ error: 'Failed to derive key from password', cause: err }) + NODE: parentPort?.postMessage({ error: 'Failed to derive key from password', cause: err }) + } finally { + new Uint8Array(password).fill(0).buffer.transfer() } } BROWSER: addEventListener('message', listener) NODE: this.#parentPort?.on('message', listener) } + static async create (type: 'BIP-44' | 'BLAKE2b', password: ArrayBuffer, salt?: ArrayBuffer): Promise> { + try { + const key = await this.#createAesKey('encrypt', password, this.#salt.buffer) + const iv = crypto.getRandomValues(new Uint8Array(32)) + const entropy = crypto.getRandomValues(new Uint8Array(32)) + const m = await this.#bip39Mnemonic(entropy) + const s = await m.toBip39Seed(salt) + this.#type = type + return { iv, salt: this.#salt.buffer } + } catch (err) { + throw new Error('Failed to import wallet', { cause: err }) + } + } + + static async derive (password: ArrayBuffer, seed: ArrayBuffer, mnemonic?: string, salt?: ArrayBuffer): Promise> { + try { + const key = await this.#createAesKey('encrypt', password, this.#salt.buffer) + return { isImported: true } + } catch (err) { + throw new Error('Failed to import wallet', { cause: err }) + } + } + + /** + * Returns the seed and, if it exists, the mnemonic. The wallet must be + * unlocked prior to backup. + */ + static async backup (): Promise> { + try { + if (this.#seed == null) { + throw new Error('Wallet is locked') + } + const result: NamedData = {} + if (this.#mnemonic != null) { + result.mnemonic = this.#mnemonic + } + return { + ...result, + seed: bytes.toHex(this.#seed) + } + } catch (err) { + throw new Error('Failed to export wallet', { cause: err }) + } + } + + static async lock (): Promise> { + try { + this.#mnemonic = undefined + this.#seed = undefined + return { isLocked: this.#mnemonic === undefined && this.#seed === undefined } + } catch (err) { + throw new Error('Failed to lock wallet', { cause: err }) + } + } + + static async sign (): Promise> { + try { + this.#mnemonic = undefined + this.#seed = undefined + return { isLocked: this.#mnemonic === undefined && this.#seed === undefined } + } catch (err) { + throw new Error('Failed to lock wallet', { cause: err }) + } + } + + static async unlock (encrypted: ArrayBuffer, password: ArrayBuffer, iv: ArrayBuffer, salt: ArrayBuffer): Promise> { + try { + const key = await this.#createAesKey('decrypt', password, salt) + return { isUnlocked: true } + } catch (err) { + throw new Error('Failed to unlock wallet', { cause: err }) + } + } + static #extractData (data: unknown) { if (data == null) { throw new TypeError('Worker received no data') @@ -50,20 +163,52 @@ export class Passkey { throw new TypeError('Password must be ArrayBuffer') } const password: ArrayBuffer = dataObject.password - if (!('purpose' in dataObject)) { - throw new TypeError('Key purpose is required') + if (!('action' in dataObject)) { + throw new TypeError('Wallet action is required') } - if (dataObject.purpose !== 'encrypt' && dataObject.purpose !== 'decrypt') { - throw new TypeError('Invalid key purpose') + if (dataObject.action !== 'STOP' + && dataObject.action !== 'create' + && dataObject.action !== 'derive' + && dataObject.action !== 'export' + && dataObject.action !== 'import' + && dataObject.action !== 'lock' + && dataObject.action !== 'sign' + && dataObject.action !== 'unlock') { + throw new TypeError('Invalid wallet action') } - const purpose: 'encrypt' | 'decrypt' = dataObject.purpose - if (purpose === 'decrypt' && !(dataObject.salt instanceof ArrayBuffer)) { - throw new TypeError('Salt required for decryption key') + const action = dataObject.action + if (action === 'unlock' && !(dataObject.salt instanceof ArrayBuffer)) { + throw new TypeError('Salt required for decryption key to unlock') } - const salt: ArrayBuffer = purpose === 'decrypt' && dataObject.salt instanceof ArrayBuffer + const salt: ArrayBuffer = action === 'unlock' && dataObject.salt instanceof ArrayBuffer ? dataObject.salt : crypto.getRandomValues(new Uint8Array(32)).buffer - return { purpose, password, salt } + return { action, type, password, iv, seed, mnemonic, salt, index, data } + } + + static async #bip39Mnemonic (entropy: Uint8Array) { + if (![16, 20, 24, 28, 32].includes(entropy.byteLength)) { + throw new RangeError('Invalid entropy byte length for BIP-39') + } + const phraseLength = 0.75 * entropy.byteLength + const sha256sum = new Uint8Array(await crypto.subtle.digest('SHA-256', entropy))[0] + const checksumBitLength = entropy.byteLength / 4 + const checksum = BigInt(sha256sum & ((1 << checksumBitLength) - 1)) + + let e = 0n + for (let i = 0; i < entropy.byteLength; i++) { + e = e << 8n | BigInt(entropy[i]) + } + + let concatenation = (e << BigInt(checksumBitLength)) | checksum + const words: string[] = [] + for (let i = 0; i < phraseLength; i++) { + const wordBits = concatenation & 2047n + const wordIndex = Number(wordBits) + words.push(Bip39Words[wordIndex]) + concatenation >>= 11n + } + return words.join(' ').normalize('NFKD') } static async #createAesKey (purpose: 'encrypt' | 'decrypt', password: ArrayBuffer, salt: ArrayBuffer): Promise { @@ -80,6 +225,21 @@ export class Passkey { } return await crypto.subtle.deriveKey(derivationAlgorithm, derivationKey, derivedKeyType, false, [purpose]) } + + static async #encryptWallet (salt: ArrayBuffer) { + const data: NamedData = { + mnemonic: this.#mnemonic, + seed: this.#seed + } + const iv = crypto.getRandomValues(new Uint8Array(32)).buffer + const encrypted = await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv }, encryptionKey, data[label]) + const record = { + iv: iv, + salt: salt, + label, + encrypted + } + } } let importWorkerThreads = ''