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.
125 lines
4.5 KiB
TypeScript
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;
|
|
}
|