e8e37d77fe
Extracted from unibus v0.13.0: the chat SPA (web/, React+Mantine, per-user BIP39 wallet) and the web gateway (cmd/webgw, REST+SSE) that acts as a bus peer for the browser. Consumes unibus as a Go module via replace => ../unibus, keeping its own replace fn-registry for the cybersecurity primitives. go build/vet/test and pnpm build green in the new location.
125 lines
4.0 KiB
TypeScript
125 lines
4.0 KiB
TypeScript
// Local at-rest encryption of the wallet's private key, using only the platform
|
|
// WebCrypto (crypto.subtle) — no extra dependency, no WASM. The password derives
|
|
// an AES-GCM key via PBKDF2; the password itself is never stored, never sent to
|
|
// the server, and is not part of the identity (it only protects the local copy
|
|
// of the private key). The identity's source of truth is the BIP39 seed.
|
|
|
|
// PBKDF2 work factor. 210k SHA-256 iterations is the OWASP 2023 floor for
|
|
// PBKDF2-HMAC-SHA256; stored alongside the blob so a future bump stays readable.
|
|
const PBKDF2_ITERS = 210_000;
|
|
|
|
// EncryptedBlob is the at-rest form of a secret: AES-256-GCM ciphertext plus the
|
|
// public KDF parameters needed to re-derive the key from the password. None of
|
|
// these fields is secret on its own — only the password (never stored) unlocks it.
|
|
export interface EncryptedBlob {
|
|
kdf: "PBKDF2-SHA256";
|
|
iters: number;
|
|
salt: string; // hex, 16 random bytes (PBKDF2 salt)
|
|
iv: string; // hex, 12 random bytes (AES-GCM nonce)
|
|
ciphertext: string; // hex (includes the GCM auth tag)
|
|
}
|
|
|
|
function toHex(b: Uint8Array): string {
|
|
let s = "";
|
|
for (const x of b) s += x.toString(16).padStart(2, "0");
|
|
return s;
|
|
}
|
|
|
|
function fromHex(h: string): Uint8Array {
|
|
const out = new Uint8Array(h.length / 2);
|
|
for (let i = 0; i < out.length; i++) {
|
|
out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function deriveAesKey(
|
|
password: string,
|
|
salt: Uint8Array,
|
|
iters: number,
|
|
): Promise<CryptoKey> {
|
|
const enc = new TextEncoder();
|
|
const baseKey = await crypto.subtle.importKey(
|
|
"raw",
|
|
enc.encode(password),
|
|
"PBKDF2",
|
|
false,
|
|
["deriveKey"],
|
|
);
|
|
return crypto.subtle.deriveKey(
|
|
{ name: "PBKDF2", salt: salt as BufferSource, iterations: iters, hash: "SHA-256" },
|
|
baseKey,
|
|
{ name: "AES-GCM", length: 256 },
|
|
false,
|
|
["encrypt", "decrypt"],
|
|
);
|
|
}
|
|
|
|
// encryptSecret seals `plaintext` under `password` with a fresh random salt+iv.
|
|
export async function encryptSecret(
|
|
plaintext: Uint8Array,
|
|
password: string,
|
|
): Promise<EncryptedBlob> {
|
|
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
const key = await deriveAesKey(password, salt, PBKDF2_ITERS);
|
|
const ct = await crypto.subtle.encrypt(
|
|
{ name: "AES-GCM", iv: iv as BufferSource },
|
|
key,
|
|
plaintext as BufferSource,
|
|
);
|
|
return {
|
|
kdf: "PBKDF2-SHA256",
|
|
iters: PBKDF2_ITERS,
|
|
salt: toHex(salt),
|
|
iv: toHex(iv),
|
|
ciphertext: toHex(new Uint8Array(ct)),
|
|
};
|
|
}
|
|
|
|
// WrongPasswordError is thrown when GCM authentication fails on decrypt — almost
|
|
// always a wrong password (or a corrupted blob). Callers map it to a friendly
|
|
// "contraseña incorrecta" message.
|
|
export class WrongPasswordError extends Error {
|
|
constructor() {
|
|
super("wrong password");
|
|
this.name = "WrongPasswordError";
|
|
}
|
|
}
|
|
|
|
// decryptSecret re-derives the key from `password` and opens the blob. A wrong
|
|
// password makes GCM verification fail, surfaced as WrongPasswordError.
|
|
export async function decryptSecret(
|
|
blob: EncryptedBlob,
|
|
password: string,
|
|
): Promise<Uint8Array> {
|
|
const key = await deriveAesKey(password, fromHex(blob.salt), blob.iters);
|
|
try {
|
|
const pt = await crypto.subtle.decrypt(
|
|
{ name: "AES-GCM", iv: fromHex(blob.iv) as BufferSource },
|
|
key,
|
|
fromHex(blob.ciphertext) as BufferSource,
|
|
);
|
|
return new Uint8Array(pt);
|
|
} catch {
|
|
throw new WrongPasswordError();
|
|
}
|
|
}
|
|
|
|
// JSON convenience: encrypt/decrypt a JS value as UTF-8 JSON. We use this to seal
|
|
// the whole WalletIdentity object (the private halves) under the password.
|
|
export async function encryptJSON(
|
|
value: unknown,
|
|
password: string,
|
|
): Promise<EncryptedBlob> {
|
|
return encryptSecret(new TextEncoder().encode(JSON.stringify(value)), password);
|
|
}
|
|
|
|
export async function decryptJSON<T>(
|
|
blob: EncryptedBlob,
|
|
password: string,
|
|
): Promise<T> {
|
|
const bytes = await decryptSecret(blob, password);
|
|
return JSON.parse(new TextDecoder().decode(bytes)) as T;
|
|
}
|