feat(kez-chat): verified-user badge in chat (X/Twitter-style, but real)

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/<h>/proofs\n<ts>", 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 <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-05-27 23:40:11 -06:00
parent fc75b27ac6
commit a2538b2886
8 changed files with 301 additions and 57 deletions

View File

@ -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<String>,
}
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::<Vec<String>>(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<String>,
}
async fn set_proofs(
State(state): State<AppState>,
Path(handle): Path<String>,
headers: axum::http::HeaderMap,
Json(req): Json<SetProofsRequest>,
) -> Result<StatusCode, ApiError> {
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: <unix_ts>:<sig>, 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 <ts>:<sig>".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
// ─────────────────────────────────────────────────────────────────────────────

View File

@ -15,6 +15,10 @@ pub struct HandleRecord {
pub handle: String,
pub primary: Identity,
pub registered_at: DateTime<Utc>,
/// 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<String>,
}
#[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(&registered_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(&registered_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<String>);
fn row_to_record_parts(row: &rusqlite::Row) -> rusqlite::Result<RecordParts> {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
}
fn build_record(parts: RecordParts) -> Result<HandleRecord, ApiError> {
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(&registered_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);

View File

@ -0,0 +1,27 @@
<script lang="ts">
// Verified check — shown next to a KEZ that has ≥1 cryptographically
// verified proof (the client checked it, X/Twitter-style). Green so it
// reads as "verified", distinct from the cyan brand accent.
interface Props {
size?: number;
/** Tooltip text. */
title?: string;
}
let { size = 16, title = "Verified — controls a proven account" }: Props = $props();
</script>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
class="inline-block shrink-0 align-text-bottom"
style="color: var(--color-verified)"
fill="currentColor"
role="img"
aria-label="verified"
{title}
>
<!-- Scalloped seal + check, the familiar verified glyph. -->
<path d="M12 1.5l2.3 1.7 2.85-.2 1 2.67 2.45 1.46-.83 2.74.83 2.74-2.45 1.46-1 2.67-2.85-.2L12 22.5l-2.3-1.7-2.85.2-1-2.67-2.45-1.46.83-2.74-.83-2.74 2.45-1.46 1-2.67 2.85.2z"/>
<path d="M10.6 14.6l-2.2-2.2-1.4 1.4 3.6 3.6 6-6-1.4-1.4z" fill="var(--color-bg)"/>
</svg>

View File

@ -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<HandleResponse>
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<void> {
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<HandleResponse> {

View File

@ -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<void> {
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;

View File

@ -48,4 +48,34 @@ export async function verifyClaim(claim: StoredClaim): Promise<VerifyResult> {
}
}
/**
* 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<boolean> {
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";

View File

@ -1,8 +1,9 @@
<script lang="ts">
import { onMount } from "svelte";
import { push } from "svelte-spa-router";
import { lookup, ApiError } from "../lib/api.js";
import { lookup, setProofs, ApiError } from "../lib/api.js";
import { session } from "../lib/store.svelte.js";
import VerifiedBadge from "../lib/VerifiedBadge.svelte";
import {
listClaims,
setVerifyResult,
@ -43,11 +44,30 @@
await setVerifyResult(c.id, result);
}
claims = await listClaims();
await publishVerifiedSubjects();
} finally {
verifyingAll = false;
}
}
/**
* Push the subjects of our currently-verified claims to the server
* profile so peers can discover + independently verify them (drives
* their verified badge for us). Best-effort — a failure here doesn't
* affect local verification.
*/
async function publishVerifiedSubjects() {
if (!session.unlocked) return;
const subjects = claims
.filter((c) => c.last_verify?.status === "ok")
.map((c) => c.envelope.payload.subject);
try {
await setProofs(session.unlocked.handle, session.unlocked.seed, subjects);
} catch (e) {
console.error("publishVerifiedSubjects failed", e);
}
}
function channelLabel(ch: string): string {
return (
{
@ -87,8 +107,9 @@
<Avatar seed={session.unlocked.primary} size={64} ring />
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-mono text-lg font-semibold text-text truncate">
{session.unlocked.handle}<span class="text-text-muted">@{session.unlocked.server}</span>
<span class="font-mono text-lg font-semibold text-text truncate inline-flex items-center gap-1">
<span class="truncate">{session.unlocked.handle}<span class="text-text-muted">@{session.unlocked.server}</span></span>
{#if verified.length > 0}<VerifiedBadge size={16} />{/if}
</span>
<button
class="text-xs px-2 py-0.5 rounded-sm border border-border text-text-secondary hover:bg-elevated hover:text-text shrink-0"

View File

@ -3,14 +3,17 @@
import { push } from "svelte-spa-router";
import { session } from "../lib/store.svelte.js";
import { sendMessage } from "../lib/messages.js";
import { lookup, ApiError } from "../lib/api.js";
import { lookup, lookupByPrimary, ApiError } from "../lib/api.js";
import { inboxService } from "../lib/inbox-service.svelte.js";
import { verifySubject } from "../lib/verify.js";
import EmojiButton from "../lib/EmojiButton.svelte";
import Avatar from "../lib/Avatar.svelte";
import VerifiedBadge from "../lib/VerifiedBadge.svelte";
import {
appendOutbound,
ensureConversation,
listConversations,
setVerified,
type Conversation,
} from "../lib/conversations-store.js";
import type { Identity } from "../lib/kez.js";
@ -153,6 +156,43 @@
conversations = await listConversations();
}
// Verify the peer whenever a conversation is opened. The 24h cache in
// verifyPeer makes this a no-op on repeat opens (and after setVerified
// refreshes the list, so no loop).
$effect(() => {
const conv = activeConv;
if (conv) void verifyPeer(conv);
});
const VERIFY_CACHE_MS = 24 * 60 * 60 * 1000;
/**
* Verify a peer's published proofs (24h cache). Fetches their claimed
* subjects from the server, runs the real channel verifiers against
* each, and caches whether ≥1 passed → drives the verified badge.
* Runs in the background on conversation open; never blocks the UI.
*/
async function verifyPeer(conv: Conversation) {
const fresh =
conv.verified_checked_at &&
Date.now() - new Date(conv.verified_checked_at).getTime() < VERIFY_CACHE_MS;
if (fresh) return;
try {
const record = await lookupByPrimary(conv.peer_primary);
let ok = false;
for (const subject of record.proofs ?? []) {
if (await verifySubject(subject, conv.peer_primary)) {
ok = true;
break; // one verified proof is enough for the badge
}
}
await setVerified(conv.peer_primary, ok);
await refresh();
} catch {
// Peer not resolvable / offline channels — leave badge as-is.
}
}
/** "Start chat with handle" — resolve, ensure conversation, open it. */
async function startConversation() {
if (!session.unlocked || !newPeerInput.trim()) return;
@ -322,7 +362,10 @@
{#if active}<span class="absolute left-0 top-0 bottom-0 w-0.5 bg-accent"></span>{/if}
<Avatar seed={c.peer_primary} size={40} />
<div class="min-w-0 flex-1">
<p class="font-mono text-sm font-semibold text-text truncate">{displayName(c)}</p>
<p class="font-mono text-sm font-semibold text-text truncate flex items-center gap-1">
<span class="truncate">{displayName(c)}</span>
{#if c.verified}<VerifiedBadge size={14} />{/if}
</p>
{#if last}
<p class="text-xs text-text-secondary truncate">
{last.direction === "out" ? "→ " : ""}{last.body}
@ -365,7 +408,10 @@
</button>
<Avatar seed={activeConv.peer_primary} size={36} />
<div class="min-w-0">
<p class="font-mono text-sm font-semibold text-text truncate">{displayName(activeConv)}</p>
<p class="font-mono text-sm font-semibold text-text truncate flex items-center gap-1">
<span class="truncate">{displayName(activeConv)}</span>
{#if activeConv.verified}<VerifiedBadge size={15} />{/if}
</p>
<p class="text-[10px] text-text-muted truncate font-mono">{activeConv.peer_primary}</p>
</div>
</div>