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 { get, set } from "idb-keyval";
|
||||||
import type { SignedClaimEnvelope } from "./kez.js";
|
import type { SignedClaimEnvelope } from "./kez.js";
|
||||||
|
import type { VerifyResult } from "./verify.js";
|
||||||
|
|
||||||
const KEY = "kez-chat:claims";
|
const KEY = "kez-chat:claims";
|
||||||
|
|
||||||
@ -14,6 +15,8 @@ export interface StoredClaim {
|
|||||||
channel: string; // "github", "dns", "web", ...
|
channel: string; // "github", "dns", "web", ...
|
||||||
published_at?: string; // user marked it published
|
published_at?: string; // user marked it published
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
/** Latest verification result, if we've checked. */
|
||||||
|
last_verify?: VerifyResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listClaims(): Promise<StoredClaim[]> {
|
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> {
|
export async function removeClaim(id: string): Promise<void> {
|
||||||
const existing = await listClaims();
|
const existing = await listClaims();
|
||||||
await set(
|
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>"). */
|
/** Build + sign a kez.claim envelope ("I control <subject>"). */
|
||||||
export function signClaim(
|
export function signClaim(
|
||||||
signer: Ed25519Identity,
|
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,
|
listClaims,
|
||||||
markPublished,
|
markPublished,
|
||||||
removeClaim,
|
removeClaim,
|
||||||
|
setVerifyResult,
|
||||||
type StoredClaim,
|
type StoredClaim,
|
||||||
} from "../lib/claims-store.js";
|
} from "../lib/claims-store.js";
|
||||||
|
import { verifyClaim } from "../lib/verify.js";
|
||||||
import { session } from "../lib/store.svelte.js";
|
import { session } from "../lib/store.svelte.js";
|
||||||
|
|
||||||
let claims = $state<StoredClaim[]>([]);
|
let claims = $state<StoredClaim[]>([]);
|
||||||
let loading = $state(true);
|
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 () => {
|
onMount(async () => {
|
||||||
if (!session.unlocked) {
|
if (!session.unlocked) {
|
||||||
@ -31,24 +37,83 @@
|
|||||||
await removeClaim(c.id);
|
await removeClaim(c.id);
|
||||||
claims = await listClaims();
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Claims</h1>
|
<h1 class="text-2xl font-bold text-gray-900">Claims</h1>
|
||||||
<a
|
<div class="flex gap-2">
|
||||||
href="#/claims/add"
|
{#if claims.length > 0}
|
||||||
class="px-3 py-2 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-700 no-underline"
|
<button
|
||||||
>
|
class="px-3 py-2 text-sm border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||||
+ Add claim
|
onclick={verifyAll}
|
||||||
</a>
|
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>
|
</div>
|
||||||
|
|
||||||
<p class="text-sm text-gray-700">
|
<p class="text-sm text-gray-700">
|
||||||
A claim is a signed envelope that says "I control this other account."
|
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
|
Publish the proof on the channel itself (a public gist, a DNS TXT
|
||||||
record, a nostr event, etc.) and anyone can verify it without
|
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>
|
</p>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
@ -66,17 +131,58 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<ul class="space-y-3">
|
<ul class="space-y-3">
|
||||||
{#each claims as c (c.id)}
|
{#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">
|
<li class="border border-gray-200 rounded-lg p-4 bg-white">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="font-mono font-semibold text-gray-900 truncate">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
{c.envelope.payload.subject}
|
<p class="font-mono font-semibold text-gray-900 truncate">
|
||||||
</p>
|
{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">
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
Channel: <span class="font-mono">{c.channel}</span> ·
|
Channel: <span class="font-mono">{c.channel}</span> ·
|
||||||
Signed: <span class="font-mono">{c.envelope.payload.created_at}</span>
|
Signed: <span class="font-mono">{c.envelope.payload.created_at}</span>
|
||||||
</p>
|
</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">
|
<p class="mt-1 text-xs text-green-700">
|
||||||
✓ You marked this published at {c.published_at}
|
✓ You marked this published at {c.published_at}
|
||||||
</p>
|
</p>
|
||||||
@ -87,6 +193,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2 shrink-0">
|
<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}
|
{#if !c.published_at}
|
||||||
<button
|
<button
|
||||||
class="text-xs px-3 py-1 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
|
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