//! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>
//! SPDX-License-Identifier: GPL-3.0-or-later
-import { Bip39 } from "./bip39"
-import { Bip44 } from "./bip44"
-import { Blake2b } from "./blake2b"
-import { NanoNaCl } from "./nano-nacl"
+import { Bip39 } from './bip39'
+import { Bip44 } from './bip44'
+import { Blake2b } from './blake2b'
+import { NanoNaCl } from './nano-nacl'
+import { WalletAesGcm } from './wallet-aes-gcm'
-export { Bip39, Bip44, Blake2b, NanoNaCl }
+export { Bip39, Bip44, Blake2b, NanoNaCl, WalletAesGcm }
--- /dev/null
+
+
+//! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>
+//! SPDX-License-Identifier: GPL-3.0-or-later
+
+import { NamedData } from "#types"
+import { utf8 } from "../convert"
+
+export class WalletAesGcm {
+ static async decrypt (type: string, key: CryptoKey, iv: ArrayBuffer, encrypted: ArrayBuffer): Promise<NamedData<ArrayBuffer>> {
+ const seedLength = type === 'BIP-44' ? 64 : 32
+ const additionalData = utf8.toBytes(type)
+ return crypto.subtle
+ .decrypt({ name: 'AES-GCM', iv, additionalData }, key, encrypted)
+ .then(decrypted => {
+ const seed = decrypted.slice(0, seedLength)
+ const mnemonic = decrypted.slice(seedLength)
+ new Uint8Array(decrypted).fill(0)
+ return { mnemonic, seed }
+ })
+ }
+
+ static async encrypt (type: string, key: CryptoKey, seed: ArrayBuffer, mnemonic?: ArrayBuffer): Promise<NamedData<ArrayBuffer>> {
+ if (type == null) {
+ throw new Error('Wallet type missing')
+ }
+ if (key == null) {
+ throw new Error('Wallet key missing')
+ }
+ if (seed == null) {
+ throw new Error('Wallet seed missing')
+ }
+ // restrict iv to 96 bits per GCM best practice
+ const iv = crypto.getRandomValues(new Uint8Array(12)).buffer
+ const additionalData = utf8.toBytes(type)
+ const encoded = new Uint8Array([...new Uint8Array(seed), ...new Uint8Array(mnemonic ?? [])])
+ return crypto.subtle
+ .encrypt({ name: 'AES-GCM', iv, additionalData }, key, encoded)
+ .then(encrypted => {
+ encoded.fill(0)
+ return { iv, encrypted }
+ })
+ }
+}
import { Data, NamedData } from '#types'
import { default as Constants } from '../constants'
import { default as Convert } from '../convert'
-import { Bip39, Bip44, Blake2b, NanoNaCl } from '../crypto'
+import { Bip39, Bip44, Blake2b, NanoNaCl, WalletAesGcm } from '../crypto'
+import { Passkey } from './passkey'
import { VaultTimer } from './vault-timer'
import { VaultWorker } from './vault-worker'
const Bip44 = ${Bip44}
const Blake2b = ${Blake2b}
const NanoNaCl = ${NanoNaCl}
+ const WalletAesGcm = ${WalletAesGcm}
+ const Passkey = ${Passkey}
const VaultTimer = ${VaultTimer}
const VaultWorker = ${VaultWorker}
const v = new VaultWorker()
//! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>
//! SPDX-License-Identifier: GPL-3.0-or-later
-export async function createKeyFromPassword (action: string, salt: ArrayBuffer, data: { [key: string]: unknown }): Promise<CryptoKey | undefined> {
- // Allowlisted wallet actions
- if (['create', 'load', 'unlock', 'update'].includes(action)) {
+export class Passkey {
+ static async create (action: string, salt: ArrayBuffer, data: { [key: string]: unknown }): Promise<CryptoKey | undefined> {
+ // Allowlisted wallet actions
+ if (['create', 'load', 'unlock', 'update'].includes(action)) {
- // Create local copy of password ASAP, then clear bytes from original buffer
- if (!(data.password instanceof ArrayBuffer)) {
- throw new TypeError('Password must be ArrayBuffer')
- }
+ // Create local copy of password ASAP, then clear bytes from original buffer
+ if (!(data.password instanceof ArrayBuffer)) {
+ throw new TypeError('Password must be ArrayBuffer')
+ }
- const password = data.password.slice()
- new Uint8Array(data.password).fill(0)
- delete data.password
+ const password = data.password.slice()
+ new Uint8Array(data.password).fill(0)
+ delete data.password
- // Only unlocking should decrypt the vault; other sensitive actions should
- // throw if the vault is still locked and encrypted
- const purpose = action === 'unlock' ? 'decrypt' : 'encrypt'
+ // Only unlocking should decrypt the vault; other sensitive actions should
+ // throw if the vault is still locked and encrypted
+ const purpose = action === 'unlock' ? 'decrypt' : 'encrypt'
- return crypto.subtle
- .importKey('raw', password, 'PBKDF2', false, ['deriveKey'])
- .then(derivationKey => {
- new Uint8Array(password).fill(0).buffer.transfer?.()
- const derivationAlgorithm: Pbkdf2Params = {
- name: 'PBKDF2',
- hash: 'SHA-512',
- iterations: 210000,
- salt
- }
- const derivedKeyType: AesKeyGenParams = {
- name: 'AES-GCM',
- length: 256
- }
- return crypto.subtle
- .deriveKey(derivationAlgorithm, derivationKey, derivedKeyType, false, [purpose])
- })
- .catch(err => {
- console.error(err)
- throw new Error('Failed to derive CryptoKey from password', { cause: err })
- })
- } else if (data.password !== undefined) {
- throw new Error('Password is not allowed for this action', { cause: action })
- } else {
- return Promise.resolve(undefined)
+ return crypto.subtle
+ .importKey('raw', password, 'PBKDF2', false, ['deriveKey'])
+ .then(derivationKey => {
+ new Uint8Array(password).fill(0).buffer.transfer?.()
+ const derivationAlgorithm: Pbkdf2Params = {
+ name: 'PBKDF2',
+ hash: 'SHA-512',
+ iterations: 210000,
+ salt
+ }
+ const derivedKeyType: AesKeyGenParams = {
+ name: 'AES-GCM',
+ length: 256
+ }
+ return crypto.subtle
+ .deriveKey(derivationAlgorithm, derivationKey, derivedKeyType, false, [purpose])
+ })
+ .catch(err => {
+ console.error(err)
+ throw new Error('Failed to derive CryptoKey from password', { cause: err })
+ })
+ } else if (data.password !== undefined) {
+ throw new Error('Password is not allowed for this action', { cause: action })
+ } else {
+ return Promise.resolve(undefined)
+ }
}
}
import { NamedData } from '#types'
import { BIP44_COIN_NANO } from '../constants'
import { utf8 } from '../convert'
-import { Bip39, Bip44, Blake2b, NanoNaCl } from '../crypto'
-import { createKeyFromPassword } from './passkey'
+import { Bip39, Bip44, Blake2b, NanoNaCl, WalletAesGcm } from '../crypto'
+import { Passkey } from './passkey'
import { VaultTimer } from './vault-timer'
/**
const data = this.#parseData(event.data)
const action = this.#parseAction(data)
const keySalt = this.#parseKeySalt(action, data)
- createKeyFromPassword(action, keySalt, data)
+ Passkey.create(action, keySalt, data)
.then((key: CryptoKey | undefined): Promise<NamedData> => {
const type = this.#parseType(action, data)
const iv = this.#parseIv(action, data)
return this.derive(index)
}
case 'load': {
- return this.#load(type, key, keySalt, mnemonicPhrase ?? seed, mnemonicSalt)
+ return this.load(type, key, keySalt, mnemonicPhrase ?? seed, mnemonicSalt)
}
case 'lock': {
return Promise.resolve(this.lock())
throw new TypeError('Wallet encrypted data is required')
}
this.#timeout?.pause()
- return this.#decryptWallet(type, key, iv, encrypted)
- .then(() => {
- if (!(this.#seed instanceof ArrayBuffer)) {
+ return WalletAesGcm.decrypt(type, key, iv, encrypted)
+ .then(({ mnemonic, seed }) => {
+ if (!(seed instanceof ArrayBuffer)) {
throw new TypeError('Invalid seed')
}
- if (this.#mnemonic != null && !(this.#mnemonic instanceof ArrayBuffer)) {
+ if (mnemonic != null && !(mnemonic instanceof ArrayBuffer)) {
throw new TypeError('Invalid mnemonic')
}
+ this.#seed = seed
+ this.#mnemonic = mnemonic
this.#locked = false
this.#timeout = new VaultTimer(() => this.lock(), 120000)
return { isUnlocked: !this.#locked }
if (this.#seed == null) {
throw new Error('Wallet seed not found')
}
+ if (this.#type == null) {
+ throw new Error('Wallet type not found')
+ }
if (key == null || salt == null) {
throw new TypeError('Wallet password is required')
}
- return this.#encryptWallet(key)
+ return WalletAesGcm.encrypt(this.#type, key, this.#seed, this.#mnemonic)
.then(({ iv, encrypted }) => {
this.#timeout = new VaultTimer(() => this.lock(), 120000)
return { iv, salt, encrypted }
}
}
- #decryptWallet (type: string, key: CryptoKey, iv: ArrayBuffer, encrypted: ArrayBuffer): Promise<void> {
- const seedLength = type === 'BIP-44' ? 64 : 32
- const additionalData = utf8.toBytes(type)
- return crypto.subtle
- .decrypt({ name: 'AES-GCM', iv, additionalData }, key, encrypted)
- .then(decrypted => {
- this.#seed = decrypted.slice(0, seedLength)
- this.#mnemonic = decrypted.slice(seedLength)
- new Uint8Array(decrypted).fill(0)
- })
- }
-
- #encryptWallet (key: CryptoKey): Promise<NamedData<ArrayBuffer>> {
- if (this.#type == null) {
- throw new Error('Invalid wallet type')
- }
- if (this.#seed == null) {
- throw new Error('Wallet seed not found')
- }
- const seed = new Uint8Array(this.#seed)
- const mnemonic = new Uint8Array(this.#mnemonic ?? [])
- // restrict iv to 96 bits per GCM best practice
- const iv = crypto.getRandomValues(new Uint8Array(12)).buffer
- const additionalData = utf8.toBytes(this.#type)
- const encoded = new Uint8Array([...seed, ...mnemonic])
- return crypto.subtle
- .encrypt({ name: 'AES-GCM', iv, additionalData }, key, encoded)
- .then(encrypted => {
- encoded.fill(0)
- return { iv, encrypted }
- })
- }
-
/**
* Parse inbound message from main thread into typechecked variables.
*/
}
return seed.then(seed => {
this.#seed = seed
- return this.#encryptWallet(key)
+ return WalletAesGcm.encrypt(type, key, this.#seed, this.#mnemonic)
.then(({ iv, encrypted }) => ({ iv, salt: keySalt, encrypted }))
})
} catch (err) {