'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.
*
* @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)
})
}
+ /**
+ * 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.
*
* @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
}
* @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)
} 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)
}
* @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)
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.
*
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 })
}
}
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
}
/**
* @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)
}
/**
*/
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)
*/
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)
*/
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
'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) {
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': {
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: {
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>) {
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 }
}
}