dgb-wallet/packages/keystore/src/keystore.ts
Digibyte.X d50286a9a5 DigiByte.X Wallet — recovery-first web wallet monorepo
Packages: core (BIP39/32/44/49/84/86 derivation, WIF), keystore (PBKDF2 +
AES-GCM), psbt, p2p (BIP157/158 SPV daemon, Dandelion++ broadcast with
relay-echo verification), blockbook, rpc, electrum, net, bns, web-ui
(React/Vite), mobile (Expo). ops/ holds the nginx vhost, systemd unit and
push.sh deploy script for digibyte.x/web.
2026-09-20 20:16:52 +02:00

88 lines
3.8 KiB
TypeScript

import type { StorageAdapter } from './storage.js';
import { encryptString, decryptString, type EncryptedBlob } from './encrypt.js';
// Namespace under which encrypted seed blobs live in the adapter's
// keyspace. Adapters may hold unrelated things (settings, UI state)
// — a prefix keeps Keystore.list from returning them.
const NS = 'dgb-wallet:seed:';
export interface StoredSeed {
label: string;
createdAt: number;
}
// Passphrase-encrypted seed vault. One instance per adapter.
//
// The class is stateless w.r.t. plaintext — passphrases and decrypted
// seeds are never held on the instance. That means every read costs a
// PBKDF2 (~500ms on a laptop, more on a phone), which is exactly the
// resistance we want: it also gates offline brute-force at the same
// cost per guess.
export class Keystore {
constructor(private readonly storage: StorageAdapter) {}
// Encrypt `seedPhrase` with `passphrase` and persist under `label`.
// Fails if a seed already exists at `label` unless overwrite=true —
// we don't want a slip in the UI to silently clobber a saved seed.
async save(label: string, seedPhrase: string, passphrase: string, overwrite = false): Promise<void> {
const key = NS + label;
if (!overwrite) {
const existing = await this.storage.get(key);
if (existing) throw new Error(`keystore: "${label}" already exists (pass overwrite=true to replace)`);
}
const blob = await encryptString(seedPhrase, passphrase);
const payload = JSON.stringify({ ...blob, meta: { label, createdAt: Date.now() } });
await this.storage.put(key, payload);
}
// Decrypt and return the seed phrase for `label`. Throws on wrong
// passphrase or unknown label.
async unlock(label: string, passphrase: string): Promise<string> {
const raw = await this.storage.get(NS + label);
if (!raw) throw new Error(`keystore: no seed named "${label}"`);
const blob = JSON.parse(raw) as EncryptedBlob & { meta?: { label: string; createdAt: number } };
return decryptString(blob, passphrase);
}
// Enumerate saved seeds without decrypting them — cheap.
async list(): Promise<StoredSeed[]> {
const keys = await this.storage.list(NS);
const out: StoredSeed[] = [];
for (const k of keys) {
const raw = await this.storage.get(k);
if (!raw) continue;
try {
const blob = JSON.parse(raw) as { meta?: { label: string; createdAt: number } };
const label = k.substring(NS.length);
out.push({ label, createdAt: blob.meta?.createdAt ?? 0 });
} catch {
// Skip malformed entries silently — probably not ours.
}
}
return out.sort((a, b) => b.createdAt - a.createdAt);
}
async delete(label: string): Promise<void> {
await this.storage.delete(NS + label);
}
// Rename = re-key the same encrypted blob. The ciphertext is untouched
// (the label is not part of the encryption), only the storage key and
// the embedded meta.label change. New label must be free.
async rename(oldLabel: string, newLabel: string): Promise<void> {
const next = newLabel.trim();
if (!next) throw new Error('keystore: new label is empty');
if (next === oldLabel) return;
const raw = await this.storage.get(NS + oldLabel);
if (!raw) throw new Error(`keystore: no seed named "${oldLabel}"`);
if (await this.storage.get(NS + next)) throw new Error(`keystore: "${next}" already exists`);
const blob = JSON.parse(raw) as EncryptedBlob & { meta?: { label: string; createdAt: number } };
blob.meta = { label: next, createdAt: blob.meta?.createdAt ?? Date.now() };
await this.storage.put(NS + next, JSON.stringify(blob));
await this.storage.delete(NS + oldLabel);
}
async exists(label: string): Promise<boolean> {
return (await this.storage.get(NS + label)) !== null;
}
}