Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions scripts/init-db.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
82 changes: 82 additions & 0 deletions servers/gateway/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
132 changes: 132 additions & 0 deletions servers/shared/identity-attestation.js
Original file line number Diff line number Diff line change
@@ -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",
]);
Loading