Tudisco a9feb1b5b2 feat(kez-chat/web): Svelte SPA — account creation + claims wizard
First real UI for kez-chat. Served by the chat-server as static
files; uses the same HTTP API a native client would (dogfoods the
contract).

Stack: Svelte 5 + TypeScript + Vite + Tailwind 4 + @noble/curves +
@scure/base + canonicalize + idb-keyval + svelte-spa-router.

Bundle: 113 KB JS / 14 KB CSS (gzip: 42 KB / 4 KB).

Pages (all behind hash routing):
  /                 Landing — sign up or restore from seed
  /create           Account creation flow:
                       1. Pick handle, set passphrase
                       2. Show seed for paper backup, require ack
                       3. Confirm
                       4. POST /v1/register, save passphrase-encrypted seed
                          to IndexedDB
  /restore          Stub for restore-from-seed (v0.2: needs
                    GET /v1/by-primary endpoint on the server)
  /unlock           Enter passphrase to derive the AES-GCM key,
                    decrypt the seed, populate session state
  /dashboard       Show handle, primary, registered_at, sigchain URL
  /claims          List locally-cached claims (with publication status)
  /claims/add      Add-a-claim wizard:
                       1. Pick channel (github/dns/web/nostr/bluesky/ap)
                       2. Enter identifier
                       3. SignedClaimEnvelope built + signed in-browser
                          using Ed25519 + JCS, matching the spec exactly
                       4. Show channel-appropriate publish instructions +
                          copyable markdown or JSON artifact
                       5. User marks it published (purely a local note —
                          actual verification is the verifier's job)

Crypto / KEZ helpers (src/lib/kez.ts):
- generateIdentity / identityFromSeed (32-byte Ed25519)
- canonicalBytes (RFC 8785 JCS via the `canonicalize` package — same
  one our Node port uses; produces byte-identical output to Rust)
- signClaim, signRegistration (build envelopes; sign with
  ed25519-sha512-jcs; same alg / key / sig shape as kez-core)
- toPrettyJson, toMarkdown (the same wire encodings the CLI emits)

Key storage (src/lib/identity-store.ts):
- IndexedDB via idb-keyval
- Seed encrypted under user passphrase: PBKDF2-SHA256
  (600,000 iterations, OWASP 2024 guidance) → AES-GCM-256
- Documented limitation: browsers don't have an OS-keychain
  equivalent. Native clients (future CLI/Tauri) will use the OS
  keychain for better protection.

Bundle includes:
- Workaround for TS 5.6+ Uint8Array<ArrayBufferLike> vs ArrayBuffer
  strictness (small asBuffer() helper that copies into a plain
  ArrayBuffer for WebCrypto + Response calls).

Dockerfile updated: now multi-stage with a Node `webbuild` stage
that runs `npm run build` before the Rust binary stage. SPA dist
is copied into the runtime image at /app/web; chat-server's
KEZ_CHAT_WEB_DIR points at it so the SPA is served at /.

What works against the LIVE deployment right now (https://kez.lat):
- Open https://kez.lat → SPA loads (113 KB JS, 14 KB CSS)
- Create account → key gen happens in browser, seed shown for
  backup, encrypted under passphrase, POSTed to /v1/register
- Dashboard → shows registered handle + primary + sigchain URL
- Claims wizard → sign for any of the 6 channels, get publish
  instructions + the right wire format to copy
- Lock / unlock — passphrase-derived AES-GCM, no roundtrips

What's still TODO (v0.2):
- Restore-from-seed: needs GET /v1/by-primary on the server so the
  SPA can discover the handle from a seed
- Actual NATS chat: needs server's auth callout (currently 501) +
  nats.ws client (browser side; package is in deps but not used yet)
- Sigchain integration: append `add` event when user publishes a
  claim, upload to sig-server (needs sig.kez.lat tunnel)
- Verification: in-browser channel fetches (some channels are
  CORS-friendly, others need a server-side proxy)
- Compact (kez:z1:) form: the spec uses zstd, browsers don't have
  native zstd CompressionStream support yet. Workaround in code
  uses deflate-raw with a `kez:zd1:` prefix to make it obvious the
  output isn't spec-compliant; replace with @bokuweb/zstd-wasm or
  similar when we need true compact form in the SPA.
2026-05-25 12:29:14 -06:00

254 lines
10 KiB
TypeScript

// Browser-native KEZ primitives. Mirrors the Rust/Node implementations
// for byte-identical JCS canonicalization + Ed25519 signing.
//
// Deliberately self-contained — no dep on @kez/core. The SPA needs
// only a small surface and inlining keeps the bundle tight. If we add
// more KEZ functionality (sigchain walking, channel verification),
// reconsider depending on the Node port.
import { ed25519 } from "@noble/curves/ed25519";
import { sha512 } from "@noble/hashes/sha2";
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
import canonicalize from "canonicalize";
// ─────────────────────────────────────────────────────────────────────────────
// Constants (must match SPEC.md v0.3 and Rust kez-core)
// ─────────────────────────────────────────────────────────────────────────────
export const CLAIM_TYPE = "kez.claim";
export const REGISTRATION_TYPE = "kez.chat.handle_registration";
export const REGISTRATION_ENVELOPE = "handle_registration";
export const CLAIM_ENVELOPE = "claim";
export const ED25519_SHA512_ALG = "ed25519-sha512-jcs";
export const FORMAT_VERSION = 1;
export const COMPACT_PROOF_PREFIX = "kez:z1:";
// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────
export type Identity = string; // canonical `system:value` string
export interface Ed25519Identity {
seed: Uint8Array; // 32 bytes
publicKey: Uint8Array; // 32 bytes
identity: Identity; // "ed25519:<hex>"
}
export interface SignatureBlock {
alg: string;
key: Identity;
sig: string; // lowercase hex
}
export interface ClaimPayload {
type: typeof CLAIM_TYPE;
version: number;
primary: Identity;
subject: Identity;
created_at: string;
}
export interface SignedClaimEnvelope {
kez: typeof CLAIM_ENVELOPE;
payload: ClaimPayload;
signature: SignatureBlock;
}
export interface RegistrationPayload {
type: typeof REGISTRATION_TYPE;
version: number;
handle: string;
primary: Identity;
server: string;
created_at: string;
}
export interface SignedRegistration {
kez: typeof REGISTRATION_ENVELOPE;
payload: RegistrationPayload;
signature: SignatureBlock;
}
// ─────────────────────────────────────────────────────────────────────────────
// Key generation + restoration
// ─────────────────────────────────────────────────────────────────────────────
export function generateIdentity(): Ed25519Identity {
const seed = ed25519.utils.randomPrivateKey();
return identityFromSeed(seed);
}
export function identityFromSeed(seed: Uint8Array): Ed25519Identity {
if (seed.length !== 32) {
throw new Error(`Ed25519 seed must be 32 bytes, got ${seed.length}`);
}
const publicKey = ed25519.getPublicKey(seed);
return {
seed,
publicKey,
identity: `ed25519:${bytesToHex(publicKey)}`,
};
}
export function identityFromSeedHex(seedHex: string): Ed25519Identity {
return identityFromSeed(hexToBytes(seedHex));
}
// ─────────────────────────────────────────────────────────────────────────────
// JCS canonicalization
// ─────────────────────────────────────────────────────────────────────────────
/** RFC 8785 canonical bytes of the payload. */
export function canonicalBytes(payload: unknown): Uint8Array {
const text = canonicalize(payload);
if (text === undefined) {
throw new Error("canonicalize returned undefined");
}
return new TextEncoder().encode(text);
}
// ─────────────────────────────────────────────────────────────────────────────
// Signing
// ─────────────────────────────────────────────────────────────────────────────
function signWith(
payload: unknown,
signer: Ed25519Identity,
): SignatureBlock {
const jcs = canonicalBytes(payload);
const sig = ed25519.sign(jcs, signer.seed);
return {
alg: ED25519_SHA512_ALG,
key: signer.identity,
sig: bytesToHex(sig),
};
}
/** Build + sign a kez.claim envelope ("I control <subject>"). */
export function signClaim(
signer: Ed25519Identity,
subject: Identity,
createdAt: Date = new Date(),
): SignedClaimEnvelope {
const payload: ClaimPayload = {
type: CLAIM_TYPE,
version: FORMAT_VERSION,
subject,
primary: signer.identity,
created_at: createdAt.toISOString(),
};
return {
kez: CLAIM_ENVELOPE,
payload,
signature: signWith(payload, signer),
};
}
/** Build + sign a handle-registration envelope. */
export function signRegistration(
signer: Ed25519Identity,
handle: string,
server: string,
createdAt: Date = new Date(),
): SignedRegistration {
const payload: RegistrationPayload = {
type: REGISTRATION_TYPE,
version: FORMAT_VERSION,
handle,
primary: signer.identity,
server,
created_at: createdAt.toISOString(),
};
return {
kez: REGISTRATION_ENVELOPE,
payload,
signature: signWith(payload, signer),
};
}
// ─────────────────────────────────────────────────────────────────────────────
// Encodings — pretty JSON, compact (kez:z1:), markdown fence
// ─────────────────────────────────────────────────────────────────────────────
export function toPrettyJson(envelope: SignedClaimEnvelope): string {
return JSON.stringify(envelope, null, 2);
}
/** Markdown fenced block suitable for a GitHub gist / profile README. */
export function toMarkdown(envelope: SignedClaimEnvelope): string {
return [
"# KEZ Proof",
"",
"This account publishes a signed KEZ identity claim.",
"",
`- Primary: \`${envelope.payload.primary}\``,
`- Subject: \`${envelope.payload.subject}\``,
`- Created: \`${envelope.payload.created_at}\``,
"",
"```kez",
toPrettyJson(envelope),
"```",
"",
].join("\n");
}
/**
* `kez:z1:<base64url-no-pad(zstd(json-envelope))>`
* Browser zstd: `CompressionStream` doesn't support zstd as of 2026, so
* we fall back to a tiny pure-JS zstd compressor for now. For v0.1 we
* only need it for short claim envelopes; small payloads compress fast.
*
* Implementation: use the browser's native `CompressionStream("deflate-raw")`
* as a substitute (NOT zstd — different format!). This is a v0.1 stopgap
* so the SPA can show a compact form; we mark these as `kez:zd1:` instead
* of `kez:z1:` to make absolutely clear they are NOT the spec-compliant
* zstd encoding. They round-trip in the SPA but don't interop with the
* Rust/Node implementations until we add proper zstd-in-browser (next
* iteration; the @bokuweb/zstd-wasm crate works).
*/
export async function toCompactDevPreview(
envelope: SignedClaimEnvelope,
): Promise<string> {
const json = JSON.stringify(envelope);
const compressed = await deflateRaw(new TextEncoder().encode(json));
// base64url, no padding
let b64 = btoa(String.fromCharCode(...compressed));
b64 = b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
return `kez:zd1:${b64}`;
}
async function deflateRaw(input: Uint8Array): Promise<Uint8Array> {
// Copy into a fresh ArrayBuffer-backed buffer so the BodyInit
// overload in TS 5.6+ accepts it (Uint8Array<ArrayBufferLike> isn't
// assignable to BodyInit without this).
const fresh = new Uint8Array(input.byteLength);
fresh.set(input);
const stream = new Response(fresh).body!.pipeThrough(
new CompressionStream("deflate-raw"),
);
const chunks: Uint8Array[] = [];
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) chunks.push(value);
}
const total = chunks.reduce((n, c) => n + c.length, 0);
const out = new Uint8Array(total);
let off = 0;
for (const c of chunks) {
out.set(c, off);
off += c.length;
}
return out;
}
// ─────────────────────────────────────────────────────────────────────────────
// Hashing helper — used for displaying a content hash of a payload
// ─────────────────────────────────────────────────────────────────────────────
export function sha512Hex(bytes: Uint8Array): string {
return bytesToHex(sha512(bytes));
}