diff --git a/kez-chat/src/api.rs b/kez-chat/src/api.rs index 06b5062..cb8ef91 100644 --- a/kez-chat/src/api.rs +++ b/kez-chat/src/api.rs @@ -41,6 +41,7 @@ pub fn router(state: AppState) -> axum::Router { .route("/v1/u/:handle", get(lookup)) .route("/v1/by-primary/:primary", get(lookup_by_primary)) .route("/v1/register", post(register)) + .route("/v1/profile/:handle/proofs", axum::routing::put(set_proofs)) .route("/v1/messages", post(crate::messages::send_message)) .route("/v1/inbox/:handle", get(crate::messages::inbox)) .route("/v1/inbox/:handle/stream", get(crate::messages::stream_inbox)) @@ -81,6 +82,9 @@ pub struct HandleResponse { pub primary: String, // e.g. "ed25519:abc..." pub sigchain_url: String, // where the sigchain lives pub registered_at: String, + /// Claim subjects the user published for discovery (e.g. + /// ["github:alice"]). Peers verify each independently. Empty if none. + pub proofs: Vec, } async fn lookup( @@ -126,6 +130,11 @@ fn handle_response(config: &Config, record: &HandleRecord) -> HandleResponse { id ), registered_at: record.registered_at.to_rfc3339(), + proofs: record + .proofs + .as_deref() + .and_then(|s| serde_json::from_str::>(s).ok()) + .unwrap_or_default(), } } @@ -154,6 +163,7 @@ async fn register( handle: req.payload.handle.clone(), primary: req.payload.primary.clone(), registered_at: Utc::now(), + proofs: None, }; state.store.register(&record).await?; @@ -163,6 +173,72 @@ async fn register( )) } +// ───────────────────────────────────────────────────────────────────────────── +// PUT /v1/profile/:handle/proofs — publish your claim subjects (authed) +// ───────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct SetProofsRequest { + /// Claim subjects, e.g. ["github:alice","dns:alice.com"]. + pub proofs: Vec, +} + +async fn set_proofs( + State(state): State, + Path(handle): Path, + headers: axum::http::HeaderMap, + Json(req): Json, +) -> Result { + validate_handle(&handle) + .map_err(|e| ApiError::BadRequest(format!("invalid handle: {e}")))?; + + let record = state.store.lookup(&handle).await?.ok_or(ApiError::NotFound)?; + + // Auth: X-KEZ-Auth: :, signed by the handle's primary + // over the canonical request line. Same 60s skew window as inbox. + let auth = headers + .get("X-KEZ-Auth") + .ok_or_else(|| ApiError::Unauthorized("missing X-KEZ-Auth header".into()))? + .to_str() + .map_err(|_| ApiError::Unauthorized("non-ASCII X-KEZ-Auth".into()))?; + verify_profile_auth(auth, &handle, record.primary.value(), Utc::now().timestamp())?; + + // Cap to keep profiles small; reject absurd payloads. + if req.proofs.len() > 64 { + return Err(ApiError::BadRequest("too many proofs (max 64)".into())); + } + let json = serde_json::to_string(&req.proofs)?; + state.store.set_proofs(&handle, &json).await?; + Ok(StatusCode::NO_CONTENT) +} + +/// Canonical message the proofs-setter signs. Distinct first line from +/// the inbox/stream auth so signatures can't be cross-replayed. +pub fn canonical_profile_message(handle: &str, ts: i64) -> String { + format!("PUT\n/v1/profile/{handle}/proofs\n{ts}") +} + +fn verify_profile_auth( + auth: &str, + handle: &str, + pubkey_hex: &str, + now_ts: i64, +) -> Result<(), ApiError> { + let (ts_str, sig_hex) = auth + .split_once(':') + .ok_or_else(|| ApiError::Unauthorized("X-KEZ-Auth must be :".into()))?; + let ts: i64 = ts_str + .parse() + .map_err(|_| ApiError::Unauthorized("auth ts must be a unix timestamp".into()))?; + if (now_ts - ts).abs() > 60 { + return Err(ApiError::Unauthorized("auth header is stale".into())); + } + let message = canonical_profile_message(handle, ts); + kez_core::verify_ed25519_hex(pubkey_hex, message.as_bytes(), sig_hex) + .map_err(|_| ApiError::Unauthorized("signature did not verify".into()))?; + Ok(()) +} + // ───────────────────────────────────────────────────────────────────────────── // GET /.well-known/webfinger — fediverse-style discovery // ───────────────────────────────────────────────────────────────────────────── diff --git a/kez-chat/src/store.rs b/kez-chat/src/store.rs index e6d5ce4..6cf95ca 100644 --- a/kez-chat/src/store.rs +++ b/kez-chat/src/store.rs @@ -15,6 +15,10 @@ pub struct HandleRecord { pub handle: String, pub primary: Identity, pub registered_at: DateTime, + /// JSON array of claim subjects the user has published to their profile + /// (e.g. ["github:alice","dns:alice.com"]). Discovery only — peers + /// independently verify each against the channel. None until set. + pub proofs: Option, } #[derive(Clone)] @@ -69,36 +73,26 @@ impl Store { let conn = self.inner.lock().await; let row = conn .query_row( - "SELECT handle, primary_id, registered_at + "SELECT handle, primary_id, registered_at, proofs FROM handles WHERE handle = ?1", params![handle], - |row| { - let handle: String = row.get(0)?; - let primary_id: String = row.get(1)?; - let registered_at: String = row.get(2)?; - Ok((handle, primary_id, registered_at)) - }, + row_to_record_parts, ) .optional()?; + row.map(build_record).transpose() + } - match row { - None => Ok(None), - Some((handle, primary_id, registered_at)) => { - let primary = Identity::parse(primary_id).map_err(|e| { - ApiError::Internal(format!("stored primary not parseable: {e}")) - })?; - let registered_at = DateTime::parse_from_rfc3339(®istered_at) - .map_err(|e| { - ApiError::Internal(format!("stored timestamp not parseable: {e}")) - })? - .with_timezone(&Utc); - Ok(Some(HandleRecord { - handle, - primary, - registered_at, - })) - } + /// Replace the published proof-subject list for `handle`. + pub async fn set_proofs(&self, handle: &str, proofs_json: &str) -> Result<(), ApiError> { + let conn = self.inner.lock().await; + let n = conn.execute( + "UPDATE handles SET proofs = ?1 WHERE handle = ?2", + params![proofs_json, handle], + )?; + if n == 0 { + return Err(ApiError::NotFound); } + Ok(()) } /// Look up the record for a primary key — used by the NATS auth @@ -111,45 +105,48 @@ impl Store { let conn = self.inner.lock().await; let row = conn .query_row( - "SELECT handle, primary_id, registered_at + "SELECT handle, primary_id, registered_at, proofs FROM handles WHERE primary_id = ?1", params![primary.to_string()], - |row| { - let handle: String = row.get(0)?; - let primary_id: String = row.get(1)?; - let registered_at: String = row.get(2)?; - Ok((handle, primary_id, registered_at)) - }, + row_to_record_parts, ) .optional()?; - - match row { - None => Ok(None), - Some((handle, primary_id, registered_at)) => { - let primary = Identity::parse(primary_id).map_err(|e| { - ApiError::Internal(format!("stored primary not parseable: {e}")) - })?; - let registered_at = DateTime::parse_from_rfc3339(®istered_at) - .map_err(|e| { - ApiError::Internal(format!("stored timestamp not parseable: {e}")) - })? - .with_timezone(&Utc); - Ok(Some(HandleRecord { - handle, - primary, - registered_at, - })) - } - } + row.map(build_record).transpose() } } +type RecordParts = (String, String, String, Option); + +fn row_to_record_parts(row: &rusqlite::Row) -> rusqlite::Result { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) +} + +fn build_record(parts: RecordParts) -> Result { + let (handle, primary_id, registered_at, proofs) = parts; + let primary = Identity::parse(primary_id) + .map_err(|e| ApiError::Internal(format!("stored primary not parseable: {e}")))?; + let registered_at = DateTime::parse_from_rfc3339(®istered_at) + .map_err(|e| ApiError::Internal(format!("stored timestamp not parseable: {e}")))? + .with_timezone(&Utc); + Ok(HandleRecord { + handle, + primary, + registered_at, + proofs, + }) +} + fn init_schema(conn: &Connection) -> Result<(), rusqlite::Error> { + // New column for existing databases — ignore "duplicate column" so + // re-running on an already-migrated DB is a no-op. + let _ = conn.execute("ALTER TABLE handles ADD COLUMN proofs TEXT", []); + conn.execute_batch( "CREATE TABLE IF NOT EXISTS handles ( handle TEXT NOT NULL PRIMARY KEY, primary_id TEXT NOT NULL UNIQUE, - registered_at TEXT NOT NULL + registered_at TEXT NOT NULL, + proofs TEXT ); CREATE INDEX IF NOT EXISTS idx_handles_primary ON handles (primary_id); diff --git a/kez-chat/web/.env b/kez-chat/web/.env new file mode 100644 index 0000000..bc06b47 --- /dev/null +++ b/kez-chat/web/.env @@ -0,0 +1,9 @@ +# Nostr branch: chat transport runs over Nostr relays instead of the +# kez-chat server inbox. The code default (see src/lib/transport.ts) is +# still "server", so main/other branches are unaffected — this file is +# what flips this branch to Nostr. +VITE_TRANSPORT=nostr + +# Relays to publish to / read from (comma-separated). Optional — the +# transport falls back to these same defaults if unset. +VITE_NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net diff --git a/kez-chat/web/DESIGN.md b/kez-chat/web/DESIGN.md new file mode 100644 index 0000000..e605e63 --- /dev/null +++ b/kez-chat/web/DESIGN.md @@ -0,0 +1,167 @@ +# KEZ Design System + +> Source of truth for the kez-chat redesign (`redesign-kez-theme` branch). +> Goal: take the chat app out of the prototype phase into a real, +> WhatsApp/Discord-caliber messenger with an iconic KEZ identity. + +## Who we're designing for + +Hackers, infosec people, privacy absolutists, anti-surveillance / sovereignty +folks, Meshtastic & off-grid comms operators, journalists/activists in hostile +environments — the Signal / Briar / Tails / Mullvad / Monero crowd. They trust +**verifiability over promises**, have a finely-tuned bullshit detector, and +bounce instantly from anything that smells like VC surveillance-ware. + +**Positioning:** _KEZ is the sovereign identity layer + encrypted comms for +people who assume the network is hostile._ + +## Aesthetic direction + +**Muted tactical terminal — restraint, not neon cosplay.** Mullvad's calm +authority. Monospace as identity. The first 3 seconds should feel like opening +an operational tool, not a brochure. Hard-ish edges, visible structure, a single +cold accent. No gradients-blobs, no mascots, no "delightful." + +### Hard DO-NOTs +- No rounded-blob/gradient SaaS look, no mascots, no illustrations of laughing people. +- No "military-grade / bank-level" marketing adjectives. Show, don't boast. +- No surveillance tells: no third-party analytics, no social login, no email-required signup. +- No stock photography. + +## Color palette (dark-first; light theme = v2, out of scope) + +Tailwind v4 `@theme` tokens, in `src/app.css`. + +| Token | Hex | Use | +|---|---|---| +| `--color-bg` | `#0B0C0E` | app background (neutral near-black) | +| `--color-surface` | `#16181C` | cards, conversation list, sidebars | +| `--color-elevated` | `#1E2127` | modals, menus, input wells | +| `--color-border` | `#2A2E35` | hairlines, dividers | +| `--color-text` | `#E8EAED` | primary text (neutral off-white) | +| `--color-text-secondary` | `#9BA3AD` | secondary | +| `--color-text-muted` | `#5C636D` | timestamps, meta | +| `--color-text-disabled` | `#3A4049` | disabled | +| `--color-accent` | `#28C8E8` | **the KEZ color** — electric cyan | +| `--color-accent-dim` | `#1B9DBC` | hover/pressed, accent borders | +| `--color-accent-contrast` | `#04131A` | text on accent fills | +| `--color-verified` | `#4ADE80` | proof verified (distinct from accent) | +| `--color-danger` | `#FF5C6C` | destructive, failed | +| `--color-warning` | `#FFB13D` | needs-attention | +| `--color-bubble-recv` | `#1B1F25` | received message bubble fill | + +Accent is used surgically: send bubbles, focus rings, active nav, the wordmark +cursor, links, live/streaming indicators. Greys carry the weight. **Verified +green is for proofs only** — never as a general accent, so a verification badge +never camouflages into accent UI. + +## Typography + +- **UI/body:** `Inter` — `--font-sans: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif` +- **Monospace:** `JetBrains Mono` — `--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace`. Used for keys, hashes, handles, the wordmark. + +Loaded via Google Fonts. + +| Role | Size / weight / line-height | Notes | +|---|---|---| +| Wordmark | 22px / 700 / 1.1 mono | `-0.02em`, + cyan block cursor | +| Section header | 12px / 600 / 1.3 sans | uppercase, `+0.08em`, secondary color | +| Body / message | 15px / 450 / 1.45 sans | | +| Conversation name | 15px / 600 / 1.3 sans | | +| Timestamp/meta | 12px / 500 / 1.2 | muted | +| Mono key display | 13px / 500 / 1.5 mono | `+0.01em` | + +## Spacing / radius / shadow + +- **Spacing** (4px base): 4, 8, 12, 16, 20, 24, 32, 48. +- **Radius — tactical, mid-soft:** `--radius-sm: 4px` (chips/badges), `--radius-md: 8px` (inputs/buttons), `--radius-lg: 12px` (bubbles/cards), `--radius-xl: 16px` (modals). Fully-round reads consumer-soft; sharp reads unfinished; 8–12 says "engineered." +- **Shadows = terminal glow, not ambient drop shadows.** Surfaces use `1px solid --color-border` hairlines. Modals: `0 8px 24px -8px rgba(0,0,0,0.6)`. Accent focus/glow: `0 0 0 1px #28C8E833, 0 0 16px -2px #28C8E866`. + +## Signature components + +- **Message bubbles** — radius `lg`, padding `8px 12px`, max-width ~78%. + - Sent: `--color-accent` fill, `--color-accent-contrast` text, `border-bottom-right-radius: 4px` tail. + - Received: `--color-bubble-recv` fill, `--color-text`, `1px solid --color-border`, `border-bottom-left-radius: 4px`. +- **Conversation row** — 64px tall, 40px avatar (`radius-md`), name 600, preview secondary, time muted. Active = left 2px accent bar + elevated bg. Unread = accent dot + name 700. +- **Buttons** — height 40px, `radius-md`, 600. Primary: accent fill / contrast text / dim hover / 0.98 active / glow focus. Secondary: transparent, `1px solid border`, hover elevated bg + accent-dim border. +- **Inputs** — elevated bg, `1px solid border`, `radius-md`, focus → accent border + `0 0 0 3px #28C8E822` ring. Key inputs use mono. +- **Handle/identity chip** — inline mono, `radius-sm`, `2px 8px`, bg `#28C8E814`, `1px solid #28C8E833`, accent text; leading `@`/`0x` muted. +- **Verified proof badge** — `radius-sm`, bg `#4ADE8014`, border `#4ADE8040`, `--color-verified` text, mono 11/600, leading ✓. +- **Avatars** — deterministic identicon generated from the ed25519 key, so every KEZ has a stable face. Eliminates the biggest "prototype" tell. + +## Motion + +Fast (120–200ms), `cubic-bezier(0.2,0.8,0.2,1)`, on state change only, respect +`prefers-reduced-motion`. +- Received message: slide-up 6px + fade. +- Sent message: spring `scale .96→1` + brief accent-glow pulse decaying ~600ms. +- Thread push/back slide on mobile. +- Tasteful terminal flourish: a single cyan block-cursor blink on the empty + compose field + after the wordmark. No content scanlines. + +## Wordmark / icon + +- **Wordmark:** `kez` lowercase, JetBrains Mono 700, `-0.02em`, primary text, + followed by a blinking cyan block cursor `▌`. The cursor is the brand mark. +- **Icon:** evolve the amber key → cyan. Recolor `public/kez-icon.svg` stroke + `#fbbf24` → `#28C8E8` on `#0B0C0E`; reshape so the key reads as a + key-meets-cursor glyph. Drop the literal 🔑 emoji everywhere. Regenerate PWA + icon set. Manifest `theme_color` + `background_color` → `#0B0C0E`. + +## Information architecture (the big structural change) + +**Land logged-in users on Chats, not a Dashboard.** The "dashboard" as a +destination is killed; its contents redistribute. + +### Navigation — 4 destinations +| Destination | Purpose | +|---|---| +| **Chats** | conversation list + threads (the home) | +| **Contacts** | known KEZs + verification status + start-new-chat | +| **Identity** | your KEZ + claims/proofs (the superpower surface) | +| **Settings** | security, backup, notifications, account, about | + +- **Mobile (PWA):** fixed bottom tab bar, 4 tabs, unread badge on Chats. + Thread view pushes full-screen with a back chevron. +- **Desktop:** slim left icon rail (4 destinations) + secondary list column + + main content pane. Replaces the current top nav bar entirely. + +### Feature → new home +| Existing | New home | +|---|---| +| Landing/Create/Restore/Unlock | unauthenticated flow (pre-nav), restyled | +| Messages (list+thread+compose, SSE, emoji, unread, notifications) | **Chats** (default surface) | +| Start chat by handle | **Contacts → New chat** (preview card before opening) | +| Claims list | **Identity → My proofs** (grouped verified/failed/pending) | +| AddClaim | **Identity → Add proof** | +| Identity display (handle@server, ed25519 key, registry) | **Identity** header card (avatar, copyable KEZ, fingerprint, QR) | +| Seed/key backup | **Settings → Security → Recovery phrase** (re-auth gated) | +| Biometric/passkey | **Settings → Security → App lock** | +| Notifications perm + test | **Settings → Notifications** | +| Build SHA / source link | **Settings → About** | + +### New-conversation flow (the KEZ moment) ++ FAB on Chats (mobile) / "New chat" in Contacts column (desktop) → +"Enter a KEZ" (`handle@server`, paste/QR) → `lookup` → **preview card**: +avatar, handle, key fingerprint, and **inline verified proofs** (✓ github:you, +✓ dns:yourdomain) → "Message". Verification is always one tap from a thread via +the contact-detail header. You see who someone is before you trust them. + +### Polish signals to ship +1. Identicon avatars everywhere (from ed25519 key). +2. Message status ticks (sent / SSE-delivered) + day separators. +3. Real empty + skeleton-loading states. +4. Verification shield badge system (green verified / neutral none / amber failed), consistent across Chats, Contacts, Identity. +5. Native push/back + send transitions; smooth auto-scroll (already shipped). + +## Implementation phases + +0. **Foundation** — `app.css` Tailwind v4 `@theme` tokens, Google Fonts, recolor icon + regenerate PWA assets, manifest colors. +1. **Shell + nav** — bottom tab bar / left rail, router lands on `/chats`, wordmark component. +2. **Chats** — restyle list + thread + bubbles, identicon avatars, empty/skeleton states. Keep SSE/emoji/notifications/auto-scroll. +3. **Identity** — identity card + proofs (migrate Claims/AddClaim). +4. **Settings** — Account / Security / Notifications / About (migrate Dashboard remainder). +5. **Contacts** — list + new-chat preview card with proofs. +6. **Auth flow restyle** — Landing/Create/Restore/Unlock to the new theme. + +All existing functionality is preserved; only its placement and presentation change. diff --git a/kez-chat/web/index.html b/kez-chat/web/index.html index e6be98d..b729c7f 100644 --- a/kez-chat/web/index.html +++ b/kez-chat/web/index.html @@ -7,6 +7,12 @@ kez-chat + + + + + @@ -22,7 +28,25 @@ - + + + +
diff --git a/kez-chat/web/public/apple-touch-icon-180x180.png b/kez-chat/web/public/apple-touch-icon-180x180.png index 02d42b2..4230fc2 100644 Binary files a/kez-chat/web/public/apple-touch-icon-180x180.png and b/kez-chat/web/public/apple-touch-icon-180x180.png differ diff --git a/kez-chat/web/public/kez-icon.svg b/kez-chat/web/public/kez-icon.svg index ab40845..a840b06 100644 --- a/kez-chat/web/public/kez-icon.svg +++ b/kez-chat/web/public/kez-icon.svg @@ -1,17 +1,14 @@ - - - - + + + + - - - - - - + + + + + diff --git a/kez-chat/web/public/maskable-icon-512x512.png b/kez-chat/web/public/maskable-icon-512x512.png index eedd1dd..fd5e997 100644 Binary files a/kez-chat/web/public/maskable-icon-512x512.png and b/kez-chat/web/public/maskable-icon-512x512.png differ diff --git a/kez-chat/web/public/pwa-192x192.png b/kez-chat/web/public/pwa-192x192.png index 54f8646..b5338f8 100644 Binary files a/kez-chat/web/public/pwa-192x192.png and b/kez-chat/web/public/pwa-192x192.png differ diff --git a/kez-chat/web/public/pwa-512x512.png b/kez-chat/web/public/pwa-512x512.png index 8da9eec..76f63f0 100644 Binary files a/kez-chat/web/public/pwa-512x512.png and b/kez-chat/web/public/pwa-512x512.png differ diff --git a/kez-chat/web/public/pwa-64x64.png b/kez-chat/web/public/pwa-64x64.png index 7f9108a..860578d 100644 Binary files a/kez-chat/web/public/pwa-64x64.png and b/kez-chat/web/public/pwa-64x64.png differ diff --git a/kez-chat/web/src/App.svelte b/kez-chat/web/src/App.svelte index 0445c4c..d5c1a5b 100644 --- a/kez-chat/web/src/App.svelte +++ b/kez-chat/web/src/App.svelte @@ -4,14 +4,17 @@ import { hasStoredIdentity } from "./lib/identity-store.js"; import { session } from "./lib/store.svelte.js"; import { inboxService } from "./lib/inbox-service.svelte.js"; + import Wordmark from "./lib/Wordmark.svelte"; import Landing from "./routes/Landing.svelte"; import CreateAccount from "./routes/CreateAccount.svelte"; import Restore from "./routes/Restore.svelte"; import Unlock from "./routes/Unlock.svelte"; - import Dashboard from "./routes/Dashboard.svelte"; + import Welcome from "./routes/Welcome.svelte"; + import Identity from "./routes/Identity.svelte"; import Claims from "./routes/Claims.svelte"; import AddClaim from "./routes/AddClaim.svelte"; + import Settings from "./routes/Settings.svelte"; import Messages from "./routes/Messages.svelte"; const routes = { @@ -19,83 +22,110 @@ "/create": CreateAccount, "/restore": Restore, "/unlock": Unlock, - "/dashboard": Dashboard, + "/welcome": Welcome, + "/chats": Messages, + "/identity": Identity, "/claims": Claims, "/claims/add": AddClaim, - "/messages": Messages, + "/settings": Settings, }; - // First-load: if there's a stored identity but session is locked, - // bounce to /unlock. If no stored identity and on a protected page, - // bounce to /. + // App routes show the nav chrome; everything else (auth flow) is full-bleed. + const APP_ROUTES = ["/welcome", "/chats", "/identity", "/claims", "/claims/add", "/settings"]; + const showNav = $derived(!!session.unlocked && APP_ROUTES.includes($location)); + onMount(async () => { const stored = await hasStoredIdentity(); - const protectedRoutes = ["/dashboard", "/claims", "/claims/add", "/messages"]; - if (!stored && protectedRoutes.includes($location)) { + // Redirect legacy paths. + if ($location === "/dashboard") return push(session.unlocked ? "/identity" : "/unlock"); + if ($location === "/messages") return push(session.unlocked ? "/chats" : "/unlock"); + if (!stored && APP_ROUTES.includes($location)) { push("/"); - } else if (stored && !session.unlocked && protectedRoutes.includes($location)) { + } else if (stored && !session.unlocked && APP_ROUTES.includes($location)) { push("/unlock"); } }); + + // Nav destinations — Chats / Identity / Settings. + const nav = [ + { path: "/chats", label: "Chats", badge: true }, + { path: "/identity", label: "Identity", badge: false }, + { path: "/settings", label: "Settings", badge: false }, + ]; + + function isActive(path: string): boolean { + if (path === "/identity") return $location === "/identity" || $location.startsWith("/claims"); + return $location === path; + } -
-
+ {/each} + -
- -
+ +
+
+ +
+
- +{:else} + +
+
+
+ +
+
+
+ +
+
+{/if} diff --git a/kez-chat/web/src/app.css b/kez-chat/web/src/app.css index c96b0f9..d1b3b7d 100644 --- a/kez-chat/web/src/app.css +++ b/kez-chat/web/src/app.css @@ -1,17 +1,170 @@ @import "tailwindcss"; -/* Base typography reset on top of Tailwind v4's preflight. */ +/* KEZ design tokens (see DESIGN.md). Dark-first, "muted tactical terminal." + Tailwind v4 turns each --color-* into bg-/text-/border- utilities, each + --font-* into font-*, each --radius-* into rounded-*. + NOTE: keep this @theme block free of fancy unicode comments and do NOT + put a CSS `@import url()` before it — either one stops Tailwind from + transforming @theme, and Lightning CSS then drops the whole block. + Fonts load via in index.html for that reason. */ +@theme { + /* Elevation ramp (neutral near-black) */ + --color-bg: #0b0c0e; + --color-surface: #16181c; + --color-elevated: #1e2127; + --color-border: #2a2e35; + + /* Text tiers */ + --color-text: #e8eaed; + --color-text-secondary: #9ba3ad; + --color-text-muted: #5c636d; + --color-text-disabled: #3a4049; + + /* The KEZ color — electric cyan, used surgically */ + --color-accent: #28c8e8; + --color-accent-dim: #1b9dbc; + --color-accent-contrast: #04131a; + + /* Semantic */ + --color-verified: #4ade80; /* proofs only */ + --color-danger: #ff5c6c; + --color-warning: #ffb13d; + + /* Chat */ + --color-bubble-recv: #1b1f25; + + /* Type */ + --font-sans: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace; + + /* Radius — tactical, mid-soft */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + + /* Accent glow for focus/send + modal elevation */ + --shadow-glow: 0 0 0 1px #28c8e833, 0 0 16px -2px #28c8e866; + --shadow-elev: 0 8px 24px -8px rgba(0, 0, 0, 0.6); +} + +/* ─── Base ─────────────────────────────────────────────────────────────── */ +/* Light theme — overrides the dark @theme token values. These utilities + all compile to var(--color-*), so flipping the variable values flips + the whole app. Unlayered + after @theme, so it wins regardless of + specificity. An inline script in index.html sets data-theme before + paint to avoid a flash; the Settings toggle updates it at runtime. */ +:root[data-theme="light"] { + color-scheme: light; + --color-bg: #f6f7f9; + --color-surface: #ffffff; + --color-elevated: #eef0f3; + --color-border: #dce0e6; + --color-text: #15181d; + --color-text-secondary: #5a626d; + --color-text-muted: #8b929c; + --color-text-disabled: #b8bdc5; + /* Deeper cyan so accent-as-text is legible on white; fills still read cyan. */ + --color-accent: #0e90b4; + --color-accent-dim: #0a7290; + --color-accent-contrast: #ffffff; + --color-verified: #15a34a; + --color-danger: #dc2626; + --color-warning: #c2700a; + --color-bubble-recv: #eceff3; +} + :root { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, - Roboto, sans-serif; - color: #1f2937; - background: #f9fafb; + font-family: var(--font-sans); + color: var(--color-text); + background: var(--color-bg); + color-scheme: dark; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; } body { margin: 0; + background: var(--color-bg); + color: var(--color-text); } -code, kbd, pre { - font-family: ui-monospace, "SF Mono", Menlo, monospace; +code, +kbd, +pre, +samp { + font-family: var(--font-mono); +} + +/* Dark-theme defaults for form controls so any page that doesn't set an + explicit bg (the auth + claims forms) still reads correctly. Components + that set bg-elevated/bg-surface explicitly override this. */ +input, +textarea, +select { + background-color: var(--color-elevated); + color: var(--color-text); + border-color: var(--color-border); +} +input::placeholder, +textarea::placeholder { + color: var(--color-text-muted); +} +input:focus-visible, +textarea:focus-visible, +select:focus-visible { + outline: none; + border-color: var(--color-accent); +} + +/* Cyan text-selection — reinforces the accent. */ +::selection { + background: #28c8e840; + color: var(--color-text); +} + +/* Scrollbars: thin, dark, unobtrusive. */ +* { + scrollbar-width: thin; + scrollbar-color: var(--color-border) transparent; +} +*::-webkit-scrollbar { + width: 10px; + height: 10px; +} +*::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 999px; + border: 2px solid transparent; + background-clip: content-box; +} +*::-webkit-scrollbar-thumb:hover { + background: var(--color-text-muted); + background-clip: content-box; +} + +/* Blinking block cursor for the wordmark + empty-compose flourish. */ +@keyframes kez-blink { + 0%, + 49% { + opacity: 1; + } + 50%, + 100% { + opacity: 0; + } +} +.kez-cursor { + display: inline-block; + width: 0.5em; + height: 1em; + margin-left: 0.08em; + vertical-align: text-bottom; + background: var(--color-accent); + animation: kez-blink 1.1s steps(1) infinite; +} +@media (prefers-reduced-motion: reduce) { + .kez-cursor { + animation: none; + } } diff --git a/kez-chat/web/src/lib/Avatar.svelte b/kez-chat/web/src/lib/Avatar.svelte new file mode 100644 index 0000000..9271d85 --- /dev/null +++ b/kez-chat/web/src/lib/Avatar.svelte @@ -0,0 +1,73 @@ + + + + + {#each [0, 1, 2, 3, 4] as col} + {#each [0, 1, 2, 3, 4] as row} + {#if isOn(col, row)} + + {/if} + {/each} + {/each} + diff --git a/kez-chat/web/src/lib/EmojiButton.svelte b/kez-chat/web/src/lib/EmojiButton.svelte index 11ffd78..c614419 100644 --- a/kez-chat/web/src/lib/EmojiButton.svelte +++ b/kez-chat/web/src/lib/EmojiButton.svelte @@ -5,6 +5,7 @@ // clicks are instant (module cached). import { onDestroy } from "svelte"; + import { theme } from "./theme.svelte.js"; interface Props { /** Called with the picked emoji's character. */ @@ -34,8 +35,8 @@ await import("emoji-picker-element"); pickerEl = document.createElement("emoji-picker") as HTMLElement; pickerEl.addEventListener("emoji-click", onEmojiClick as EventListener); - // Match the dark-on-light scheme of the rest of the app. - pickerEl.classList.add("light"); + // Follow the app theme (emoji-picker-element honors .light/.dark). + pickerEl.classList.add(theme.effective); } catch (e) { loadError = (e as Error).message; } finally { @@ -116,7 +117,7 @@ is themeable here. Width comes from the component itself (~340px). */ :global(emoji-picker) { height: 360px; - --background: #fff; - --border-color: #e5e7eb; + --background: var(--color-elevated); + --border-color: var(--color-border); } diff --git a/kez-chat/web/src/lib/VerifiedBadge.svelte b/kez-chat/web/src/lib/VerifiedBadge.svelte new file mode 100644 index 0000000..feac3ad --- /dev/null +++ b/kez-chat/web/src/lib/VerifiedBadge.svelte @@ -0,0 +1,27 @@ + + + + + + + diff --git a/kez-chat/web/src/lib/Wordmark.svelte b/kez-chat/web/src/lib/Wordmark.svelte new file mode 100644 index 0000000..1ace0e8 --- /dev/null +++ b/kez-chat/web/src/lib/Wordmark.svelte @@ -0,0 +1,18 @@ + + + + kez{#if cursor}{/if} + diff --git a/kez-chat/web/src/lib/api.ts b/kez-chat/web/src/lib/api.ts index 0a85170..ff06f50 100644 --- a/kez-chat/web/src/lib/api.ts +++ b/kez-chat/web/src/lib/api.ts @@ -1,6 +1,8 @@ // Thin HTTP client for kez-chat-server. Same calls a native CLI would // make — the SPA dogfoods the API surface. +import { ed25519 } from "@noble/curves/ed25519"; +import { bytesToHex } from "@noble/hashes/utils"; import type { SignedRegistration } from "./kez.js"; export interface HandleResponse { @@ -9,6 +11,8 @@ export interface HandleResponse { primary: string; sigchain_url: string; registered_at: string; + /** Claim subjects the user published for discovery, e.g. ["github:alice"]. */ + proofs: string[]; } export interface ApiErrorBody { @@ -78,6 +82,32 @@ export async function lookupByPrimary(primary: string): Promise return unwrap(resp); } +/** + * Publish your verified claim subjects to your server profile so peers + * can discover (and independently verify) them — drives the verified + * badge in chat. Authed with X-KEZ-Auth signed by your primary. + */ +export async function setProofs( + handle: string, + seed: Uint8Array, + subjects: string[], +): Promise { + const ts = Math.floor(Date.now() / 1000); + const msg = `PUT\n/v1/profile/${handle}/proofs\n${ts}`; + const sig = ed25519.sign(new TextEncoder().encode(msg), seed); + const resp = await fetch(url(`/v1/profile/${encodeURIComponent(handle)}/proofs`), { + method: "PUT", + headers: { + "content-type": "application/json", + "X-KEZ-Auth": `${ts}:${bytesToHex(sig)}`, + }, + body: JSON.stringify({ proofs: subjects }), + }); + if (!resp.ok) { + throw new ApiError(resp.status, `setProofs → ${resp.status}`); + } +} + export async function register( signed: SignedRegistration, ): Promise { diff --git a/kez-chat/web/src/lib/conversations-store.ts b/kez-chat/web/src/lib/conversations-store.ts index c920a0c..8e3230e 100644 --- a/kez-chat/web/src/lib/conversations-store.ts +++ b/kez-chat/web/src/lib/conversations-store.ts @@ -34,6 +34,10 @@ export interface Conversation { messages: ConversationMessage[]; /** Max server-seq we've processed for any message from this peer. */ last_seq: number; + /** Whether ≥1 of the peer's published proofs verified (the badge). */ + verified?: boolean; + /** ISO timestamp of the last verification check (24h cache window). */ + verified_checked_at?: string; } interface Store { @@ -99,6 +103,19 @@ export async function ensureConversation( return fresh; } +/** Record a peer's verification result (drives the verified badge). */ +export async function setVerified( + peer_primary: Identity, + verified: boolean, +): Promise { + const s = await read(); + const conv = s.by_peer[peer_primary]; + if (!conv) return; + conv.verified = verified; + conv.verified_checked_at = new Date().toISOString(); + await write(s); +} + export async function appendInbound(opts: { peer_primary: Identity; peer_handle: string; diff --git a/kez-chat/web/src/lib/inbox-service.svelte.ts b/kez-chat/web/src/lib/inbox-service.svelte.ts index e15de70..e955a92 100644 --- a/kez-chat/web/src/lib/inbox-service.svelte.ts +++ b/kez-chat/web/src/lib/inbox-service.svelte.ts @@ -27,7 +27,7 @@ import { streamInbox, type InboxMessage, type StreamHandle, -} from "./messages.js"; +} from "./transport.js"; import { lookupByPrimary } from "./api.js"; import { appendInbound, diff --git a/kez-chat/web/src/lib/nostr-id.ts b/kez-chat/web/src/lib/nostr-id.ts new file mode 100644 index 0000000..a6cd0b1 --- /dev/null +++ b/kez-chat/web/src/lib/nostr-id.ts @@ -0,0 +1,61 @@ +// Bridges the KEZ ed25519 identity onto Nostr's secp256k1 world. +// +// Nostr signs with secp256k1 (Schnorr); KEZ identities are ed25519. +// The two curves can't be cross-derived, so we can't turn someone's +// ed25519 primary into "their" Nostr pubkey. Instead: +// +// • The *signing* key is a secp256k1 key derived deterministically +// from the user's own ed25519 seed (HKDF, domain-separated). It is +// a pure transport credential — internal to this app, never the +// user's real Nostr account, and its pubkey is never advertised. +// +// • *Addressing* is done by a tag derived from the recipient's +// ed25519 PRIMARY (which is public, so any sender can compute it). +// The recipient subscribes to relays filtering on the same tag. +// +// This keeps the whole thing "internal": nothing Nostr-specific leaks +// into the UI or the KEZ identity model. Nostr is just the pipe. + +import { hkdf } from "@noble/hashes/hkdf"; +import { sha256 } from "@noble/hashes/sha2"; +import { bytesToHex } from "@noble/hashes/utils"; +import type { Identity } from "./kez.js"; + +/** Regular event kind (1000–9999 → relays persist it, which the inbox needs). */ +export const KEZ_DM_KIND = 4242; + +/** Tag name carrying the recipient address. `#h` filter on the relay side. */ +export const ADDR_TAG = "h"; + +const SIGNKEY_SALT = new TextEncoder().encode("kez-chat:nostr-signkey"); +const SIGNKEY_INFO = new TextEncoder().encode("v1"); +const ADDR_SALT = new TextEncoder().encode("kez-chat:nostr-addr"); +const ADDR_INFO = new TextEncoder().encode("v1"); + +/** + * Derive the secp256k1 secret key this identity signs Nostr events with. + * Deterministic from the 32-byte ed25519 seed, so the same account always + * produces the same Nostr signer — but it reveals nothing about the seed. + * + * HKDF output is a uniformly random 32-byte value; the probability it is + * not a valid secp256k1 scalar (≥ curve order n) is ~2⁻¹²⁸, i.e. never. + */ +export function nostrSecretFromSeed(seed: Uint8Array): Uint8Array { + if (seed.length !== 32) throw new Error(`seed must be 32 bytes, got ${seed.length}`); + return hkdf(sha256, seed, SIGNKEY_SALT, SIGNKEY_INFO, 32); +} + +/** + * Deterministic 32-byte (hex) address for a recipient, derived from their + * public ed25519 primary. Both parties can compute it: the sender from the + * directory lookup, the recipient from their own primary. Used as the value + * of the `#h` tag so a relay subscription can fan messages to the right box. + * + * It is *not* the recipient's Nostr pubkey (can't be — wrong curve); it is an + * opaque routing label. Using a hash also means the raw primary isn't sitting + * in a relay-queryable tag. + */ +export function addrFromPrimary(primary: Identity): string { + const bytes = new TextEncoder().encode(primary); + return bytesToHex(hkdf(sha256, bytes, ADDR_SALT, ADDR_INFO, 32)); +} diff --git a/kez-chat/web/src/lib/nostr-transport.ts b/kez-chat/web/src/lib/nostr-transport.ts new file mode 100644 index 0000000..c3eaea7 --- /dev/null +++ b/kez-chat/web/src/lib/nostr-transport.ts @@ -0,0 +1,243 @@ +// Nostr-relay transport. Drop-in replacement for the server inbox in +// messages.ts — same public surface (sendMessage / pollInbox / +// streamInbox / decrypt) so inbox-service and the Messages UI don't know +// or care which pipe is in use. +// +// What changes vs. the server transport: instead of POSTing the sealed +// envelope to /v1/messages and polling /v1/inbox, we publish it as the +// content of a Nostr event and subscribe to relays for events addressed +// to us. The envelope itself is byte-identical — it's still produced by +// crypto.ts (our own ed25519/x25519 + AES-GCM layer). Nostr only moves +// the bytes; our key still does the encrypting. +// +// Addressing: events carry an `#h` tag = addrFromPrimary(recipient). We +// subscribe with a `{ "#h": [myAddr] }` filter. Events are signed by a +// secp256k1 key derived from our ed25519 seed (see nostr-id.ts) purely so +// relays accept them — that key is never surfaced to the user. + +import { SimplePool, finalizeEvent, type Event, type EventTemplate } from "nostr-tools"; +import { sealMessage, type SealedEnvelope } from "./crypto.js"; +import { lookup } from "./api.js"; +import { identityFromSeed, type Identity } from "./kez.js"; +import { nostrSecretFromSeed, addrFromPrimary, KEZ_DM_KIND, ADDR_TAG } from "./nostr-id.js"; +// Decryption is transport-agnostic (just our crypto), so reuse it verbatim. +import { decrypt, type InboxMessage, type StreamHandle } from "./messages.js"; + +export { decrypt }; +export type { InboxMessage, StreamHandle }; + +/** Relays to publish to / read from. Override with VITE_NOSTR_RELAYS (csv). */ +const RELAYS: string[] = ( + import.meta.env.VITE_NOSTR_RELAYS ?? + "wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net" +) + .split(",") + .map((r: string) => r.trim()) + .filter(Boolean); + +/** One pool for the whole session — relay connections are reused. */ +let _pool: SimplePool | null = null; +function pool(): SimplePool { + if (!_pool) _pool = new SimplePool(); + return _pool; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Per-handle cursor + dedupe (localStorage, survives reloads) +// ───────────────────────────────────────────────────────────────────────────── + +const SINCE_KEY = (h: string) => `kez-chat:nostr:since:${h}`; +const SEEN_KEY = (h: string) => `kez-chat:nostr:seen:${h}`; +const SEEN_CAP = 500; + +/** Relay `since` filter (unix seconds). Start a little in the past so a + * fresh device still catches very recent messages. */ +function readSince(handle: string): number { + try { + const v = localStorage.getItem(SINCE_KEY(handle)); + return v ? parseInt(v, 10) : Math.floor(Date.now() / 1000) - 7 * 24 * 3600; + } catch { + return 0; + } +} +function bumpSince(handle: string, createdAt: number) { + try { + if (createdAt > readSince(handle)) { + localStorage.setItem(SINCE_KEY(handle), String(createdAt)); + } + } catch { + /* private mode — fine */ + } +} + +function readSeen(handle: string): Set { + try { + return new Set(JSON.parse(localStorage.getItem(SEEN_KEY(handle)) ?? "[]")); + } catch { + return new Set(); + } +} +/** Returns true if this id is new (and records it); false if already seen. */ +function markSeen(handle: string, id: string): boolean { + const seen = readSeen(handle); + if (seen.has(id)) return false; + seen.add(id); + // Bound the set so localStorage doesn't grow forever. + const arr = [...seen].slice(-SEEN_CAP); + try { + localStorage.setItem(SEEN_KEY(handle), JSON.stringify(arr)); + } catch { + /* ignore */ + } + return true; +} + +/** + * Map a Nostr event → InboxMessage. `seq` must be monotonic across sessions + * (the inbox-service notification watermark and conversations-store dedupe + * both rely on it), so we base it on created_at and spread within a second + * using the event id to avoid same-second collisions between distinct msgs. + */ +function toInboxMessage(ev: Event): InboxMessage | null { + let envelope: SealedEnvelope; + try { + envelope = JSON.parse(ev.content) as SealedEnvelope; + } catch { + return null; // not one of ours / malformed + } + const subMilli = parseInt(ev.id.slice(0, 3), 16) % 1000; + const seq = ev.created_at * 1000 + subMilli; + return { + seq, + envelope, + created_at: new Date(ev.created_at * 1000).toISOString(), + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Send +// ───────────────────────────────────────────────────────────────────────────── + +export async function sendMessage(opts: { + senderHandle: string; + senderSeed: Uint8Array; + senderPrimary: Identity; + recipient: string; + body: string; +}): Promise<{ seq: number }> { + const recipientHandle = opts.recipient.split("@")[0]; + const record = await lookup(recipientHandle); // throws on 404 + const recipientPrimary = record.primary as Identity; + + // Our own encryption layer — identical to the server transport. + const envelope = await sealMessage({ + senderSeed: opts.senderSeed, + senderPrimary: opts.senderPrimary, + recipientHandle, + recipientPrimary, + body: opts.body, + }); + + const sk = nostrSecretFromSeed(opts.senderSeed); + const tmpl: EventTemplate = { + kind: KEZ_DM_KIND, + created_at: Math.floor(Date.now() / 1000), + tags: [[ADDR_TAG, addrFromPrimary(recipientPrimary)]], + content: JSON.stringify(envelope), + }; + const signed = finalizeEvent(tmpl, sk); + + // Succeed if at least one relay accepts. + const results = await Promise.allSettled(pool().publish(RELAYS, signed)); + if (!results.some((r) => r.status === "fulfilled")) { + const why = results + .map((r) => (r.status === "rejected" ? String(r.reason) : "")) + .filter(Boolean) + .join("; "); + throw new Error(`no relay accepted the message${why ? `: ${why}` : ""}`); + } + return { seq: signed.created_at }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Inbox poll (one-shot relay query — the heartbeat catch-up path) +// ───────────────────────────────────────────────────────────────────────────── + +export async function pollInbox(opts: { + handle: string; + seed: Uint8Array; + since: number; // ignored: server uses a seq cursor, we keep a time cursor + limit?: number; +}): Promise<{ messages: InboxMessage[]; cursor: number }> { + const myPrimary = identityFromSeed(opts.seed).identity; + const addr = addrFromPrimary(myPrimary); + const since = readSince(opts.handle); + + const events = await pool().querySync(RELAYS, { + kinds: [KEZ_DM_KIND], + [`#${ADDR_TAG}`]: [addr], + since, + ...(opts.limit ? { limit: opts.limit } : {}), + }); + + const messages: InboxMessage[] = []; + let maxSeq = 0; + for (const ev of events.sort((a, b) => a.created_at - b.created_at)) { + if (!markSeen(opts.handle, ev.id)) continue; + const m = toInboxMessage(ev); + if (!m) continue; + messages.push(m); + bumpSince(opts.handle, ev.created_at); + if (m.seq > maxSeq) maxSeq = m.seq; + } + return { messages, cursor: maxSeq }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Live subscription (the SSE-equivalent push path) +// ───────────────────────────────────────────────────────────────────────────── + +export function streamInbox(opts: { + handle: string; + seed: Uint8Array; + onMessage: (msg: InboxMessage) => void; + onStatus?: (status: "connecting" | "live" | "reconnecting") => void; +}): StreamHandle { + const myPrimary = identityFromSeed(opts.seed).identity; + const addr = addrFromPrimary(myPrimary); + let closed = false; + + opts.onStatus?.("connecting"); + const sub = pool().subscribeMany( + RELAYS, + { kinds: [KEZ_DM_KIND], [`#${ADDR_TAG}`]: [addr], since: readSince(opts.handle) }, + { + onevent(ev: Event) { + if (closed) return; + if (!markSeen(opts.handle, ev.id)) return; + const m = toInboxMessage(ev); + if (!m) return; + bumpSince(opts.handle, ev.created_at); + opts.onMessage(m); + }, + oneose() { + // End of stored events from all relays → we're now live-tailing. + opts.onStatus?.("live"); + }, + }, + ); + + return { + close() { + closed = true; + sub.close(); + }, + get readyState() { + // EventSource-compatible: OPEN(1) while subscribed, CLOSED(2) after. + return closed ? 2 : 1; + }, + }; +} + +export type { SealedEnvelope }; +export type { MessagePlaintext } from "./crypto.js"; diff --git a/kez-chat/web/src/lib/onboarding.svelte.ts b/kez-chat/web/src/lib/onboarding.svelte.ts new file mode 100644 index 0000000..4e0f551 --- /dev/null +++ b/kez-chat/web/src/lib/onboarding.svelte.ts @@ -0,0 +1,48 @@ +// First-run onboarding state. Two persisted flags: +// • onboarded — user finished/skipped the Getting Started checklist +// • seedAcked — user confirmed they backed up their recovery seed +// Both in localStorage; exposed as Svelte 5 $state so the "finish setup" +// nudge reactively disappears. Everything else in the checklist derives +// from real app state (claims, biometric, notification permission). + +const ONBOARDED = "kez-chat:onboarded"; +const SEED_ACKED = "kez-chat:seed_acked"; + +function read(key: string): boolean { + try { + return localStorage.getItem(key) === "1"; + } catch { + return false; + } +} +function write(key: string, val: boolean) { + try { + if (val) localStorage.setItem(key, "1"); + else localStorage.removeItem(key); + } catch { + /* private mode — fine */ + } +} + +class Onboarding { + onboarded = $state(read(ONBOARDED)); + seedAcked = $state(read(SEED_ACKED)); + + finish() { + this.onboarded = true; + write(ONBOARDED, true); + } + + ackSeed() { + this.seedAcked = true; + write(SEED_ACKED, true); + } + + /** Re-open the checklist (e.g. from Settings → Getting started). */ + reopen() { + this.onboarded = false; + write(ONBOARDED, false); + } +} + +export const onboarding = new Onboarding(); diff --git a/kez-chat/web/src/lib/theme.svelte.ts b/kez-chat/web/src/lib/theme.svelte.ts new file mode 100644 index 0000000..e5999a9 --- /dev/null +++ b/kez-chat/web/src/lib/theme.svelte.ts @@ -0,0 +1,73 @@ +// Light / dark / system theme switching. +// +// The CSS lives in app.css: dark is the @theme default, and a +// :root[data-theme="light"] block overrides the token values. This +// module just decides which data-theme attribute to set on and +// persists the user's choice. +// +// "system" follows prefers-color-scheme live (re-applies when the OS +// flips). An inline script in index.html sets the initial attribute +// before first paint to avoid a flash of the wrong theme. + +export type ThemeChoice = "light" | "dark" | "system"; + +const KEY = "kez-chat:theme"; + +function systemPrefersLight(): boolean { + return ( + typeof matchMedia !== "undefined" && + matchMedia("(prefers-color-scheme: light)").matches + ); +} + +function effectiveOf(choice: ThemeChoice): "light" | "dark" { + if (choice === "system") return systemPrefersLight() ? "light" : "dark"; + return choice; +} + +function loadChoice(): ThemeChoice { + const v = (typeof localStorage !== "undefined" && localStorage.getItem(KEY)) || ""; + return v === "light" || v === "dark" || v === "system" ? v : "system"; +} + +class ThemeStore { + choice = $state(loadChoice()); + /** The resolved theme actually applied right now. */ + effective = $state<"light" | "dark">(effectiveOf(loadChoice())); + + constructor() { + // Re-apply when the OS theme changes, but only while on "system". + if (typeof matchMedia !== "undefined") { + matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => { + if (this.choice === "system") this.#apply(); + }); + } + } + + set(choice: ThemeChoice) { + this.choice = choice; + try { + localStorage.setItem(KEY, choice); + } catch { + /* private mode / storage disabled — fine, just won't persist */ + } + this.#apply(); + } + + #apply() { + this.effective = effectiveOf(this.choice); + if (typeof document !== "undefined") { + document.documentElement.dataset.theme = this.effective; + // Keep the mobile browser-chrome (status bar) color in sync. + const meta = document.querySelector('meta[name="theme-color"]'); + if (meta) { + meta.setAttribute( + "content", + this.effective === "light" ? "#f6f7f9" : "#0b0c0e", + ); + } + } + } +} + +export const theme = new ThemeStore(); diff --git a/kez-chat/web/src/lib/transport.ts b/kez-chat/web/src/lib/transport.ts new file mode 100644 index 0000000..af7a379 --- /dev/null +++ b/kez-chat/web/src/lib/transport.ts @@ -0,0 +1,28 @@ +// Transport facade. The rest of the app (inbox-service, Messages) imports +// send/poll/stream/decrypt from here and never learns which pipe carries +// the bytes. Both transports expose an identical surface and both ship the +// same sealed envelope from crypto.ts — only the delivery mechanism differs. +// +// VITE_TRANSPORT=server (default) → kez-chat server inbox over HTTP/SSE +// VITE_TRANSPORT=nostr → Nostr relays +// +// Set it in .env / .env.local. Switching transports is build-time; there's +// no reason to flip it at runtime and keeping it static lets Vite tree-shake +// the unused transport out of the bundle. + +import * as server from "./messages.js"; +import * as nostr from "./nostr-transport.js"; + +const TRANSPORT = (import.meta.env.VITE_TRANSPORT ?? "server") as "server" | "nostr"; + +const impl = TRANSPORT === "nostr" ? nostr : server; + +export const sendMessage = impl.sendMessage; +export const pollInbox = impl.pollInbox; +export const streamInbox = impl.streamInbox; +export const decrypt = impl.decrypt; + +export type { InboxMessage, StreamHandle, SealedEnvelope, MessagePlaintext } from "./messages.js"; + +/** Which transport this build is using — handy for a debug line in the UI. */ +export const activeTransport = TRANSPORT; diff --git a/kez-chat/web/src/lib/verify.ts b/kez-chat/web/src/lib/verify.ts index e3b7a14..7bc4453 100644 --- a/kez-chat/web/src/lib/verify.ts +++ b/kez-chat/web/src/lib/verify.ts @@ -48,4 +48,34 @@ export async function verifyClaim(claim: StoredClaim): Promise { } } +/** + * Verify that `primary` genuinely controls `subject` by fetching the + * proof from the subject's channel and checking the signature — same + * real check as verifyClaim, but driven by just (subject, primary) + * since we don't hold a peer's envelope locally. Used for the verified + * badge on other people in chat. + * + * The channel is the subject prefix (github:, dns:, …). Verifiers read + * ctx.subject + ctx.primary; `expected` is unused by them, so a stub is + * fine here. + */ +export async function verifySubject( + subject: string, + primary: string, +): Promise { + const channel = subject.split(":")[0]; + const handler = REGISTRY[channel]; + if (!handler) return false; + try { + const res = await handler({ + subject, + primary, + expected: undefined as never, + }); + return res.status === "ok"; + } catch { + return false; + } +} + export type { VerifyResult } from "./verifiers/types.js"; diff --git a/kez-chat/web/src/main.ts b/kez-chat/web/src/main.ts index 2930448..b7300a3 100644 --- a/kez-chat/web/src/main.ts +++ b/kez-chat/web/src/main.ts @@ -1,6 +1,10 @@ import { mount } from "svelte"; import App from "./App.svelte"; import "./app.css"; +// Side-effect: instantiate the theme store so its prefers-color-scheme +// listener is live app-wide (the no-flash initial is set by the inline +// script in index.html). +import "./lib/theme.svelte.js"; const app = mount(App, { target: document.getElementById("app")!, diff --git a/kez-chat/web/src/routes/AddClaim.svelte b/kez-chat/web/src/routes/AddClaim.svelte index 08d311c..30761e5 100644 --- a/kez-chat/web/src/routes/AddClaim.svelte +++ b/kez-chat/web/src/routes/AddClaim.svelte @@ -246,9 +246,9 @@
-

Add a claim

+

Add a claim

-
    -
  1. 1. Channel
  2. +
      +
    1. 1. Channel
    2. -
    3. 2. Identifier
    4. +
    5. 2. Identifier
    6. -
    7. 3. Publish
    8. +
    9. 3. Publish
    10. -
    11. 4. Done
    12. +
    13. 4. Done
    {#if step === "pick"}
    {#each CHANNELS as c} {/each}
    @@ -286,7 +286,7 @@ onsubmit={(e) => { e.preventDefault(); buildAndSign(); }} >
    -
    @@ -317,14 +317,14 @@
    @@ -367,20 +367,20 @@
    {#await renderArtifact(envelope, format)} -
    Computing…
    +
    Computing…
    {:then text} -
    {text}
    +
    {text}
    {#if format === "compact"} -

    +

    {text.length} chars · zstd-compressed signed envelope, base64url-encoded.

    {/if} {:catch e} -
    Error: {e.message}
    +
    Error: {e.message}
    {/await}
{#if selected.key === "nostr" && nip07Available} -
-

⚡ One-click publish via your nostr extension

-

+

+

⚡ One-click publish via your nostr extension

+

Wraps the markdown block in a normal nostr post (kind 1), asks your extension to sign it, and broadcasts to the relay pool. Verifiers (web + Rust CLI) will pick it up automatically.

{#if nostrPublish.status === "ok"} - + ✓ Posted to {nostrPublish.result.ok.length} relay(s). view on njump.me {:else if nostrPublish.status === "error"} - ✗ {nostrPublish.message} + ✗ {nostrPublish.message} {/if}
{#if nostrPublish.status === "ok" && nostrPublish.result.failed.length > 0} -

+

{nostrPublish.result.failed.length} relay(s) didn't ack: {nostrPublish.result.failed.map((f) => f.relay).join(", ")}

@@ -427,15 +427,15 @@
{/if} -
+
-

+

A claim is a signed envelope that says "I control this other account." Publish the proof on the channel itself (a public gist, a DNS TXT record, a nostr event, etc.) and anyone can verify it without @@ -117,13 +117,13 @@

{#if loading} -

Loading…

+

Loading…

{:else if claims.length === 0} -
-

No claims yet.

+
+

No claims yet.

Add your first claim @@ -134,67 +134,67 @@ {@const badge = statusBadge(c)} {@const isVerifying = verifying.has(c.id)} {@const isExpanded = expanded.has(c.id)} -
  • +
  • -

    +

    {c.envelope.payload.subject}

    {badge.text}
    -

    +

    Channel: {c.channel} · Signed: {c.envelope.payload.created_at}

    {#if c.last_verify} -

    +

    {c.last_verify.summary} - · checked {formatChecked(c.last_verify.checked_at)} + · checked {formatChecked(c.last_verify.checked_at)}

    {#if c.last_verify.evidence_url || c.last_verify.details} {#if isExpanded} -
    +
    {#if c.last_verify.evidence_url}
    - Evidence URL: + Evidence URL: {c.last_verify.evidence_url}
    {/if} {#if c.last_verify.details} -
    {c.last_verify.details}
    +
    {c.last_verify.details}
    {/if}
    {/if} {/if} {:else if c.published_at} -

    +

    ✓ You marked this published at {c.published_at}

    {:else} -

    +

    ⚠ Not marked as published yet

    {/if}
    {#if !c.published_at} {/if}
    -
  • + + +
  • + {hasVerifiedProof ? "✓" : "✦"} +
    +

    Add your first proof

    +

    + Link a GitHub, a domain, or your nostr key to earn your verified + badge — so people know it's really you. +

    + {#if hasVerifiedProof} +

    ✓ You have a verified proof

    + {:else} + Add a proof + {/if} +
    +
  • + + + {#if biometricAvailable || biometricEnrolled} +
  • + {biometricEnrolled ? "✓" : "🔓"} +
    +

    Enable app lock · optional

    +

    Unlock with Touch ID / Face ID / Windows Hello instead of your passphrase.

    + {#if biometricEnrolled} +

    ✓ Enabled

    + {:else} + + {/if} +
    +
  • + {/if} + + + {#if notifPerm !== "unsupported"} +
  • + {notifPerm === "granted" ? "✓" : "🔔"} +
    +

    Turn on notifications · optional

    +

    Get pinged when a message arrives while you're away.

    + {#if notifPerm === "granted"} +

    ✓ Enabled

    + {:else if notifPerm === "denied"} +

    Blocked — re-enable in site settings.

    + {:else} + + {/if} +
    +
  • + {/if} + + +
    + + +
    +
    +{/if} diff --git a/kez-chat/web/vite.config.ts b/kez-chat/web/vite.config.ts index bea44c2..5d1f83f 100644 --- a/kez-chat/web/vite.config.ts +++ b/kez-chat/web/vite.config.ts @@ -50,8 +50,8 @@ export default defineConfig({ start_url: "/", scope: "/", display: "standalone", - background_color: "#111827", - theme_color: "#111827", + background_color: "#0b0c0e", + theme_color: "#0b0c0e", categories: ["social", "communication"], icons: [ { src: "pwa-64x64.png", sizes: "64x64", type: "image/png" }, diff --git a/nodejs/README.md b/nodejs/README.md index 4c99720..6b1894f 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -17,6 +17,12 @@ nodejs/ └── README.md (this file) ``` +> **New to KEZ?** Read [**`TUTORIAL.md`**](TUTORIAL.md) — a friendly +> step-by-step walkthrough that takes you from "I have a nostr `nsec`" +> to "I have a verified, published sigchain." It assumes nothing. +> +> This README is the reference; the tutorial is the on-ramp. + ## Requirements - Node.js 22+ (for the built-in WebSocket the nostr channel uses) diff --git a/nodejs/TUTORIAL.md b/nodejs/TUTORIAL.md new file mode 100644 index 0000000..0281306 --- /dev/null +++ b/nodejs/TUTORIAL.md @@ -0,0 +1,632 @@ +# Tutorial — your first KEZ identity, end to end (Node.js) + +This is a hands-on walkthrough. By the end you'll have: + +- ✅ A KEZ identity tied to a key you already trust (your existing nostr + `nsec`, or a brand-new Ed25519 key). +- ✅ A signed proof that *you* control a GitHub account (or DNS domain, or + nostr handle, etc.) — verifiable by anyone, no central server needed. +- ✅ A sigchain that ties multiple identities together, exported in a + portable format, and published where strangers can find it. +- ✅ The ability to verify other people's identities the same way. + +If you've used [Keybase](https://keybase.io), the mental model is the same. +The difference: KEZ has no required central authority. Your proofs live +wherever you publish them; the verifier just walks the links. + +This is the Node.js implementation. It is **wire-compatible** with the +[Rust implementation](../rust/TUTORIAL.md) — a claim signed by `npm run cli` +verifies in `cargo run` and vice versa. + +For the full protocol spec, see [`../SPEC.md`](../SPEC.md). This document +is the friendly cousin. + +> **Time budget:** 10–15 minutes for the first claim. A bit more if you +> want to set up DNS or a sigchain publish. + +--- + +## 0. Install + +You'll need: + +- **Node.js 22+** — earlier versions don't have the global `WebSocket` + the nostr channel relies on. Check with `node --version`. +- **npm 9+** for workspaces. + +Then: + +```sh +git clone https://git.ptud.biz/DukeInc/Kez.git +cd Kez/nodejs +npm install +npm test # optional: run all vitest suites +``` + +Verify the CLI works: + +```sh +npm run cli -- --help +``` + +You should see subcommands `identity`, `claim`, `verify`, and `sigchain`. + +> **Note on `--`.** The bare `--` before the subcommand stops npm from +> swallowing flags. Every example below uses `npm run cli -- `. + +> **Want a global `kez` command instead?** From inside +> `nodejs/packages/kez-cli/` run `npm link` once. After that, plain +> `kez claim create …` works from anywhere — substitute `kez` for +> `npm run cli --` in every example below. + +> **Optional but recommended:** `export GITHUB_TOKEN=ghp_...` in your +> shell before verifying github claims. Anonymous GitHub limits you to +> 60 requests/hour; with a token it's 5000/hour. Any read-only token +> works; KEZ never sends it anywhere but `api.github.com`. + +--- + +## 1. Pick your primary key + +Your **primary key** is the one private key the rest of your identity +hangs off of. It signs every claim you make. Two choices: + +### Option A: use your existing nostr key (recommended if you have one) + +If you already use nostr (Damus, Amethyst, primal, etc.), you already +have an `nsec1...` private key. Use it. KEZ understands nostr keys +natively as Schnorr/secp256k1. + +Export the `nsec` from your nostr client (every client has a way — +usually Settings → Keys → Show / Export). Keep it secret; treat it the +same as a wallet seed. + +> **Warning.** Pasting your `nsec` into a CLI is fine on a machine you +> trust. Don't do it on a shared box, and consider whether you want +> shell history to remember it (`unset HISTFILE` for the session, or +> prefix the command with a space if `HISTCONTROL=ignorespace`). + +You don't need any command to "register" an existing nsec — just pass +it with `--nsec` on the first claim you sign. + +### Option B: generate a fresh primary + +A new nostr keypair: + +```sh +npm run cli -- identity new +``` + +Or a new Ed25519 keypair: + +```sh +npm run cli -- identity new --key-type ed25519 +``` + +Output (Ed25519): + +``` +Primary: ed25519:7a3b4c… +Public: 7a3b4c… (hex) +Secret: 9e3f51… (32-byte seed) +``` + +> **Save the secret.** It's the only thing that can sign as this +> identity. There's no recovery flow — lose it and the identity is +> gone. Write it down offline, or paste it into a password manager. +> From here on this tutorial assumes you stored it. + +For the rest of this tutorial we'll use a nostr key for examples and +write the secret as `nsec1FAKE...` — substitute your real one. + +--- + +## 2. Sign your first claim + +A **claim** is just a signed sentence: *"the key I signed this with also +controls ``."* The subject is a `system:identifier` string — +`github:tudisco`, `dns:tud.ink`, `nostr:npub1…`, etc. + +Say you want to prove you control the GitHub username `tudisco`. + +```sh +npm run cli -- claim create github:tudisco \ + --nsec nsec1FAKE... \ + --format markdown \ + --out github-tudisco.kez.md +``` + +That writes a file like: + +```markdown +# KEZ Proof + +This account publishes a signed KEZ identity claim. + +- Primary: `nostr:npub1tkf…` +- Subject: `github:tudisco` +- Created: `2026-05-27T19:21:46Z` + +```kez +{ + "kez": "claim", + "payload": { ... }, + "signature": { + "alg": "nostr-schnorr-bip340-jcs", + "key": "nostr:npub1tkf…", + "sig": "abc123…" + } +} +``` +``` + +### Picking the right format + +Same claim, three packagings — same signature inside: + +| Format | When to use | Command | +|---|---|---| +| **markdown** | Anywhere you can paste rich text — gists, profile READMEs, social posts. Most human-readable. | `--format markdown` | +| **compact** | Tight places: DNS TXT records, QR codes, chat messages. One-liner that decompresses back to the full envelope. | `--format compact` | +| **json** | Self-hosted `.well-known/kez.json`, developer tooling, anything that wants the raw envelope. | (default — no flag needed) | + +If you skip `--out`, the proof prints to stdout — handy for piping. + +--- + +## 3. Publish the proof + +This is where KEZ does its job: you put the signed claim in a place that +only *that specific account* could have put it. Anyone who can fetch +that place can then verify it themselves. + +Pick the section that matches the subject system you claimed. + +### GitHub + +You signed `github:tudisco`. Publish the markdown block to either: + +**A public gist named `kez.md`** — easiest. +1. Go to . +2. New gist → filename `kez.md` → paste the contents of + `github-tudisco.kez.md`. +3. Click **Create public gist**. + +**Or your profile README** — fancier but you only get one. +1. Make a repo named the same as your username (e.g. + `tudisco/tudisco`). GitHub treats it as your profile README. +2. Add the markdown block to `README.md`. +3. Push. + +KEZ's GitHub verifier checks public gists first, then the profile +README. + +### DNS — your own domain + +You signed `dns:tud.ink`. The CLI generates a ready-to-paste zone-file +line for you: + +```sh +npm run cli -- claim dns tud.ink --nsec nsec1FAKE... +``` + +Output (abbreviated): + +``` +_kez.tud.ink. 3600 IN TXT + "kez:z1:KLUv_WAsACUHAD……" + "…" +``` + +Add that TXT record at `_kez.` in your DNS provider's +console (Cloudflare, Route 53, Gandi, Porkbun — wherever you registered +the domain). Most providers will accept the whole compact string in one +field and split it for you; the multi-chunk form above is the safe one +for providers that don't. + +Wait a minute or two for propagation, then you can verify it. + +### Nostr — your own npub + +You signed `nostr:npub1...`. Three places work (verifiers check all of +them): + +- **Profile `about` field** (kind-0 event) — easiest, one-time. Edit + your nostr profile and paste the markdown block into your bio. +- **A normal post** (kind-1) containing the markdown block — quickest if + you're already active. +- **A NIP-78 kind-30078 event** with `d` tag = `kez` — cleanest for + tooling, but most clients don't expose it. + +### Bluesky + +Post the markdown block (or just the compact `kez:z1:…` string) as a +public post on the account you claimed. The verifier scans your recent +posts. + +### Mastodon / ActivityPub + +You signed `ap:@user@instance`. Add the markdown block to your profile +**metadata** field (most instances expose 4 of them), or post it as a +pinned toot. The verifier resolves via WebFinger → actor JSON → checks +those fields. + +### Your own website + +You signed `web:https://example.com`. Upload the JSON form to +`https://example.com/.well-known/kez.json`: + +```sh +npm run cli -- claim create web:https://example.com --nsec nsec1FAKE... > kez.json +scp kez.json youruser@example.com:/var/www/.well-known/kez.json +``` + +Make sure it's publicly fetchable (no auth gate). + +--- + +## 4. Verify it + +This is the moment of truth. Pretend you're a stranger checking that the +claim is real: + +```sh +npm run cli -- verify id github:tudisco +``` + +Output: + +``` +Primary: nostr:npub1tkf... + +Verified identities: +- github:tudisco + +Status: valid +Confidence: strong +``` + +Same shape for any channel: + +```sh +npm run cli -- verify id dns:tud.ink +npm run cli -- verify id nostr:npub1tkf... +npm run cli -- verify id bluesky:tudisco.bsky.social +npm run cli -- verify id ap:@tudisco@mastodon.social +npm run cli -- verify id web:https://tud.ink +``` + +The verifier: + +1. Figured out which channel from the prefix. +2. Fetched the proof from where you published it (gist, TXT, etc.). +3. Decoded the envelope. +4. Verified the cryptographic signature against the key inside. + +**No KEZ server was involved.** Each side of the conversation independently +proves the claim — that's the whole point. + +### Cross-implementation verification + +This is wire-compatible with the [Rust CLI](../rust/TUTORIAL.md). You +can sign in one and verify in the other: + +```sh +# Sign in Node… +npm run cli -- claim create github:tudisco --nsec nsec1FAKE... --out p.kez.md + +# …verify the same file in Rust +cd ../rust && cargo run -p kez-cli -- verify file ../nodejs/p.kez.md +``` + +Same bytes, same signature, both implementations agree. + +### If verification fails + +A few common ones: + +- **`not_found`** — the proof isn't where the verifier looked. For + GitHub, check the gist is public and the filename contains `kez`. For + DNS, the TXT record is at `_kez.`, not `` itself; give + propagation a minute. +- **`subject_mismatch`** — you published a proof for one subject but + asked the verifier to check a different one. The claim's `subject` + must equal the identifier you're verifying. +- **`invalid_signature`** — the proof was tampered with, or you + re-signed with a different key after publishing. Re-sign and + re-publish. +- **GitHub `403 rate_limited`** — anonymous gets 60 req/hr; export + `GITHUB_TOKEN`. +- **Nostr "WebSocket is not defined"** — your Node is older than 22. + Upgrade. + +--- + +## 5. Sigchain — link multiple identities together + +A **sigchain** is an append-only log of "this key controls X" events, +each signed by your primary. Once you have more than one claim, you +want a sigchain so: + +- Verifiers can discover your full identity graph from a single + starting point. +- You can later **revoke** a claim (e.g., you lost access to that + github account) without invalidating the others. +- Old events stay verifiable; the chain head is the current truth. + +Chains live at `~/.kez/sigchains/.jsonl`. The CLI creates +the directory on first use; you don't manage it manually. + +Add the github claim you already signed: + +```sh +npm run cli -- sigchain add github:tudisco --nsec nsec1FAKE... +``` + +Add a DNS claim too: + +```sh +npm run cli -- sigchain add dns:tud.ink --nsec nsec1FAKE... +``` + +You can optionally include a `--proof-url` pointing to where you +published this claim's proof (your gist URL, etc.). Verifiers can use +it to skip discovery. + +Inspect what you've got: + +```sh +npm run cli -- sigchain show --nsec nsec1FAKE... +``` + +Output: + +``` +Primary: nostr:npub1tkf... +Path: /home/you/.kez/sigchains/nostr_npub1tkf….jsonl +Length: 2 events +Head: sha256:9c3a… +Events: + 1. add github:tudisco proof_url=https://gist.github.com/tudisco/abc + 2. add dns:tud.ink +``` + +Read-only view of a published chain (no secret needed): + +```sh +npm run cli -- sigchain show --primary nostr:npub1tkf... +``` + +This is what other people will do to inspect your identity graph. + +### Revoking + +If you ever lose control of an account (your github gets hacked, you +sell a domain), revoke that subject: + +```sh +npm run cli -- sigchain revoke github:tudisco --nsec nsec1FAKE... +``` + +That appends a revoke event. Subsequent verifications treat that subject +as "no longer claimed" by your primary, even if the old proof is still +out there. + +--- + +## 6. Publish your sigchain + +Now make your chain discoverable so anyone with your primary can walk +it. Options, in rough order of how much infra they need: + +### To a kez-sig-server (zero setup) + +If you have access to a [`kez-sig-server`](../rust-sig-server/) (one +runs at `https://sig.kez.lat`): + +```sh +npm run cli -- sigchain publish --nsec nsec1FAKE... \ + --server https://sig.kez.lat +``` + +Each event is POSTed to the server, which exposes them at predictable +URLs. Cheap, fast, but you're trusting that server to stay up. Mitigate +by also publishing to one of the channels below. + +### To your own website (self-sovereign) + +Export the chain bundle and host it yourself: + +```sh +npm run cli -- sigchain publish --nsec nsec1FAKE... \ + --web --out kez-sigchain.jsonl +``` + +Then upload `kez-sigchain.jsonl` to +`https:///.well-known/kez-sigchain.jsonl`. Verifiers +fetch it directly. Hardest to censor; you own it. + +### To DNS + +```sh +npm run cli -- sigchain publish --nsec nsec1FAKE... --dns tud.ink +``` + +Prints a TXT record at `_kez-chain.` containing the +compressed chain. Add it to your zone. Works for short chains; for +long chains, prefer `--web` (TXT records are size-limited). + +### To nostr + +```sh +npm run cli -- sigchain publish --nsec nsec1FAKE... \ + --nostr wss://relay.damus.io +``` + +Publishes the compact bundle as a kind-30078 event on that relay. Any +nostr client / verifier subscribed can find it. + +### Pick more than one + +`publish` accepts any combination of these flags — you can mirror to +all four in one shot: + +```sh +npm run cli -- sigchain publish --nsec nsec1FAKE... \ + --server https://sig.kez.lat \ + --web --out kez-sigchain.jsonl \ + --dns tud.ink \ + --nostr wss://relay.damus.io +``` + +Redundancy is good. If one channel goes down, the others still serve +your identity graph. + +### Export-only (no publish) + +If you want to see the bundle without publishing: + +```sh +npm run cli -- sigchain export --nsec nsec1FAKE... --format compact > my-chain.txt +npm run cli -- sigchain export --nsec nsec1FAKE... --format jsonl > my-chain.jsonl +``` + +--- + +## 7. Verifying someone else + +You've done the publishing side. Here's the receiving side — how to +verify someone *else's* identity: + +```sh +# Start from any identifier they've published a proof for. +npm run cli -- verify id github:linus + +# Or walk their chain from any known endpoint: +npm run cli -- sigchain show --primary nostr:npub1abc... +``` + +If you have the chain bundle on disk: + +```sh +npm run cli -- verify file ./their-chain.jsonl +``` + +`verify id` is the friendly day-to-day verb. `sigchain show +--primary ` is what you'd reach for to see the whole graph at once. + +--- + +## 8. Programmatic use — embedding KEZ in a Node app + +You don't have to go through the CLI. The same logic is exported as a +library by the `@kez/core` and `@kez/channels` workspace packages. + +```ts +import { + Identity, + NostrSecret, + newClaimPayload, + signClaim, + toMarkdown, +} from "@kez/core"; +import { defaultRegistry } from "@kez/channels"; + +// Sign a claim +const secret = NostrSecret.fromNsec("nsec1FAKE..."); +const subject = Identity.parse("github:tudisco"); +const payload = newClaimPayload(subject, secret.identity(), new Date()); +const claim = signClaim(payload, secret); +console.log(toMarkdown(claim)); + +// Verify a peer +const registry = await defaultRegistry(); +const hit = await registry.verify(Identity.parse("dns:tud.ink")); +console.log(hit.status); // "valid" +``` + +For testing without hitting the live channels, every channel takes an +injectable fetcher (`TxtResolver`, `NostrFetcher`, etc.) — see the +package READMEs and `__tests__/` folders for the exact shapes. The +implementations themselves are <300 lines each. + +--- + +## 9. Quick reference card + +```sh +# Generate a fresh primary +npm run cli -- identity new +npm run cli -- identity new --key-type ed25519 + +# Sign a claim +npm run cli -- claim create --nsec # nostr key +npm run cli -- claim create --ed25519-seed # ed25519 key +npm run cli -- claim create --nsec --format markdown --out file.md +npm run cli -- claim create --nsec --format compact # one-liner +npm run cli -- claim dns --nsec # zone-file output + +# Verify +npm run cli -- verify id # live channel fetch +npm run cli -- verify file # local file + +# Sigchain +npm run cli -- sigchain add --nsec [--proof-url ] +npm run cli -- sigchain revoke --nsec +npm run cli -- sigchain show --nsec # your own +npm run cli -- sigchain show --primary # someone else's +npm run cli -- sigchain export --nsec --format jsonl|compact [--out file] +npm run cli -- sigchain publish --nsec \ + [--server ] [--web --out ] [--dns ] [--nostr ] +``` + +--- + +## 10. Common confusions + +**"Do I need a sigchain to use KEZ?"** No. A single signed claim, +published, works on its own. The sigchain is for when you have several +claims and want them discoverable together (and revocable). + +**"Why two key types — nostr and ed25519?"** Different ecosystems use +different curves. Nostr is secp256k1/Schnorr; the rest of the world +mostly likes Ed25519. KEZ supports both natively so you can use the +key you already have rather than spinning up a new one for KEZ +specifically. + +**"Is my `nsec` sent to KEZ servers?"** No, never. The CLI uses it +locally to sign things. Only the *signed envelope* (public key + claim ++ signature) ever leaves your machine. + +**"What if I publish a proof and then someone else copies it and +publishes it as theirs?"** They can copy the bytes, but the signature +inside is over *your* primary. Their primary won't match, so any +verifier sees through it immediately. + +**"What if my key is compromised?"** Append a `sigchain revoke +` for the affected subjects, and ideally rotate to a new +primary by signing a final "this primary is succeeded by " event +(planned for the spec; not yet enforced by the CLI in v0.1). + +**"Is the Node version slower than Rust?"** For everything but +sigchain export of large chains, no — both use the same Noble curves +underneath and the verifier is I/O-bound on the channel HTTP call. +For batch sigchain work, the Rust binary will be a touch faster. + +--- + +## 11. Where to go next + +- The web client at — same protocol, no CLI. + Useful for showing non-technical friends. +- [`../SPEC.md`](../SPEC.md) — the formal protocol, if you want to know + exactly what every byte means. +- [`../rust/TUTORIAL.md`](../rust/TUTORIAL.md) — the same tutorial for + the Rust implementation. Identical surface; faster binary. +- [`../rust-sig-server/`](../rust-sig-server/) — run your own + sig-server, federate with others. +- The channel plugin interface in + [`packages/kez-channels/src/index.ts`](packages/kez-channels/src/index.ts) — + ~40 lines, add a new channel in an afternoon. + +That's the whole tutorial. Welcome to KEZ. diff --git a/rust/README.md b/rust/README.md index 2d07a11..2eff3ab 100644 --- a/rust/README.md +++ b/rust/README.md @@ -41,6 +41,12 @@ Three crates, ~2,500 lines of Rust, **99 tests**. ## Quick start +> **New to KEZ?** Read [**`TUTORIAL.md`**](TUTORIAL.md) — a friendly +> step-by-step walkthrough that takes you from "I have a nostr `nsec`" +> to "I have a verified, published sigchain." It assumes nothing. +> +> This README is the reference; the tutorial is the on-ramp. + ```sh # Build, test, and install the `kez` binary to ~/.cargo/bin (one time) cargo build diff --git a/rust/TUTORIAL.md b/rust/TUTORIAL.md new file mode 100644 index 0000000..b193df8 --- /dev/null +++ b/rust/TUTORIAL.md @@ -0,0 +1,557 @@ +# Tutorial — your first KEZ identity, end to end + +This is a hands-on walkthrough. By the end you'll have: + +- ✅ A KEZ identity tied to a key you already trust (your existing nostr + `nsec`, or a brand-new Ed25519 key). +- ✅ A signed proof that *you* control a GitHub account (or DNS domain, or + nostr handle, etc.) — verifiable by anyone, no central server needed. +- ✅ A sigchain that ties multiple identities together, exported in a + portable format, and published where strangers can find it. +- ✅ The ability to verify other people's identities the same way. + +If you've used [Keybase](https://keybase.io), the mental model is the same. +The difference: KEZ has no required central authority. Your proofs live +wherever you publish them; the verifier just walks the links. + +For the full protocol spec, see [`../SPEC.md`](../SPEC.md). This document +is the friendly cousin. + +> **Time budget:** 10–15 minutes for the first claim. A bit more if you +> want to set up DNS or a sigchain publish. + +--- + +## 0. Install + +```sh +git clone https://git.ptud.biz/DukeInc/Kez.git +cd Kez/rust +cargo build --release +cargo install --path crates/kez-cli # puts `kez` in ~/.cargo/bin +``` + +Verify: + +```sh +kez --help +``` + +You should see subcommands `identity`, `claim`, `verify`, and `sigchain`. + +> **Don't want to install globally?** Replace every `kez` below with +> `cargo run -p kez-cli --` (from the `rust/` directory). Slower to +> start each time, but no install side effects. + +> **Optional but recommended:** `export GITHUB_TOKEN=ghp_...` in your +> shell before verifying github claims. Anonymous GitHub limits you to +> 60 requests/hour; with a token it's 5000/hour. Any read-only token +> works; KEZ never sends it anywhere but `api.github.com`. + +--- + +## 1. Pick your primary key + +Your **primary key** is the one private key the rest of your identity +hangs off of. It signs every claim you make. Two choices: + +### Option A: use your existing nostr key (recommended if you have one) + +If you already use nostr (Damus, Amethyst, primal, etc.), you already +have an `nsec1...` private key. Use it. KEZ understands nostr keys +natively as Schnorr/secp256k1. + +Export the `nsec` from your nostr client (every client has a way — +usually Settings → Keys → Show / Export). Keep it secret; treat it the +same as a wallet seed. + +> **Warning.** Pasting your `nsec` into a CLI is fine on a machine you +> trust. Don't do it on a shared box, and consider whether you want +> shell history to remember it (`unset HISTFILE` for the session, or +> prefix the command with a space if `HISTCONTROL=ignorespace`). + +You can confirm KEZ accepts your key without signing anything yet: + +```sh +kez identity new --key-type nostr # only if you want a NEW key +# vs. +# (no command needed to "register" an existing nsec — just pass it +# directly with --nsec on the first claim you sign) +``` + +### Option B: generate a fresh Ed25519 primary + +If you'd rather start clean, generate a new Ed25519 key: + +```sh +kez identity new --key-type ed25519 +``` + +Output: + +``` +Primary: ed25519:7a3b4c… +Public: 7a3b4c… (hex) +Secret: 9e3f51… (hex — 64 chars, KEEP SECRET) +``` + +> **Save the secret.** It's the only thing that can sign as this +> identity. There's no recovery flow — lose it and the identity is +> gone. Write it down offline, or paste it into a password manager. +> From here on this tutorial assumes you stored it. + +For the rest of this tutorial we'll use a nostr key for examples and +write the secret as `nsec1FAKE...` — substitute your real one. + +--- + +## 2. Sign your first claim + +A **claim** is just a signed sentence: *"the key I signed this with also +controls ``."* The subject is a `system:identifier` string — +`github:tudisco`, `dns:tud.ink`, `nostr:npub1…`, etc. + +Say you want to prove you control the GitHub username `tudisco`. + +```sh +kez claim create github:tudisco \ + --nsec nsec1FAKE... \ + --format markdown \ + --out github-tudisco.kez.md +``` + +That writes a file like: + +```markdown +# KEZ Proof + +This account publishes a signed KEZ identity claim. + +- Primary: `nostr:npub1tkf…` +- Subject: `github:tudisco` +- Created: `2026-05-27T19:21:46Z` + +```kez +{ + "kez": "claim", + "payload": { ... }, + "signature": { + "alg": "ed25519-sha512-jcs" / "nostr-schnorr-bip340-jcs", + "key": "nostr:npub1tkf…", + "sig": "abc123…" + } +} +``` +``` + +### Picking the right format + +Same claim, three packagings — same signature inside: + +| Format | When to use | Command | +|---|---|---| +| **markdown** | Anywhere you can paste rich text — gists, profile READMEs, social posts. Most human-readable. | `--format markdown` | +| **compact** | Tight places: DNS TXT records, QR codes, chat messages. One-liner that decompresses back to the full envelope. | `--format compact` | +| **json** | Self-hosted `.well-known/kez.json`, developer tooling, anything that wants the raw envelope. | (default — no flag needed) | + +If you skip `--out`, the proof prints to stdout — handy for piping. + +--- + +## 3. Publish the proof + +This is where KEZ does its job: you put the signed claim in a place that +only *that specific account* could have put it. Anyone who can fetch +that place can then verify it themselves. + +Pick the section that matches the subject system you claimed. + +### GitHub + +You signed `github:tudisco`. Publish the markdown block to either: + +**A public gist named `kez.md`** — easiest. +1. Go to . +2. New gist → filename `kez.md` → paste the contents of + `github-tudisco.kez.md`. +3. Click **Create public gist**. + +**Or your profile README** — fancier but you only get one. +1. Make a repo named the same as your username (e.g. + `tudisco/tudisco`). GitHub treats it as your profile README. +2. Add the markdown block to `README.md`. +3. Push. + +KEZ's GitHub verifier checks public gists first, then the profile +README. + +### DNS — your own domain + +You signed `dns:tud.ink`. The CLI generates a ready-to-paste zone-file +line for you: + +```sh +kez claim dns tud.ink --nsec nsec1FAKE... +``` + +Output (abbreviated): + +``` +_kez.tud.ink. 3600 IN TXT + "kez:z1:KLUv_WAsACUHAD……" + "…" +``` + +Add that TXT record at `_kez.` in your DNS provider's +console (Cloudflare, Route 53, Gandi, Porkbun — wherever you registered +the domain). Most providers will accept the whole compact string in one +field and split it for you; the multi-chunk form above is the safe one +for providers that don't. + +Wait a minute or two for propagation, then you can verify it. + +### Nostr — your own npub + +You signed `nostr:npub1...`. Three places work (verifiers check all of +them): + +- **Profile `about` field** (kind-0 event) — easiest, one-time. Edit + your nostr profile and paste the markdown block into your bio. +- **A normal post** (kind-1) containing the markdown block — quickest if + you're already active. +- **A NIP-78 kind-30078 event** with `d` tag = `kez` — cleanest for + tooling, but most clients don't expose it. + +### Bluesky + +Post the markdown block (or just the compact `kez:z1:…` string) as a +public post on the account you claimed. The verifier scans your recent +posts. + +### Mastodon / ActivityPub + +You signed `ap:@user@instance`. Add the markdown block to your profile +**metadata** field (most instances expose 4 of them), or post it as a +pinned toot. The verifier resolves via WebFinger → actor JSON → checks +those fields. + +### Your own website + +You signed `web:https://example.com`. Upload the JSON form to +`https://example.com/.well-known/kez.json`: + +```sh +kez claim create web:https://example.com --nsec nsec1FAKE... > kez.json +scp kez.json youruser@example.com:/var/www/.well-known/kez.json +``` + +Make sure it's publicly fetchable (no auth gate). + +--- + +## 4. Verify it + +This is the moment of truth. Pretend you're a stranger checking that the +claim is real: + +```sh +kez verify id github:tudisco +``` + +Output: + +``` +Primary: nostr:npub1tkf... + +Verified identities: +- github:tudisco + +Status: valid +Confidence: strong +``` + +Same shape for any channel: + +```sh +kez verify id dns:tud.ink +kez verify id nostr:npub1tkf... +kez verify id bluesky:tudisco.bsky.social +kez verify id ap:@tudisco@mastodon.social +kez verify id web:https://tud.ink +``` + +The verifier: + +1. Figured out which channel from the prefix. +2. Fetched the proof from where you published it (gist, TXT, etc.). +3. Decoded the envelope. +4. Verified the cryptographic signature against the key inside. + +**No KEZ server was involved.** Each side of the conversation independently +proves the claim — that's the whole point. + +### If verification fails + +A few common ones: + +- **`not_found`** — the proof isn't where the verifier looked. For + GitHub, check the gist is public and the filename contains `kez`. For + DNS, the TXT record is at `_kez.`, not `` itself; give + propagation a minute. +- **`subject_mismatch`** — you published a proof for one subject but + asked the verifier to check a different one. The claim's `subject` + must equal the identifier you're verifying. +- **`invalid_signature`** — the proof was tampered with, or you + re-signed with a different key after publishing. Re-sign and + re-publish. +- **GitHub `403 rate_limited`** — anonymous gets 60 req/hr; export + `GITHUB_TOKEN`. + +--- + +## 5. Sigchain — link multiple identities together + +A **sigchain** is an append-only log of "this key controls X" events, +each signed by your primary. Once you have more than one claim, you +want a sigchain so: + +- Verifiers can discover your full identity graph from a single + starting point. +- You can later **revoke** a claim (e.g., you lost access to that + github account) without invalidating the others. +- Old events stay verifiable; the chain head is the current truth. + +Chains live at `~/.kez/sigchains/.jsonl`. The CLI creates +the directory on first use; you don't manage it manually. + +Add the github claim you already signed: + +```sh +kez sigchain add github:tudisco --nsec nsec1FAKE... +``` + +Add a DNS claim too: + +```sh +kez sigchain add dns:tud.ink --nsec nsec1FAKE... +``` + +You can optionally include a `--proof-url` pointing to where you +published this claim's proof (your gist URL, etc.). Verifiers can use +it to skip discovery. + +Inspect what you've got: + +```sh +kez sigchain show --nsec nsec1FAKE... +``` + +Output: + +``` +Primary: nostr:npub1tkf... +Path: /home/you/.kez/sigchains/nostr_npub1tkf….jsonl +Length: 2 events +Head: sha256:9c3a… +Events: + 1. add github:tudisco proof_url=https://gist.github.com/tudisco/abc + 2. add dns:tud.ink +``` + +Read-only view of a published chain (no secret needed): + +```sh +kez sigchain show --primary nostr:npub1tkf... +``` + +This is what other people will do to inspect your identity graph. + +### Revoking + +If you ever lose control of an account (your github gets hacked, you +sell a domain), revoke that subject: + +```sh +kez sigchain revoke github:tudisco --nsec nsec1FAKE... +``` + +That appends a revoke event. Subsequent verifications treat that subject +as "no longer claimed" by your primary, even if the old proof is still +out there. + +--- + +## 6. Publish your sigchain + +Now make your chain discoverable so anyone with your primary can walk +it. Options, in rough order of how much infra they need: + +### To a kez-sig-server (zero setup) + +If you have access to a [`kez-sig-server`](../rust-sig-server/) (one +runs at `https://sig.kez.lat`): + +```sh +kez sigchain publish --nsec nsec1FAKE... \ + --server https://sig.kez.lat +``` + +Each event is POSTed to the server, which exposes them at predictable +URLs. Cheap, fast, but you're trusting that server to stay up. Mitigate +by also publishing to one of the channels below. + +### To your own website (self-sovereign) + +Export the chain bundle and host it yourself: + +```sh +kez sigchain publish --nsec nsec1FAKE... \ + --web --out kez-sigchain.jsonl +``` + +Then upload `kez-sigchain.jsonl` to +`https:///.well-known/kez-sigchain.jsonl`. Verifiers +fetch it directly. Hardest to censor; you own it. + +### To DNS + +```sh +kez sigchain publish --nsec nsec1FAKE... --dns tud.ink +``` + +Prints a TXT record at `_kez-chain.` containing the +compressed chain. Add it to your zone. Works for short chains; for +long chains, prefer `--web` (TXT records are size-limited). + +### To nostr + +```sh +kez sigchain publish --nsec nsec1FAKE... \ + --nostr wss://relay.damus.io +``` + +Publishes the compact bundle as a kind-30078 event on that relay. Any +nostr client / verifier subscribed can find it. + +### Pick more than one + +`publish` accepts any combination of these flags — you can mirror to +all four in one shot: + +```sh +kez sigchain publish --nsec nsec1FAKE... \ + --server https://sig.kez.lat \ + --web --out kez-sigchain.jsonl \ + --dns tud.ink \ + --nostr wss://relay.damus.io +``` + +Redundancy is good. If one channel goes down, the others still serve +your identity graph. + +### Export-only (no publish) + +If you want to see the bundle without publishing: + +```sh +kez sigchain export --nsec nsec1FAKE... --format compact > my-chain.txt +kez sigchain export --nsec nsec1FAKE... --format jsonl > my-chain.jsonl +``` + +--- + +## 7. Verifying someone else + +You've done the publishing side. Here's the receiving side — how to +verify someone *else's* identity: + +```sh +# Start from any identifier they've published a proof for. +kez verify id github:linus + +# Or walk their chain from any known endpoint: +kez sigchain show --primary nostr:npub1abc... +``` + +If you have the chain bundle on disk: + +```sh +kez verify file ./their-chain.jsonl +``` + +`verify id` is the friendly day-to-day verb. `sigchain show +--primary ` is what you'd reach for to see the whole graph at once. + +--- + +## 8. Quick reference card + +```sh +# Generate a fresh primary +kez identity new +kez identity new --key-type ed25519 + +# Sign a claim +kez claim create --nsec # nostr key +kez claim create --ed25519-seed # ed25519 key +kez claim create --nsec --format markdown --out file.md +kez claim create --nsec --format compact # one-liner +kez claim dns --nsec # zone-file output + +# Verify +kez verify id # live channel fetch +kez verify file # local file + +# Sigchain +kez sigchain add --nsec [--proof-url ] +kez sigchain revoke --nsec +kez sigchain show --nsec # your own +kez sigchain show --primary # someone else's +kez sigchain export --nsec --format jsonl|compact [--out file] +kez sigchain publish --nsec \ + [--server ] [--web --out ] [--dns ] [--nostr ] +``` + +--- + +## 9. Common confusions + +**"Do I need a sigchain to use KEZ?"** No. A single signed claim, +published, works on its own. The sigchain is for when you have several +claims and want them discoverable together (and revocable). + +**"Why two key types — nostr and ed25519?"** Different ecosystems use +different curves. Nostr is secp256k1/Schnorr; the rest of the world +mostly likes Ed25519. KEZ supports both natively so you can use the +key you already have rather than spinning up a new one for KEZ +specifically. + +**"Is my `nsec` sent to KEZ servers?"** No, never. The CLI uses it +locally to sign things. Only the *signed envelope* (public key + claim ++ signature) ever leaves your machine. + +**"What if I publish a proof and then someone else copies it and +publishes it as theirs?"** They can copy the bytes, but the signature +inside is over *your* primary. Their primary won't match, so any +verifier sees through it immediately. + +**"What if my key is compromised?"** Append a `sigchain revoke +` for the affected subjects, and ideally rotate to a new +primary by signing a final "this primary is succeeded by " event +(planned for the spec; not yet enforced by the CLI in v0.1). + +--- + +## 10. Where to go next + +- The web client at — same protocol, no CLI. + Useful for showing non-technical friends. +- [`../SPEC.md`](../SPEC.md) — the formal protocol, if you want to know + exactly what every byte means. +- [`../rust-sig-server/`](../rust-sig-server/) — run your own + sig-server, federate with others. +- The channel plugin trait in + [`crates/kez-channels/src/lib.rs`](crates/kez-channels/src/lib.rs) — + ~40 lines, add a new channel in an afternoon. + +That's the whole tutorial. Welcome to KEZ.