From 41f66ae366030e713f4dc9bd150e801171b1dc3a Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Mon, 25 May 2026 13:40:07 -0600 Subject: [PATCH] feat(kez-chat/web): in-browser claim verification with per-channel plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin layout (one file per channel — easy to extend): lib/verifiers/{dns,web,github,nostr,bluesky,ap}.ts lib/verifiers/types.ts — VerifyResult + ok/fail/skipped builders lib/verify.ts — dispatcher routing on claim.channel Live verifiers (browser-native, no CORS proxy): • DNS — Cloudflare DoH /dns-query, TXT at _kez. • web — fetch /.well-known/kez.json • github — public gists API for kez.md + / README Deferred to v0.2 (stubs return "skipped" with a hint): • nostr — needs ws relay pool + NIP-19 • bluesky — needs AT-Proto client • ap — WebFinger CORS hostile from browsers Verification flow (all channels): 1. Fetch the published artifact via the channel's transport 2. parseAnyEnvelope() handles kez:z1: compact, ```kez fences, or raw 3. Check subject + primary against the stored claim 4. Re-canonicalize payload (JCS) and verify ed25519 signature UI changes on /claims: • Status badge per claim: ✓ Verified / ✗ Failed / — Skipped / Not verified • Per-claim "Verify" button + a "Verify all" button at the top • Expandable details panel showing the evidence URL and any error info • Latest result persists in IndexedDB (with $state.snapshot for cloning) kez.ts gains verifyEnvelope() and parseAnyEnvelope() — also useful to any future verifier (CLI, sig-server, third-party). Co-Authored-By: Claude Opus 4.7 --- kez-chat/web/src/lib/claims-store.ts | 15 +++ kez-chat/web/src/lib/kez.ts | 63 ++++++++++ kez-chat/web/src/lib/verifiers/ap.ts | 15 +++ kez-chat/web/src/lib/verifiers/bluesky.ts | 14 +++ kez-chat/web/src/lib/verifiers/dns.ts | 98 ++++++++++++++++ kez-chat/web/src/lib/verifiers/github.ts | 127 ++++++++++++++++++++ kez-chat/web/src/lib/verifiers/nostr.ts | 16 +++ kez-chat/web/src/lib/verifiers/types.ts | 73 ++++++++++++ kez-chat/web/src/lib/verifiers/web.ts | 61 ++++++++++ kez-chat/web/src/lib/verify.ts | 51 ++++++++ kez-chat/web/src/routes/Claims.svelte | 135 ++++++++++++++++++++-- 11 files changed, 657 insertions(+), 11 deletions(-) create mode 100644 kez-chat/web/src/lib/verifiers/ap.ts create mode 100644 kez-chat/web/src/lib/verifiers/bluesky.ts create mode 100644 kez-chat/web/src/lib/verifiers/dns.ts create mode 100644 kez-chat/web/src/lib/verifiers/github.ts create mode 100644 kez-chat/web/src/lib/verifiers/nostr.ts create mode 100644 kez-chat/web/src/lib/verifiers/types.ts create mode 100644 kez-chat/web/src/lib/verifiers/web.ts create mode 100644 kez-chat/web/src/lib/verify.ts diff --git a/kez-chat/web/src/lib/claims-store.ts b/kez-chat/web/src/lib/claims-store.ts index 386bd21..333102c 100644 --- a/kez-chat/web/src/lib/claims-store.ts +++ b/kez-chat/web/src/lib/claims-store.ts @@ -5,6 +5,7 @@ import { get, set } from "idb-keyval"; import type { SignedClaimEnvelope } from "./kez.js"; +import type { VerifyResult } from "./verify.js"; const KEY = "kez-chat:claims"; @@ -14,6 +15,8 @@ export interface StoredClaim { channel: string; // "github", "dns", "web", ... published_at?: string; // user marked it published notes?: string; + /** Latest verification result, if we've checked. */ + last_verify?: VerifyResult; } export async function listClaims(): Promise { @@ -35,6 +38,18 @@ export async function markPublished(id: string): Promise { } } +export async function setVerifyResult( + id: string, + result: VerifyResult, +): Promise { + const existing = await listClaims(); + const target = existing.find((c) => c.id === id); + if (target) { + target.last_verify = result; + await set(KEY, existing); + } +} + export async function removeClaim(id: string): Promise { const existing = await listClaims(); await set( diff --git a/kez-chat/web/src/lib/kez.ts b/kez-chat/web/src/lib/kez.ts index ccdcffc..4895d08 100644 --- a/kez-chat/web/src/lib/kez.ts +++ b/kez-chat/web/src/lib/kez.ts @@ -125,6 +125,69 @@ function signWith( }; } +// ───────────────────────────────────────────────────────────────────────────── +// Verification +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Verify an `ed25519-sha512-jcs` signature on an envelope. + * + * Re-canonicalizes the payload, parses the signer's identity (must be + * `ed25519:`), and checks the signature byte-for-byte. Returns + * true on valid, false on invalid/malformed input — never throws on a + * bad sig, only on structurally broken input. + */ +export function verifyEnvelope(env: { + payload: unknown; + signature: SignatureBlock; +}): boolean { + const sigBlock = env.signature; + if (!sigBlock || sigBlock.alg !== ED25519_SHA512_ALG) return false; + if (!sigBlock.key.startsWith("ed25519:")) return false; + const pubkeyHex = sigBlock.key.slice("ed25519:".length); + let pubkey: Uint8Array; + let sig: Uint8Array; + try { + pubkey = hexToBytes(pubkeyHex); + sig = hexToBytes(sigBlock.sig); + } catch { + return false; + } + const jcs = canonicalBytes(env.payload); + try { + return ed25519.verify(sig, jcs, pubkey); + } catch { + return false; + } +} + +/** + * Parse any of the three published forms — `kez:z1:` compact, a + * fenced ```kez block, or raw JSON — into a SignedClaimEnvelope. + * Throws on parse failure; does NOT verify the signature. + */ +export async function parseAnyEnvelope( + text: string, +): Promise { + const trimmed = text.trim(); + // Look for kez:z1: anywhere in the text (DNS TXT, gist body, etc.). + const compactMatch = trimmed.match(/kez:z1:[A-Za-z0-9_-]+/); + if (compactMatch) { + return fromCompact(compactMatch[0]); + } + // Fenced block: ```kez\n{...}\n``` + const fenceMatch = trimmed.match(/```kez\s*\n([\s\S]*?)\n```/); + if (fenceMatch) { + return JSON.parse(fenceMatch[1].trim()) as SignedClaimEnvelope; + } + // Raw JSON — find the first `{` and parse from there. + const jsonStart = trimmed.indexOf("{"); + if (jsonStart >= 0) { + return JSON.parse(trimmed.slice(jsonStart)) as SignedClaimEnvelope; + } + throw new Error("no kez envelope found"); +} + /** Build + sign a kez.claim envelope ("I control "). */ export function signClaim( signer: Ed25519Identity, diff --git a/kez-chat/web/src/lib/verifiers/ap.ts b/kez-chat/web/src/lib/verifiers/ap.ts new file mode 100644 index 0000000..a5737f8 --- /dev/null +++ b/kez-chat/web/src/lib/verifiers/ap.ts @@ -0,0 +1,15 @@ +// ActivityPub channel verifier — v0.2. +// +// WebFinger → actor JSON → look for the kez fence in `summary` or +// `attachment[].value`. Most fediverse instances do not send CORS +// headers on /.well-known/webfinger, so verifying from the browser +// would require a server-side proxy. Deferred. + +import { skipped, type Verifier } from "./types.js"; + +export const verifyAp: Verifier = async () => { + return skipped( + "ActivityPub verification is coming in v0.2", + "WebFinger lookups require a server-side proxy (CORS). You can still publish the artifact manually.", + ); +}; diff --git a/kez-chat/web/src/lib/verifiers/bluesky.ts b/kez-chat/web/src/lib/verifiers/bluesky.ts new file mode 100644 index 0000000..c7e4531 --- /dev/null +++ b/kez-chat/web/src/lib/verifiers/bluesky.ts @@ -0,0 +1,14 @@ +// Bluesky channel verifier — v0.2. +// +// Verification will walk the user's AT-Proto repo for the post that +// contains the kez fence. ATProto client + cursor-paged record listing +// is heavier than what we want to inline for v0.1. + +import { skipped, type Verifier } from "./types.js"; + +export const verifyBluesky: Verifier = async () => { + return skipped( + "Bluesky verification is coming in v0.2", + "Need an AT-Proto client. You can still publish the artifact manually.", + ); +}; diff --git a/kez-chat/web/src/lib/verifiers/dns.ts b/kez-chat/web/src/lib/verifiers/dns.ts new file mode 100644 index 0000000..ab45674 --- /dev/null +++ b/kez-chat/web/src/lib/verifiers/dns.ts @@ -0,0 +1,98 @@ +// DNS channel verifier. +// +// A claim with subject `dns:` should be published as a TXT +// record at `_kez.` containing the `kez:z1:` compact form +// (other forms are allowed but compact is the canonical choice for +// DNS — 255-byte segment limits, no fences, no whitespace). +// +// Browsers can't speak DNS directly, so we use DNS-over-HTTPS via +// Cloudflare's public endpoint, which is CORS-friendly. + +import { parseAnyEnvelope, verifyEnvelope } from "../kez.js"; +import { fail, ok, type Verifier } from "./types.js"; + +interface DohAnswer { + name: string; + type: number; + TTL: number; + data: string; +} + +interface DohResponse { + Status: number; + Answer?: DohAnswer[]; +} + +const DOH_ENDPOINT = "https://cloudflare-dns.com/dns-query"; + +export const verifyDns: Verifier = async (ctx) => { + if (!ctx.subject.startsWith("dns:")) { + return fail(`DNS verifier got non-DNS subject: ${ctx.subject}`); + } + const domain = ctx.subject.slice("dns:".length); + const queryName = `_kez.${domain}`; + const url = `${DOH_ENDPOINT}?name=${encodeURIComponent(queryName)}&type=TXT`; + + let resp: Response; + try { + resp = await fetch(url, { headers: { Accept: "application/dns-json" } }); + } catch (e) { + return fail(`DoH request failed: ${(e as Error).message}`, { + evidence_url: url, + }); + } + if (!resp.ok) { + return fail(`DoH responded ${resp.status}`, { evidence_url: url }); + } + const json = (await resp.json()) as DohResponse; + if (json.Status !== 0) { + return fail(`DoH status ${json.Status} (NXDOMAIN or SERVFAIL)`, { + evidence_url: url, + }); + } + if (!json.Answer || json.Answer.length === 0) { + return fail(`No TXT record at ${queryName}`, { evidence_url: url }); + } + + // Each TXT answer is a quoted, possibly multi-segment string. + // Strip outer quotes and concatenate segments. + const candidates = json.Answer + .filter((a) => a.type === 16) + .map((a) => + a.data + .split(/"\s+"/) + .map((s) => s.replace(/^"|"$/g, "")) + .join(""), + ); + + for (const txt of candidates) { + try { + const fetched = await parseAnyEnvelope(txt); + if (fetched.payload.subject !== ctx.subject) continue; + if (fetched.payload.primary !== ctx.primary) { + return fail( + `TXT envelope's primary (${fetched.payload.primary}) doesn't match your key`, + { evidence_url: url, fetched }, + ); + } + if (!verifyEnvelope(fetched)) { + return fail(`TXT envelope signature is invalid`, { + evidence_url: url, + fetched, + }); + } + return ok(`Verified via DNS TXT at ${queryName}`, { + evidence_url: url, + fetched, + details: `Fetched ${candidates.length} TXT record(s); signature checked against ${ctx.primary}.`, + }); + } catch { + // not a kez envelope — try the next TXT record + continue; + } + } + return fail(`Found TXT records at ${queryName} but none contained a valid kez envelope for ${ctx.subject}`, { + evidence_url: url, + details: `Candidates examined:\n${candidates.map((c) => ` • ${c.slice(0, 80)}${c.length > 80 ? "…" : ""}`).join("\n")}`, + }); +}; diff --git a/kez-chat/web/src/lib/verifiers/github.ts b/kez-chat/web/src/lib/verifiers/github.ts new file mode 100644 index 0000000..fbd5d63 --- /dev/null +++ b/kez-chat/web/src/lib/verifiers/github.ts @@ -0,0 +1,127 @@ +// GitHub channel verifier. +// +// A claim with subject `github:` can be proven two ways: +// 1. A public gist named `kez.md` containing the ```kez fence. +// 2. The user's profile README at /. +// +// Both endpoints (api.github.com, raw.githubusercontent.com) send CORS +// headers, so the browser can fetch them directly. Unauthenticated +// rate limits are 60 req/hr per IP — generous for our use. + +import { parseAnyEnvelope, verifyEnvelope } from "../kez.js"; +import { fail, ok, type Verifier, type VerifyResult } from "./types.js"; + +interface GistFile { + filename: string; + raw_url: string; +} +interface Gist { + id: string; + html_url: string; + files: Record; +} + +async function tryGists( + ctx: { subject: string; primary: string }, + user: string, +): Promise { + const url = `https://api.github.com/users/${encodeURIComponent(user)}/gists?per_page=100`; + const resp = await fetch(url, { + headers: { Accept: "application/vnd.github+json" }, + }); + if (!resp.ok) return null; + const gists = (await resp.json()) as Gist[]; + for (const g of gists) { + const kezFile = Object.values(g.files).find( + (f) => f.filename.toLowerCase() === "kez.md", + ); + if (!kezFile) continue; + try { + const raw = await fetch(kezFile.raw_url).then((r) => r.text()); + const fetched = await parseAnyEnvelope(raw); + if (fetched.payload.subject !== ctx.subject) continue; + if (fetched.payload.primary !== ctx.primary) { + return fail( + `Gist envelope's primary doesn't match your key`, + { evidence_url: g.html_url, fetched }, + ); + } + if (!verifyEnvelope(fetched)) { + return fail(`Gist envelope signature is invalid`, { + evidence_url: g.html_url, + fetched, + }); + } + return ok(`Verified via public gist`, { + evidence_url: g.html_url, + fetched, + }); + } catch { + continue; + } + } + return null; +} + +async function tryProfileReadme( + ctx: { subject: string; primary: string }, + user: string, +): Promise { + // Try main and master. + for (const branch of ["main", "master"]) { + const url = `https://raw.githubusercontent.com/${user}/${user}/${branch}/README.md`; + const resp = await fetch(url); + if (!resp.ok) continue; + const raw = await resp.text(); + try { + const fetched = await parseAnyEnvelope(raw); + if (fetched.payload.subject !== ctx.subject) continue; + if (fetched.payload.primary !== ctx.primary) { + return fail( + `Profile README envelope's primary doesn't match your key`, + { evidence_url: url, fetched }, + ); + } + if (!verifyEnvelope(fetched)) { + return fail(`Profile README envelope signature is invalid`, { + evidence_url: url, + fetched, + }); + } + return ok(`Verified via ${user}/${user} README`, { + evidence_url: url, + fetched, + }); + } catch { + // README doesn't contain a kez envelope — keep going. + continue; + } + } + return null; +} + +export const verifyGithub: Verifier = async (ctx) => { + if (!ctx.subject.startsWith("github:")) { + return fail(`GitHub verifier got non-github subject: ${ctx.subject}`); + } + const user = ctx.subject.slice("github:".length); + if (!user) return fail("Empty GitHub username"); + + const gistResult = await tryGists(ctx, user); + if (gistResult) return gistResult; + + const readmeResult = await tryProfileReadme(ctx, user); + if (readmeResult) return readmeResult; + + return fail( + `No kez proof found for github:${user}`, + { + evidence_url: `https://gist.github.com/${user}`, + details: + `Looked in:\n` + + ` • all public gists for a file named kez.md\n` + + ` • https://github.com/${user}/${user}/blob/{main,master}/README.md\n` + + `If you just published it, GitHub's API can lag by a few seconds.`, + }, + ); +}; diff --git a/kez-chat/web/src/lib/verifiers/nostr.ts b/kez-chat/web/src/lib/verifiers/nostr.ts new file mode 100644 index 0000000..9993259 --- /dev/null +++ b/kez-chat/web/src/lib/verifiers/nostr.ts @@ -0,0 +1,16 @@ +// Nostr channel verifier — v0.2. +// +// Verifying a `nostr:` claim means connecting to a pool of relays +// over WebSocket, subscribing to kind-30078 events tagged d='kez' from +// that pubkey, and checking the event's content is a valid kez envelope. +// That's a chunky dependency (relay list, ws pool, NIP-19 decode) so we +// defer it; the user can still create the claim and copy the artifact. + +import { skipped, type Verifier } from "./types.js"; + +export const verifyNostr: Verifier = async () => { + return skipped( + "Nostr verification is coming in v0.2", + "Need a relay pool + NIP-19 decoder. You can still publish the artifact manually.", + ); +}; diff --git a/kez-chat/web/src/lib/verifiers/types.ts b/kez-chat/web/src/lib/verifiers/types.ts new file mode 100644 index 0000000..17a8d17 --- /dev/null +++ b/kez-chat/web/src/lib/verifiers/types.ts @@ -0,0 +1,73 @@ +// Shared types for the per-channel verifier plugins. +// +// Each channel exports a `verify(claim) -> VerifyResult` function. The +// dispatcher in ../verify.ts routes to the right one based on +// claim.channel. The result is rendered on the Claims page. + +import type { SignedClaimEnvelope } from "../kez.js"; + +export type VerifyStatus = "ok" | "fail" | "skipped"; + +export interface VerifyResult { + status: VerifyStatus; + /** One-line summary for the UI. */ + summary: string; + /** URL the verifier fetched (when relevant) — shown as evidence link. */ + evidence_url?: string; + /** The envelope as fetched from the channel, when we got one. */ + fetched?: SignedClaimEnvelope; + /** Long-form details (multi-line). Shown in an expandable panel. */ + details?: string; + /** Wall-clock timestamp the verify ran. */ + checked_at: string; +} + +export interface VerifierContext { + /** The locally-stored claim we're trying to verify. */ + subject: string; + primary: string; + /** The exact bytes we expect to find published. */ + expected: SignedClaimEnvelope; +} + +export type Verifier = (ctx: VerifierContext) => Promise; + +/** Build a result that says "we couldn't even try." */ +export function skipped(summary: string, details?: string): VerifyResult { + return { + status: "skipped", + summary, + details, + checked_at: new Date().toISOString(), + }; +} + +/** Build a failure result with optional evidence URL + details. */ +export function fail( + summary: string, + opts?: { evidence_url?: string; details?: string; fetched?: SignedClaimEnvelope }, +): VerifyResult { + return { + status: "fail", + summary, + evidence_url: opts?.evidence_url, + details: opts?.details, + fetched: opts?.fetched, + checked_at: new Date().toISOString(), + }; +} + +/** Build a success result. */ +export function ok( + summary: string, + opts?: { evidence_url?: string; details?: string; fetched?: SignedClaimEnvelope }, +): VerifyResult { + return { + status: "ok", + summary, + evidence_url: opts?.evidence_url, + details: opts?.details, + fetched: opts?.fetched, + checked_at: new Date().toISOString(), + }; +} diff --git a/kez-chat/web/src/lib/verifiers/web.ts b/kez-chat/web/src/lib/verifiers/web.ts new file mode 100644 index 0000000..02a3e1e --- /dev/null +++ b/kez-chat/web/src/lib/verifiers/web.ts @@ -0,0 +1,61 @@ +// Web channel verifier. +// +// A claim with subject `web:` should be published at +// `/.well-known/kez.json` — the raw signed envelope JSON. +// We fetch it and verify the signature. CORS is up to the target site; +// many sites allow it on .well-known/, but if not the user sees the +// browser's CORS error in the UI. + +import { verifyEnvelope, type SignedClaimEnvelope } from "../kez.js"; +import { fail, ok, type Verifier } from "./types.js"; + +export const verifyWeb: Verifier = async (ctx) => { + if (!ctx.subject.startsWith("web:")) { + return fail(`Web verifier got non-web subject: ${ctx.subject}`); + } + const base = ctx.subject.slice("web:".length).replace(/\/$/, ""); + const url = `${base}/.well-known/kez.json`; + + let resp: Response; + try { + resp = await fetch(url, { headers: { Accept: "application/json" } }); + } catch (e) { + return fail( + `Fetch failed (likely CORS): ${(e as Error).message}`, + { + evidence_url: url, + details: + `If the file is there but the browser blocks it, the site needs to send\n` + + ` Access-Control-Allow-Origin: *\n` + + `on /.well-known/kez.json. Most static hosts can configure this in one line.`, + }, + ); + } + if (!resp.ok) { + return fail(`${url} responded ${resp.status}`, { evidence_url: url }); + } + let fetched: SignedClaimEnvelope; + try { + fetched = (await resp.json()) as SignedClaimEnvelope; + } catch (e) { + return fail(`Response was not JSON: ${(e as Error).message}`, { + evidence_url: url, + }); + } + if (fetched.payload?.subject !== ctx.subject) { + return fail( + `Envelope subject (${fetched.payload?.subject}) doesn't match the claim (${ctx.subject})`, + { evidence_url: url, fetched }, + ); + } + if (fetched.payload.primary !== ctx.primary) { + return fail( + `Envelope primary (${fetched.payload.primary}) doesn't match your key`, + { evidence_url: url, fetched }, + ); + } + if (!verifyEnvelope(fetched)) { + return fail(`Signature invalid`, { evidence_url: url, fetched }); + } + return ok(`Verified via ${url}`, { evidence_url: url, fetched }); +}; diff --git a/kez-chat/web/src/lib/verify.ts b/kez-chat/web/src/lib/verify.ts new file mode 100644 index 0000000..e3b7a14 --- /dev/null +++ b/kez-chat/web/src/lib/verify.ts @@ -0,0 +1,51 @@ +// Channel dispatcher for claim verification. +// +// The Claims page calls verifyClaim(storedClaim) — we look at +// claim.channel, hand off to the right plugin in ./verifiers/, and +// return its result. + +import type { StoredClaim } from "./claims-store.js"; +import { verifyDns } from "./verifiers/dns.js"; +import { verifyWeb } from "./verifiers/web.js"; +import { verifyGithub } from "./verifiers/github.js"; +import { verifyNostr } from "./verifiers/nostr.js"; +import { verifyBluesky } from "./verifiers/bluesky.js"; +import { verifyAp } from "./verifiers/ap.js"; +import { + fail, + skipped, + type Verifier, + type VerifierContext, + type VerifyResult, +} from "./verifiers/types.js"; + +const REGISTRY: Record = { + dns: verifyDns, + web: verifyWeb, + github: verifyGithub, + nostr: verifyNostr, + bluesky: verifyBluesky, + ap: verifyAp, +}; + +export async function verifyClaim(claim: StoredClaim): Promise { + const handler = REGISTRY[claim.channel]; + if (!handler) { + return skipped(`No verifier registered for channel "${claim.channel}"`); + } + const ctx: VerifierContext = { + subject: claim.envelope.payload.subject, + primary: claim.envelope.payload.primary, + expected: claim.envelope, + }; + try { + return await handler(ctx); + } catch (e) { + return fail( + `Verifier crashed: ${(e as Error).message}`, + { details: (e as Error).stack }, + ); + } +} + +export type { VerifyResult } from "./verifiers/types.js"; diff --git a/kez-chat/web/src/routes/Claims.svelte b/kez-chat/web/src/routes/Claims.svelte index 5324aa2..13a6bde 100644 --- a/kez-chat/web/src/routes/Claims.svelte +++ b/kez-chat/web/src/routes/Claims.svelte @@ -5,12 +5,18 @@ listClaims, markPublished, removeClaim, + setVerifyResult, type StoredClaim, } from "../lib/claims-store.js"; + import { verifyClaim } from "../lib/verify.js"; import { session } from "../lib/store.svelte.js"; let claims = $state([]); let loading = $state(true); + /** ids currently mid-verify, so we can disable the button + show a spinner. */ + let verifying = $state>(new Set()); + /** Which claims have their details panel expanded. */ + let expanded = $state>(new Set()); onMount(async () => { if (!session.unlocked) { @@ -31,24 +37,83 @@ await removeClaim(c.id); claims = await listClaims(); } + + async function runVerify(c: StoredClaim) { + verifying = new Set(verifying).add(c.id); + try { + // $state.snapshot — IDB can't clone proxies (see AddClaim.svelte fix). + const result = await verifyClaim($state.snapshot(c) as StoredClaim); + await setVerifyResult(c.id, result); + claims = await listClaims(); + } catch (e) { + console.error("verify failed", e); + alert(`Verify crashed: ${(e as Error).message}`); + } finally { + const next = new Set(verifying); + next.delete(c.id); + verifying = next; + } + } + + async function verifyAll() { + for (const c of claims) { + await runVerify(c); + } + } + + function toggleExpand(id: string) { + const next = new Set(expanded); + if (next.has(id)) next.delete(id); + else next.add(id); + expanded = next; + } + + function statusBadge(c: StoredClaim) { + const v = c.last_verify; + if (!v) return { text: "Not verified", color: "bg-gray-100 text-gray-600" }; + if (v.status === "ok") return { text: "✓ Verified", color: "bg-green-100 text-green-800" }; + if (v.status === "fail") return { text: "✗ Failed", color: "bg-red-100 text-red-800" }; + return { text: "— Skipped", color: "bg-yellow-100 text-yellow-800" }; + } + + function formatChecked(iso: string): string { + const d = new Date(iso); + const diffSec = (Date.now() - d.getTime()) / 1000; + if (diffSec < 60) return "just now"; + if (diffSec < 3600) return `${Math.floor(diffSec / 60)} min ago`; + if (diffSec < 86400) return `${Math.floor(diffSec / 3600)} h ago`; + return d.toLocaleString(); + }

Claims

- - + Add claim - +
+ {#if claims.length > 0} + + {/if} + + + Add claim + +

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 - trusting this server. + trusting this server. Hit Verify to have your + browser fetch the proof and check the signature.

{#if loading} @@ -66,17 +131,58 @@ {:else}
    {#each claims as c (c.id)} + {@const badge = statusBadge(c)} + {@const isVerifying = verifying.has(c.id)} + {@const isExpanded = expanded.has(c.id)}
  • -

    - {c.envelope.payload.subject} -

    +
    +

    + {c.envelope.payload.subject} +

    + + {badge.text} + +

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

    - {#if c.published_at} + {#if c.last_verify} +

    + {c.last_verify.summary} + · 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} + + {/if} + {#if c.last_verify.details} +
    {c.last_verify.details}
    + {/if} +
    + {/if} + {/if} + {:else if c.published_at}

    ✓ You marked this published at {c.published_at}

    @@ -87,6 +193,13 @@ {/if}
    + {#if !c.published_at}