'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')
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> {
}
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 = ''