From c870ad71a00d128067f9dcb563d871e920937824 Mon Sep 17 00:00:00 2001 From: Kevin Hopper Date: Sun, 12 Apr 2026 16:12:51 -0500 Subject: [PATCH] F.11: Identity attestation layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ninth in the Phase 2 rollout; first non-bundle PR in the series. Stacked on F.8 (PeerTube). Ships the core plumbing for tying per-app fediverse handles (Mastodon @user@host, Funkwhale channels, Matrix MXIDs, etc.) to Crow's root Ed25519 identity with publicly-verifiable signatures. **This is attestation, not key replacement.** Each federated app still uses its own keys for federation; the Crow root key signs only the binding (crow_id, app, external_handle, version). New files: - servers/shared/identity-attestation.js — Core Ed25519 sign/verify helpers. canonicalPayload() produces sorted-key JSON for deterministic signing. verifyCrowIdBinding() catches swap attacks (crow_id is derived from the root pubkey; a valid sig with the wrong pubkey doesn't bind to the claimed crow_id). signRevocation/verifyRevocation for the revocation list. SUPPORTED_APPS constant caps the attestation surface to the 8 federated bundles shipped in F.1-F.8 (prevents typos like "matodon"). - skills/crow-identity.md — Full workflow skill: when to attest, when NOT to attest (ephemeral identities, pseudonymous accounts), endpoint surface, payload format, key-rotation flow, safety notes around permanent publication. Modified files: - scripts/init-db.js — Three new tables: - moderation_actions (bundle_id, action_type, payload_json, requested_at, expires_at, status, idempotency_key UNIQUE). Finally creates the table the federated bundles have been writing to since F.1; before F.11 the queueModerationAction() helper returned "queued_unavailable" gracefully when the table was absent. - identity_attestations (crow_id, app, external_handle, app_pubkey?, sig, version, created_at, revoked_at). UNIQUE(crow_id, app, external_handle, version) — each rotation adds a new version row rather than overwriting. Partial index for active (non-revoked) lookups. - identity_attestation_revocations (attestation_id FK CASCADE, revoked_at, reason, sig). Signed revocations stored separately so the revocation list itself is cryptographically verifiable. - contacts extended with external_handle + external_source columns (idempotent via addColumnIfMissing) so federated contacts can be linked into the existing contacts table. - servers/sharing/server.js — Four new MCP tools: - crow_identity_attest(app, external_handle, app_pubkey?, confirm) — creates row, signs with root key, returns attestation_id + sig + publish_url. Confirms permanence explicitly. - crow_identity_verify(crow_id, app, external_handle, max_age?) — fetches latest non-revoked row, verifies sig AND verifies the crow_id→pubkey binding. Currently only verifies attestations belonging to THIS instance; cross-instance verification uses the gateway's /.well-known endpoint instead (rate-limited there). - crow_identity_revoke(attestation_id, reason?, confirm) — signs a revocation, updates the attestation row's revoked_at, publishes via the revocation list. Refuses to revoke attestations that belong to a different crow_id. - crow_identity_list(include_revoked?, app?, limit?) — surfaces what this instance has attested. - servers/gateway/index.js — Two new public endpoints: - GET /.well-known/crow-identity.json — paginated active attestations (256 per page, cursor-based). Returns root_pubkey + active_attestations array. Cache-Control: 60s. - GET /.well-known/crow-identity-revocations.json — paginated revocations with signed proofs. Same pagination. - Shared identityLimiter (60 req/min/IP) on both endpoints to prevent verification-storm DoS against the attestation surface. - skills/superpowers.md — Trigger row added (EN+ES): attest identity, prove I am, link my mastodon, verify handle, keyoxide, revoke attestation. - CLAUDE.md — DB schema docs for moderation_actions + identity_attestations + identity_attestation_revocations. Skills Reference entry for crow-identity.md. Security design points: - No gossip over crow-sharing. Attestations publish only via .well-known for now (per plan Q3). Revisit after F.12 lands. - Pinned-post attestation (Keyoxide pattern) remains manual — automation would open forgery vectors (plan Critical #3 answer). - Publication is effectively permanent. Revocations themselves are public. Consent text in the tool descriptions warns about this explicitly. - Rate-limited at the HTTP surface to 60 req/min/IP — the verify endpoint can be turned into a DoS vector against small hosts otherwise. - Pagination capped at 256 entries/page per plan §Publication. Verified: - node --check on all modified + new files - node scripts/init-db.js runs cleanly; the three new tables + two new contacts columns land without conflicts on existing DBs - createSharingServer() boots with the four new tools registered - Core sign/verify/tamper-rejection/binding-check round-trip exercised via ad-hoc script - (Live gateway .well-known HTTP test deferred — the bundled gateway is hostile to the sandboxed test environment on this machine) Next: - F.12 cross-app bridging (now the final piece) — matrix-bridges meta-bundle + crow-native crosspost with 60s-delay consent UX. F.12 can now assume the moderation_actions + identity_attestations tables exist and the .well-known publication surface is live. --- CLAUDE.md | 4 + scripts/init-db.js | 66 +++++++ servers/gateway/index.js | 82 +++++++++ servers/shared/identity-attestation.js | 132 +++++++++++++ servers/sharing/server.js | 244 +++++++++++++++++++++++++ skills/crow-identity.md | 118 ++++++++++++ skills/superpowers.md | 1 + 7 files changed, 647 insertions(+) create mode 100644 servers/shared/identity-attestation.js create mode 100644 skills/crow-identity.md diff --git a/CLAUDE.md b/CLAUDE.md index e90f7f2..209bb88 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -201,6 +201,9 @@ Uses `@libsql/client` for local SQLite files (default: `~/.crow/data/crow.db`, g - **contact_groups** — Contact organization groups (id, name, color, sort_order) - **contact_group_members** — Many-to-many contacts-to-groups (group_id FK, contact_id FK, unique index) - **push_subscriptions** — Web Push notification subscriptions (endpoint UNIQUE, keys_json, platform, device_name) +- **moderation_actions** — F.11 queued destructive moderation actions from federated bundles (bundle_id, action_type, payload_json, requested_at, expires_at, status, idempotency_key UNIQUE). 72h default TTL; operator confirms via Nest panel +- **identity_attestations** — F.11 signed bindings (crow_id, app, external_handle, app_pubkey?, sig, version, revoked_at). Published via gateway `/.well-known/crow-identity.json`. UNIQUE(crow_id, app, external_handle, version) — new version row per rotation +- **identity_attestation_revocations** — F.11 signed revocations (attestation_id FK CASCADE, revoked_at, reason, sig). Published via `/.well-known/crow-identity-revocations.json` - **iptv_playlists** — IPTV M3U playlist sources (name, url, auto_refresh, channel_count) - **iptv_channels** — IPTV channels from playlists (playlist_id FK, name, stream_url, tvg_id, group_title, is_favorite) - **iptv_epg** — Electronic Program Guide entries (channel_tvg_id, title, start_time, end_time, indexed) @@ -436,6 +439,7 @@ Consult `skills/superpowers.md` first — it routes user intent to the right ski - `extension-dev.md` — Extension development: scaffold, test, and publish bundles, panels, MCP servers, and skills - `developer-kit.md` — Developer kit: scaffold, test, and submit Crow extensions to the registry - `network-setup.md` — Tailscale remote access guidance +- `crow-identity.md` — F.11 identity attestations: sign per-app handles (Mastodon/Funkwhale/Matrix/etc.) with the Crow root Ed25519 key, publish via `/.well-known/crow-identity.json`, verify + revoke. Off by default; opt-in per handle — public linkage is effectively permanent - `add-ons.md` — Add-on browsing, installation, removal - `scheduling.md` — Scheduled and recurring task management - `tutoring.md` — Socratic tutoring with progress tracking diff --git a/scripts/init-db.js b/scripts/init-db.js index 114cce6..5a2c816 100644 --- a/scripts/init-db.js +++ b/scripts/init-db.js @@ -481,6 +481,72 @@ await initTable("audit_log table", ` CREATE INDEX IF NOT EXISTS idx_audit_log_event_created ON audit_log(event_type, created_at); `); +// --- F.11: Moderation Actions (queued destructive moderation) --- +// Bundles (gotosocial, funkwhale, pixelfed, lemmy, mastodon, peertube) INSERT +// rows here when an AI invokes a destructive moderation verb. The operator +// confirms from the Nest panel before the action fires. +await initTable("moderation_actions table", ` + CREATE TABLE IF NOT EXISTS moderation_actions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bundle_id TEXT NOT NULL, + action_type TEXT NOT NULL, + payload_json TEXT NOT NULL, + requested_by TEXT NOT NULL, + requested_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + confirmed_by TEXT, + confirmed_at INTEGER, + error TEXT, + idempotency_key TEXT UNIQUE NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_moderation_actions_status ON moderation_actions(status, expires_at); + CREATE INDEX IF NOT EXISTS idx_moderation_actions_bundle ON moderation_actions(bundle_id, requested_at DESC); +`); + +// --- F.11: Identity Attestations --- +// Crow's root Ed25519 identity signs per-app handles so remote viewers can +// verify "these handles belong to the same root identity" via the gateway's +// /.well-known/crow-identity.json endpoint. version + revoked_at support +// key rotation. +await initTable("identity_attestations table", ` + CREATE TABLE IF NOT EXISTS identity_attestations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + crow_id TEXT NOT NULL, + app TEXT NOT NULL, + external_handle TEXT NOT NULL, + app_pubkey TEXT, + sig TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + revoked_at INTEGER + ); + + CREATE INDEX IF NOT EXISTS idx_identity_attestations_crow ON identity_attestations(crow_id, app); + CREATE INDEX IF NOT EXISTS idx_identity_attestations_active ON identity_attestations(app, external_handle) WHERE revoked_at IS NULL; + CREATE UNIQUE INDEX IF NOT EXISTS idx_identity_attestations_uniq ON identity_attestations(crow_id, app, external_handle, version); +`); + +await initTable("identity_attestation_revocations table", ` + CREATE TABLE IF NOT EXISTS identity_attestation_revocations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + attestation_id INTEGER NOT NULL REFERENCES identity_attestations(id) ON DELETE CASCADE, + revoked_at INTEGER NOT NULL, + reason TEXT, + sig TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_identity_attestation_revocations_attestation ON identity_attestation_revocations(attestation_id); + CREATE INDEX IF NOT EXISTS idx_identity_attestation_revocations_revoked_at ON identity_attestation_revocations(revoked_at DESC); +`); + +// Extend contacts with external_handle + external_source so discovered +// federated contacts (Mastodon follows, Lemmy community subscribers, etc.) +// can be linked to the local contacts table. +await addColumnIfMissing("contacts", "external_handle", "TEXT"); +await addColumnIfMissing("contacts", "external_source", "TEXT"); + // --- Per-Device Context Support --- // Existing installs have section_key UNIQUE constraint that blocks device overrides. // Migration: add device_id column, drop the old UNIQUE constraint, add partial indexes. diff --git a/servers/gateway/index.js b/servers/gateway/index.js index d6779b9..958136b 100644 --- a/servers/gateway/index.js +++ b/servers/gateway/index.js @@ -281,6 +281,88 @@ app.get("/api/turn-credentials", turnCredLimiter, (req, res) => { }); }); +// --- F.11: Identity Attestation (.well-known endpoints, rate-limited) --- +// Public, unauthenticated, rate-limited to 60 req/min/IP. Paginated at +// 256 active attestations per page. Revocations >1 year move to cold +// storage (not yet implemented — current window retains everything). +const identityLimiter = rateLimit({ windowMs: 60 * 1000, max: 60, standardHeaders: true }); +const IDENTITY_PAGE_SIZE = 256; + +app.get("/.well-known/crow-identity.json", identityLimiter, async (req, res) => { + try { + const { loadOrCreateIdentity } = await import("../sharing/identity.js"); + const identity = loadOrCreateIdentity(); + const cursor = Number(req.query.cursor) || 0; + const rows = await relayDb.execute({ + sql: `SELECT id, app, external_handle, app_pubkey, sig, version, created_at + FROM identity_attestations + WHERE crow_id = ? AND revoked_at IS NULL + ORDER BY id ASC LIMIT ? OFFSET ?`, + args: [identity.crowId, IDENTITY_PAGE_SIZE + 1, cursor], + }); + const hasNext = rows.rows.length > IDENTITY_PAGE_SIZE; + const page = rows.rows.slice(0, IDENTITY_PAGE_SIZE); + res.set("Cache-Control", "public, max-age=60"); + res.json({ + version: 1, + crow_id: identity.crowId, + root_pubkey: identity.ed25519Pubkey, + page_size: IDENTITY_PAGE_SIZE, + cursor, + next: hasNext ? cursor + IDENTITY_PAGE_SIZE : null, + active_attestations: page.map(r => ({ + id: Number(r.id), + app: r.app, + external_handle: r.external_handle, + app_pubkey: r.app_pubkey || null, + sig: r.sig, + version: Number(r.version), + created_at: Number(r.created_at), + })), + revocation_list_url: "/.well-known/crow-identity-revocations.json", + }); + } catch (err) { + res.status(500).json({ error: "identity publication unavailable" }); + } +}); + +app.get("/.well-known/crow-identity-revocations.json", identityLimiter, async (req, res) => { + try { + const { loadOrCreateIdentity } = await import("../sharing/identity.js"); + const identity = loadOrCreateIdentity(); + const cursor = Number(req.query.cursor) || 0; + const rows = await relayDb.execute({ + sql: `SELECT r.id, r.attestation_id, r.revoked_at, r.reason, r.sig, a.app, a.external_handle, a.version + FROM identity_attestation_revocations r + JOIN identity_attestations a ON a.id = r.attestation_id + WHERE a.crow_id = ? + ORDER BY r.revoked_at DESC LIMIT ? OFFSET ?`, + args: [identity.crowId, IDENTITY_PAGE_SIZE + 1, cursor], + }); + const hasNext = rows.rows.length > IDENTITY_PAGE_SIZE; + const page = rows.rows.slice(0, IDENTITY_PAGE_SIZE); + res.set("Cache-Control", "public, max-age=60"); + res.json({ + version: 1, + crow_id: identity.crowId, + page_size: IDENTITY_PAGE_SIZE, + cursor, + next: hasNext ? cursor + IDENTITY_PAGE_SIZE : null, + revocations: page.map(r => ({ + attestation_id: Number(r.attestation_id), + app: r.app, + external_handle: r.external_handle, + version: Number(r.version), + revoked_at: Number(r.revoked_at), + reason: r.reason || null, + sig: r.sig, + })), + }); + } catch (err) { + res.status(500).json({ error: "revocation list unavailable" }); + } +}); + // --- Health Check --- app.get("/health", async (req, res) => { const proxyStatus = getProxyStatus(); diff --git a/servers/shared/identity-attestation.js b/servers/shared/identity-attestation.js new file mode 100644 index 0000000..9575ec9 --- /dev/null +++ b/servers/shared/identity-attestation.js @@ -0,0 +1,132 @@ +/** + * F.11: Identity attestation core. + * + * Crow's root Ed25519 identity signs per-app handles. Remote verifiers + * (other fediverse actors, other Crow instances) fetch the signed + * attestation from `/.well-known/crow-identity.json` on the gateway and + * verify the signature using the root pubkey discovered via the crow_id + * (which is sha256(ed25519_pub) — circular, so verification requires the + * caller to also fetch the pubkey from a trusted source, or pin it). + * + * Attestation payload (canonical JSON, sorted keys): + * { crow_id, app, external_handle, app_pubkey?, version, created_at } + * + * Signed with the root Ed25519 private key; signature is stored hex. + * + * Revocations are also signed — the revocation list carries a signed + * { attestation_id, revoked_at, reason } payload so pinning the + * revocation list requires the same root key that made the attestation. + * + * Verification model: + * - Always fetch fresh; cache only if caller passes max_age_seconds. + * - Rate-limited at the HTTP surface to 60 req/min/IP (gateway). + * - No gossip via crow-sharing yet — only .well-known publication. + */ + +import * as ed from "@noble/ed25519"; +import { sha512 } from "@noble/hashes/sha2"; +import { createHash } from "node:crypto"; + +// noble/ed25519 requires sha512 sync +ed.etc.sha512Sync = (...m) => sha512(ed.etc.concatBytes(...m)); + +/** + * Produce the canonical JSON string of an attestation payload for signing. + * Keys are sorted lexicographically; nullish fields are omitted. + */ +export function canonicalPayload({ crow_id, app, external_handle, app_pubkey, version, created_at }) { + const obj = { app, created_at, crow_id, external_handle, version }; + if (app_pubkey) obj.app_pubkey = app_pubkey; + // Re-sort after optional insertion + const sorted = Object.keys(obj).sort().reduce((a, k) => { a[k] = obj[k]; return a; }, {}); + return JSON.stringify(sorted); +} + +/** + * Sign an attestation payload with the root Ed25519 private key. + * Returns hex signature. + */ +export function signAttestation(identity, payload) { + const msg = new TextEncoder().encode(canonicalPayload(payload)); + const sig = ed.sign(msg, identity.ed25519Priv); + return Buffer.from(sig).toString("hex"); +} + +/** + * Verify an attestation signature. Returns boolean. + * `rootPubkey` is the hex Ed25519 public key of the claimed crow_id. + */ +export function verifyAttestation(payload, sigHex, rootPubkey) { + try { + const msg = new TextEncoder().encode(canonicalPayload(payload)); + const sig = Buffer.from(sigHex, "hex"); + const pub = Buffer.from(rootPubkey, "hex"); + return ed.verify(sig, msg, pub); + } catch { + return false; + } +} + +/** + * Verify that the supplied ed25519 pubkey actually maps to the claimed + * crow_id (crow_id = first-8-bytes-of-sha256(ed25519_pub) encoded base36, + * prefixed "crow:" — see servers/sharing/identity.js computeCrowId). + * Catches swapped-pubkey attacks where an attacker publishes someone + * else's crow_id with their own key. + */ +export function verifyCrowIdBinding(crowId, ed25519PubkeyHex) { + try { + const pub = Buffer.from(ed25519PubkeyHex, "hex"); + const hash = createHash("sha256").update(pub).digest(); + const num = hash.readBigUInt64BE(0); + const b36 = num.toString(36).slice(0, 10).padStart(10, "0"); + const expected = `crow:${b36}`; + return expected === crowId; + } catch { + return false; + } +} + +/** + * Canonical payload for a revocation signature. + */ +export function canonicalRevocationPayload({ attestation_id, revoked_at, reason }) { + const obj = { attestation_id, revoked_at }; + if (reason) obj.reason = reason; + const sorted = Object.keys(obj).sort().reduce((a, k) => { a[k] = obj[k]; return a; }, {}); + return JSON.stringify(sorted); +} + +export function signRevocation(identity, payload) { + const msg = new TextEncoder().encode(canonicalRevocationPayload(payload)); + const sig = ed.sign(msg, identity.ed25519Priv); + return Buffer.from(sig).toString("hex"); +} + +export function verifyRevocation(payload, sigHex, rootPubkey) { + try { + const msg = new TextEncoder().encode(canonicalRevocationPayload(payload)); + const sig = Buffer.from(sigHex, "hex"); + const pub = Buffer.from(rootPubkey, "hex"); + return ed.verify(sig, msg, pub); + } catch { + return false; + } +} + +/** + * Canonical list of app names we accept attestations for. Kept deliberately + * small and explicit rather than free-form — if an operator wants to attest + * for an app not on this list, they bump this constant in a follow-up PR. + * (Caps the surface for accidental typos like "matodon" vs "mastodon".) + */ +export const SUPPORTED_APPS = Object.freeze([ + "mastodon", + "gotosocial", + "writefreely", + "funkwhale", + "pixelfed", + "lemmy", + "matrix-dendrite", + "peertube", +]); diff --git a/servers/sharing/server.js b/servers/sharing/server.js index 1fec809..762eb1d 100644 --- a/servers/sharing/server.js +++ b/servers/sharing/server.js @@ -36,6 +36,15 @@ import { parseInviteCode, computeSafetyNumber, } from "./identity.js"; +import { + canonicalPayload as canonicalAttestationPayload, + signAttestation, + verifyAttestation, + verifyCrowIdBinding, + signRevocation, + verifyRevocation, + SUPPORTED_APPS as ATTESTATION_APPS, +} from "../shared/identity-attestation.js"; import { PeerManager } from "./peer-manager.js"; import { SyncManager } from "./sync.js"; import { InstanceSyncManager } from "./instance-sync.js"; @@ -2182,5 +2191,240 @@ export function createSharingServer(dbPath, options = {}) { } ); + // --- F.11: Identity attestation tools --- + + server.tool( + "crow_identity_attest", + "Create a signed attestation linking a per-app handle (e.g., @alice@m.example on Mastodon) to this Crow root identity. The signature can be verified by remote parties via /.well-known/crow-identity.json. OFF BY DEFAULT — opt-in per-handle; publication is permanent and can only be retracted via signed revocation (which itself is public).", + { + app: z.enum(ATTESTATION_APPS).describe("Federated app the handle belongs to."), + external_handle: z.string().min(3).max(320).describe("Full handle, e.g. @alice@m.example or !community@lemmy.example or @user:server.org (Matrix)."), + app_pubkey: z.string().max(1024).optional().describe("Optional: app-side public key (Matrix MXID signing key, Funkwhale actor key, etc.). Omit if the app doesn't expose a stable signing key."), + confirm: z.literal("yes").describe("Public linkage is effectively permanent; confirm intent."), + }, + async ({ app, external_handle, app_pubkey }) => { + try { + const identity = loadOrCreateIdentity(); + const db = createDbClient(); + try { + // Check for an existing active attestation; bump version if present + const existing = await db.execute({ + sql: `SELECT MAX(version) AS v FROM identity_attestations WHERE crow_id = ? AND app = ? AND external_handle = ?`, + args: [identity.crowId, app, external_handle], + }); + const prevVersion = existing.rows[0]?.v ? Number(existing.rows[0].v) : 0; + const version = prevVersion + 1; + const created_at = Math.floor(Date.now() / 1000); + const payload = { crow_id: identity.crowId, app, external_handle, app_pubkey, version, created_at }; + const sig = signAttestation(identity, payload); + + const result = await db.execute({ + sql: `INSERT INTO identity_attestations + (crow_id, app, external_handle, app_pubkey, sig, version, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`, + args: [identity.crowId, app, external_handle, app_pubkey || null, sig, version, created_at], + }); + const id = Number(result.rows[0].id); + + return { + content: [{ + type: "text", + text: JSON.stringify({ + attestation_id: id, + crow_id: identity.crowId, + app, + external_handle, + version, + sig, + publish_url: "/.well-known/crow-identity.json", + note: "Attestation is now public via the .well-known endpoint. Use crow_identity_revoke to invalidate it (publication of the revocation itself is also public).", + }, null, 2), + }], + }; + } finally { + try { db.close(); } catch {} + } + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }, + ); + + server.tool( + "crow_identity_verify", + "Verify an attestation for a given (crow_id, app, handle) triple. Fetches the latest non-revoked attestation from the local database and cryptographically verifies the signature. For cross-instance verification, the caller's gateway is expected to fetch /.well-known/crow-identity.json on the target host instead (rate-limited to 60 req/min/IP at that endpoint).", + { + crow_id: z.string().min(6).max(64), + app: z.enum(ATTESTATION_APPS), + external_handle: z.string().min(3).max(320), + max_age_seconds: z.number().int().min(0).max(86400 * 30).optional().describe("If set, accept cached records up to this age; otherwise always fetch fresh (local DB read is already fresh — this is semantic only for HTTP callers)."), + }, + async ({ crow_id, app, external_handle }) => { + try { + const db = createDbClient(); + try { + const row = await db.execute({ + sql: `SELECT id, app_pubkey, sig, version, created_at, revoked_at + FROM identity_attestations + WHERE crow_id = ? AND app = ? AND external_handle = ? AND revoked_at IS NULL + ORDER BY version DESC LIMIT 1`, + args: [crow_id, app, external_handle], + }); + if (row.rows.length === 0) { + return { content: [{ type: "text", text: JSON.stringify({ valid: false, reason: "no_active_attestation", crow_id, app, external_handle }, null, 2) }] }; + } + const r = row.rows[0]; + // Re-derive pubkey from local identity iff crow_id matches local + const localIdentity = loadOrCreateIdentity(); + let rootPubkey = null; + if (localIdentity.crowId === crow_id) rootPubkey = localIdentity.ed25519Pubkey; + if (!rootPubkey) { + return { + content: [{ + type: "text", + text: JSON.stringify({ + valid: null, + reason: "remote_crow_id_pubkey_unavailable", + note: "This tool only verifies attestations that belong to THIS Crow instance. For cross-instance verification, fetch /.well-known/crow-identity.json on the remote host.", + crow_id, app, external_handle, + }, null, 2), + }], + }; + } + const payload = { crow_id, app, external_handle, app_pubkey: r.app_pubkey || undefined, version: Number(r.version), created_at: Number(r.created_at) }; + const ok = verifyAttestation(payload, r.sig, rootPubkey) && verifyCrowIdBinding(crow_id, rootPubkey); + return { + content: [{ + type: "text", + text: JSON.stringify({ + valid: ok, + version: Number(r.version), + created_at: Number(r.created_at), + fetched_at: Math.floor(Date.now() / 1000), + attestation_id: Number(r.id), + }, null, 2), + }], + }; + } finally { + try { db.close(); } catch {} + } + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }, + ); + + server.tool( + "crow_identity_revoke", + "Sign a revocation for a previously-published attestation. The revocation is added to /.well-known/crow-identity-revocations.json and the original attestation is marked revoked (but retained in the DB for audit). Rotating an app key should automatically chain revoke → attest; expose that via the bundle's own key-rotation flow.", + { + attestation_id: z.number().int(), + reason: z.string().max(500).optional(), + confirm: z.literal("yes").describe("Revocations themselves are public; confirm intent."), + }, + async ({ attestation_id, reason }) => { + try { + const identity = loadOrCreateIdentity(); + const db = createDbClient(); + try { + const row = await db.execute({ + sql: "SELECT crow_id, revoked_at FROM identity_attestations WHERE id = ?", + args: [attestation_id], + }); + if (row.rows.length === 0) { + return { content: [{ type: "text", text: "Error: attestation not found." }] }; + } + if (row.rows[0].crow_id !== identity.crowId) { + return { content: [{ type: "text", text: "Error: this attestation belongs to a different crow_id — only the owner can revoke." }] }; + } + if (row.rows[0].revoked_at) { + return { content: [{ type: "text", text: JSON.stringify({ already_revoked: true, revoked_at: Number(row.rows[0].revoked_at) }, null, 2) }] }; + } + + const revoked_at = Math.floor(Date.now() / 1000); + const payload = { attestation_id, revoked_at, reason }; + const sig = signRevocation(identity, payload); + + await db.execute({ + sql: "UPDATE identity_attestations SET revoked_at = ? WHERE id = ?", + args: [revoked_at, attestation_id], + }); + await db.execute({ + sql: `INSERT INTO identity_attestation_revocations (attestation_id, revoked_at, reason, sig) VALUES (?, ?, ?, ?)`, + args: [attestation_id, revoked_at, reason || null, sig], + }); + + return { + content: [{ + type: "text", + text: JSON.stringify({ + attestation_id, + revoked_at, + sig, + publish_url: "/.well-known/crow-identity-revocations.json", + }, null, 2), + }], + }; + } finally { + try { db.close(); } catch {} + } + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }, + ); + + server.tool( + "crow_identity_list", + "List attestations for this Crow instance. Includes both active and revoked entries; filter with include_revoked=false to see only active ones.", + { + include_revoked: z.boolean().optional(), + app: z.enum(ATTESTATION_APPS).optional(), + limit: z.number().int().min(1).max(200).optional(), + }, + async ({ include_revoked, app, limit }) => { + try { + const identity = loadOrCreateIdentity(); + const db = createDbClient(); + try { + const clauses = ["crow_id = ?"]; + const args = [identity.crowId]; + if (app) { clauses.push("app = ?"); args.push(app); } + if (include_revoked === false) clauses.push("revoked_at IS NULL"); + args.push(limit ?? 100); + const rows = await db.execute({ + sql: `SELECT id, app, external_handle, version, created_at, revoked_at + FROM identity_attestations + WHERE ${clauses.join(" AND ")} + ORDER BY created_at DESC + LIMIT ?`, + args, + }); + return { + content: [{ + type: "text", + text: JSON.stringify({ + crow_id: identity.crowId, + count: rows.rows.length, + attestations: rows.rows.map(r => ({ + id: Number(r.id), + app: r.app, + external_handle: r.external_handle, + version: Number(r.version), + created_at: Number(r.created_at), + revoked_at: r.revoked_at ? Number(r.revoked_at) : null, + })), + }, null, 2), + }], + }; + } finally { + try { db.close(); } catch {} + } + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }, + ); + return server; } diff --git a/skills/crow-identity.md b/skills/crow-identity.md new file mode 100644 index 0000000..cee2c8f --- /dev/null +++ b/skills/crow-identity.md @@ -0,0 +1,118 @@ +--- +name: crow-identity +description: Identity attestations — link federated app handles to your Crow root identity with signed, publicly-verifiable proofs. +triggers: + - "attest identity" + - "prove I am" + - "link my mastodon" + - "verify handle" + - "keyoxide" + - "crow identity attestation" + - "revoke attestation" +tools: + - crow_identity_attest + - crow_identity_verify + - crow_identity_revoke + - crow_identity_list +--- + +# Crow Identity Attestations (F.11) + +Crow's root Ed25519 identity can sign attestations for your per-app handles — your Mastodon `@alice@example.com`, your Funkwhale channel, your Matrix MXID, your Lemmy account. Remote viewers fetch these attestations from your gateway's `/.well-known/crow-identity.json` endpoint and verify the signatures cryptographically, giving them a portable "these handles are all the same person" proof that doesn't depend on any single platform. + +This is **attestation, not key replacement.** Each federated app still uses its own native keys for federation. The Crow root key signs only the binding `(crow_id, app, external_handle)`. + +## When to use this + +- You run bundles on multiple federated apps and want followers to verify all your handles come from the same Crow. +- You're migrating platforms and want remote contacts to match your old handles to new ones via a signed trail. +- You want a Keyoxide/ariadne-style "proof set" anchored in your own infrastructure, not a third party. + +## When NOT to use this + +- **Ephemeral identities.** Once published via `.well-known`, attestations are permanent-until-explicitly-revoked. Revocations themselves are public. Don't attest alts you want deniable. +- **Pseudonymous accounts.** Attesting `@realname@work.example` and `@anonposter@shitposter.example` from the same crow_id links them cryptographically and forever. +- **You don't run the gateway.** Attestations are served from the Crow gateway's `.well-known` endpoints — without that, there's no publication surface. + +## Workflow + +### Attest a handle + +``` +crow_identity_attest { + "app": "mastodon", + "external_handle": "@alice@mastodon.example", + "confirm": "yes" +} +``` + +Returns `{ attestation_id, crow_id, sig, publish_url }`. The attestation is immediately visible at `https:///.well-known/crow-identity.json`. + +Optional `app_pubkey` parameter when the app exposes a stable signing key you want included in the binding (Matrix MXID signing keys, Funkwhale actor keys). + +### List your attestations + +``` +crow_identity_list {} +# or filter: +crow_identity_list { "app": "mastodon", "include_revoked": false } +``` + +### Verify + +Local-DB verification (only works for attestations on THIS Crow instance): + +``` +crow_identity_verify { + "crow_id": "crow:kdq7zskhat", + "app": "mastodon", + "external_handle": "@alice@mastodon.example" +} +``` + +Cross-instance verification is a plain HTTP fetch of the remote gateway's `.well-known` — no special tooling needed. Rate-limited at the server side to 60 req/min per remote IP to prevent verification storms. + +### Revoke + +``` +crow_identity_revoke { + "attestation_id": 42, + "reason": "Account migrated to new instance", + "confirm": "yes" +} +``` + +Revocations are signed and appear in `/.well-known/crow-identity-revocations.json`. The original attestation row stays in the DB marked revoked for audit; it no longer appears in the active attestations list. + +### Key rotation + +When a bundle rotates its app key (e.g., you regenerate your Mastodon OAuth app), call `crow_identity_revoke` on the old attestation and `crow_identity_attest` with the new `app_pubkey`. Version counter increments automatically. Verifiers that cached old versions see the revocation on next fresh fetch. + +## Endpoints + +- **`/.well-known/crow-identity.json`** — paginated active attestations, 256 per page, `?cursor=N` for next page. Cache-Control: 60s. +- **`/.well-known/crow-identity-revocations.json`** — paginated revocations with signed revocation proofs. Same pagination scheme. +- Both rate-limited to 60 requests/minute/IP. Both return 500 with `{ error }` if the DB is unavailable. + +## Payload format + +Canonical JSON (sorted keys) signed with the root Ed25519 private key: + +```json +{ + "app": "mastodon", + "app_pubkey": "optional", + "created_at": 1744502400, + "crow_id": "crow:kdq7zskhat", + "external_handle": "@alice@mastodon.example", + "version": 1 +} +``` + +Signature is hex-encoded Ed25519 over the UTF-8 bytes of the canonical JSON. Verifiers MUST also check `verifyCrowIdBinding(crow_id, root_pubkey)` — the crow_id is derived from the root pubkey, and a swap attack is only caught if you verify the derivation (not just the signature). + +## Safety notes + +- **The root pubkey is in `.well-known`.** That's the whole point — it's the trust anchor. But it also means publication is permanent; losing the root private key while attestations are live creates an irrevocable attestation surface. Back up your identity seed (`npm run identity:export`). +- **No gossip over crow-sharing.** Per the plan, attestations are only published via `.well-known` for now. No Nostr, no Hypercore feed. Revisit after F.12 lands. +- **Pinned posts are manual.** You can paste an attestation blob into a pinned Mastodon toot (Keyoxide-style) — that's an operator choice, not automated, because automation would open forgery vectors. diff --git a/skills/superpowers.md b/skills/superpowers.md index 3e86548..42f5d40 100644 --- a/skills/superpowers.md +++ b/skills/superpowers.md @@ -87,6 +87,7 @@ This is the master routing skill. Consult this **before every task** to determin | "lemmy", "link aggregator", "reddit alternative", "subscribe community", "post link", "fediverse discussion", "upvote" | "lemmy", "agregador enlaces", "alternativa reddit", "suscribir comunidad", "publicar enlace", "discusión fediverso" | lemmy | crow-lemmy | | "mastodon", "toot", "fediverse", "activitypub", "@user@server", "federated timeline", "boost", "mastodon instance" | "mastodon", "tootear", "fediverso", "activitypub", "@usuario@servidor", "linea temporal federada", "reblog" | mastodon | crow-mastodon | | "peertube", "upload video", "federated video", "video channel", "fediverse video", "youtube alternative", "webtorrent" | "peertube", "subir video", "video federado", "canal video", "video fediverso", "alternativa youtube" | peertube | crow-peertube | +| "attest identity", "prove I am", "link my mastodon", "verify handle", "keyoxide", "crow identity attestation", "revoke attestation" | "atestar identidad", "probar que soy", "vincular mi mastodon", "verificar handle", "keyoxide", "atestación identidad crow" | crow-identity | crow-sharing | | "tutor me", "teach me", "quiz me", "help me understand" | "enséñame", "explícame", "evalúame" | tutoring | crow-memory | | "wrap up", "summarize session", "what did we do" | "resumir sesión", "qué hicimos" | session-summary | crow-memory | | "change language", "speak in..." | "cambiar idioma", "háblame en..." | i18n | crow-memory |