feat(kez-chat/web): in-browser claim verification with per-channel plugins
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.<domain>
• web — fetch <base>/.well-known/kez.json
• github — public gists API for kez.md + <user>/<user> 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 <noreply@anthropic.com>
This commit is contained in:
parent
8622da2ba4
commit
41f66ae366
@ -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<StoredClaim[]> {
|
||||
@ -35,6 +38,18 @@ export async function markPublished(id: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function setVerifyResult(
|
||||
id: string,
|
||||
result: VerifyResult,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
const existing = await listClaims();
|
||||
await set(
|
||||
|
||||
@ -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:<hex>`), 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<SignedClaimEnvelope> {
|
||||
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 <subject>"). */
|
||||
export function signClaim(
|
||||
signer: Ed25519Identity,
|
||||
|
||||
15
kez-chat/web/src/lib/verifiers/ap.ts
Normal file
15
kez-chat/web/src/lib/verifiers/ap.ts
Normal file
@ -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.",
|
||||
);
|
||||
};
|
||||
14
kez-chat/web/src/lib/verifiers/bluesky.ts
Normal file
14
kez-chat/web/src/lib/verifiers/bluesky.ts
Normal file
@ -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.",
|
||||
);
|
||||
};
|
||||
98
kez-chat/web/src/lib/verifiers/dns.ts
Normal file
98
kez-chat/web/src/lib/verifiers/dns.ts
Normal file
@ -0,0 +1,98 @@
|
||||
// DNS channel verifier.
|
||||
//
|
||||
// A claim with subject `dns:<domain>` should be published as a TXT
|
||||
// record at `_kez.<domain>` 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")}`,
|
||||
});
|
||||
};
|
||||
127
kez-chat/web/src/lib/verifiers/github.ts
Normal file
127
kez-chat/web/src/lib/verifiers/github.ts
Normal file
@ -0,0 +1,127 @@
|
||||
// GitHub channel verifier.
|
||||
//
|
||||
// A claim with subject `github:<username>` can be proven two ways:
|
||||
// 1. A public gist named `kez.md` containing the ```kez fence.
|
||||
// 2. The user's profile README at <user>/<user>.
|
||||
//
|
||||
// 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<string, GistFile>;
|
||||
}
|
||||
|
||||
async function tryGists(
|
||||
ctx: { subject: string; primary: string },
|
||||
user: string,
|
||||
): Promise<VerifyResult | null> {
|
||||
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<VerifyResult | null> {
|
||||
// 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.`,
|
||||
},
|
||||
);
|
||||
};
|
||||
16
kez-chat/web/src/lib/verifiers/nostr.ts
Normal file
16
kez-chat/web/src/lib/verifiers/nostr.ts
Normal file
@ -0,0 +1,16 @@
|
||||
// Nostr channel verifier — v0.2.
|
||||
//
|
||||
// Verifying a `nostr:<npub>` 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.",
|
||||
);
|
||||
};
|
||||
73
kez-chat/web/src/lib/verifiers/types.ts
Normal file
73
kez-chat/web/src/lib/verifiers/types.ts
Normal file
@ -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<VerifyResult>;
|
||||
|
||||
/** 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(),
|
||||
};
|
||||
}
|
||||
61
kez-chat/web/src/lib/verifiers/web.ts
Normal file
61
kez-chat/web/src/lib/verifiers/web.ts
Normal file
@ -0,0 +1,61 @@
|
||||
// Web channel verifier.
|
||||
//
|
||||
// A claim with subject `web:<https-url>` should be published at
|
||||
// `<https-url>/.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 });
|
||||
};
|
||||
51
kez-chat/web/src/lib/verify.ts
Normal file
51
kez-chat/web/src/lib/verify.ts
Normal file
@ -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<string, Verifier> = {
|
||||
dns: verifyDns,
|
||||
web: verifyWeb,
|
||||
github: verifyGithub,
|
||||
nostr: verifyNostr,
|
||||
bluesky: verifyBluesky,
|
||||
ap: verifyAp,
|
||||
};
|
||||
|
||||
export async function verifyClaim(claim: StoredClaim): Promise<VerifyResult> {
|
||||
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";
|
||||
@ -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<StoredClaim[]>([]);
|
||||
let loading = $state(true);
|
||||
/** ids currently mid-verify, so we can disable the button + show a spinner. */
|
||||
let verifying = $state<Set<string>>(new Set());
|
||||
/** Which claims have their details panel expanded. */
|
||||
let expanded = $state<Set<string>>(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();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Claims</h1>
|
||||
<a
|
||||
href="#/claims/add"
|
||||
class="px-3 py-2 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-700 no-underline"
|
||||
>
|
||||
+ Add claim
|
||||
</a>
|
||||
<div class="flex gap-2">
|
||||
{#if claims.length > 0}
|
||||
<button
|
||||
class="px-3 py-2 text-sm border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||
onclick={verifyAll}
|
||||
disabled={verifying.size > 0}
|
||||
>
|
||||
{verifying.size > 0 ? `Verifying ${verifying.size}…` : "Verify all"}
|
||||
</button>
|
||||
{/if}
|
||||
<a
|
||||
href="#/claims/add"
|
||||
class="px-3 py-2 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-700 no-underline"
|
||||
>
|
||||
+ Add claim
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-700">
|
||||
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 <strong>Verify</strong> to have your
|
||||
browser fetch the proof and check the signature.
|
||||
</p>
|
||||
|
||||
{#if loading}
|
||||
@ -66,17 +131,58 @@
|
||||
{:else}
|
||||
<ul class="space-y-3">
|
||||
{#each claims as c (c.id)}
|
||||
{@const badge = statusBadge(c)}
|
||||
{@const isVerifying = verifying.has(c.id)}
|
||||
{@const isExpanded = expanded.has(c.id)}
|
||||
<li class="border border-gray-200 rounded-lg p-4 bg-white">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-mono font-semibold text-gray-900 truncate">
|
||||
{c.envelope.payload.subject}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<p class="font-mono font-semibold text-gray-900 truncate">
|
||||
{c.envelope.payload.subject}
|
||||
</p>
|
||||
<span class={`text-xs px-2 py-0.5 rounded font-medium ${badge.color}`}>
|
||||
{badge.text}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Channel: <span class="font-mono">{c.channel}</span> ·
|
||||
Signed: <span class="font-mono">{c.envelope.payload.created_at}</span>
|
||||
</p>
|
||||
{#if c.published_at}
|
||||
{#if c.last_verify}
|
||||
<p class="mt-1 text-xs text-gray-700">
|
||||
{c.last_verify.summary}
|
||||
<span class="text-gray-400">· checked {formatChecked(c.last_verify.checked_at)}</span>
|
||||
</p>
|
||||
{#if c.last_verify.evidence_url || c.last_verify.details}
|
||||
<button
|
||||
class="mt-1 text-xs text-gray-500 hover:text-gray-900 underline"
|
||||
onclick={() => toggleExpand(c.id)}
|
||||
>
|
||||
{isExpanded ? "Hide" : "Show"} details
|
||||
</button>
|
||||
{#if isExpanded}
|
||||
<div class="mt-2 text-xs bg-gray-50 border border-gray-200 rounded p-3 space-y-2">
|
||||
{#if c.last_verify.evidence_url}
|
||||
<div>
|
||||
<span class="text-gray-500">Evidence URL:</span>
|
||||
<a
|
||||
href={c.last_verify.evidence_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="font-mono text-blue-700 hover:underline break-all"
|
||||
>
|
||||
{c.last_verify.evidence_url}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
{#if c.last_verify.details}
|
||||
<pre class="whitespace-pre-wrap font-mono text-gray-700">{c.last_verify.details}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else if c.published_at}
|
||||
<p class="mt-1 text-xs text-green-700">
|
||||
✓ You marked this published at {c.published_at}
|
||||
</p>
|
||||
@ -87,6 +193,13 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 shrink-0">
|
||||
<button
|
||||
class="text-xs px-3 py-1 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||
onclick={() => runVerify(c)}
|
||||
disabled={isVerifying}
|
||||
>
|
||||
{isVerifying ? "Verifying…" : "Verify"}
|
||||
</button>
|
||||
{#if !c.published_at}
|
||||
<button
|
||||
class="text-xs px-3 py-1 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user