]> git.codecow.com Git - libnemo.git/commitdiff
Continue building out passkey file which will likely just become wallet worker.
authorChris Duncan <chris@zoso.dev>
Tue, 29 Jul 2025 07:32:49 +0000 (00:32 -0700)
committerChris Duncan <chris@zoso.dev>
Tue, 29 Jul 2025 07:32:49 +0000 (00:32 -0700)
src/lib/safe/passkey.ts

index a886f44fdfff246b60802757fce1bddcccd0fe1d..196d6b4958b2f453e0d9f3669786945c0b0d8566 100644 (file)
 
 '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<ArrayBuffer> = crypto.getRandomValues(new Uint8Array(32))
+       static #type?: 'BIP-44' | 'BLAKE2b'
+       static #seed?: Uint8Array<ArrayBuffer>
+       static #mnemonic?: string
+       static #parentPort?: any
 
        static {
                NODE: this.#parentPort = parentPort
                const listener = async (message: MessageEvent<any>): Promise<void> => {
-                       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<NamedData<ArrayBuffer>> {
+               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<NamedData<boolean>> {
+               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<NamedData<string>> {
+               try {
+                       if (this.#seed == null) {
+                               throw new Error('Wallet is locked')
+                       }
+                       const result: NamedData<string> = {}
+                       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<NamedData<boolean>> {
+               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<NamedData<boolean>> {
+               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<NamedData<boolean>> {
+               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<ArrayBuffer>) {
+               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<CryptoKey> {
@@ -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<ArrayBuffer> = {
+                       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 = ''