+++ /dev/null
-//! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>
-//! SPDX-License-Identifier: GPL-3.0-or-later
-
-'use strict'
-
-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?: 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, encrypted, 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(type, index)
- break
- }
- case 'import': {
- result = await this.import(type, password, seed, mnemonic)
- break
- }
- case 'lock': {
- result = await this.lock()
- break
- }
- case 'sign': {
- result = await this.sign(index, data)
- break
- }
- case 'unlock': {
- result = await this.unlock(password, iv, salt, encrypted)
- break
- }
- case 'verify': {
- result = await this.verify(seed, mnemonic)
- 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<boolean> {
- try {
- 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 unlock wallet', { cause: err })
- }
- }
-
- static async derive (type: 'BIP-44' | 'BLAKE2b', index: number): Promise<NamedData<ArrayBuffer>> {
- try {
- 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 derive account', { cause: err })
- }
- }
-
- static async import (type?: 'BIP-44' | 'BLAKE2b', password?: ArrayBuffer, seed?: ArrayBuffer, mnemonic?: string, salt?: ArrayBuffer): Promise<boolean> {
- try {
- if (type == null) {
- throw new TypeError('Wallet type is required')
- }
- if (password == null) {
- throw new TypeError('Wallet password is required')
- }
- 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 import wallet', { cause: err })
- }
- }
-
- static async lock (): Promise<void> {
- this.#mnemonic = undefined
- this.#seed = undefined
- this.#locked = true
- }
-
- /**
- * 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 {
- 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 sign message', { cause: err })
- }
- }
-
- /**
- * 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)
- 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 })
- }
- }
-
- /**
- * 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 })
- }
- }
-
- 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> {
- const derivationKey = await crypto.subtle.importKey('raw', password, 'PBKDF2', false, ['deriveBits', 'deriveKey'])
- const derivationAlgorithm: Pbkdf2Params = {
- name: 'PBKDF2',
- hash: 'SHA-512',
- iterations: 210000,
- salt
- }
- const derivedKeyType: AesKeyGenParams = {
- name: 'AES-GCM',
- length: 256
- }
- return await crypto.subtle.deriveKey(derivationAlgorithm, derivationKey, derivedKeyType, false, [purpose])
- }
-
- 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 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 }
- }
-}
-
-let importWorkerThreads = ''
-NODE: importWorkerThreads = `import { parentPort } from 'node:worker_threads'`
-export default `
- ${importWorkerThreads}
- const Passkey = ${Passkey}
-`
'use strict'
+import { parentPort } from 'node:worker_threads'
+import { Bip39Words } from '../bip39-wordlist'
import { Bip44Ckd } from './bip44-ckd'
-import { Blake2b } from './blake2b'
-import { WorkerInterface } from './worker-interface'
-import { PBKDF2_ITERATIONS } from '#src/lib/constants.js'
-import { default as Convert, bytes } from '#src/lib/convert.js'
-import { Entropy } from '#src/lib/entropy.js'
-import { NamedData, SafeRecord } from '#types'
+import { Blake2bCkd } from './blake2b-ckd'
+import { NanoNaCl } from '../nano-nacl'
+import { Bip39Mnemonic } from '#src/lib/bip39-mnemonic.js'
+import { bytes, dec, hex, utf8 } from '#src/lib/convert.js'
+import { NamedData } from '#src/types.js'
/**
-* Encrypts and stores data in the browser using IndexedDB.
+* Cross-platform worker for managing wallet secrets.
*/
-export class Safe extends WorkerInterface {
- static DB_NAME = 'libnemo'
- static DB_STORES = ['Wallet', 'Account', 'Rolodex']
- static ERR_MSG = 'Failed to store item in Safe'
- static #storage: IDBDatabase
-
+export class Safe {
+ static #locked: boolean = true
+ static #type?: 'BIP-44' | 'BLAKE2b'
+ static #seed?: ArrayBuffer
+ static #mnemonic?: Bip39Mnemonic
+ static #parentPort?: any
static {
- this.listen()
+ NODE: this.#parentPort = parentPort
}
- static async work (data: NamedData | unknown): Promise<NamedData<boolean | ArrayBuffer>> {
- if (data == null) {
- throw new TypeError('Worker received no data')
- }
- if (typeof data !== 'object') {
- throw new TypeError('Invalid data')
- }
- let { method, names, store, password, ...buffers } = data as { [key: string]: unknown }
- if (typeof method !== 'string') {
- throw new TypeError('Invalid method')
- }
- if (typeof names === 'string') names = [names]
- function validateNames (names: unknown): asserts names is string[] {
- if (names !== undefined && (!Array.isArray(names) || names.some(n => typeof n !== 'string'))) {
- throw new TypeError('Invalid name')
- }
- }
- validateNames(names)
- if (typeof store !== 'string') {
- throw new TypeError('Invalid store')
- }
- if (password != null && !(password instanceof ArrayBuffer)) {
- throw new TypeError('Invalid password')
- }
- this.#storage = await this.#open(this.DB_NAME)
- try {
- switch (method) {
- case 'store': {
- return { result: await this.store(buffers, store, password) }
- }
- case 'fetch': {
- return await this.fetch(names, store, password)
- }
- case 'export': {
- return await this.export(store, password)
- }
- case 'destroy': {
- return { result: await this.destroy(names, store) }
+ static {
+ NODE: this.#parentPort = parentPort
+ const listener = async (message: MessageEvent<any>): Promise<void> => {
+ const {
+ action,
+ type,
+ key,
+ keySalt,
+ seed,
+ mnemonicPhrase,
+ mnemonicSalt,
+ index,
+ encrypted,
+ data
+ } = await this.#extractData(message.data)
+ try {
+ let result: NamedData
+ switch (action) {
+ case 'STOP': {
+ BROWSER: close()
+ NODE: process.exit()
+ }
+ case 'create': {
+ result = await this.create(type, key)
+ break
+ }
+ case 'derive': {
+ result = await this.derive(index)
+ break
+ }
+ case 'import': {
+ result = await this.import(type, key, mnemonicPhrase ?? seed, mnemonicSalt)
+ break
+ }
+ case 'lock': {
+ result = await this.lock()
+ break
+ }
+ case 'sign': {
+ result = await this.sign(index, data)
+ break
+ }
+ case 'unlock': {
+ result = await this.unlock(key, keySalt, encrypted)
+ break
+ }
+ case 'verify': {
+ result = await this.verify(seed, mnemonicPhrase)
+ break
+ }
+ default: {
+ throw new Error(`Unknown wallet action '${action}'`)
+ }
}
- default: {
- throw new Error(`unknown Safe method ${method}`)
+ 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 })
}
- } catch (err) {
- console.error(err)
- throw new Error('Safe error', { cause: err })
}
+ BROWSER: addEventListener('message', listener)
+ NODE: this.#parentPort?.on('message', listener)
}
/**
- * Removes data from the Safe without decrypting.
+ * Generates a new mnemonic and seed and then returns the initialization vector
+ * vector, salt, and encrypted data representing the wallet in a locked state.
*/
- static async destroy (names: string[], store: string): Promise<boolean> {
+ static async create (type?: 'BIP-44' | 'BLAKE2b', key?: CryptoKey, mnemonicSalt?: string): Promise<NamedData<ArrayBuffer>> {
try {
- return await this.#delete(names, store)
+ const entropy = crypto.getRandomValues(new Uint8Array(32))
+ const mnemonicPhrase = (await Bip39Mnemonic.fromEntropy(entropy)).phrase
+ return await this.import(type, key, mnemonicPhrase, mnemonicSalt)
} catch (err) {
- console.error(err)
- throw new Error(this.ERR_MSG)
+ throw new Error('Failed to unlock wallet', { cause: err })
}
}
/**
- * Encrypts data with a password byte array and stores it in the Safe.
+ * Derives the private and public keys of a child account from the current
+ * wallet seed at a specified index and then returns the public key. The wallet
+ * must be unlocked prior to derivation.
*/
- static async store (data: NamedData | unknown, store: string | unknown, password: ArrayBuffer | unknown): Promise<boolean> {
- this.#isDataValid(data)
- if (typeof store !== 'string' || store === '') {
- throw new Error('Invalid database store name')
- }
- if (!(password instanceof ArrayBuffer)) {
- throw new Error('Invalid password')
+ static async derive (index?: number): Promise<NamedData<ArrayBuffer>> {
+ try {
+ if (this.#locked) {
+ throw new Error('Wallet is locked')
+ }
+ if (this.#seed == null) {
+ throw new Error('Wallet seed not found')
+ }
+ if (this.#type !== 'BIP-44' && this.#type !== 'BLAKE2b') {
+ throw new Error('Invalid wallet type')
+ }
+ if (typeof index !== 'number') {
+ throw new Error('Invalid wallet account index')
+ }
+ const prv = this.#type === 'BIP-44'
+ ? await Bip44Ckd.nanoCKD(this.#seed, index)
+ : await Blake2bCkd.ckd(this.#seed, index)
+ const pub = await NanoNaCl.convert(new Uint8Array(prv))
+ return { publicKey: pub.buffer }
+ } catch (err) {
+ throw new Error('Failed to derive account', { cause: err })
}
+ }
- const records: SafeRecord[] = []
+ /**
+ * Encrypts an existing seed or mnemonic+salt and returns the initialization
+ * vector, salt, and encrypted data representing the wallet in a locked state.
+ */
+ static async import (type?: 'BIP-44' | 'BLAKE2b', key?: CryptoKey, secret?: string | ArrayBuffer, salt?: string): Promise<NamedData<ArrayBuffer>> {
try {
- const salt = await Entropy.create()
- const encryptionKey = await this.#createAesKey('encrypt', password, salt.buffer)
- for (const label of Object.keys(data)) {
- const iv = await Entropy.create()
- const encrypted = await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv.buffer }, encryptionKey, data[label])
- const record: SafeRecord = {
- iv: iv.buffer,
- salt: salt.buffer,
- label,
- encrypted
+ if (!this.#locked) {
+ throw new Error('Wallet is in use')
+ }
+ if (type == null) {
+ throw new TypeError('Wallet type is required')
+ }
+ if (type !== 'BIP-44' && type !== 'BLAKE2b') {
+ throw new TypeError('Invalid wallet type')
+ }
+ if (key == null) {
+ throw new TypeError('Wallet password is required')
+ }
+ if (secret == null) {
+ throw new TypeError('Seed or mnemonic is required')
+ }
+ if (typeof secret !== 'string' && salt !== undefined) {
+ throw new TypeError('Mnemonic must be a string')
+ }
+ if (type === 'BIP-44') {
+ if (secret instanceof ArrayBuffer && (secret.byteLength < 16 || secret.byteLength < 32)) {
+ throw new RangeError('Seed for BIP-44 wallet must be 16-32 bytes')
+ }
+ }
+ if (type === 'BLAKE2b') {
+ if (secret instanceof ArrayBuffer && secret.byteLength !== 32) {
+ throw new RangeError('Invalid seed for BLAKE2b wallet')
}
- records.push(record)
}
- return await this.#put(records, store)
+ this.#type = type
+ if (secret instanceof ArrayBuffer) {
+ this.#seed = secret
+ } else {
+ this.#mnemonic = await Bip39Mnemonic.fromPhrase(secret)
+ this.#seed = type === 'BIP-44'
+ ? (await this.#mnemonic.toBip39Seed(salt ?? '')).buffer
+ : (await this.#mnemonic.toBlake2bSeed()).buffer
+ }
+ return await this.#encryptWallet(key)
} catch (err) {
- throw new Error(this.ERR_MSG)
- } finally {
- bytes.erase(password)
+ this.lock()
+ throw new Error('Failed to import wallet', { cause: err })
}
}
+ static lock (): NamedData<boolean> {
+ this.#mnemonic = undefined
+ this.#seed = undefined
+ this.#locked = true
+ return { isLocked: this.#locked }
+ }
+
/**
- * Retrieves data from the Safe and decrypts it with a password byte array.
+ * 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 fetch (names: string[], store: string, password: ArrayBuffer | unknown): Promise<NamedData<ArrayBuffer>> {
- if (password == null || !(password instanceof ArrayBuffer)) {
- throw new TypeError('Invalid password')
- }
- const results: NamedData<ArrayBuffer> = {}
+ static async sign (index?: number, data?: ArrayBuffer): Promise<NamedData<ArrayBuffer>> {
try {
- const records: SafeRecord[] = await this.#get(names, store)
- if (records == null) {
- throw new Error('')
+ if (this.#locked) {
+ throw new Error('Wallet is locked')
+ }
+ if (this.#seed == null) {
+ throw new Error('Wallet seed not found')
}
- const decryptionKeys: { [salt: string]: CryptoKey } = {}
- for (const record of records) {
- const salt = await Entropy.import(record.salt)
- decryptionKeys[salt.hex] ??= await this.#createAesKey('decrypt', password, salt.buffer)
- const iv = await Entropy.import(record.iv)
- const decrypted = await globalThis.crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv.buffer }, decryptionKeys[salt.hex], record.encrypted)
- results[record.label] = decrypted
+ if (index == null) {
+ throw new Error('Wallet account index is required to sign')
}
- return results
+ if (data == null) {
+ throw new Error('Data to sign not found')
+ }
+ const prv = await Bip44Ckd.nanoCKD(this.#seed, index)
+ const sig = await NanoNaCl.detached(new Uint8Array(data), new Uint8Array(prv))
+ return { signature: sig.buffer }
} catch (err) {
- console.error(err)
- throw new Error('Failed to get records', { cause: err })
- } finally {
- bytes.erase(password)
+ throw new Error('Failed to sign message', { cause: err })
}
}
/**
- * Retrieves all data from a specified Safe table. If a password is not
- * provided, the records are returned as encrypted data.
+ * Decrypts the input and sets the seed and, if it is included, the mnemonic.
*/
- static async export (store: string | unknown, password?: ArrayBuffer | unknown): Promise<NamedData<ArrayBuffer>> {
- if (typeof store !== 'string' || store === '') {
- throw new Error('Invalid database store name')
- }
- if (password != null && !(password instanceof ArrayBuffer)) {
- throw new Error('Invalid password')
+ static async unlock (key: CryptoKey, iv: ArrayBuffer, encrypted?: ArrayBuffer): Promise<NamedData<boolean>> {
+ try {
+ if (encrypted == null) {
+ throw new TypeError('Wallet encrypted secrets required to unlock')
+ }
+ const { seed, mnemonic } = await this.#decryptWallet(key, iv, encrypted)
+ if (!(seed instanceof ArrayBuffer)) {
+ throw new TypeError('Invalid seed')
+ }
+ if (mnemonic != null && typeof mnemonic !== 'string') {
+ throw new TypeError('Invalid seed')
+ }
+ this.#seed = seed
+ this.#mnemonic = await Bip39Mnemonic.fromPhrase(mnemonic)
+ this.#locked = false
+ return { isUnlocked: !this.#locked }
+ } catch (err) {
+ throw new Error('Failed to unlock wallet', { cause: err })
}
+ }
- const results: NamedData<ArrayBuffer> = {}
+ /**
+ * Checks the seed and, if it exists, the mnemonic against input. The wallet
+ * must be unlocked prior to verification.
+ */
+ static async verify (seed?: ArrayBuffer, mnemonicPhrase?: string): Promise<NamedData<boolean>> {
try {
- const records: SafeRecord[] = await this.#getAll(store)
- if (records == null) {
- throw new Error('')
+ if (this.#locked) {
+ throw new Error('Wallet is locked')
+ }
+ if (this.#seed == null) {
+ throw new Error('Wallet seed not found')
+ }
+ if (seed == null && mnemonicPhrase == null) {
+ throw new Error('Seed or mnemonic phrase not found')
+ }
+ if (seed != null && mnemonicPhrase != null) {
+ throw new Error('Seed or mnemonic phrase must be verified separately')
}
- if (password instanceof ArrayBuffer) {
- const decryptionKeys: { [salt: string]: CryptoKey } = {}
- for (const record of records) {
- const salt = await Entropy.import(record.salt)
- decryptionKeys[salt.hex] ??= await this.#createAesKey('decrypt', password, salt.buffer)
- const iv = await Entropy.import(record.iv)
- const decrypted = await globalThis.crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv.buffer }, decryptionKeys[salt.hex], record.encrypted)
- results[record.label] = decrypted
+ let isVerified = false
+ if (seed != null) {
+ if (seed.byteLength === this.#seed.byteLength) {
+ const userSeed = new Uint8Array(seed)
+ const thisSeed = new Uint8Array(this.#seed)
+ for (let i = 0; i < seed.byteLength; i++) {
+ if (userSeed[i] === thisSeed[i]) {
+ isVerified = true
+ } else {
+ isVerified = false
+ break
+ }
+ }
}
- } else {
- for (const record of records) {
- results[record.label] = record.encrypted
+ }
+ if (mnemonicPhrase != null) {
+ if (mnemonicPhrase === this.#mnemonic?.phrase) {
+ isVerified = true
}
}
- return results
+ return { isVerified }
} catch (err) {
- console.error(err)
- throw new Error(`Failed to export ${store} records`, { cause: err })
- } finally {
- if (password instanceof ArrayBuffer) bytes.erase(password)
+ throw new Error('Failed to export wallet', { cause: err })
}
}
static async #createAesKey (purpose: 'encrypt' | 'decrypt', password: ArrayBuffer, salt: ArrayBuffer): Promise<CryptoKey> {
- const derivationKey = await globalThis.crypto.subtle.importKey('raw', password, 'PBKDF2', false, ['deriveBits', 'deriveKey'])
+ const derivationKey = await crypto.subtle.importKey('raw', password, 'PBKDF2', false, ['deriveBits', 'deriveKey'])
+ new Uint8Array(password).fill(0).buffer.transfer()
const derivationAlgorithm: Pbkdf2Params = {
name: 'PBKDF2',
hash: 'SHA-512',
- iterations: PBKDF2_ITERATIONS,
+ iterations: 210000,
salt
}
const derivedKeyType: AesKeyGenParams = {
name: 'AES-GCM',
length: 256
}
- return await globalThis.crypto.subtle.deriveKey(derivationAlgorithm, derivationKey, derivedKeyType, false, [purpose])
+ return await crypto.subtle.deriveKey(derivationAlgorithm, derivationKey, derivedKeyType, false, [purpose])
}
- static async #delete (names: string[], store: string): Promise<boolean> {
- const transaction = this.#storage.transaction(store, 'readwrite')
- const db = transaction.objectStore(store)
- return new Promise((resolve, reject) => {
- const requests = names.map(name => {
- const request = db.delete(name)
- request.onsuccess = (event) => {
- }
- request.onerror = (event) => {
- console.error('getAll request error before transaction committed')
- }
- return request
- })
- transaction.oncomplete = (event) => {
- for (const request of requests) {
- if (request?.error != null) {
- reject(request.error)
- }
- if (request.result !== undefined) {
- resolve(false)
- }
- }
- resolve(true)
- }
- transaction.onerror = (event) => {
- console.error('Database error')
- reject((event.target as IDBRequest).error)
- }
- })
+ 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 #get (names: string[], store: string): Promise<SafeRecord[]> {
- const transaction = this.#storage.transaction(store, 'readonly')
- const db = transaction.objectStore(store)
- return new Promise((resolve, reject) => {
- const requests = names.map(name => {
- const request = db.get(name)
- request.onsuccess = (event) => {
- }
- request.onerror = (event) => {
- console.error('get request error before transaction committed')
- }
- return request
- })
- transaction.oncomplete = (event) => {
- const results = []
- for (const request of requests) {
- if (request?.error == null && request.result != null) {
- results.push(request.result)
- }
- }
- resolve(results)
- }
- transaction.onerror = (event) => {
- console.error('Database error')
- reject((event.target as IDBRequest).error)
- }
- })
- }
-
- static async #getAll (store: string): Promise<SafeRecord[]> {
- const transaction = this.#storage.transaction(store, 'readonly')
- const db = transaction.objectStore(store)
- return new Promise((resolve, reject) => {
- const request = db.getAll()
- request.onsuccess = (event) => {
- }
- request.onerror = (event) => {
- console.error('getAll request error before transaction committed')
- }
- transaction.oncomplete = (event) => {
- if (request?.error != null) {
- reject(request.error)
- } else if (request.result == null) {
- reject('getAll request failed')
- } else {
- resolve(request.result)
- }
- }
- transaction.onerror = (event) => {
- console.error('Database error')
- reject((event.target as IDBRequest).error)
- }
- })
+ static async #encryptWallet (key: CryptoKey): Promise<NamedData<ArrayBuffer>> {
+ if (this.#seed == null) {
+ throw new Error('Wallet seed not found')
+ }
+ const data: NamedData<string> = {
+ seed: bytes.toHex(new Uint8Array(this.#seed))
+ }
+ if (this.#mnemonic?.phrase != null) data.mnemonic = this.#mnemonic.phrase
+ const iv = crypto.getRandomValues(new Uint8Array(32)).buffer
+ const encoded = utf8.toBytes(JSON.stringify(data))
+ const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded)
+ return { iv, encrypted }
}
- static #isDataValid (data: unknown): asserts data is { [key: string]: ArrayBuffer } {
- if (typeof data !== 'object') {
- throw new Error('Invalid data')
+ /**
+ * Parse inbound message from main thread into typechecked variables.
+ */
+ static async #extractData (message: unknown) {
+ // Message itself
+ if (message == null) {
+ throw new TypeError('Worker received no data')
}
- const dataObject = data as { [key: string]: unknown }
- if (Object.keys(dataObject).some(k => !(dataObject[k] instanceof ArrayBuffer))) {
+ if (typeof message !== 'object') {
throw new Error('Invalid data')
}
- }
+ const messageData = message as { [key: string]: unknown }
- static async #open (database: string): Promise<IDBDatabase> {
- return new Promise((resolve, reject) => {
- const request = indexedDB.open(database, 1)
- request.onupgradeneeded = (event) => {
- const db = (event.target as IDBOpenDBRequest).result
- for (const DB_STORE of this.DB_STORES) {
- if (!db.objectStoreNames.contains(DB_STORE)) {
- db.createObjectStore(DB_STORE)
- }
- }
- }
- request.onsuccess = (event) => {
- resolve((event.target as IDBOpenDBRequest).result)
+ // Action for selecting method execution
+ if (!('action' in messageData)) {
+ throw new TypeError('Wallet action is required')
+ }
+ if (messageData.action !== 'STOP'
+ && messageData.action !== 'create'
+ && messageData.action !== 'derive'
+ && messageData.action !== 'import'
+ && messageData.action !== 'lock'
+ && messageData.action !== 'sign'
+ && messageData.action !== 'unlock'
+ && messageData.action !== 'verify') {
+ throw new TypeError('Invalid wallet action')
+ }
+ const action = messageData.action
+
+ // Password for lock/unlock key
+ if ('password' in messageData || !(messageData.password instanceof ArrayBuffer)) {
+ throw new TypeError('Password must be ArrayBuffer')
+ }
+ const password: ArrayBuffer = messageData.password
+
+ // IV for crypto key, included if unlocking or generated if creating
+ if (action === 'unlock' && !(messageData.iv instanceof ArrayBuffer)) {
+ throw new TypeError('Initialization vector required to unlock wallet')
+ }
+ const iv: ArrayBuffer = action === 'unlock' && messageData.iv instanceof ArrayBuffer
+ ? messageData.iv
+ : crypto.getRandomValues(new Uint8Array(32)).buffer
+
+ // Salt for decryption key to unlock
+ if (action === 'unlock' && !(messageData.keySalt instanceof ArrayBuffer)) {
+ throw new TypeError('Salt required to unlock wallet')
+ }
+ const keySalt: ArrayBuffer = action === 'unlock' && messageData.keySalt instanceof ArrayBuffer
+ ? messageData.keySalt
+ : crypto.getRandomValues(new Uint8Array(32)).buffer
+
+ // CryptoKey from password, decryption key if unlocking else encryption key
+ const key = await this.#createAesKey(action === 'unlock' ? 'decrypt' : 'encrypt', password, keySalt)
+
+ // Type of wallet
+ if (messageData.type !== undefined && messageData.type !== 'BIP-44' && messageData.type !== 'BLAKE2b') {
+ throw new TypeError('Invalid wallet type', { cause: messageData.type })
+ }
+ const type: 'BIP-44' | 'BLAKE2b' | undefined = messageData.type
+
+ // Seed to import
+ if (action === 'import' && !(messageData.seed instanceof ArrayBuffer)) {
+ throw new TypeError('Seed required to import wallet')
+ }
+ const seed = messageData.seed instanceof ArrayBuffer
+ ? messageData.seed
+ : undefined
+
+ // Mnemonic phrase to import
+ if (action === 'import' && typeof messageData.mnemonicPhrase !== 'string') {
+ throw new TypeError('Invalid mnemonic phrase')
+ }
+ const mnemonicPhrase = typeof messageData.mnemonicPhrase === 'string'
+ ? messageData.mnemonicPhrase
+ : undefined
+
+ // Mnemonic salt for mnemonic phrase to import
+ if (action === 'import' && messageData.mnemonicSalt != undefined && typeof messageData.mnemonicSalt !== 'string') {
+ throw new TypeError('Invalid mnemonic salt for mnemonic phrase')
+ }
+ const mnemonicSalt = typeof messageData.mnemonicSalt === 'string'
+ ? messageData.mnemonicSalt
+ : undefined
+
+ // Encrypted seed and possibly mnemonic
+ if (action === 'unlock') {
+ if (messageData.encrypted == null) {
+ throw new TypeError('Wallet encrypted secrets not found')
}
- request.onerror = (event) => {
- reject(new Error('Database error', { cause: event }))
+ if (!(messageData.encrypted instanceof ArrayBuffer)) {
+ throw new TypeError('Invalid wallet encrypted secrets')
}
- })
- }
+ }
+ const encrypted = messageData.encrypted instanceof ArrayBuffer
+ ? messageData.encrypted
+ : undefined
- static async #put (records: SafeRecord[], store: string): Promise<boolean> {
- const transaction = this.#storage.transaction(store, 'readwrite')
- const db = transaction.objectStore(store)
- return new Promise((resolve, reject) => {
- const requests = records.map(record => {
- const request = db.put(record, record.label)
- request.onsuccess = (event) => {
- }
- request.onerror = (event) => {
- console.error('put request error before transaction committed')
- }
- return request
- })
- transaction.oncomplete = (event) => {
- const results = []
- for (const request of requests) {
- if (request?.error == null && request.result != null) {
- results.push(request.result)
- }
- }
- resolve(results.length > 0)
+ // Index for child account to derive or sign
+ if ((action === 'derive' || action === 'sign') && typeof messageData.index !== 'number') {
+ throw new TypeError('Index is required to derive an account private key')
+ }
+ const index = typeof messageData.index === 'number'
+ ? messageData.index
+ : undefined
+
+ // Data to sign
+ if (action === 'sign') {
+ if (messageData.data == null) {
+ throw new TypeError('Data to sign not found')
}
- transaction.onerror = (event) => {
- console.error('Database error')
- reject((event.target as IDBRequest).error)
+ if (!(messageData.data instanceof ArrayBuffer)) {
+ throw new TypeError('Invalid data to sign')
}
- })
+ }
+ const data = messageData.data instanceof ArrayBuffer
+ ? messageData.data
+ : undefined
+
+ return { action, type, key, iv, keySalt, seed, mnemonicPhrase, mnemonicSalt, encrypted, index, data }
}
}
let importWorkerThreads = ''
-let importFakeIndexedDb = ''
NODE: importWorkerThreads = `import { parentPort } from 'node:worker_threads'`
-NODE: importFakeIndexedDb = `import 'fake-indexeddb/auto'`
export default `
${importWorkerThreads}
- ${importFakeIndexedDb}
- ${Convert}
- const PBKDF2_ITERATIONS = ${PBKDF2_ITERATIONS}
- const Entropy = ${Entropy}
- const Blake2b = ${Blake2b}
- const Bip44Ckd = ${Bip44Ckd}
- const WorkerInterface = ${WorkerInterface}
const Safe = ${Safe}
`