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.
254 lines
10 KiB
TypeScript
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));
|
|
}
|