feat: initial scaffold of uniweb — unibus web frontend (SPA + gateway)
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.
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user