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:
agent
2026-06-13 21:23:10 +02:00
commit e8e37d77fe
42 changed files with 5439 additions and 0 deletions
+124
View File
@@ -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;
}