dgb-wallet/packages/keystore/src/encrypt.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

125 lines
4.5 KiB
TypeScript

// Passphrase-based encryption using WebCrypto. Standard PBKDF2 + AES-GCM
// — the same shape as Ethereum's V3 keystore, MetaMask's vault, and
// Bitcoin Core's wallet encryption. No dependencies; works in browser,
// Node 20+, and React Native 0.74+ (Hermes / RN core provide SubtleCrypto).
//
// Blob format (JSON, versioned so we can migrate later):
// {
// "v": 1,
// "kdf": { "algo": "PBKDF2", "hash": "SHA-256",
// "salt": "<hex>", "iter": 210000 },
// "enc": { "algo": "AES-GCM", "iv": "<hex>",
// "ct": "<hex — ciphertext + 16-byte auth tag> " }
// }
//
// 210,000 iterations follows OWASP 2023's recommendation for
// PBKDF2-SHA256; each additional factor of 10 buys ~one bit of
// resistance for the same wall-clock cost on the user.
export const KEYSTORE_VERSION = 1;
const PBKDF2_ITERATIONS = 210_000;
const SALT_LEN = 16;
const IV_LEN = 12;
export interface EncryptedBlob {
v: number;
kdf: { algo: 'PBKDF2'; hash: 'SHA-256'; salt: string; iter: number };
enc: { algo: 'AES-GCM'; iv: string; ct: string };
}
// Encrypt a UTF-8 string (typically a seed phrase) with the passphrase.
// Every call generates a fresh salt + IV — never reuse. Returns a
// self-describing JSON-serializable blob.
export async function encryptString(plaintext: string, passphrase: string): Promise<EncryptedBlob> {
const enc = new TextEncoder();
const salt = randomBytes(SALT_LEN);
const iv = randomBytes(IV_LEN);
const aesKey = await deriveKey(passphrase, salt);
const ctBuf = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
aesKey,
enc.encode(plaintext),
);
return {
v: KEYSTORE_VERSION,
kdf: { algo: 'PBKDF2', hash: 'SHA-256', salt: toHex(salt), iter: PBKDF2_ITERATIONS },
enc: { algo: 'AES-GCM', iv: toHex(iv), ct: toHex(new Uint8Array(ctBuf)) },
};
}
// Decrypt a blob produced by encryptString. Throws a specific error
// on wrong passphrase (AES-GCM auth tag mismatch surfaces as a generic
// DOMException — we translate it into something the UI can display).
export async function decryptString(blob: EncryptedBlob, passphrase: string): Promise<string> {
assertSupportedBlob(blob);
const salt = fromHex(blob.kdf.salt);
const iv = fromHex(blob.enc.iv);
const ct = fromHex(blob.enc.ct);
const aesKey = await deriveKey(passphrase, salt, blob.kdf.iter);
let ptBuf: ArrayBuffer;
try {
ptBuf = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, aesKey, ct);
} catch {
// WebCrypto AES-GCM throws a generic OperationError on tag mismatch
// — same signal for wrong passphrase and for tampered ciphertext.
// We can't distinguish the two, so we say "wrong passphrase or
// corrupted blob" and leave forensics to logs.
throw new Error('decrypt failed — wrong passphrase or corrupted keystore blob');
}
return new TextDecoder().decode(ptBuf);
}
function assertSupportedBlob(blob: EncryptedBlob): void {
if (blob.v !== KEYSTORE_VERSION) {
throw new Error(`unsupported keystore version ${blob.v} (this build understands v${KEYSTORE_VERSION})`);
}
if (blob.kdf.algo !== 'PBKDF2' || blob.kdf.hash !== 'SHA-256') {
throw new Error(`unsupported KDF ${blob.kdf.algo}/${blob.kdf.hash}`);
}
if (blob.enc.algo !== 'AES-GCM') {
throw new Error(`unsupported cipher ${blob.enc.algo}`);
}
}
async function deriveKey(
passphrase: string,
salt: Uint8Array<ArrayBuffer>,
iterations = PBKDF2_ITERATIONS,
): Promise<CryptoKey> {
const raw = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(passphrase),
{ name: 'PBKDF2' },
false,
['deriveKey'],
);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
raw,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
}
// Byte buffers explicitly backed by ArrayBuffer (never SharedArrayBuffer)
// so the resulting Uint8Array type is `Uint8Array<ArrayBuffer>`, which
// satisfies BufferSource under TS 5.6+ lib.dom.
function randomBytes(n: number): Uint8Array<ArrayBuffer> {
const out = new Uint8Array(new ArrayBuffer(n));
crypto.getRandomValues(out);
return out;
}
function toHex(bytes: Uint8Array): string {
let s = '';
for (const b of bytes) s += b.toString(16).padStart(2, '0');
return s;
}
function fromHex(hex: string): Uint8Array<ArrayBuffer> {
const clean = hex.length % 2 === 0 ? hex : `0${hex}`;
const out = new Uint8Array(new ArrayBuffer(clean.length / 2));
for (let i = 0; i < out.length; i++) out[i] = parseInt(clean.substr(i * 2, 2), 16);
return out;
}