From a2538b2886f87f6c6fff4021c1ecd222ed36be81 Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Wed, 27 May 2026 23:40:11 -0600 Subject: [PATCH] feat(kez-chat): verified-user badge in chat (X/Twitter-style, but real) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A green check next to any KEZ that controls a proven account. Unlike Twitter's "we say so," the badge means YOUR browser independently verified ≥1 of the peer's published proofs against the channel. Server: • handles.proofs column (JSON array of claim subjects) + ALTER for existing DBs. Returned in /v1/u/:handle and /v1/by-primary as `proofs` — pure discovery; peers verify each themselves. • PUT /v1/profile/:handle/proofs (authed X-KEZ-Auth, signed over "PUT\n/v1/profile//proofs\n", distinct line from inbox/stream so sigs can't cross-replay; 60s skew; max 64 subjects). • All 20 existing http tests still pass. Client: • api.ts: HandleResponse.proofs + setProofs() (signs + PUTs). • verify.ts: verifySubject(subject, primary) — runs the real channel verifier given just subject+primary (no local envelope needed). • conversations-store: cache verified + verified_checked_at per peer. • Messages: on conversation open, fetch the peer's proof subjects and verify them in the background (24h cache → snappy, rate-limit friendly). VerifiedBadge in the conversation row + thread header. • Identity: reverify now publishes your verified subjects to your profile (so peers can discover them) + shows the badge on your own card. • VerifiedBadge.svelte: scalloped-seal check in verified-green (distinct from the cyan brand accent). Flow: you reverify your proofs on Identity → they publish to your profile → when someone opens a chat with you, their client fetches + verifies them → you get the check on their screen. Co-Authored-By: Claude Opus 4.7 --- kez-chat/src/api.rs | 76 ++++++++++++++++ kez-chat/src/store.rs | 99 ++++++++++----------- kez-chat/web/src/lib/VerifiedBadge.svelte | 27 ++++++ kez-chat/web/src/lib/api.ts | 30 +++++++ kez-chat/web/src/lib/conversations-store.ts | 17 ++++ kez-chat/web/src/lib/verify.ts | 30 +++++++ kez-chat/web/src/routes/Identity.svelte | 27 +++++- kez-chat/web/src/routes/Messages.svelte | 52 ++++++++++- 8 files changed, 301 insertions(+), 57 deletions(-) create mode 100644 kez-chat/web/src/lib/VerifiedBadge.svelte 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/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/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/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/routes/Identity.svelte b/kez-chat/web/src/routes/Identity.svelte index 8e17437..03269ae 100644 --- a/kez-chat/web/src/routes/Identity.svelte +++ b/kez-chat/web/src/routes/Identity.svelte @@ -1,8 +1,9 @@