]> git.codecow.com Git - libnemo.git/commitdiff
Continue developing secure wallet worker.
authorChris Duncan <chris@zoso.dev>
Thu, 31 Jul 2025 04:03:26 +0000 (21:03 -0700)
committerChris Duncan <chris@zoso.dev>
Thu, 31 Jul 2025 04:03:26 +0000 (21:03 -0700)
src/lib/account.ts
src/lib/bip39-mnemonic.ts
src/lib/database.ts [moved from src/lib/db.ts with 75% similarity]
src/lib/rolodex.ts
src/lib/safe/passkey.ts
src/lib/wallets/bip44-wallet.ts

index 5fea41c0b92672fed452695b72488d4bbb7452e4..3af15a988a78fdaca641e2a16d6a05f28a3f54c7 100644 (file)
@@ -8,7 +8,6 @@ import { base32, bytes, hex, utf8 } from './convert'
 import { NanoNaCl } from './nano-nacl'\r
 import { Rpc } from './rpc'\r
 import { Key, KeyPair, NamedData } from '#types'\r
-import { SafeWorker } from '#workers'\r
 \r
 /**\r
 * Represents a single Nano address and the associated public key. To include the\r
@@ -76,16 +75,6 @@ export class Account {
                this.#receivable = undefined\r
                this.#representative = undefined\r
                this.#weight = undefined\r
-               try {\r
-                       await SafeWorker.request<boolean>({\r
-                               method: 'destroy',\r
-                               store: 'Account',\r
-                               names: this.publicKey\r
-                       })\r
-               } catch (err) {\r
-                       console.error(err)\r
-                       throw new Error('failed to destroy account', { cause: err })\r
-               }\r
        }\r
 \r
        /**\r
index a8dab4150af7f6a39689b7b096386d43f10b6046..2acdccfd7ec633382866d2e50b17969e8debc999 100644 (file)
@@ -173,14 +173,14 @@ export class Bip39Mnemonic {
        * @returns {Promise<string>}  Promise for seed as hexadecimal string\r
        */\r
        async toBip39Seed (passphrase: string, format: 'hex'): Promise<string>\r
-       async toBip39Seed (passphrase: string, format?: 'hex'): Promise<string | Uint8Array<ArrayBuffer>> {\r
+       async toBip39Seed (passphrase: unknown, format?: 'hex'): Promise<string | Uint8Array<ArrayBuffer>> {\r
                if (this.phrase == null) {\r
                        throw new Error('BIP-39 mnemonic phrase not found')\r
                }\r
                if (this.#bip39Seed == null) {\r
-                       if (passphrase == null || typeof passphrase !== 'string') {\r
-                               passphrase = ''\r
-                       }\r
+                       const salt = (passphrase == null || typeof passphrase !== 'string')\r
+                               ? ''\r
+                               : passphrase\r
                        const keyData = utf8.toBytes(this.phrase)\r
                        const phraseKey = await globalThis.crypto.subtle.importKey('raw', keyData, 'PBKDF2', false, ['deriveBits', 'deriveKey'])\r
                        const derivedKeyType: HmacImportParams = {\r
@@ -189,11 +189,11 @@ export class Bip39Mnemonic {
                                length: 512\r
                        }\r
 \r
-                       passphrase = `mnemonic${passphrase.normalize('NFKD')}`\r
+                       passphrase = `mnemonic${salt.normalize('NFKD')}`\r
                        const algorithm: Pbkdf2Params = {\r
                                name: 'PBKDF2',\r
                                hash: 'SHA-512',\r
-                               salt: utf8.toBytes(passphrase),\r
+                               salt: utf8.toBytes(salt),\r
                                iterations: BIP39_ITERATIONS\r
                        }\r
                        const seedKey = await globalThis.crypto.subtle.deriveKey(algorithm, phraseKey, derivedKeyType, true, ['sign'])\r
similarity index 75%
rename from src/lib/db.ts
rename to src/lib/database.ts
index ac7a887ff078c2e488b2aa20ec36f52de5da9cea..f6b35780672840f8543af24332818e03217dab8a 100644 (file)
@@ -3,16 +3,24 @@
 
 'use strict'
 
-import { NamedData } from '#types'
+import { Data, NamedData } from '#types'
 
 /**
 * Encrypts and stores data in the browser using IndexedDB.
 */
-export class Safe {
+export class Database {
        static DB_NAME = 'libnemo'
        static DB_STORES = ['Wallet', 'Account', 'Rolodex']
        static #storage: IDBDatabase
 
+       /**
+       * Deletes a record from a datastore.
+       *
+       * @param {string} name - Index key of the record to delete
+       * @param {string} store - Datastore from which to delete the record
+       * @returns {Promise<boolean>} True if data was successfully removed, else false
+       */
+       static async delete (name: string, store: string): Promise<boolean>
        /**
        * Deletes records from a datastore.
        *
@@ -20,7 +28,9 @@ export class Safe {
        * @param {string} store - Datastore from which to delete records
        * @returns {Promise<boolean>} True if data was successfully removed, else false
        */
-       static async delete (names: string[], store: string): Promise<boolean> {
+       static async delete (names: string[], store: string): Promise<boolean>
+       static async delete (names: string | string[], store: string): Promise<boolean> {
+               if (!Array.isArray(names)) names = [names]
                this.#storage ??= await this.#open(this.DB_NAME)
                const transaction = this.#storage.transaction(store, 'readwrite')
                const db = transaction.objectStore(store)
@@ -43,6 +53,14 @@ export class Safe {
                })
        }
 
+       /**
+       * Gets a specific record from a datastore.
+       *
+       * @param {string[]} names - Index key of the record to get
+       * @param {string} store - Datastore from which to get the record
+       * @returns {Promise<NamedData>} Object of key-value pairs
+       */
+       static async get<T extends Data> (name: string, store: string): Promise<NamedData<T>>
        /**
        * Gets specific records from a datastore.
        *
@@ -50,14 +68,16 @@ export class Safe {
        * @param {string} store - Datastore from which to get records
        * @returns {Promise<NamedData>} Object of key-value pairs
        */
-       static async get (names: string[], store: string): Promise<NamedData> {
+       static async get<T extends Data> (names: string[], store: string): Promise<NamedData<T>>
+       static async get<T extends Data> (names: string | string[], store: string): Promise<NamedData<T>> {
+               if (!Array.isArray(names)) names = [names]
                this.#storage ??= await this.#open(this.DB_NAME)
                const transaction = this.#storage.transaction(store, 'readonly')
                const db = transaction.objectStore(store)
                return new Promise((resolve, reject) => {
                        const requests = names.map(name => db.get(name))
                        transaction.oncomplete = (event) => {
-                               const results: NamedData = {}
+                               const results: NamedData<T> = {}
                                for (const request of requests) {
                                        results[request.result.id] = request.error ?? request.result
                                }
@@ -75,7 +95,7 @@ export class Safe {
        * @param {string} store - Datastore from which to get records
        * @returns {Promise<NamedData>} Object of key-value pairs
        */
-       static async getAll (store: string): Promise<NamedData> {
+       static async getAll<T extends Data> (store: string): Promise<NamedData<T>> {
                this.#storage ??= await this.#open(this.DB_NAME)
                const transaction = this.#storage.transaction(store, 'readonly')
                const db = transaction.objectStore(store)
@@ -87,9 +107,9 @@ export class Safe {
                                } else if (request.result == null) {
                                        reject('getAll request failed')
                                } else {
-                                       const results: NamedData = {}
+                                       const results: NamedData<T> = {}
                                        for (const result of request.result) {
-                                               results[result.id] = request.error ?? request.result
+                                               results[result.id] = request.error ?? result[result.id]
                                        }
                                        resolve(results)
                                }
@@ -107,7 +127,7 @@ export class Safe {
        * @param {string} store - Datastore in which to put records
        * @returns {Promise<(IDBValidKey | DOMException)[]>} Index keys of the records inserted
        */
-       static async put (data: NamedData, store: string): Promise<(IDBValidKey | DOMException)[]> {
+       static async put<T extends Data> (data: NamedData<T>, store: string): Promise<(IDBValidKey | DOMException)[]> {
                this.#storage ??= await this.#open(this.DB_NAME)
                const transaction = this.#storage.transaction(store, 'readwrite')
                const db = transaction.objectStore(store)
index 83b77f2cbd577757207ed3e064e755a1d7e29f64..06a434d7d0b80ef39daae9a5448ebcacf843707b 100644 (file)
@@ -3,15 +3,16 @@
 
 import { Account } from './account'
 import { bytes, utf8 } from './convert'
+import { Database } from './database'
 import { verify } from './tools'
 import { NamedData } from '#types'
-import { SafeWorker } from '#workers'
 
 /**
 * Represents a basic address book of Nano accounts. Multiple addresses can be
 * saved under one nickname.
 */
 export class Rolodex {
+       static #DB_NAME = 'Rolodex'
        /**
        * Adds an address to the rolodex under a specific nickname.
        *
@@ -52,22 +53,22 @@ export class Rolodex {
                                return true
                        }
                        const data: NamedData = {
-                               method: 'store',
-                               store: 'Rolodex',
-                               password: utf8.toBuffer(''),
                                [address]: utf8.toBuffer(name)
                        }
                        if (existingName != null) {
-                               const filteredAddresses = (await this.getAddresses(existingName)).filter(a => a !== address).sort()
-                               data[existingName] = utf8.toBuffer(JSON.stringify(filteredAddresses))
+                               const existingAddresses = await this.getAddresses(existingName)
+                               data[existingName] = existingAddresses.filter(a => a !== address).sort()
                        }
                        const existingAddresses = await this.getAddresses(name)
                        existingAddresses.push(account.address)
-                       data[name] = utf8.toBuffer(JSON.stringify(existingAddresses))
-                       const { result } = await SafeWorker.request<boolean>(data)
-                       return result
+                       data[name] = existingAddresses
+                       const results = await Database.put(data, this.#DB_NAME)
+                       if (results.length !== Object.keys(data).length) {
+                               throw new Error('Unexpected results from adding address', { cause: results })
+                       }
+                       return true
                } catch (err) {
-                       throw new Error('failed to add address', { cause: err })
+                       throw new Error('Failed to add address', { cause: err })
                }
        }
 
@@ -83,21 +84,15 @@ export class Rolodex {
                        return false
                }
                const addresses = (await this.getAddresses(name)).filter(a => a !== address).sort()
-               const { result: isUpdated } = await SafeWorker.request<boolean>({
-                       method: 'store',
-                       store: 'Rolodex',
-                       password: utf8.toBuffer(''),
-                       [name]: utf8.toBuffer(JSON.stringify(addresses))
-               })
+               const data = {
+                       [name]: addresses
+               }
+               const isUpdated = await Database.put(data, this.#DB_NAME)
                if (!isUpdated) {
                        throw new Error('failed to remove address from existing name')
                }
-               const { result } = await SafeWorker.request<boolean>({
-                       method: 'destroy',
-                       store: 'Rolodex',
-                       names: address
-               })
-               return result
+               const isDeleted = await Database.delete(address, this.#DB_NAME)
+               return isDeleted
        }
 
        /**
@@ -107,18 +102,9 @@ export class Rolodex {
        * @returns {Promise<boolean>} Promise for true if name and related addresses successfully removed, else false
        */
        static async deleteName (name: string): Promise<boolean> {
-               const data: NamedData = {
-                       method: 'destroy',
-                       store: 'Rolodex'
-               }
-               const names: string[] = [name]
-               const addresses = await this.getAddresses(name)
-               for (const address of addresses) {
-                       names.push(address)
-               }
-               data.names = names
-               const { result } = await SafeWorker.request<boolean>(data)
-               return result
+               const data = await this.getAddresses(name)
+               data.push(name)
+               return await Database.delete(data, this.#DB_NAME)
        }
 
        /**
@@ -129,15 +115,10 @@ export class Rolodex {
        */
        static async getAddresses (name: string): Promise<string[]> {
                try {
-                       const response = await SafeWorker.request<ArrayBuffer>({
-                               method: 'fetch',
-                               names: name,
-                               store: 'Rolodex',
-                               password: utf8.toBuffer('')
-                       })
+                       const response = await Database.get<string[]>(name, this.#DB_NAME)
                        const addresses = response[name]
                        return addresses
-                               ? JSON.parse(bytes.toUtf8(new Uint8Array(addresses))).sort()
+                               ? addresses.sort()
                                : []
                } catch (err) {
                        console.error(err)
@@ -152,11 +133,7 @@ export class Rolodex {
        */
        static async getAllNames (): Promise<string[]> {
                try {
-                       const response = await SafeWorker.request<ArrayBuffer>({
-                               method: 'export',
-                               store: 'Rolodex',
-                               password: utf8.toBuffer('')
-                       })
+                       const response = await Database.getAll(this.#DB_NAME)
                        return Object.keys(response).filter(v => v.slice(0, 5) !== 'nano_')
                } catch (err) {
                        console.error(err)
@@ -172,16 +149,9 @@ export class Rolodex {
        */
        static async getName (address: string): Promise<string | null> {
                try {
-                       const response = await SafeWorker.request<ArrayBuffer>({
-                               method: 'fetch',
-                               names: address,
-                               store: 'Rolodex',
-                               password: utf8.toBuffer('')
-                       })
+                       const response = await Database.get<string>(address, this.#DB_NAME)
                        const name = response[address]
                        return name
-                               ? bytes.toUtf8(new Uint8Array(name))
-                               : null
                } catch (err) {
                        console.error(err)
                        return null
index 196d6b4958b2f453e0d9f3669786945c0b0d8566..6bcb9813d9d7e55496171cdf1f34a69c7abff9da 100644 (file)
@@ -3,25 +3,29 @@
 
 'use strict'
 
-import { bytes } from '#src/lib/convert.js'
+import { Bip39Mnemonic } from '#src/lib/bip39-mnemonic.js'
+import { bytes, dec, hex, utf8 } from '#src/lib/convert.js'
 import { NamedData } from '#src/types.js'
 import { parentPort } from 'node:worker_threads'
 import { Bip39Words } from '../bip39-wordlist'
+import { NanoNaCl } from '../nano-nacl'
+import { Bip44Ckd } from './bip44-ckd'
 
 /**
 * Cross-platform worker for managing wallet secrets.
 */
 export class Passkey {
+       static #locked: boolean = true
        static #salt: Uint8Array<ArrayBuffer> = crypto.getRandomValues(new Uint8Array(32))
        static #type?: 'BIP-44' | 'BLAKE2b'
        static #seed?: Uint8Array<ArrayBuffer>
-       static #mnemonic?: string
+       static #mnemonic?: Bip39Mnemonic
        static #parentPort?: any
 
        static {
                NODE: this.#parentPort = parentPort
                const listener = async (message: MessageEvent<any>): Promise<void> => {
-                       const { action, type, password, iv, salt, seed, mnemonic, index, data } = this.#extractData(message.data)
+                       const { action, type, password, iv, salt, seed, mnemonic, index, encrypted, data } = this.#extractData(message.data)
                        try {
                                let result: NamedData
                                switch (action) {
@@ -34,11 +38,11 @@ export class Passkey {
                                                break
                                        }
                                        case 'derive': {
-                                               result = await this.derive(index)
+                                               result = await this.derive(type, index)
                                                break
                                        }
-                                       case 'backup': {
-                                               result = await this.backup()
+                                       case 'import': {
+                                               result = await this.import(type, password, seed, mnemonic)
                                                break
                                        }
                                        case 'lock': {
@@ -46,10 +50,15 @@ export class Passkey {
                                                break
                                        }
                                        case 'sign': {
-                                               result = await this sign(data)
+                                               result = await this.sign(index, data)
+                                               break
                                        }
                                        case 'unlock': {
-                                               result = await this.unlock(password, iv, salt)
+                                               result = await this.unlock(password, iv, salt, encrypted)
+                                               break
+                                       }
+                                       case 'verify': {
+                                               result = await this.verify(seed, mnemonic)
                                                break
                                        }
                                        default: {
@@ -77,113 +86,131 @@ export class Passkey {
                NODE: this.#parentPort?.on('message', listener)
        }
 
-       static async create (type: 'BIP-44' | 'BLAKE2b', password: ArrayBuffer, salt?: ArrayBuffer): Promise<NamedData<ArrayBuffer>> {
+       static async create (type?: 'BIP-44' | 'BLAKE2b', password?: ArrayBuffer, salt?: ArrayBuffer): Promise<boolean> {
                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 }
+                       const mnemonic = await Bip39Mnemonic.fromEntropy(crypto.getRandomValues(new Uint8Array(32)))
+                       return await this.import(type, password, undefined, mnemonic.phrase, salt)
                } catch (err) {
-                       throw new Error('Failed to import wallet', { cause: err })
+                       throw new Error('Failed to unlock wallet', { cause: err })
                }
        }
 
-       static async derive (password: ArrayBuffer, seed: ArrayBuffer, mnemonic?: string, salt?: ArrayBuffer): Promise<NamedData<boolean>> {
+       static async derive (type: 'BIP-44' | 'BLAKE2b', index: number): Promise<NamedData<ArrayBuffer>> {
                try {
-                       const key = await this.#createAesKey('encrypt', password, this.#salt.buffer)
-                       return { isImported: true }
+                       if (this.#seed == null) {
+                               throw new Error('Wallet is locked')
+                       }
+                       const prv = await Bip44Ckd.nanoCKD(this.#seed.buffer, index)
+                       const pub = await NanoNaCl.convert(new Uint8Array(prv))
+                       return { publicKey: pub.buffer }
                } catch (err) {
-                       throw new Error('Failed to import wallet', { cause: err })
+                       throw new Error('Failed to derive account', { 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>> {
+       static async import (type?: 'BIP-44' | 'BLAKE2b', password?: ArrayBuffer, seed?: ArrayBuffer, mnemonic?: string, salt?: ArrayBuffer): Promise<boolean> {
                try {
-                       if (this.#seed == null) {
-                               throw new Error('Wallet is locked')
+                       if (type == null) {
+                               throw new TypeError('Wallet type is required')
                        }
-                       const result: NamedData<string> = {}
-                       if (this.#mnemonic != null) {
-                               result.mnemonic = this.#mnemonic
+                       if (password == null) {
+                               throw new TypeError('Wallet password is required')
                        }
-                       return {
-                               ...result,
-                               seed: bytes.toHex(this.#seed)
+                       if (seed == null && mnemonic == null) {
+                               throw new TypeError('Seed or mnemonic is required')
+                       }
+                       if (mnemonic == null && salt != null) {
+                               throw new TypeError('Mnemonic is required to use salt')
+                       }
+                       this.#type = type
+                       const key = await this.#createAesKey('decrypt', password, this.#salt.buffer)
+                       const encrypted = await this.#encryptWallet(key, this.#salt.buffer)
+                       if (!(encrypted.seed instanceof Uint8Array)) {
+                               throw new TypeError('Invalid seed')
+                       }
+                       if (encrypted.mnemonic != null && typeof encrypted.mnemonic !== 'string') {
+                               throw new TypeError('Invalid seed')
                        }
+                       this.#seed = new Uint8Array(encrypted.seed)
+                       this.#mnemonic = await Bip39Mnemonic.fromPhrase(encrypted.mnemonic)
+                       this.#locked = false
+                       return this.#seed != null
                } catch (err) {
-                       throw new Error('Failed to export wallet', { cause: err })
+                       throw new Error('Failed to import 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 lock (): Promise<void> {
+               this.#mnemonic = undefined
+               this.#seed = undefined
+               this.#locked = true
        }
 
-       static async sign (): Promise<NamedData<boolean>> {
+       /**
+       * Derives the account private key at a specified index, signs the input data,
+       * and returns a signature. The wallet must be unlocked prior to verification.
+       */
+       static async sign (index: number, data: ArrayBuffer): Promise<NamedData<ArrayBuffer>> {
                try {
-                       this.#mnemonic = undefined
-                       this.#seed = undefined
-                       return { isLocked: this.#mnemonic === undefined && this.#seed === undefined }
+                       if (this.#locked) {
+                               throw new Error('Wallet is locked')
+                       }
+                       if (this.#seed == null) {
+                               throw new Error('Wallet seed not found')
+                       }
+                       const prv = await Bip44Ckd.nanoCKD(this.#seed.buffer, index)
+                       const sig = await NanoNaCl.detached(new Uint8Array(data), new Uint8Array(prv))
+                       return { signature: sig.buffer }
                } catch (err) {
-                       throw new Error('Failed to lock wallet', { cause: err })
+                       throw new Error('Failed to sign message', { cause: err })
                }
        }
 
-       static async unlock (encrypted: ArrayBuffer, password: ArrayBuffer, iv: ArrayBuffer, salt: ArrayBuffer): Promise<NamedData<boolean>> {
+       /**
+       * Decrypts the input and sets the seed and, if it is included, the mnemonic.
+       */
+       static async unlock (encrypted: ArrayBuffer, password: ArrayBuffer, iv: ArrayBuffer, salt: ArrayBuffer): Promise<boolean> {
                try {
                        const key = await this.#createAesKey('decrypt', password, salt)
-                       return { isUnlocked: true }
+                       const { seed, mnemonic } = await this.#decryptWallet(key, iv, encrypted)
+                       if (!(seed instanceof Uint8Array)) {
+                               throw new TypeError('Invalid seed')
+                       }
+                       if (mnemonic != null && typeof mnemonic !== 'string') {
+                               throw new TypeError('Invalid seed')
+                       }
+                       this.#seed = new Uint8Array(seed)
+                       this.#mnemonic = await Bip39Mnemonic.fromPhrase(mnemonic)
+                       this.#locked = false
+                       return this.#seed != null
                } 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')
-               }
-               if (typeof data !== 'object') {
-                       throw new Error('Invalid data')
-               }
-               const dataObject = data as { [key: string]: unknown }
-               if (!('password' in dataObject) || !(dataObject.password instanceof ArrayBuffer)) {
-                       throw new TypeError('Password must be ArrayBuffer')
-               }
-               const password: ArrayBuffer = dataObject.password
-               if (!('action' in dataObject)) {
-                       throw new TypeError('Wallet action is required')
-               }
-               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 action = dataObject.action
-               if (action === 'unlock' && !(dataObject.salt instanceof ArrayBuffer)) {
-                       throw new TypeError('Salt required for decryption key to unlock')
+       /**
+       * Checks the seed and, if it exists, the mnemonic against input. The wallet
+       * must be unlocked prior to verification.
+       */
+       static async verify (seed: ArrayBuffer, mnemonic: ArrayBuffer): Promise<NamedData<string>> {
+               try {
+                       if (this.#locked) {
+                               throw new Error('Wallet is locked')
+                       }
+                       if (this.#seed == null) {
+                               throw new Error('Wallet seed not found')
+                       }
+                       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 })
                }
-               const salt: ArrayBuffer = action === 'unlock' && dataObject.salt instanceof ArrayBuffer
-                       ? dataObject.salt
-                       : crypto.getRandomValues(new Uint8Array(32)).buffer
-               return { action, type, password, iv, seed, mnemonic, salt, index, data }
        }
 
        static async #bip39Mnemonic (entropy: Uint8Array<ArrayBuffer>) {
@@ -226,19 +253,83 @@ 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
+       static async #decryptWallet (key: CryptoKey, iv: ArrayBuffer, encrypted: ArrayBuffer): Promise<NamedData<string | ArrayBuffer>> {
+               const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encrypted)
+               const decoded = JSON.parse(bytes.toUtf8(new Uint8Array(decrypted)))
+               const seed = hex.toBuffer(decoded.seed)
+               const mnemonic = decoded.mnemonic
+               return { seed, mnemonic }
+       }
+
+       static async #encryptWallet (key: CryptoKey, salt: ArrayBuffer): Promise<NamedData<ArrayBuffer>> {
+               if (this.#seed == null) {
+                       throw new Error('Wallet seed not found')
                }
+               const data: NamedData<string> = {
+                       seed: bytes.toHex(this.#seed)
+               }
+               if (this.#mnemonic?.phrase != null) data.mnemonic = this.#mnemonic.phrase
                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
+               const encoded = utf8.toBytes(JSON.stringify(data))
+               const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded)
+               return { iv, salt, encrypted }
+       }
+
+       static #extractData (data: unknown) {
+               if (data == null) {
+                       throw new TypeError('Worker received no data')
+               }
+               if (typeof data !== 'object') {
+                       throw new Error('Invalid data')
+               }
+               const dataObject = data as { [key: string]: unknown }
+               if (!('action' in dataObject)) {
+                       throw new TypeError('Wallet action is required')
+               }
+               if (dataObject.action !== 'STOP'
+                       && dataObject.action !== 'create'
+                       && dataObject.action !== 'derive'
+                       && dataObject.action !== 'import'
+                       && dataObject.action !== 'lock'
+                       && dataObject.action !== 'sign'
+                       && dataObject.action !== 'unlock'
+                       && dataObject.action !== 'verify') {
+                       throw new TypeError('Invalid wallet action')
+               }
+               const action = dataObject.action
+
+               if (dataObject.type !== undefined && dataObject.type !== 'BIP-44' && dataObject.type !== 'BLAKE2b') {
+                       throw new TypeError('Invalid wallet type', { cause: dataObject.type })
+               }
+               const type: 'BIP-44' | 'BLAKE2b' | undefined = dataObject.type
+
+               if (!('password' in dataObject) || !(dataObject.password instanceof ArrayBuffer)) {
+                       throw new TypeError('Password must be ArrayBuffer')
+               }
+               const password: ArrayBuffer = dataObject.password
+
+               if (action === 'unlock' && !(dataObject.iv instanceof ArrayBuffer)) {
+                       throw new TypeError('Initialization vector required to unlock wallet')
+               }
+               const iv: ArrayBuffer = action === 'unlock' && dataObject.iv instanceof ArrayBuffer
+                       ? dataObject.iv
+                       : crypto.getRandomValues(new Uint8Array(32)).buffer
+
+               if (action === 'unlock' && !(dataObject.salt instanceof ArrayBuffer)) {
+                       throw new TypeError('Salt required to unlock wallet')
+               }
+               const salt: ArrayBuffer = action === 'unlock' && dataObject.salt instanceof ArrayBuffer
+                       ? dataObject.salt
+                       : crypto.getRandomValues(new Uint8Array(32)).buffer
+
+               if (action === 'import' && !(dataObject.seed instanceof ArrayBuffer)) {
+                       throw new TypeError('Seed required to import wallet')
                }
+               const seed: ArrayBuffer = action === 'import' && dataObject.seed instanceof ArrayBuffer
+                       ? dataObject.seed
+                       : crypto.getRandomValues(new Uint8Array(32)).buffer
+
+               return { action, type, password, iv, seed, mnemonic, salt, encrypted, indexes, data }
        }
 }
 
index 3849232224db7aa995d57a25dccdd2896fbb1c4b..242777f717795d1648a23bfb5d6f72b8b95fbfdf 100644 (file)
@@ -7,6 +7,7 @@ import { SEED_LENGTH_BIP44 } from '#src/lib/constants.js'
 import { hex } from '#src/lib/convert.js'\r
 import { Entropy } from '#src/lib/entropy.js'\r
 import { Key, KeyPair } from '#types'\r
+import { PasskeyWorker } from '../safe'\r
 \r
 /**\r
 * Hierarchical deterministic (HD) wallet created by using a source of entropy to\r
@@ -179,7 +180,9 @@ export class Bip44Wallet extends Wallet {
                if (this.isLocked) {\r
                        throw new Error('wallet must be unlocked to derive accounts')\r
                }\r
-               const results = await Bip44CkdWorker.request({\r
+               const results = await PasskeyWorker.request({\r
+                       action: 'derive',\r
+                       type: 'BIP-44',\r
                        indexes,\r
                        seed: hex.toBuffer(this.seed)\r
                })\r