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.
88 lines
3.8 KiB
TypeScript
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;
|
|
}
|
|
}
|