diff --git a/servers/gateway/capability-ids.js b/servers/gateway/capability-ids.js new file mode 100644 index 000000000..b4f01974f --- /dev/null +++ b/servers/gateway/capability-ids.js @@ -0,0 +1,81 @@ +/** + * The ids of the capabilities THIS instance actually has. + * + * N1b-8 review round 1 (B2): on the `/router` mount a peer names the tool + * (`crow_`) and the gate turns that into `crow-`. That + * string then keyed a Fix-it card, its dedupe key and a + * `peer_exposure_card_suppressed` counter entry — so a peer could mint + * unlimited distinct "capabilities" out of junk names (`crow_junk1`, + * `crow_JUNK2`, a 10 KB name …). The id must therefore be canonicalised and + * matched against the instance's OWN catalog before anything is raised or + * counted. + * + * This module is a leaf (static tool manifests + the extension registry, no + * settings, no db) so capability-registry.js, peer-exposure.js and the Fix-it + * detector can all use it without an import cycle. It is the same set the + * public catalog is built from (`coreTools()` + `addonTools()` in + * capability-registry.js), so "in the catalog" and "a capability this instance + * has" mean exactly one thing. + */ +import { TOOL_MANIFESTS } from "./tool-manifests.js"; +import { listInstalledExtensions, resolveCrowHome } from "../../scripts/pi-bots/ext_registry.mjs"; + +/** Canonical server id for a core manifest category (crow-memory, etc.). */ +export function canonicalForCategory(category) { + return `crow-${category}`; +} + +/** + * A peer-chosen `/router` tool name → canonical capability id, or null. + * + * `crow_memory` → `crow-memory`; the case variant `crow_Memory` → the SAME + * `crow-memory` (so it is one capability, not an unknown one — and it lands on + * the same card and the same dedupe key). Anything whose tail is not a plain + * identifier (`crow_../etc`, `crow_`, a 4 KB name, `crow_store_memory` → + * `crow-store_memory` — the tail keeps its underscore) canonicalises to an id + * that no catalog contains, and is therefore unknown. + * + * Note this is only a PROPOSAL: the caller must still require the returned id + * to be in `getLocalCapabilityIds()`. + */ +const PEER_TOOL_RE = /^crow_([A-Za-z0-9][A-Za-z0-9_-]{0,63})$/; +export function canonicalizePeerCapabilityTool(toolName) { + if (typeof toolName !== "string") return null; + const m = PEER_TOOL_RE.exec(toolName); + if (!m) return null; + return canonicalForCategory(m[1].toLowerCase()); +} + +// The live aggregation re-reads installed extensions (a few small JSON files + +// readdirs), which is fine per-DENIAL but not per-request: a deny costs one +// scan per TTL window per CROW_HOME. A newly installed addon shows up within +// this window, which is bounded because the window is short. +const CACHE_TTL_MS = 30_000; +const cache = new Map(); // crowHome -> { at, ids } + +/** + * Every canonical capability id on this instance: core manifests + installed + * extensions. Never throws; returns a fresh Set the caller may keep. + * + * @param {string} [crowHome] + * @returns {Set} + */ +export function getLocalCapabilityIds(crowHome = resolveCrowHome()) { + const now = Date.now(); + const hit = cache.get(crowHome); + if (hit && now - hit.at < CACHE_TTL_MS) return new Set(hit.ids); + const ids = new Set(); + for (const category of Object.keys(TOOL_MANIFESTS)) ids.add(canonicalForCategory(category)); + try { + for (const ext of listInstalledExtensions(crowHome)) { + if (ext && typeof ext.id === "string" && ext.id) ids.add(ext.id); + } + } catch { /* addon listing unavailable: core ids only */ } + cache.set(crowHome, { at: now, ids }); + return new Set(ids); +} + +/** Test seam: forget the cached scans. */ +export function _clearCapabilityIdCache() { + cache.clear(); +} \ No newline at end of file diff --git a/servers/gateway/capability-registry.js b/servers/gateway/capability-registry.js index f0a721e37..c15aa145b 100644 --- a/servers/gateway/capability-registry.js +++ b/servers/gateway/capability-registry.js @@ -12,13 +12,15 @@ import { normalizeSkillName } from "../../scripts/pi-bots/skill_proposals.mjs"; import { readdirSync } from "node:fs"; import { offSkills } from "../shared/skill-state.js"; import { getExposedCapabilities } from "./peer-exposure.js"; +import { getExposureForPeer } from "./peer-exposure-store.js"; +import { canonicalForCategory } from "./capability-ids.js"; import { getPeerManagedBots } from "./bot-management-exposure.js"; import { CROW_VOICE_BOT_ID } from "../shared/voice-bot.js"; -/** Canonical server id for a core manifest category (crow-memory, etc.). */ -export function canonicalForCategory(category) { - return `crow-${category}`; -} +// Canonical id shape lives in capability-ids.js (a leaf, so the peer-exposure +// gate can use it without a cycle); re-exported here because callers expect it +// from the catalog module. +export { canonicalForCategory }; // ---- public-safe projectors (security boundary) ---- @@ -109,9 +111,20 @@ async function localBots(db) { /** * The local, vocab-normalized catalog with everything projected public-safe. + * + * N1b-8 review round 1 (S2): when the catalog is served to a VERIFIED peer, + * pass `peerId` — a capability is then marked `exposed` if it is in the + * all-trusted-peers allowlist OR that peer holds an active one-device grant. + * Only THAT peer's grants are merged: another peer's allowance is never + * visible here (the per-peer store is read for one id, never in bulk). + * + * @param {object} db + * @param {{crowHome?: string, instanceId?: string|null, instanceName?: string|null, peerId?: string|null}} [opts] */ -export async function getLocalCatalog(db, { crowHome = resolveCrowHome(), instanceId = null, instanceName = null } = {}) { - const exposedSet = await getExposedCapabilities(db); +export async function getLocalCatalog(db, { crowHome = resolveCrowHome(), instanceId = null, instanceName = null, peerId = null } = {}) { + const exposedSet = peerId + ? await getExposureForPeer(db, peerId) + : await getExposedCapabilities(db); const managedSet = await getPeerManagedBots(db); const tools = [...coreTools(), ...addonTools(crowHome)].map((e) => toPublicTool(e, exposedSet)); const skills = localSkills(crowHome).map(toPublicSkill); diff --git a/servers/gateway/dashboard/panels/health.js b/servers/gateway/dashboard/panels/health.js index ecfd1cc67..8fc7eca50 100644 --- a/servers/gateway/dashboard/panels/health.js +++ b/servers/gateway/dashboard/panels/health.js @@ -227,7 +227,7 @@ export default { // Flash param from post-backup / fix-it redirects const flash = (typeof req.query?.flash === "string" && - ["backup_ok", "backup_fail", "fixit_fixed", "fixit_dismissed", "fixit_error"].includes(req.query.flash)) + ["backup_ok", "backup_fail", "fixit_fixed", "fixit_dismissed", "fixit_error", "fixit_not_pending"].includes(req.query.flash)) ? req.query.flash : null; diff --git a/servers/gateway/dashboard/panels/nest/html.js b/servers/gateway/dashboard/panels/nest/html.js index 902fcd161..c984f1abe 100644 --- a/servers/gateway/dashboard/panels/nest/html.js +++ b/servers/gateway/dashboard/panels/nest/html.js @@ -127,6 +127,8 @@ function buildHealthStrip(health, lang, flash) { flashHtml = `
Dismissed for now.
`; } else if (flash === "fixit_error") { flashHtml = `
Couldn't apply that fix.
`; + } else if (flash === "fixit_not_pending") { + flashHtml = `
That card isn't open any more — nothing was changed.
`; } if (!health) return flashHtml; diff --git a/servers/gateway/dashboard/settings/sections/remote-exposure.js b/servers/gateway/dashboard/settings/sections/remote-exposure.js index 10a984b9b..722603ba6 100644 --- a/servers/gateway/dashboard/settings/sections/remote-exposure.js +++ b/servers/gateway/dashboard/settings/sections/remote-exposure.js @@ -1,21 +1,97 @@ /** * Settings Section: Remote Tool Exposure (Multi-Instance group) — F4a Layer 2a. * - * Per-instance, LOCAL-ONLY (never synced) allowlist of capabilities this - * instance lets trusted peer instances invoke. Default = nothing exposed - * (deny-all). The authoritative enforcement is server-side in - * peer-exposure.js; this UI only edits the `remote_exposed_tools` setting. + * Per-instance, LOCAL-ONLY (never synced) exposure controls, in two shapes: + * 1. the all-trusted-peers allowlist (`remote_exposed_tools`) — the checkbox + * list below, an explicit "share with all my Crows" statement; + * 2. per-device allowances (`remote_exposed_tools_by_peer`, N1b-8) — what a + * Fix-it "Allow" card grants to the ONE peer that asked, with an expiry. + * Listed here with the peer and the expiry, and renewable or removable. + * Default = nothing exposed (deny-all). The authoritative enforcement is + * server-side in peer-exposure.js; this UI only edits the exposure settings. * - * `remote_exposed_tools` is deliberately ABSENT from sync-allowlist.js, so - * writeSetting downgrades it to local scope automatically — each instance is - * sovereign over what it exposes. + * Both keys are deliberately ABSENT from sync-allowlist.js, so writeSetting + * downgrades them to local scope automatically — each instance is sovereign + * over what it exposes. */ import { writeSetting } from "../registry.js"; +import { + getPeerExposures, + grantIsActive, + renewPeerGrant, + removePeerGrant, +} from "../../../peer-exposure-store.js"; +import { normalizePeerId } from "../../../../shared/peer-id.js"; import { getLocalCatalog } from "../../../capability-registry.js"; -import { getOrCreateLocalInstanceId } from "../../../instance-registry.js"; +import { getOrCreateLocalInstanceId, getInstance } from "../../../instance-registry.js"; import { escapeHtml } from "../../shared/components.js"; import { resolveFriendlyName } from "../../../fix-it/friendly-names.js"; +const SECTION_URL = "/dashboard/settings?section=remote-exposure"; + +/** Format an ISO timestamp for display; "" when unparseable. */ +function formatWhen(iso) { + const t = Date.parse(iso || ""); + if (!Number.isFinite(t)) return ""; + return new Date(t).toISOString().slice(0, 10); +} + +/** Best-effort display label for a peer: its stored name, else a short id. */ +async function peerLabel(db, peerId) { + let name = null; + try { name = (await getInstance(db, peerId))?.name || null; } catch { name = null; } + return name && String(name).trim() ? String(name).trim() : `${peerId.slice(0, 8)}…`; +} + +/** + * The "Per-device allowances" block: one row per (peer, capability) grant, with + * its state — active with an expiry, expired (still listed so it can be renewed + * or removed), or "until you remove it". + */ +async function renderPeerGrants(db) { + const grants = await getPeerExposures(db); + const now = Date.now(); + const entries = []; + for (const [peerId, caps] of grants) { + for (const [capability, grant] of caps) entries.push({ peerId, capability, grant }); + } + entries.sort((a, b) => `${a.peerId}:${a.capability}`.localeCompare(`${b.peerId}:${b.capability}`)); + + const states = await Promise.all(entries.map(async (e) => { + const label = await peerLabel(db, e.peerId); + let state; + if (e.grant.expiresAt == null) state = "until you remove it"; + else if (grantIsActive(e.grant, now)) state = `expires ${escapeHtml(formatWhen(e.grant.expiresAt))}`; + else state = `expired ${escapeHtml(formatWhen(e.grant.expiresAt))}`; + return { ...e, label, state }; + })); + + const rows = states.map((e) => { + const friendly = resolveFriendlyName(e.capability); + const actionForm = (action, buttonText) => `
+ + + + +
`; + return `
+ ${escapeHtml(e.label)} (${escapeHtml(e.peerId)}) +
${escapeHtml(friendly)} — ${e.state}
+ ${actionForm("renew_peer_exposure", "Renew 30 days")} + ${actionForm("remove_peer_exposure", "Remove")} +
`; + }).join(""); + + const body = rows || `

No one-device allowances yet. An “Allow” card on the Nest creates one, for the device that asked only.

`; + return `

Per-device allowances

+
+ Capabilities allowed for a single device, usually from an “Allow” card on the Nest. An expired allowance + stops working on its own and can be renewed or removed here. Nothing here is listed above, so it is never + shared with your other devices. +
+
${body}
`; +} + export default { id: "remote-exposure", group: "otherCrows", @@ -69,10 +145,25 @@ export default {
- `; + + ${await renderPeerGrants(db)}`; }, async handleAction({ req, res, db, action }) { + const body = req.body || {}; + // N1b-8: one-device allowances — renew (+30 days from now) or remove. + if (action === "renew_peer_exposure" || action === "remove_peer_exposure") { + const peerId = normalizePeerId(body.peer); + const capability = typeof body.cap === "string" ? body.cap.trim() : ""; + if (!peerId || !capability) { + res.redirectAfterPost(SECTION_URL); + return true; + } + if (action === "remove_peer_exposure") await removePeerGrant(db, { peerId, capability }); + else await renewPeerGrant(db, { peerId, capability, days: 30 }); + res.redirectAfterPost(SECTION_URL); + return true; + } if (action !== "set_remote_exposure") return false; // Checkboxes: req.body.cap is a string (one) or array (many) or undefined (none). let selected = req.body.cap; @@ -81,7 +172,7 @@ export default { const clean = [...new Set(selected.filter((x) => typeof x === "string" && x.length > 0))]; // Local scope (not in sync-allowlist → writeSetting downgrades anyway; be explicit). await writeSetting(db, "remote_exposed_tools", JSON.stringify(clean), { scope: "local" }); - res.redirectAfterPost("/dashboard/settings?section=remote-exposure"); + res.redirectAfterPost(SECTION_URL); return true; }, }; diff --git a/servers/gateway/fix-it/detectors/remote-exposure.js b/servers/gateway/fix-it/detectors/remote-exposure.js index f8e5e91c4..464f45196 100644 --- a/servers/gateway/fix-it/detectors/remote-exposure.js +++ b/servers/gateway/fix-it/detectors/remote-exposure.js @@ -1,29 +1,154 @@ /** * v1 Fix-it detector: a peer instance was denied a tool call because the owning - * capability isn't in this instance's remote-exposure allowlist. Hooks the - * `peer-exposure:denied` event emitted at the enforcePeerExposure chokepoint. + * capability isn't exposed to THAT peer. Hooks the `peer-exposure:denied` event + * emitted at the enforcePeerExposure chokepoint. * - * DB-free: it only composes strings from the (already name-enriched) payload - * and the pure friendly-name map, then calls the db-bound store it's handed. + * N1b-8 (security audit): the card used to be a single "Allow" that added the + * capability to the all-trusted-peers allowlist — one peer's request, one click, + * every peer and no expiry. Now: + * - the card is keyed by the VERIFIED peer instance id (`requestingInstance`, + * never a name or id from the request body) and the Allow remedies grant + * that peer only, for a chosen duration; + * - at most MAX_OPEN_CARDS_PER_PEER open cards per peer, and at most one new + * card per peer per capability per 24 h (peer-card-policy.js); + * - requests that earn no card are COUNTED, not dropped silently. + * + * Review round 1: + * - B1: the decide-then-upsert step runs under a per-peer lock + * (peer-card-lock.js), so concurrent denials cannot all see "no cards" and + * each create one; + * - B2: `capabilityKnown === false` (the gate could not match the id against + * this instance's own catalog) earns no card and ONE counter bucket per + * peer, never a key built from the peer's own string. + * + * Review round 2: + * - SF1: a `dedupe` decision acts on the EXISTING card, whatever key shape it + * was written with — it never falls through to an upsert that would insert + * a second open card under the new key; + * - N-b: that per-peer queue is bounded (peer-card-lock.js), so a flood is + * dropped and counted in memory instead of queued without limit. + * + * Fix round 3: the card records `context.pairingTag` — the tag of the pairing + * the denial was verified under (the gate sends it; see + * servers/gateway/peer-exposure.js). The remedy refuses a card whose tag does + * not match the peer's CURRENT `auth_token_hash`, which is what stops an old + * card from granting the peer after a revoke→re-pair under the same id. A + * pending card carrying another pairing's tag is stale: the policy names it and + * this detector resolves it before deciding again (a new card must never dedupe + * onto a card that can never be allowed). + * + * DB-free: it composes strings from the (already name-enriched) payload and the + * pure friendly-name map, then calls the db-bound store it's handed. */ import { resolveFriendlyName } from "../friendly-names.js"; +import { normalizePeerId, normalizePairingTag } from "../../../shared/peer-id.js"; +import { UNKNOWN_CAPABILITY_BUCKET } from "../peer-card-counters.js"; +import { withPeerLock } from "../peer-card-lock.js"; +import { + cardRemedies, + decidePeerExposureCard, + dedupKeyFor, +} from "../peer-card-policy.js"; + +const SOURCE = "remote-exposure"; + +/** The decide-then-upsert body, run under the per-peer lock. */ +async function decideAndStore({ capability, peerId, peerName, toolName, tag, store, now }) { + let items = []; + try { + items = typeof store?.listBySource === "function" ? await store.listBySource(SOURCE) : []; + } catch { items = []; } + + const decision = decidePeerExposureCard({ items, peerId, capability, pairingTag: tag, now }); + + // Fix round 3: a pending card raised under another pairing can never be + // allowed (the remedy compares tags), so it must not answer this request. + // Resolve it first, then carry on with the decision, which was already made + // as if it did not exist. Best-effort, like the rest of this path. + for (const staleId of decision.staleItemIds || []) { + try { + if (store?.markResolved) await store.markResolved(staleId); + } catch { /* best-effort: this runs on the peer-exposure request path */ } + } + + // SF1 (review round 2): `dedupe` means this (capability, peer) pair already + // owns a card — possibly one written under the PRE-round-1 key shape + // (`expose::`). Act on THAT row: upserting here would write the + // NEW key and insert a SECOND open card for the same request. + if (decision.action === "dedupe") { + try { + if (store?.recordDuplicate) await store.recordDuplicate(decision.itemId); + } catch { /* best-effort: this runs on the peer-exposure request path */ } + return; + } + + if (decision.action === "drop" || decision.action === "count") { + // Recorded, but no card (or no NEW card). Never let bookkeeping break the + // request path — the caller is the peer-exposure gate. + try { + if (decision.action === "count" && store?.recordDuplicate) { + await store.recordDuplicate(decision.itemId); + } + if (store?.recordSuppressed) { + await store.recordSuppressed({ peerId, capability, reason: decision.reason || decision.action, now }); + } + } catch { /* best-effort bookkeeping */ } + return; // no card, no NEW card + } + + const friendly = resolveFriendlyName(capability); + // `requestingInstanceName` is the peer's label as THIS instance stored it + // (fix-it/index.js resolves it from the instance registry by the verified + // id) — display text only; identity stays `peerId`. + const peer = peerName || "another device"; + await store.upsertItem({ + source: SOURCE, + dedupKey: dedupKeyFor(peerId, capability, tag), + title: `Your ${peer} tried to use ${friendly}, but it isn't shared with this device yet`, + why: `Allow it for ${peer} only — your other Crows stay unchanged, and the allowance can expire or be removed in Settings.`, + severity: "warn", + remedies: cardRemedies(capability, peerId), + context: { capability, requestingInstance: peerId, toolName: toolName || null, pairingTag: normalizePairingTag(tag) }, + }); +} export default { - source: "remote-exposure", + source: SOURCE, events: ["peer-exposure:denied"], async onEvent(_eventName, payload, store) { - const { capability, requestingInstance, requestingInstanceName, toolName } = payload || {}; + const { capability, requestingInstance, requestingInstanceName, toolName, capabilityKnown, pairingTag } = payload || {}; + + // The identity is the id the request was VERIFIED with. Anything else (a + // display name, a missing id, a non-id string) earns no card at all: a card + // we cannot scope to one peer is a card we must not offer. + const peerId = normalizePeerId(requestingInstance); + if (!peerId) return; + + const now = typeof store?.now === "function" ? store.now() : Date.now(); + + // B2: the peer named a tool the gate could not match to a capability of + // this instance (it may not even be id-shaped, hence the check before the + // `!capability` guard). It is refused — the gate already did that — and + // counted in one bounded bucket: no card, and no counter key the peer chose. + if (capabilityKnown === false) { + try { + if (store?.recordSuppressed) { + await store.recordSuppressed({ + peerId, + capability: UNKNOWN_CAPABILITY_BUCKET, + reason: "unknown_capability", + now, + }); + } + } catch { /* best-effort bookkeeping */ } + return; + } + if (!capability) return; // only real, resolvable capabilities become cards - const friendly = resolveFriendlyName(capability); - const peer = requestingInstanceName || "another device"; - await store.upsertItem({ - source: "remote-exposure", - dedupKey: `expose:${capability}:${requestingInstance}`, - title: `Your ${peer} tried to use ${friendly}, but it isn't shared with this device yet`, - why: "Share it so your other Crow devices can use it.", - severity: "warn", - remedies: [{ label: "Allow", actionId: "expose-capability", args: { capability }, kind: "instant" }], - context: { capability, requestingInstance, toolName: toolName || null }, - }); + + // B1: serialise the read-decide-write per peer. + await withPeerLock(peerId, () => decideAndStore({ + capability, peerId, peerName: requestingInstanceName, toolName, tag: pairingTag, store, now, + })); }, -}; +}; \ No newline at end of file diff --git a/servers/gateway/fix-it/index.js b/servers/gateway/fix-it/index.js index 6d8503af5..2ab5efaf7 100644 --- a/servers/gateway/fix-it/index.js +++ b/servers/gateway/fix-it/index.js @@ -10,6 +10,8 @@ import remoteExposureDetector from "./detectors/remote-exposure.js"; import caddyProxySocketDetector from "./detectors/caddy-proxy-socket.js"; import bootGuardDetector from "./detectors/boot-guard.js"; import exposeCapabilityRemedy from "./remedies/expose-capability.js"; +import { recordSuppressedRequest } from "./peer-card-counters.js"; +import { CARD_ACTION_IDS } from "./peer-card-policy.js"; import { createNotification } from "../../shared/notifications.js"; import { getInstance } from "../instance-registry.js"; import { escapeHtml } from "../dashboard/shared/components.js"; @@ -22,6 +24,10 @@ export function wireFixIt() { registry.registerDetector(caddyProxySocketDetector); registry.registerDetector(bootGuardDetector); registry.registerRemedy("expose-capability", exposeCapabilityRemedy); + // N1b-8: the peer-scoped card offers three durations, and each button needs + // its own actionId (the POST carries only action_id). All three run the same + // one-device grant; the duration rides in the stored args. + for (const actionId of CARD_ACTION_IDS) registry.registerRemedy(actionId, exposeCapabilityRemedy); wired = true; } @@ -48,6 +54,14 @@ function boundStore(db) { return r; }, resolveByKey: (s, k) => store.resolveByKey(db, s, k), + // N1b-8 rate limits: a detector needs the peer's existing cards to decide + // whether a denial earns a card, and somewhere to count the ones that don't. + listBySource: (s) => store.listBySource(db, s), + recordDuplicate: (id) => store.recordDuplicate(db, id), + recordSuppressed: (info) => recordSuppressedRequest(db, info), + // Fix round 3: a pending card raised under another pairing is stale (the + // remedy refuses it), so the detector answers it before creating the new one. + markResolved: (id) => store.markResolved(db, id), }; } @@ -55,12 +69,21 @@ function boundStore(db) { * Fire a Fix-it event. Fire-and-forget — never throws, never blocks the caller * (it runs on the peer-exposure request path). Enriches the payload with the * requesting peer's display name (generic: any payload with `requestingInstance`). + * + * N1b-8: for the peer-exposure card the name is ALWAYS resolved here, from this + * instance's own registry row for the verified id — a payload-supplied name is + * overwritten. The name is display text only; the card's identity (and the id an + * "Allow" is scoped to) is `requestingInstance`, which the gate takes from + * `req.instanceAuth.instance.id`. */ export async function emitFixIt(db, eventName, payload) { try { wireFixIt(); const p = { ...(payload || {}) }; - if (p.requestingInstance && !p.requestingInstanceName) { + if (p.requestingInstance && (!p.requestingInstanceName || eventName === "peer-exposure:denied")) { + // Resolution may yield nothing (an unknown id): drop whatever the payload + // claimed rather than display it. + p.requestingInstanceName = null; try { const inst = await getInstance(db, p.requestingInstance); if (inst && inst.name) p.requestingInstanceName = inst.name; @@ -159,6 +182,13 @@ export async function handleFixItAction(req, res, { db }) { if (action === "remedy") { const item = await store.getItem(db, itemId); if (!item) return res.redirectAfterPost("/dashboard/nest?flash=fixit_error"); + // N1b-8 review round 1 (N2): only a card that is still open may run its + // remedy. A stale page, a double submit or a replayed POST would + // otherwise re-apply a fix the operator already answered (re-granting an + // allowance, or granting one for a card they dismissed). + if (item.status !== "pending") { + return res.redirectAfterPost("/dashboard/nest?flash=fixit_not_pending"); + } const entry = (item.remedies || []).find((r) => r.actionId === body.action_id); if (!entry) return res.redirectAfterPost("/dashboard/nest?flash=fixit_error"); if (entry.kind && entry.kind !== "instant") { diff --git a/servers/gateway/fix-it/peer-card-counters.js b/servers/gateway/fix-it/peer-card-counters.js new file mode 100644 index 000000000..1d4bbbe93 --- /dev/null +++ b/servers/gateway/fix-it/peer-card-counters.js @@ -0,0 +1,110 @@ +/** + * N1b-8: where a rate-limited peer-exposure request is recorded. + * + * A denial that does not earn a card (see peer-card-policy.js) is still + * COUNTED — otherwise a peer retrying in a loop would leave no trace at all. + * Local-only JSON setting, keyed "|", no schema change. + * + * Bounded by construction (review round 1, B2): the key is built from a + * canonical capability id that the peer-exposure gate matched against this + * instance's own catalog, and a capability the gate could NOT match is counted + * in ONE bucket per peer (`|unknown`) — never under the peer's own + * string. The map itself is capped too: entries older than + * SUPPRESSED_COUNTER_WINDOW_MS are pruned on every write and, when the map is + * still full, the oldest entry is evicted. So a peer cannot grow this setting + * without bound, whatever it sends. + */ +import { readSetting, writeSetting } from "../dashboard/settings/registry.js"; +import { normalizePeerId } from "../../shared/peer-id.js"; + +/** Local-only (never-synced) setting key. */ +export const PEER_CARD_SUPPRESSED_KEY = "peer_exposure_card_suppressed"; + +/** The capability name used for every id the gate could not match (B2). */ +export const UNKNOWN_CAPABILITY_BUCKET = "unknown"; + +/** Hard cap on the number of counter entries, across every peer. */ +export const SUPPRESSED_COUNTER_MAX_ENTRIES = 500; + +/** An entry untouched for this long is dropped on the next write. */ +export const SUPPRESSED_COUNTER_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; + +/** Read the counters → Map<"|", {count, last_at, reason}>. Never throws. */ +export async function getSuppressedRequests(db) { + let raw; + try { raw = await readSetting(db, PEER_CARD_SUPPRESSED_KEY); } catch { return new Map(); } + const out = new Map(); + if (raw == null) return out; + let parsed; + try { parsed = JSON.parse(raw); } catch { return out; } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return out; + for (const [key, value] of Object.entries(parsed)) { + if (!value || typeof value !== "object") continue; + out.set(key, { + count: Number(value.count) || 0, + lastAt: typeof value.last_at === "string" ? value.last_at : null, + reason: typeof value.reason === "string" ? value.reason : null, + }); + } + return out; +} + +/** + * Drop every counter whose window has passed (or whose timestamp is + * unreadable — an entry we cannot age is an entry we cannot bound). + * Exported for tests. + */ +export function pruneSuppressedCounters(counts, now = Date.now()) { + for (const [key, value] of counts) { + const t = Date.parse(value?.lastAt || ""); + if (!Number.isFinite(t) || now - t > SUPPRESSED_COUNTER_WINDOW_MS) counts.delete(key); + } + return counts; +} + +/** + * Enforce the entry cap by evicting the least recently seen entries. Exported + * for tests. + */ +export function capSuppressedCounters(counts, cap = SUPPRESSED_COUNTER_MAX_ENTRIES) { + while (counts.size > cap) { + let oldestKey = null; + let oldestAt = Infinity; + for (const [key, value] of counts) { + const t = Date.parse(value?.lastAt || ""); + const at = Number.isFinite(t) ? t : -Infinity; // unreadable = oldest + if (at < oldestAt) { oldestAt = at; oldestKey = key; } + } + if (oldestKey === null) break; + counts.delete(oldestKey); + } + return counts; +} + +let writeChain = Promise.resolve(); + +/** + * Count one request that created no card. + * @param {object} db + * @param {{peerId: string, capability: string, reason?: string, now?: number}} info + */ +export async function recordSuppressedRequest(db, { peerId, capability, reason = null, now = Date.now() } = {}) { + const id = normalizePeerId(peerId); + if (!id) return { ok: false, error: "unknown_peer" }; + const cap = typeof capability === "string" ? capability.trim() : ""; + if (!cap) return { ok: false, error: "no_capability" }; + const key = `${id}|${cap}`; + const run = writeChain.then(async () => { + const counts = await getSuppressedRequests(db); + pruneSuppressedCounters(counts, now); + const prev = counts.get(key); + counts.set(key, { count: (prev?.count || 0) + 1, lastAt: new Date(now).toISOString(), reason }); + capSuppressedCounters(counts); + const obj = Object.create(null); + for (const [k, v] of counts) obj[k] = { count: v.count, last_at: v.lastAt, reason: v.reason }; + await writeSetting(db, PEER_CARD_SUPPRESSED_KEY, JSON.stringify(obj), { scope: "local" }); + return { ok: true, key, count: counts.get(key)?.count ?? 0, entries: counts.size }; + }); + writeChain = run.then(() => {}, () => {}); + return run; +} \ No newline at end of file diff --git a/servers/gateway/fix-it/peer-card-lock.js b/servers/gateway/fix-it/peer-card-lock.js new file mode 100644 index 000000000..0dbcf0769 --- /dev/null +++ b/servers/gateway/fix-it/peer-card-lock.js @@ -0,0 +1,101 @@ +/** + * N1b-8 review round 1 (B1): one serialised decide-then-upsert per peer. + * + * The Fix-it card path is a read-modify-write: read this detector's open cards + * (`store.listBySource`), decide (peer-card-policy.js), then upsert. Two + * denials for the same peer arriving together both read "no cards yet" and both + * create one, so the MAX_OPEN_CARDS_PER_PEER cap did not hold: 20 concurrent + * denials from one peer made 20 pending cards. + * + * A process-level promise chain per key (the same shape peer-exposure-store.js + * uses for its writes) makes the read and the write of one peer atomic with + * respect to the others. Notes: + * + * - The chain is keyed by the VERIFIED peer id, so two peers never wait on + * each other's read-decide-write of the CARD store. They DO share the one + * process-level write queue for the suppressed-request counters + * (peer-card-counters.js `recordSuppressedRequest`): an event that is + * counted rather than carded waits, in arrival order, behind every other + * peer's counter write. Counting is serialised globally; the card path is + * not. + * - Review round 2 (N-b): the queue per key is BOUNDED + * (MAX_QUEUED_TASKS_PER_KEY). A peer that fires thousands of denials at + * once cannot park thousands of pending read-decide-write operations in + * memory; events beyond the cap are dropped before they touch the db and + * counted in memory (`_peerLockOverflow()`), never silently. + * - The key is dropped as soon as its tail settles: the map cannot grow with + * the number of peers ever seen. + * - One failing event never wedges the chain — the tail is a promise that + * always resolves, so the next event still runs; the failing event's own + * rejection is the caller's to handle. + */ + +/** + * How many tasks one key may have accepted but not finished (the running one + * plus the queued ones). Well above anything a human-paced peer produces + * (the card path itself allows at most MAX_OPEN_CARDS_PER_PEER open cards and + * one new card per capability per 24 h), and small enough that a flood costs + * bounded memory. + */ +export const MAX_QUEUED_TASKS_PER_KEY = 16; + +/** @type {Map, queued: number}>} one live state per key */ +const chains = new Map(); + +/** How many events were DROPPED because their key's queue was full (memory only). */ +let overflow = 0; + +/** + * Run `fn` with no other call for `key` in flight, in arrival order. + * + * When the key already holds MAX_QUEUED_TASKS_PER_KEY unfinished tasks, `fn` is + * NOT run: the event is dropped (no db work at all) and counted in `overflow`. + * The returned promise resolves to undefined, so the fire-and-forget caller + * (`emitFixIt`) behaves exactly as on success. + * + * @template T + * @param {string} key + * @param {() => Promise} fn + * @returns {Promise} the result (or rejection) of `fn`, or + * undefined when the event was dropped + */ +export function withPeerLock(key, fn) { + const k = typeof key === "string" && key ? key : "\u0000unkeyed"; + let state = chains.get(k); + if (state && state.queued >= MAX_QUEUED_TASKS_PER_KEY) { + overflow += 1; + return Promise.resolve(undefined); + } + // One mutable state object per key, so the counters cannot drift apart. + if (!state) { + state = { tail: Promise.resolve(), queued: 0 }; + chains.set(k, state); + } + state.queued += 1; + const run = state.tail.then(fn, fn); // never inherits the previous failure + const tail = run.then(() => {}, () => {}); + state.tail = tail; + // Drop the key when the tail settles, but only if nothing else has queued + // behind it in the meantime (otherwise we would orphan a live chain). + tail.then(() => { + state.queued -= 1; + if (state.queued <= 0 && chains.get(k) === state) chains.delete(k); + }); + return run; +} + +/** Test seam: how many keys are currently queued (must return to 0). */ +export function _peerLockSize() { + return chains.size; +} + +/** Test seam: how many events were dropped because a key's queue was full. */ +export function _peerLockOverflow() { + return overflow; +} + +/** Test seam: forget the drop counter and the chains (tests that assert them). */ +export function _resetPeerLockForTest() { + chains.clear(); + overflow = 0; +} \ No newline at end of file diff --git a/servers/gateway/fix-it/peer-card-policy.js b/servers/gateway/fix-it/peer-card-policy.js new file mode 100644 index 000000000..9f58bec00 --- /dev/null +++ b/servers/gateway/fix-it/peer-card-policy.js @@ -0,0 +1,161 @@ +/** + * N1b-8: pure policy for a peer-exposure "Allow" card. + * + * The old card was a single button that added the capability to the ALL-PEERS + * allowlist — one peer, one click, every peer forever. The card is now scoped to + * the peer that asked (identified by the VERIFIED instance id) and the operator + * picks how long the grant lasts. This module holds the parts that need no I/O: + * the remedy set, the dedupe key, and the rate-limit decision, so they can be + * asserted directly with an injected clock. + * + * Rate limits (per peer, so one noisy peer cannot flood the Nest): + * - at most MAX_OPEN_CARDS_PER_PEER pending "Allow" cards; + * - at most ONE new card per peer per capability per 24 h — a repeat inside + * that window bumps the existing card's count instead of creating a card; + * - anything beyond a limit is counted (peer_exposure_card_suppressed) and + * creates no card. + * + * Fix round 3 (N1b-8): a card is also bound to the PAIRING it was raised under + * (`context.pairingTag`, a tag of the peer's `auth_token_hash`). A pending card + * whose tag is not this event's tag was raised by a pairing that is gone, so the + * remedy will refuse it — it must not own this request's dedupe slot either. + * Such a card is STALE: the decision names it in `staleItemIds` so the detector + * resolves it and the request is decided as if it did not exist. + */ +import { normalizePairingTag } from "../../shared/peer-id.js"; + +export const MAX_OPEN_CARDS_PER_PEER = 3; +export const NEW_CARD_WINDOW_MS = 24 * 60 * 60 * 1000; + +/** + * The durations the operator may pick on the card. 30 days is the default (it + * renders first); `days: null` = "until I remove it" and never expires. + * + * Each has its OWN actionId: the Nest renders one POST form per remedy and the + * action handler submits only `action_id`, so three buttons sharing one id would + * be indistinguishable (every click would mean 30 days). + */ +export const CARD_DURATIONS = Object.freeze([ + { label: "Allow for 30 days", days: 30, actionId: "expose-capability-30d" }, + { label: "Allow for 7 days", days: 7, actionId: "expose-capability-7d" }, + { label: "Allow until I remove it", days: null, actionId: "expose-capability-forever" }, +]); + +/** Every actionId this card can post; the gateway registers all of them. */ +export const CARD_ACTION_IDS = Object.freeze(CARD_DURATIONS.map((d) => d.actionId)); + +/** + * Field separator for the dedupe key (N1, review round 1). + * + * The old key was `expose::`, but BOTH parts may contain a + * colon — peer ids are `[A-Za-z0-9._:-]{1,128}` (servers/shared/peer-id.js), and + * a capability id may be an installed-extension id (an `mcp-addons.json` key) — + * so `expose:a:b:c` was ambiguous. `|` cannot appear in a peer id (the regex + * excludes it). Nothing checks the SHAPE of a capability id — capability-ids.js + * only requires provenance: the id is either `crow-` or a key + * of this instance's own addon file, never a string a peer chose (round 2, N-e — + * in round 1 the peer's tool name could reach this key). Cards written before + * this change are still found by legacyDedupKeyFor(). + */ +const DEDUP_SEP = "|"; + +/** + * Stable dedupe key: one card per (capability, peer) — and, fix round 3, one per + * PAIRING when the event carries a pairing tag. + * + * The tag segment exists because `fix_it_items` is UNIQUE(source, dedup_key): + * when a stale card (raised under an earlier pairing) is resolved and the current + * pairing raises its own, both rows must be able to exist, so the new card needs + * its own key. It is also the honest shape — a card from another pairing is a + * DIFFERENT question, not a repeat of this one. The tag is 16 hex chars + * (servers/shared/peer-id.js), so it cannot make the key ambiguous; a card + * written before tags existed keeps the plain three-part key, which the policy + * still finds (by key and by its stored capability). + */ +export function dedupKeyFor(peerId, capability, tag = null) { + const base = `expose${DEDUP_SEP}${capability}${DEDUP_SEP}${peerId}`; + const pairing = normalizePairingTag(tag); + return pairing ? `${base}${DEDUP_SEP}${pairing}` : base; +} + +/** The pre-round-1 key shape, still read so an open card keeps its dedupe. */ +export function legacyDedupKeyFor(peerId, capability) { + return `expose:${capability}:${peerId}`; +} + +/** + * The card's remedy buttons. Every arg needed to scope the grant travels in the + * stored card (`capability` + the verified `requestingInstance`), never from the + * request that triggered the card. + */ +export function cardRemedies(capability, peerId) { + return CARD_DURATIONS.map(({ label, days, actionId }) => ({ + label, + actionId, + kind: "instant", + args: days == null + ? { capability, requestingInstance: peerId, untilRemoved: true } + : { capability, requestingInstance: peerId, days }, + })); +} + +/** + * SQLite stores `datetime('now')` as "YYYY-MM-DD HH:MM:SS" in UTC, which + * Date.parse() reads as LOCAL time. Treat a bare timestamp as UTC. + */ +export function parseDbTime(value) { + if (typeof value !== "string" || !value) return NaN; + const iso = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(value) ? `${value.replace(" ", "T")}Z` : value; + return Date.parse(iso); +} + +/** + * Decide what a denial event should do with the card store. + * + * @param {object} o + * @param {Array} o.items every existing card of this detector's source (any status) + * @param {string} o.peerId verified peer instance id + * @param {string} o.capability canonical capability id + * @param {string|null} [o.pairingTag] tag of the pairing this event was verified under + * @param {number} [o.now] injected clock (tests) + * @returns {{action: "create"|"dedupe"|"count"|"drop", itemId?: number, reason?: string, staleItemIds?: number[]}} + */ +export function decidePeerExposureCard({ items = [], peerId, capability, pairingTag: tag = null, now = Date.now() } = {}) { + const key = dedupKeyFor(peerId, capability, tag); + const legacyKey = legacyDedupKeyFor(peerId, capability); + const incomingTag = normalizePairingTag(tag); + const mineAll = (items || []).filter((i) => i && i.context && i.context.requestingInstance === peerId); + // An OPEN card is stale when this event's pairing is KNOWN and that card was + // raised under a different one (or before tags existed, which is `null`): it + // can never be allowed, so it must not own this request's dedupe slot. Two + // nulls are the same (untagged) pairing, so a pre-round-3 card still dedupes + // onto itself rather than multiplying; and an event whose tag could not be + // read (null) makes no claim about an existing card, so it leaves it alone. + const stale = incomingTag + ? mineAll.filter((i) => i.status === "pending" && normalizePairingTag(i.context?.pairingTag) !== incomingTag) + : []; + const mine = stale.length ? mineAll.filter((i) => !stale.includes(i)) : mineAll; + // Both key shapes (a card opened before round 1 still owns its dedupe slot), + // else the stored capability — which is what the rate limit really means. + const existing = mine.find((i) => i.dedupKey === key || i.dedupKey === legacyKey) + || mine.find((i) => i.context?.capability === capability); + // Name the stale cards on whatever we decide, so the detector can resolve them + // first. Omitted when empty: the decision objects are asserted literally. + const withStale = (d) => (stale.length ? { ...d, staleItemIds: stale.map((i) => i.id) } : d); + + if (existing) { + // Same request again while the card is open → the store's upsert bumps its + // count and keeps one card (that IS the dedupe). + if (existing.status === "pending") return withStale({ action: "dedupe", itemId: existing.id }); + // Already answered (resolved or dismissed) inside the 24 h window: record + // the request on that card, but do NOT reopen it — reopening is a NEW card. + const touched = parseDbTime(existing.updatedAt || existing.createdAt); + if (Number.isFinite(touched) && now - touched < NEW_CARD_WINDOW_MS) { + return withStale({ action: "count", itemId: existing.id, reason: "same_capability_within_24h" }); + } + } + + const open = mine.filter((i) => i.status === "pending"); + if (open.length >= MAX_OPEN_CARDS_PER_PEER) return withStale({ action: "drop", reason: "open_card_cap" }); + return withStale({ action: "create" }); +} \ No newline at end of file diff --git a/servers/gateway/fix-it/remedies/expose-capability.js b/servers/gateway/fix-it/remedies/expose-capability.js index 7c5f76afe..49c72f07f 100644 --- a/servers/gateway/fix-it/remedies/expose-capability.js +++ b/servers/gateway/fix-it/remedies/expose-capability.js @@ -1,10 +1,54 @@ /** - * v1 Fix-it remedy (instant): add a capability to THIS instance's local - * `remote_exposed_tools` allowlist so trusted peers may invoke it. Only ever - * ADDS to this instance's own local setting — no cross-instance write, no sync. + * v1 Fix-it remedy (instant): grant a capability to the ONE peer that asked. + * + * N1b-8 (security audit): this used to add the capability to the local + * all-trusted-peers allowlist (`remote_exposed_tools`), so a single click caused + * by one peer's request exposed the capability to every peer, permanently. + * Now: + * - the target peer is the VERIFIED instance id recorded on the card + * (`args.requestingInstance`, or the stored card's context as a fallback); + * there is deliberately NO path from here to the all-peer list; + * - a missing/ill-shaped peer id refuses the remedy (fail closed) rather than + * widening the grant; + * - a peer that is no longer TRUSTED here (revoked, paused, untrusted, or its + * row is gone) refuses too (review round 2, N-a): a revoke→re-pair reuses + * the instance id, so a grant left behind for an untrusted id would come + * alive the moment that id pairs again; + * - Fix round 3 (N1b-8): the peer's row can be trusted again because it + * RE-PAIRED under the same id — a different device (or the same one with a + * new token) that never asked. The card therefore carries + * `context.pairingTag`, a tag of the `auth_token_hash` that verified the + * request; the remedy refuses unless it equals the tag of the peer's + * CURRENT hash. A card with no tag (written before this round) refuses as + * well — fail closed, on a mismatch and on an unreadable row alike; + * - the grant carries `expires_at` (`days` from now, default 30) or is + * permanent ("until I remove it": `untilRemoved` / `days: null`). + * + * Local-only: writes this instance's own settings; never a cross-instance write, + * never synced. */ -import { getExposedCapabilities } from "../../peer-exposure.js"; -import { writeSetting } from "../../dashboard/settings/registry.js"; +import { grantPeerCapability } from "../../peer-exposure-store.js"; +import { normalizePeerId, normalizePairingTag, pairingTag } from "../../../shared/peer-id.js"; +import { peerTrust } from "../../../shared/peer-trust.js"; + +const DEFAULT_DAYS = 30; +const MAX_DAYS = 3650; + +/** + * The refusal for a card raised by a pairing that is gone (re-pair, token + * rotation, or a card written before cards carried a pairing at all). + */ +export const PAIRED_AGAIN_MESSAGE = + "That device's pairing has changed since it asked, so nothing was shared. If it still needs this, it will ask again."; + +/** Resolve the grant duration from the card's remedy args. */ +export function resolveDuration(args) { + if (!args || typeof args !== "object") return { untilRemoved: false, days: DEFAULT_DAYS }; + if (args.untilRemoved === true || args.days === null) return { untilRemoved: true, days: null }; + const n = Number(args.days); + if (!Number.isFinite(n) || n <= 0) return { untilRemoved: false, days: DEFAULT_DAYS }; + return { untilRemoved: false, days: Math.min(Math.floor(n), MAX_DAYS) }; +} export default async function exposeCapability(args, ctx) { const capability = args && typeof args.capability === "string" ? args.capability.trim() : ""; @@ -12,10 +56,50 @@ export default async function exposeCapability(args, ctx) { // A federation peer's proxy entry (instance-) is never a capability of // this instance: exposing it would let one peer reach another through us. if (capability.startsWith("instance-")) return { resolved: false, message: "That is another instance, not a capability of this one." }; - const db = ctx.db; - const exposed = await getExposedCapabilities(db); // Set, deny-all on error - exposed.add(capability); - await writeSetting(db, "remote_exposed_tools", JSON.stringify([...exposed]), { scope: "local" }); - console.log(`[fix-it] exposed capability "${capability}" to trusted peers (local scope)`); - return { resolved: true, message: `${capability} is now shared with your trusted devices.` }; -} + + // The peer that asked, as recorded on the card from the verified request + // identity. Never a name, never a value taken from the peer's own request. + const peerId = normalizePeerId(args?.requestingInstance) || normalizePeerId(ctx?.item?.context?.requestingInstance); + if (!peerId) { + return { resolved: false, message: "This card has no verified requesting device, so the allowance cannot be limited to one. Nothing was shared." }; + } + + // N1b-8 review round 2 (N-a): the card is a snapshot of a past request; the + // peer must still be trusted NOW. This is the same predicate the gate uses — + // `enforcePeerExposure` only ever runs for an instance whose bearer hash + // matched `TRUSTED_PEER_SQL` (trusted = 1 AND status IN ('active','offline')) + // at the moment of use. A revoked or paused peer (or one this instance can no + // longer read) is refused, and the lookup fails closed. + const db = ctx?.db; + if (!db) return { resolved: false, message: "Could not read this instance's peer list, so nothing was shared." }; + const trust = await peerTrust(db, peerId); + if (!trust.ok) { + const why = trust.reason === "trust_lookup_failed" + ? "Crow couldn't confirm that device is still paired, so nothing was shared." + : "That device isn't a trusted peer of this Crow any more, so nothing was shared. Pair it again and it can ask afresh."; + return { resolved: false, message: why }; + } + + // Fix round 3 (N1b-8): the row being trusted again is NOT enough — a + // revoke→re-pair reuses the instance id, so `peerTrust` passes for a device + // that never made this request. `peerTrust` read the peer's row (`SELECT * + // FROM crow_instances`) a moment ago, so its `auth_token_hash` is the CURRENT + // pairing: the card must carry the tag of that same hash. A card with no tag + // (written before this round) cannot be matched either, so it refuses too. + // Fail closed: a missing/unreadable hash is a refusal, never a grant. + const cardTag = normalizePairingTag(ctx?.item?.context?.pairingTag); + const currentTag = pairingTag(trust.row?.auth_token_hash); + if (!cardTag || !currentTag || cardTag !== currentTag) { + return { resolved: false, message: PAIRED_AGAIN_MESSAGE }; + } + + const { untilRemoved, days } = resolveDuration(args); + const now = Date.now(); + const expiresAt = untilRemoved ? null : new Date(now + days * 86_400_000).toISOString(); + const result = await grantPeerCapability(db, { peerId, capability, expiresAt, now }); + if (!result.ok) return { resolved: false, message: "Could not record the allowance." }; + + const when = untilRemoved ? "until you remove it" : `for ${days} day${days === 1 ? "" : "s"}`; + console.log(`[fix-it] granted capability "${capability}" to peer ${peerId} ${when} (local scope)`); + return { resolved: true, message: `${capability} is now shared with that device ${when}.` }; +} \ No newline at end of file diff --git a/servers/gateway/peer-exposure-store.js b/servers/gateway/peer-exposure-store.js new file mode 100644 index 000000000..33c9e9b62 --- /dev/null +++ b/servers/gateway/peer-exposure-store.js @@ -0,0 +1,276 @@ +/** + * F4a Layer 2a exposure storage — WHO may invoke WHAT on this instance. + * + * Two settings, both local-only (deliberately absent from sync-allowlist.js — + * writeSetting downgrades them to local scope, so each instance is sovereign + * over what it exposes): + * + * 1. `remote_exposed_tools` — the legacy ALL-TRUSTED-PEERS allowlist: a flat + * JSON array of canonical capability ids. Unchanged by the N1b-8 fix; a + * capability listed here is invocable by every trusted peer, exactly as + * before. The Settings UI's checkbox list is this control, and it stays + * the operator's explicit "share with all my Crows" statement. + * + * 2. `remote_exposed_tools_by_peer` — N1b-8: PER-PEER grants, keyed by the + * VERIFIED peer instance id (never a name from a request body): + * + * { "": { + * "": { + * "expires_at": "2026-01-31T12:00:00.000Z" | null, // null = until removed + * "granted_at": "2026-01-01T12:00:00.000Z" + * } } } + * + * A Fix-it "Allow" card writes here only — it can no longer reach the + * all-peer list. Expiry is evaluated at check time (an expired grant is + * refused) but the row is KEPT, so Settings can show it as expired and + * renew or remove it. + * + * Storage decision (N1b-8): a JSON-backed settings value, NOT a table. The + * exposure allowlist was already a local setting; keeping the per-peer grants in + * the same place needs no schema change and no SCHEMA_GENERATION bump, and the + * two shapes live side by side without migrating anything. + * + * This module is a leaf: it imports only the settings registry, so the Fix-it + * detector/remedy and the Settings section can use it without pulling in + * peer-exposure.js (which itself imports the Fix-it emitter). + */ +import { readSetting, writeSetting } from "./dashboard/settings/registry.js"; +import { normalizePeerId } from "../shared/peer-id.js"; + +/** Local-only (never-synced) setting key for the all-trusted-peers allowlist. */ +export const EXPOSURE_SETTING_KEY = "remote_exposed_tools"; +/** Local-only setting key for the per-peer grants (N1b-8). */ +export const PEER_EXPOSURE_SETTING_KEY = "remote_exposed_tools_by_peer"; + +const EMPTY_SET = new Set(); + +/** + * Read the all-peer exposure allowlist → Set. Tolerates + * absent/malformed settings by returning an empty set (deny-all). Never throws. + */ +export async function getExposedCapabilities(db) { + let raw; + try { raw = await readSetting(db, EXPOSURE_SETTING_KEY); } catch { return new Set(); } + if (raw == null) return new Set(); + let parsed; + try { parsed = JSON.parse(raw); } catch { return new Set(); } + if (!Array.isArray(parsed)) return new Set(); + return new Set(parsed.filter((x) => typeof x === "string" && x.length > 0)); +} + +/** + * Parse the per-peer store → Map>. + * Tolerates absent/malformed input (deny-all) and drops anything that is not a + * plain, id-shaped grant. Never throws. + */ +export function parsePeerExposures(raw) { + const out = new Map(); + if (raw == null) return out; + let parsed; + try { parsed = JSON.parse(raw); } catch { return out; } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return out; + for (const [rawPeerId, grants] of Object.entries(parsed)) { + const peerId = normalizePeerId(rawPeerId); + if (!peerId) continue; + if (!grants || typeof grants !== "object" || Array.isArray(grants)) continue; + const caps = new Map(); + for (const [capability, grant] of Object.entries(grants)) { + if (typeof capability !== "string" || capability.length === 0) continue; + if (!grant || typeof grant !== "object" || Array.isArray(grant)) continue; + const expiresAt = typeof grant.expires_at === "string" ? grant.expires_at : null; + const grantedAt = typeof grant.granted_at === "string" ? grant.granted_at : null; + caps.set(capability, { expiresAt, grantedAt }); + } + if (caps.size > 0) out.set(peerId, caps); + } + return out; +} + +/** Serialize the per-peer store (Map shape) back to the stored JSON. */ +export function serializePeerExposures(exposures) { + const obj = Object.create(null); + for (const [peerId, caps] of exposures || []) { + const out = Object.create(null); + for (const [capability, grant] of caps) { + out[capability] = { expires_at: grant?.expiresAt ?? null, granted_at: grant?.grantedAt ?? null }; + } + obj[peerId] = out; + } + return JSON.stringify(obj); +} + +/** Read every stored per-peer grant (including expired ones — Settings shows them). */ +export async function getPeerExposures(db) { + let raw; + try { raw = await readSetting(db, PEER_EXPOSURE_SETTING_KEY); } catch { return new Map(); } + return parsePeerExposures(raw); +} + +/** + * Is a stored grant still in force? `expires_at` null/absent = "until I remove + * it" (never expires). A malformed timestamp fails CLOSED. + */ +export function grantIsActive(grant, now = Date.now()) { + if (!grant || typeof grant !== "object") return false; + if (grant.expiresAt == null) return true; + const t = Date.parse(grant.expiresAt); + if (!Number.isFinite(t)) return false; + return t > now; +} + +/** + * The capabilities that peer is allowed to invoke RIGHT NOW: non-expired grants + * only. Deny-all on a missing/unknown/ill-shaped peer id. Never throws. + * + * @param {object} db + * @param {string} peerId verified peer instance id (req.instanceAuth.instance.id) + * @param {{now?: number}} [opts] `now` is injected by tests for expiry + */ +export async function getPeerGrantedCapabilities(db, peerId, { now = Date.now() } = {}) { + const id = normalizePeerId(peerId); + if (!id) return EMPTY_SET; + const grants = (await getPeerExposures(db)).get(id); + if (!grants) return EMPTY_SET; + const out = new Set(); + for (const [capability, grant] of grants) if (grantIsActive(grant, now)) out.add(capability); + return out.size > 0 ? out : EMPTY_SET; +} + +/** Per-call check: is `capability` granted to THIS peer and not expired? */ +export async function isCapabilityExposedToPeer(db, capability, peerId, { now = Date.now() } = {}) { + if (typeof capability !== "string" || capability.length === 0) return false; + return (await getPeerGrantedCapabilities(db, peerId, { now })).has(capability); +} + +/** + * What ONE peer may invoke right now: the all-trusted-peers allowlist UNION that + * peer's own active grants. Used where the catalog is served to a verified peer + * (S2) — its Bot Builder must see a one-device allowance as exposed without + * ever seeing anybody else's. + * + * Never throws. An ill-shaped peer id yields the all-peer set only. + * + * @param {object} db + * @param {string} peerId verified peer instance id + * @param {{now?: number}} [opts] injected clock (tests) + * @returns {Promise>} + */ +export async function getExposureForPeer(db, peerId, { now = Date.now() } = {}) { + const out = new Set(); + try { for (const cap of await getExposedCapabilities(db)) out.add(cap); } catch { /* deny-all for this half */ } + try { for (const cap of await getPeerGrantedCapabilities(db, peerId, { now })) out.add(cap); } catch { /* deny-all for this half */ } + return out; +} + +// --- mutations ------------------------------------------------------------- +// Read-modify-write on ONE settings key, serialized per process so two +// concurrent fixes cannot clobber each other's grant. +let writeChain = Promise.resolve(); +function withStoreLock(fn) { + const run = writeChain.then(fn, fn); + writeChain = run.then(() => {}, () => {}); + return run; +} + +async function mutatePeerExposures(db, mutate) { + return withStoreLock(async () => { + const current = await getPeerExposures(db); + const next = mutate(current) || current; + await writeSetting(db, PEER_EXPOSURE_SETTING_KEY, serializePeerExposures(next), { scope: "local" }); + return next; + }); +} + +function normalizeExpiry(expiresAt) { + if (expiresAt == null) return { ok: true, expiresAt: null }; + const d = expiresAt instanceof Date ? expiresAt : new Date(expiresAt); + if (!Number.isFinite(d.getTime())) return { ok: false, expiresAt: null }; + return { ok: true, expiresAt: d.toISOString() }; +} + +/** + * Grant ONE capability to ONE peer. `expiresAt` null = until removed. + * @returns {Promise<{ok: boolean, error?: string, peerId?: string, capability?: string, expiresAt?: string|null}>} + */ +export async function grantPeerCapability(db, { peerId, capability, expiresAt = null, now = Date.now() } = {}) { + const id = normalizePeerId(peerId); + if (!id) return { ok: false, error: "unknown_peer" }; + const cap = typeof capability === "string" ? capability.trim() : ""; + if (!cap) return { ok: false, error: "no_capability" }; + const exp = normalizeExpiry(expiresAt); + if (!exp.ok) return { ok: false, error: "bad_expiry" }; + await mutatePeerExposures(db, (all) => { + const caps = all.get(id) || new Map(); + caps.set(cap, { expiresAt: exp.expiresAt, grantedAt: new Date(now).toISOString() }); + all.set(id, caps); + return all; + }); + return { ok: true, peerId: id, capability: cap, expiresAt: exp.expiresAt }; +} + +/** + * Push one grant's expiry out by `days` from now ("Renew"), or make it + * permanent with days = null. + */ +export async function renewPeerGrant(db, { peerId, capability, days = 30, now = Date.now() } = {}) { + const id = normalizePeerId(peerId); + if (!id) return { ok: false, error: "unknown_peer" }; + const cap = typeof capability === "string" ? capability.trim() : ""; + if (!cap) return { ok: false, error: "no_capability" }; + const n = Number(days); + const expiresAt = days == null ? null + : (Number.isFinite(n) && n > 0 ? new Date(now + n * 86_400_000).toISOString() : null); + if (days != null && !expiresAt) return { ok: false, error: "bad_expiry" }; + let found = false; + await mutatePeerExposures(db, (all) => { + const caps = all.get(id); + const grant = caps?.get(cap); + if (!grant) return all; + found = true; + caps.set(cap, { expiresAt, grantedAt: grant.grantedAt }); + return all; + }); + return found ? { ok: true, peerId: id, capability: cap, expiresAt } : { ok: false, error: "not_granted" }; +} + +/** Remove one peer's grant for one capability. */ +export async function removePeerGrant(db, { peerId, capability } = {}) { + const id = normalizePeerId(peerId); + if (!id) return { ok: false, error: "unknown_peer" }; + const cap = typeof capability === "string" ? capability.trim() : ""; + if (!cap) return { ok: false, error: "no_capability" }; + let found = false; + await mutatePeerExposures(db, (all) => { + const caps = all.get(id); + if (!caps || !caps.has(cap)) return all; + found = true; + caps.delete(cap); + if (caps.size === 0) all.delete(id); + return all; + }); + return found ? { ok: true, peerId: id, capability: cap } : { ok: false, error: "not_granted" }; +} + +/** + * Drop EVERY grant for one peer. Called when the peer is revoked or removed: + * a revoked id must not come back with allowances attached if it is paired + * again (a revoke→re-pair reuses the id). Goes through the same serialised + * writer as every other mutation, and writes nothing when the peer has no + * grants. + * + * @param {object} db + * @param {{peerId: string}} args + * @returns {Promise<{ok: boolean, peerId?: string, removed?: number, error?: string}>} + */ +export async function removePeerExposures(db, { peerId } = {}) { + const id = normalizePeerId(peerId); + if (!id) return { ok: false, error: "unknown_peer", removed: 0 }; + return withStoreLock(async () => { + const current = await getPeerExposures(db); + const caps = current.get(id); + if (!caps) return { ok: true, peerId: id, removed: 0 }; + current.delete(id); + await writeSetting(db, PEER_EXPOSURE_SETTING_KEY, serializePeerExposures(current), { scope: "local" }); + return { ok: true, peerId: id, removed: caps.size }; + }); +} \ No newline at end of file diff --git a/servers/gateway/peer-exposure.js b/servers/gateway/peer-exposure.js index ad6b71a6d..9c9de3d59 100644 --- a/servers/gateway/peer-exposure.js +++ b/servers/gateway/peer-exposure.js @@ -3,36 +3,61 @@ * * The trust boundary for remote tool invocation. A trusted peer instance * (req.instanceAuth.instance) may invoke a tool ONLY if its owning capability - * canonicalId is in this instance's `remote_exposed_tools` allowlist. - * DEFAULT-DENY: nothing is invocable by a peer until the operator exposes it. + * canonicalId is exposed to it: either in this instance's all-trusted-peers + * `remote_exposed_tools` allowlist, or in a per-peer grant for THAT peer + * (`remote_exposed_tools_by_peer`, N1b-8). DEFAULT-DENY: nothing is invocable + * by a peer until the operator exposes it. * * This gate is the security keystone — it enforces server-side, independent of * any UI affordance, even against a peer crafting a raw JSON-RPC call to any * mounted MCP endpoint. The catalog `exposed` flag is convenience, NOT the * boundary. Local-operator calls are never gated here (they don't carry * req.instanceAuth.instance). + * + * N1b-8: the per-peer side exists so one peer's one-click "Allow" card cannot + * widen a capability to every peer forever. The peer identity used here is the + * VERIFIED one (`req.instanceAuth.instance.id`, from the bearer token hash) and + * nothing else — a request body cannot assert it. */ -import { readSetting } from "./dashboard/settings/registry.js"; import { getOrCreateLocalInstanceId } from "./instance-registry.js"; +import { pairingTag } from "../shared/peer-id.js"; import { auditCrossHostCall } from "../shared/cross-host-auth.js"; import { emitFixIt } from "./fix-it/index.js"; +import { canonicalizePeerCapabilityTool, getLocalCapabilityIds } from "./capability-ids.js"; +import { + EXPOSURE_SETTING_KEY, + PEER_EXPOSURE_SETTING_KEY, + getExposedCapabilities, + getPeerExposures, + getPeerGrantedCapabilities, + getExposureForPeer, + isCapabilityExposedToPeer, + grantPeerCapability, + renewPeerGrant, + removePeerGrant, + removePeerExposures, + grantIsActive, +} from "./peer-exposure-store.js"; -/** Local-only (never-synced) setting key. Deliberately absent from sync-allowlist.js. */ -export const EXPOSURE_SETTING_KEY = "remote_exposed_tools"; +// The exposure storage lives in peer-exposure-store.js (a leaf module the +// Fix-it detector/remedy and the Settings section can import without a cycle); +// re-exported here because this is where callers expect to find it. +export { + EXPOSURE_SETTING_KEY, + PEER_EXPOSURE_SETTING_KEY, + getExposedCapabilities, + getPeerExposures, + getPeerGrantedCapabilities, + getExposureForPeer, + isCapabilityExposedToPeer, + grantPeerCapability, + renewPeerGrant, + removePeerGrant, + removePeerExposures, + grantIsActive, +}; -/** - * Read the exposure allowlist → Set. Tolerates absent/malformed - * settings by returning an empty set (deny-all). Never throws. - */ -export async function getExposedCapabilities(db) { - let raw; - try { raw = await readSetting(db, EXPOSURE_SETTING_KEY); } catch { return new Set(); } - if (raw == null) return new Set(); - let parsed; - try { parsed = JSON.parse(raw); } catch { return new Set(); } - if (!Array.isArray(parsed)) return new Set(); - return new Set(parsed.filter((x) => typeof x === "string" && x.length > 0)); -} +const EMPTY_SET = new Set(); // Mount prefix → canonical capability id, for prefixes where ALL tools belong // to one capability. Root "" is the single-server memory mount. @@ -77,14 +102,19 @@ function resolveProxyTool(toolName, connectedServers) { } /** - * Resolve the canonical capability id a peer call targets. - * @returns {string|null|"__allow__"} canonicalId to check; null = deny (fail - * closed); "__allow__" = ungated (discovery / non-tools-call method). + * Resolve the canonical capability id a peer call targets, and where the id + * came from: `peerChosen` is true only when the PEER named it (a `/router` + * `crow_` tool), which is the one case where the id must be matched + * against this instance's own catalog before it may raise a card. + * + * @returns {{id: string|null|"__allow__", peerChosen: boolean}} */ -export function resolveCalledCanonicalId(prefix, body, connectedServers) { +export function resolveCalledCapability(prefix, body, connectedServers) { // A batch (array) or a non-object body names no single method: never allow. - if (Array.isArray(body) || (body !== undefined && (body === null || typeof body !== "object"))) return null; - if (!body || body.method !== "tools/call") return "__allow__"; + if (Array.isArray(body) || (body !== undefined && (body === null || typeof body !== "object"))) { + return { id: null, peerChosen: false }; + } + if (!body || body.method !== "tools/call") return { id: "__allow__", peerChosen: false }; const toolName = body.params?.name; const p = String(prefix || "").replace(/^\/+/, ""); @@ -92,30 +122,77 @@ export function resolveCalledCanonicalId(prefix, body, connectedServers) { // consulted: the whole mount maps to exactly one capability, and the // underlying MCP server validates the tool name downstream — so an // un-exposed tool cannot ride in on a single-capability mount. - if (Object.prototype.hasOwnProperty.call(PREFIX_CANON, p)) return PREFIX_CANON[p]; + if (Object.prototype.hasOwnProperty.call(PREFIX_CANON, p)) { + return { id: PREFIX_CANON[p], peerChosen: false }; + } if (p === "router") { - if (toolName === "crow_discover") return "__allow__"; + if (toolName === "crow_discover") return { id: "__allow__", peerChosen: false }; if (toolName === "crow_tools") { const args = body.params?.arguments || {}; - if (args.instance_id) return null; // onward relay to a third instance — deny this hop - return resolveProxyTool(args.action, connectedServers); + if (args.instance_id) return { id: null, peerChosen: false }; // onward relay to a third instance — deny this hop + return { id: resolveProxyTool(args.action, connectedServers), peerChosen: false }; } if (typeof toolName === "string" && toolName.startsWith("crow_")) { - return `crow-${toolName.slice("crow_".length)}`; + // N1b-8 review round 1 (B2): the PEER picked this string. Canonicalise it + // (`crow_Memory` → `crow-memory`, junk → an id no catalog holds) and let + // the gate require it to be a capability of this instance before a card + // or a counter may mention it. + return { id: canonicalizePeerCapabilityTool(toolName), peerChosen: true }; } - return null; + return { id: null, peerChosen: false }; } if (p === "tools" || p.startsWith("tools-")) { - return resolveProxyTool(toolName, connectedServers); + return { id: resolveProxyTool(toolName, connectedServers), peerChosen: false }; } - return null; // unknown prefix — fail closed + return { id: null, peerChosen: false }; // unknown prefix — fail closed +} + +/** + * Resolve the canonical capability id a peer call targets. + * @returns {string|null|"__allow__"} canonicalId to check; null = deny (fail + * closed); "__allow__" = ungated (discovery / non-tools-call method). + */ +export function resolveCalledCanonicalId(prefix, body, connectedServers) { + return resolveCalledCapability(prefix, body, connectedServers).id; +} + +/** + * Is `id` a capability THIS instance has? A peer-named id must be in the local + * catalog. An id the instance itself resolved (a mount's canonical id, or an + * addon found in the live server map) is known by construction. Never throws — + * an unknown answer fails closed (the capability stays unknown, so it gets no + * card). + */ +function isKnownCapability(id, peerChosen, connectedServers) { + try { + if (getLocalCapabilityIds().has(id)) return true; + if (peerChosen) return false; + return connectedServers instanceof Map && connectedServers.has(id); + } catch { return false; } } const DENY_CODE = -32001; const INVALID_REQUEST = -32600; + +/** + * N1b-8 fix round 3/4: the tag of the PAIRING this request was verified under, + * so a card can be bound to it and a later re-pair (same id, new hash) refuses + * it. + * + * `instanceAuthMiddleware` resolves the caller through `validateInstanceToken`, + * which matched the presented bearer against `auth_token_hash` and returns the + * whole `crow_instances` row (`SELECT *`), so the verified instance ALWAYS + * carries the hash here. The tag is taken from that row and nowhere else: a + * re-read of the row by id could tag an old pairing's request with a NEW + * pairing's hash after a re-pair. No hash → no tag (fix round 4: fail closed), + * and a card raised with no tag can never be allowed (the remedy refuses it). + */ +function pairingTagForVerified(instance) { + return pairingTag(instance?.auth_token_hash); +} // Verbs that carry no JSON-RPC body on an MCP mount (open the session stream, // close the session). Every other peer request must be ONE plain object. const BODYLESS = new Set(["GET", "HEAD", "DELETE"]); @@ -143,10 +220,12 @@ function isSinglePeerRequest(req) { * @param {object} o.res express res * @param {object} o.db libsql client (for exposure read + audit) * @param {Map} o.connectedServers proxy map (for addon/proxy resolution) - * @param {Set} [o.exposedSetOverride] test hook — skip the db read + * @param {Set} [o.exposedSetOverride] test hook — all-peer set, skips the db read + * @param {Map>} [o.peerGrantsOverride] test hook — per-peer caps, skips the db read + * @param {number} [o.now] test hook — injectable clock for grant expiry * @param {Function} [o.auditFn] test hook — defaults to auditCrossHostCall */ -export async function enforcePeerExposure({ prefix, req, res, db, connectedServers, exposedSetOverride, auditFn = auditCrossHostCall, emitFn = emitFixIt }) { +export async function enforcePeerExposure({ prefix, req, res, db, connectedServers, exposedSetOverride, peerGrantsOverride, now = Date.now(), auditFn = auditCrossHostCall, emitFn = emitFixIt }) { // Only peer-instance callers are gated. Local-operator calls don't carry this. if (!req?.instanceAuth?.instance) return true; @@ -176,13 +255,25 @@ export async function enforcePeerExposure({ prefix, req, res, db, connectedServe } return false; } - const canonicalId = resolveCalledCanonicalId(prefix, body, connectedServers); + const resolved = resolveCalledCapability(prefix, body, connectedServers); + const canonicalId = resolved.id; if (canonicalId === "__allow__") return true; // discovery / non-tools-call + const sourceId = req.instanceAuth.instance.id; const exposed = exposedSetOverride || await getExposedCapabilities(db); - const allowed = typeof canonicalId === "string" && exposed.has(canonicalId); + // N1b-8: per-peer grants. Skipped when a test hook supplies the sets (the + // hooks exist so unit tests never need a db); a read failure denies the + // per-peer path only — the all-peer allowlist is unaffected. + let peerCaps = EMPTY_SET; + try { + if (peerGrantsOverride !== undefined && peerGrantsOverride !== null) { + peerCaps = peerGrantsOverride.get?.(sourceId) || EMPTY_SET; + } else if (exposedSetOverride === undefined) { + peerCaps = await getPeerGrantedCapabilities(db, sourceId, { now }); + } + } catch { peerCaps = EMPTY_SET; } + const allowed = typeof canonicalId === "string" && (exposed.has(canonicalId) || peerCaps.has(canonicalId)); - const sourceId = req.instanceAuth.instance.id; let localId = null; try { localId = getOrCreateLocalInstanceId(); } catch { /* best-effort */ } const toolName = body?.params?.name || null; @@ -204,15 +295,41 @@ export async function enforcePeerExposure({ prefix, req, res, db, connectedServe if (allowed) return true; // Fix-it: a resolvable capability was denied → surface a one-click "Allow" - // card. Fire-and-forget; must never block or break the gate. Only real, - // resolvable capabilities (string canonicalId) become cards — a null-canonical - // (malformed) deny is skipped. - if (typeof canonicalId === "string") { + // card. Fire-and-forget; must never block or break the gate. A deny that + // resolved to nothing this instance could name emits nothing at all (see the + // `emitCapability` note below). + // + // N1b-8: the payload carries ONLY the verified peer id (`sourceId`) — that is + // what scopes the all-peer card to this one peer. No name, id or header the + // peer wrote into its request body ever reaches this payload. + // + // Fix round 3: the payload also carries `pairingTag`, a one-way tag of the + // verified row's `auth_token_hash`. It binds the card to THIS pairing, so a + // revoke→re-pair (same id, new hash) refuses the old card instead of granting + // the re-paired peer. The raw hash never leaves this process. + // + // N1b-8 review round 1 (B2): `capabilityKnown` says whether the id matched a + // capability of THIS instance's catalog. When it did not (a peer picked + // `crow_junk99` on /router, or a name that is not even id-shaped), the + // detector records ONE bounded "unknown" bucket for that peer instead of a + // card and a counter keyed by junk. An id THIS instance resolved that came + // out empty (an unknown /tools tool name) emits nothing at all, as before. + const emitCapability = typeof canonicalId === "string" + ? canonicalId + : (resolved.peerChosen ? null : undefined); + if (emitCapability !== undefined) { + const capabilityKnown = typeof canonicalId === "string" + ? isKnownCapability(canonicalId, resolved.peerChosen, connectedServers) + : false; + // The pairing the request was verified under (see pairingTagForVerified). + const tag = pairingTagForVerified(req.instanceAuth.instance); try { Promise.resolve(emitFn(db, "peer-exposure:denied", { - capability: canonicalId, + capability: emitCapability, + capabilityKnown, requestingInstance: sourceId, toolName, + pairingTag: tag, })).catch(() => {}); } catch { /* never breaks the gate */ } } diff --git a/servers/gateway/peer-trust-sweeper.js b/servers/gateway/peer-trust-sweeper.js index b37191190..462463b33 100644 --- a/servers/gateway/peer-trust-sweeper.js +++ b/servers/gateway/peer-trust-sweeper.js @@ -30,6 +30,14 @@ import { existsSync, writeFileSync, copyFileSync, chmodSync } from "node:fs"; import { TRUSTED_PEER_SQL } from "../shared/peer-trust.js"; +import { getPeerExposures, removePeerExposures } from "./peer-exposure-store.js"; +// Fix round 3: a row-only revoke (written by ANOTHER process) must answer the +// peer's open peer-exposure cards as well as drop its grants, exactly like the +// revoke DOOR does. The card sweep lives in revoke-peer.js, next to the revoke +// that needs it; reusing it keeps the two doors identical. +// Fix round 4: the cards are listed ONCE per sweep (revoked rows are never +// deleted, so listing per revoked row was N reads every 10 s). +import { listPeerExposureCards, resolvePeerExposureCards } from "../sharing/revoke-peer.js"; import { updatePeerCreds, entryMintMs, loadPeerCreds, peerTokensPath, migratePeerCredsStore } from "../shared/peer-credentials.js"; import { setUntrustedWithCreds, setOrphanKeys } from "../shared/peer-trust-health.js"; import { closeSessionsOwnedByEverywhere, countSessionsOwnedByEverywhere, peerSessionOwner } from "./session-manager.js"; @@ -65,9 +73,15 @@ export function snapshotStoreOnce({ now = () => Date.now() } = {}) { /** * Reconcile the credential store against crow_instances. Idempotent. + * + * @param {object} db + * @param {object} [opts] + * @param {string|null} [opts.localInstanceId] + * @param {object|null} [opts.fixItStore] test seam: the Fix-it store the card + * sweep reads/writes (defaults to the real one, servers/shared/fix-it/store.js) * @returns {Promise<{deleted: string[], keptSameSecond: string[], orphans: string[], untrusted: string[]}>} */ -export async function reconcilePeerCreds(db, { localInstanceId = null } = {}) { +export async function reconcilePeerCreds(db, { localInstanceId = null, fixItStore = null } = {}) { // Rows first (async), then the synchronous store lock. const { rows } = await db.execute({ sql: `SELECT id, name, status, updated_at, auth_token_hash, (${TRUSTED_PEER_SQL}) AS ok FROM crow_instances`, @@ -76,6 +90,41 @@ export async function reconcilePeerCreds(db, { localInstanceId = null } = {}) { const byId = new Map(rows.map((r) => [r.id, r])); const revoked = rows.filter((r) => r.status === "revoked"); + // N1b-8 (review round 1, S3): a revoked peer keeps no one-device allowances. + // The revoke DOOR drops them (servers/sharing/revoke-peer.js); this covers a + // revoke written by ANOTHER process, which lands on the row only. Read first, + // write only when that peer actually holds a grant. + if (revoked.length) { + try { + const grants = await getPeerExposures(db); + for (const r of revoked) { + if (grants.has(r.id)) await removePeerExposures(db, { peerId: r.id }); + } + } catch { /* the row gate already refuses the peer */ } + // Fix round 3: and its open "Allow" card(s) are answered too. Otherwise a + // revoke→re-pair (same id, new hash) leaves a card in the Nest whose click + // would grant the re-paired peer without a fresh request. Fix round 4: the + // card table is read ONCE for the whole sweep (a revoked row is never + // deleted, so a per-row read would grow with every revoke), and only a + // peer that actually has a pending card is resolved. Best-effort, since the + // row revoke is what refuses the peer. + try { + const cardStore = fixItStore || undefined; + const pending = await listPeerExposureCards(db, { store: cardStore }); + if (pending.length) { + const revokedIds = new Set(revoked.map((r) => r.id)); + const ids = new Set(); + for (const item of pending) { + const peerId = item.context?.requestingInstance; + if (revokedIds.has(peerId)) ids.add(peerId); + } + for (const id of ids) { + await resolvePeerExposureCards(db, id, { items: pending, store: cardStore }); + } + } + } catch { /* the row gate already refuses the peer */ } + } + const deleted = []; const keptSameSecond = []; // Lock the store only when a revoked peer still has an entry (an unlocked diff --git a/servers/gateway/routes/audio-proxy.js b/servers/gateway/routes/audio-proxy.js index baa21a73b..1a0549525 100644 --- a/servers/gateway/routes/audio-proxy.js +++ b/servers/gateway/routes/audio-proxy.js @@ -7,14 +7,15 @@ * Funkwhale addon env (never from caller input — no SSRF), fetch it with OUR * token, follow the storage redirect (dropping the bearer), and stream the bytes * back to the peer. The peer authenticates with its instance bearer - * (instanceAuthMiddleware) and the funkwhale capability must be exposed to peers. + * (instanceAuthMiddleware) and the funkwhale capability must be exposed to THAT + * peer (the all-trusted-peers allowlist, or its own per-device grant — N1b-8). * * Mounted under /audio → outside PUBLIC_FUNNEL_PREFIXES → Funnel-blocked. */ import { Router } from "express"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { getExposedCapabilities } from "../peer-exposure.js"; +import { getExposedCapabilities, isCapabilityExposedToPeer } from "../peer-exposure.js"; import { resolveCrowHome } from "../proxy.js"; const ID_RE = /^[0-9a-fA-F-]{8,64}$/; @@ -40,7 +41,7 @@ export function readFunkwhaleEnv(crowHome = resolveCrowHome()) { return null; } -export default function audioProxyRouter({ createDbClient, fetchImpl = fetch, fwEnv = readFunkwhaleEnv, getExposed = getExposedCapabilities } = {}) { +export default function audioProxyRouter({ createDbClient, fetchImpl = fetch, fwEnv = readFunkwhaleEnv, getExposed = getExposedCapabilities, isExposedToPeer = isCapabilityExposedToPeer, now = () => Date.now() } = {}) { const router = Router(); router.get("/audio/stream", async (req, res) => { @@ -51,8 +52,15 @@ export default function audioProxyRouter({ createDbClient, fetchImpl = fetch, fw const db = createDbClient(); try { + // Exposure is per-caller: the all-trusted-peers allowlist OR a grant made + // to THIS peer (N1b-8 — a peer-scoped Allow must work for audio too). const exposed = await getExposed(db); - if (!exposed.has("funkwhale")) return res.status(403).json({ error: "not_exposed" }); + let allowed = exposed.has("funkwhale"); + if (!allowed) { + try { allowed = await isExposedToPeer(db, "funkwhale", req.instanceAuth.instance.id, { now: now() }); } + catch { allowed = false; } // fail closed + } + if (!allowed) return res.status(403).json({ error: "not_exposed" }); } finally { try { db.close(); } catch {} } const fw = fwEnv(); diff --git a/servers/gateway/routes/federation.js b/servers/gateway/routes/federation.js index 6181a4fd7..577ed9b3e 100644 --- a/servers/gateway/routes/federation.js +++ b/servers/gateway/routes/federation.js @@ -242,7 +242,12 @@ export default function federationRouter({ createDbClient }) { try { const localId = getOrCreateLocalInstanceId(); const inst = await getInstance(db, localId); - const catalog = await getLocalCatalog(db, { instanceId: localId, instanceName: inst?.name || null }); + // N1b-8 review round 1 (S2): the caller is a VERIFIED peer (HMAC + bearer + // above), so mark a capability exposed when it is in the all-peer + // allowlist OR that peer holds an active one-device grant. Only this + // peer's grants are merged — another peer's allowance stays invisible. + const peerId = req.crossHostAuth?.sourceInstanceId || null; + const catalog = await getLocalCatalog(db, { instanceId: localId, instanceName: inst?.name || null, peerId }); res.type("application/json").send(JSON.stringify({ instance: { id: localId, name: inst?.name || null }, capabilities: { tools: catalog.tools, skills: catalog.skills, bots: catalog.bots }, diff --git a/servers/shared/fix-it/store.js b/servers/shared/fix-it/store.js index 49b91f268..7e5af5ad0 100644 --- a/servers/shared/fix-it/store.js +++ b/servers/shared/fix-it/store.js @@ -80,6 +80,38 @@ export async function upsertItem(db, item) { return { id, notify }; } +/** + * The items of one detector's source that the peer-exposure card's rate limits + * (N1b-8) need: every OPEN card (any age — the per-peer open-card cap counts + * them) plus anything touched in the last two days (the "one new card per peer + * per capability per 24 h" window). Older, answered cards cannot affect either + * decision, so they are not read — the scan stays bounded however long this + * instance runs. + */ +export async function listBySource(db, source) { + const { rows } = await db.execute({ + sql: `SELECT * FROM fix_it_items + WHERE source = ? + AND (status = 'pending' OR updated_at >= datetime('now', '-2 days')) + ORDER BY updated_at DESC, id DESC`, + args: [source], + }); + return rows.map(rowToItem); +} + +/** + * Record one more request on an EXISTING card without reopening it: bump `count` + * only. `updated_at` is deliberately left alone — the 24 h "one new card per peer + * per capability" window is measured from when the card was last created or + * answered, so a peer retrying in a loop cannot keep pushing its own window out. + */ +export async function recordDuplicate(db, id) { + await db.execute({ + sql: `UPDATE fix_it_items SET count = count + 1 WHERE id = ?`, + args: [id], + }); +} + export async function resolveByKey(db, source, dedupKey) { await db.execute({ sql: "UPDATE fix_it_items SET status = 'resolved', updated_at = datetime('now') WHERE source = ? AND dedup_key = ?", diff --git a/servers/shared/peer-id.js b/servers/shared/peer-id.js new file mode 100644 index 000000000..3df58d2f4 --- /dev/null +++ b/servers/shared/peer-id.js @@ -0,0 +1,67 @@ +/** + * Peer instance ids — the ONE identity a per-peer grant may be keyed by. + * + * N1b-8 (security audit): a one-click "Allow" card must never widen a + * capability to every peer. The grant is recorded against the peer that asked, + * and the peer's identity is the id the request was VERIFIED with + * (`req.instanceAuth.instance.id`, resolved from the bearer token hash by + * `validateInstanceToken`) — never a name, hostname or id a request body + * asserts about itself. + * + * The shape is the one enrollment already enforces on `source_instance_id` + * (servers/gateway/routes/instance-enroll.js, ID_RE): [A-Za-z0-9._:-], 1..128 + * characters. Anything else (an object, a display name with spaces, an empty + * string, a `__proto__`-style key) is not a peer id and must be refused rather + * than used as a JSON object key. + */ + +import { createHash } from "node:crypto"; + +const PEER_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/; + +/** + * @param {unknown} raw + * @returns {string|null} the trimmed id when it is id-shaped, else null. + */ +export function normalizePeerId(raw) { + if (typeof raw !== "string") return null; + const id = raw.trim(); + if (!PEER_ID_RE.test(id)) return null; + // The regex allows `_`, so `__proto__`-style names DO pass it: this guard is + // what keeps them from becoming JSON object keys. + if (id === "__proto__" || id === "prototype" || id === "constructor") return null; + return id; +} + +/** + * N1b-8 fix round 3: bind an Allow card to the PAIRING it was raised under. + * + * A revoke→re-pair reuses the instance id, so an id alone cannot tell "the peer + * that asked" from "a later peer wearing the same id". `crow_instances.auth_token_hash` + * can: it changes on every re-pair (NULL on revoke, a new hash on enroll/repair) + * and on every rotation. The card stores only this 16-hex tag — never the raw + * hash, which is a live credential — and the remedy compares it with the tag of + * the peer's CURRENT hash. A mismatch is a refusal (the message says the + * device's pairing has changed). + * + * Rotation also changes the hash, so rotating a peer's token invalidates the + * cards it left open. That fails closed: the peer asks again. + * + * @param {unknown} authTokenHash crow_instances.auth_token_hash (already a hash) + * @returns {string|null} 16 hex chars, or null when there is no hash to bind to + */ +export function pairingTag(authTokenHash) { + if (typeof authTokenHash !== "string") return null; + const hash = authTokenHash.trim(); + if (!hash) return null; + return createHash("sha256").update(`crow-peer-card-pairing-v1:${hash}`).digest("hex").slice(0, 16); +} + +/** + * The one accepted tag shape: what `pairingTag()` produces. Anything else + * (a missing field on a card written before this round, a hand-edited card, a + * value a payload made up) becomes null, which the remedy refuses. + */ +export function normalizePairingTag(raw) { + return typeof raw === "string" && /^[0-9a-f]{16}$/.test(raw) ? raw : null; +} \ No newline at end of file diff --git a/servers/sharing/revoke-peer.js b/servers/sharing/revoke-peer.js index 457e823c0..72146c664 100644 --- a/servers/sharing/revoke-peer.js +++ b/servers/sharing/revoke-peer.js @@ -15,6 +15,11 @@ * Skipped, and reported as `store_mismatch`, when the resolved store and * the open DB do not belong to the same instance; the owning gateway's * trust sweeper then deletes it (servers/gateway/peer-trust-sweeper.js). + * 2b. N1b-8 (review round 1, S3): drop every per-device allowance the peer + * held (`remote_exposed_tools_by_peer`), so a revoke→re-pair with the same + * id starts with no grants. Review round 2 (N-a): the peer's open + * peer-exposure "Allow" cards are answered at the same time — a card for a + * peer that is gone is not a question any more. * 3. PT-1: end the SSO sessions the peer minted (scopes * 'dashboard sso src:') and any untagged legacy SSO sessions. * 4. instanceSyncManager.teardownRevokedPeer(): close the peer's feeds and @@ -33,6 +38,8 @@ */ import { getInstance, revokeInstance } from "../gateway/instance-registry.js"; +import { removePeerExposures } from "../gateway/peer-exposure-store.js"; +import * as fixItStore from "../shared/fix-it/store.js"; import { createNotification } from "../shared/notifications.js"; import { deletePeerCreds, getPeerCreds, peerTokensPath } from "../shared/peer-credentials.js"; import { resolveInstanceDataDir, resolveCrowHome, sameDbLocation, isWithin } from "../shared/crow-home.js"; @@ -73,6 +80,109 @@ let _mgrResolverForTest = null; /** Test seam: resolve the sync manager for revokePeer calls that pass none. */ export function _setSyncManagerResolverForTest(fn) { _mgrResolverForTest = fn; } +/** + * N1b-8 review round 1 (S3): a revoked id must never come back with allowances + * attached — a revoke→re-pair reuses the instance id, so a grant left behind + * would silently re-open the capability to the re-paired peer. + * + * Best-effort: the row revoke is authoritative and must not be undone by a + * settings write, so a failure here is logged, never thrown. + */ +async function dropPeerExposureGrants(db, id) { + if (!id) return { removed: 0 }; + try { + const r = await removePeerExposures(db, { peerId: id }); + if (r && r.removed > 0) { + console.log(`[revoke-peer] dropped ${r.removed} one-device allowance(s) for ${String(id).slice(0, 12)}`); + } + return r || { removed: 0 }; + } catch (err) { + console.warn(`[revoke-peer] could not drop one-device allowances for ${String(id).slice(0, 12)}: ${err.message}`); + return { removed: 0 }; + } +} + +/** + * The Fix-it source of the peer-exposure "Allow" card. A literal on purpose: + * this module must not pull in the gateway's Fix-it wiring, and the N1b-8 test + * asserts it equals the detector's own `source`. + */ +export const PEER_EXPOSURE_CARD_SOURCE = "remote-exposure"; + +/** + * Read the peer-exposure cards a revoke/sweep needs to answer: the OPEN ones + * only (the read itself is `listBySource`, the detector's own window). + * + * Fix round 4: exported separately so the trust sweeper can list ONCE per sweep + * and hand the result to `resolvePeerExposureCards` for every revoked id, + * instead of re-reading the same cards once per revoked row (revoked rows are + * never deleted, so that was N reads every 10 s). + * + * @returns {Promise} pending items (never throws: [] on a read error) + */ +export async function listPeerExposureCards(db, { store = fixItStore } = {}) { + try { + const items = await store.listBySource(db, PEER_EXPOSURE_CARD_SOURCE); + return items.filter((item) => item && item.status === "pending"); + } catch (err) { + console.warn(`[revoke-peer] could not read the open peer-exposure card(s): ${err.message}`); + return []; + } +} + +/** + * N1b-8 review round 2 (N-a): a revoked peer's open "Allow" cards are ANSWERED + * by the revoke. Otherwise a card raised by the peer that asked stays in the + * Nest, and after a revoke→re-pair (which reuses the instance id) a click on it + * would grant the re-paired peer without a fresh request. + * + * `resolved`, not `dismissed`: `store.dismiss` writes a suppression window, + * which would also hide a legitimate NEW request from that id after a re-pair. + * Resolved says the question is answered. It is NOT silently reopened: a later + * denial from the same id inside the 24 h window is only COUNTED on this row + * (peer-card-policy.js returns `count`), so there is no card for up to a day; + * past the window the same (capability, peer) earns a fresh card. (The round-2 + * comment here said the upsert reopens it on a later denial — false: inside the + * window no upsert happens at all.) + * + * Fix round 3: exported so the trust sweeper can answer the cards of a peer + * revoked in ANOTHER process (servers/gateway/peer-trust-sweeper.js), the same + * way it already drops that peer's grants. + * + * Fix round 4: `opts.items` lets a caller that already listed the cards (the + * sweeper, once per sweep) reuse that read instead of repeating it per id. + * + * Best-effort, like the grant drop: the row revoke is authoritative and must not + * be undone by a fix_it_items write. + * + * @param {object} db + * @param {string} id the peer whose cards are answered + * @param {{items?: object[]|null, store?: object}} [opts] + * @returns {Promise<{resolved: number}>} + */ +export async function resolvePeerExposureCards(db, id, { items = null, store = fixItStore } = {}) { + if (!id) return { resolved: 0 }; + let resolved = 0; + try { + // listBySource returns every OPEN card (any age) plus anything touched in + // the last two days; only the open ones for THIS peer are ours. + const cards = items || await store.listBySource(db, PEER_EXPOSURE_CARD_SOURCE); + for (const item of cards) { + if (!item || item.status !== "pending") continue; + if (item.context?.requestingInstance !== id) continue; + await store.markResolved(db, item.id); + resolved += 1; + } + } catch (err) { + console.warn(`[revoke-peer] could not answer the peer-exposure card(s) for ${String(id).slice(0, 12)}: ${err.message}`); + return { resolved: 0 }; + } + if (resolved > 0) { + console.log(`[revoke-peer] answered ${resolved} open peer-exposure card(s) for ${String(id).slice(0, 12)}`); + } + return { resolved }; +} + async function defaultManager() { if (_mgrResolverForTest) return _mgrResolverForTest(); try { @@ -97,6 +207,8 @@ export async function revokePeer(db, id, { instanceSyncManager, localInstanceId // A key with no row cannot be revoked through the row; purge the key. if (id && (!localInstanceId || id !== localInstanceId) && getPeerCreds(id)) { const creds = deleteCredsGuarded(id); + await dropPeerExposureGrants(db, id); + await resolvePeerExposureCards(db, id); return { ok: creds.creds_deleted === true, orphan: true, ...creds, ...(creds.creds_deleted ? {} : { reason: creds.reason }) }; } return { ok: false, reason: "not_found" }; @@ -110,6 +222,11 @@ export async function revokePeer(db, id, { instanceSyncManager, localInstanceId // 2. The signing key. const creds = deleteCredsGuarded(id); + // 2b. N1b-8 (S3): every one-device allowance for this peer goes with it, and + // (round 2, N-a) so does its open "Allow" card. + await dropPeerExposureGrants(db, id); + await resolvePeerExposureCards(db, id); + // 3. SSO sessions the peer minted, plus legacy untagged ones (≤24 h). try { await db.execute({ diff --git a/tests/audio-proxy.test.js b/tests/audio-proxy.test.js index 7a3f324fd..731b7e9c2 100644 --- a/tests/audio-proxy.test.js +++ b/tests/audio-proxy.test.js @@ -6,12 +6,14 @@ import audioProxyRouter, { validateAudioParams } from "../servers/gateway/routes // Spin up a throwaway express app with the audio-proxy router mounted, plus a // header-driven req.instanceAuth shim (mirrors instanceAuthMiddleware). All deps // injected → fully offline, no prod services booted. -async function withApp({ exposed = new Set(["funkwhale"]), fw = { url: "http://fw", token: "T" }, fetchImpl } = {}, run) { +async function withApp({ exposed = new Set(["funkwhale"]), peerGrants = new Set(), fw = { url: "http://fw", token: "T" }, fetchImpl } = {}, run) { const app = express(); app.use((req, _res, next) => { if (req.headers["x-peer"]) req.instanceAuth = { instance: { id: req.headers["x-peer"] } }; next(); }); app.use(audioProxyRouter({ createDbClient: () => ({ close() {} }), getExposed: async () => exposed, + // N1b-8: a peer-scoped grant (from a Fix-it Allow card) must reach audio too. + isExposedToPeer: async (_db, capability, peerId) => capability === "funkwhale" && peerGrants.has(peerId), fwEnv: () => fw, fetchImpl: fetchImpl || (async () => ({ ok: true, status: 200, body: null, headers: new Map() })), })); @@ -43,6 +45,19 @@ test("GET /audio/stream: 403 when funkwhale not exposed", async () => { }); }); +test("GET /audio/stream: a per-peer grant lets THAT peer stream and nobody else (N1b-8)", async () => { + const bytes = Buffer.from("AUDIODATA"); + const fetchImpl = async () => ({ + ok: true, status: 200, + headers: new Map([["content-type", "audio/ogg"], ["content-length", String(bytes.length)]]), + body: { getReader() { let done = false; return { read: async () => done ? { done: true } : (done = true, { value: bytes, done: false }), cancel() {} }; } }, + }); + await withApp({ exposed: new Set(), peerGrants: new Set(["p1"]), fetchImpl }, async ({ get }) => { + assert.equal((await get(OK_QS, { "x-peer": "p1" })).status, 200, "the granted peer streams"); + assert.equal((await get(OK_QS, { "x-peer": "p2" })).status, 403, "a second peer is still refused"); + }); +}); + test("GET /audio/stream: 503 when funkwhale not configured", async () => { await withApp({ fw: null }, async ({ get }) => { assert.equal((await get(OK_QS, { "x-peer": "p1" })).status, 503); diff --git a/tests/capability-registry.test.js b/tests/capability-registry.test.js index 2ab7407cc..9429b8968 100644 --- a/tests/capability-registry.test.js +++ b/tests/capability-registry.test.js @@ -45,6 +45,57 @@ test("catalog reflects the exposure set on tool entries", async () => { assert.ok(others.every((t) => t.exposed === false), "non-exposed tools are exposed:false"); }); +// N1b-8 review round 1 (S2): `exposed` used to come from the all-peer list +// only, so a requesting peer's Bot Builder showed a granted tool as NOT +// exposed. Served to a verified peer, the catalog must mark a capability +// exposed when it is in the all-peer list OR that peer holds an active grant — +// and never show another peer's allowance. +test("catalog served to a verified peer marks its OWN grant exposed, and nobody else's", async () => { + const now = Date.now(); + const prior = new Date(now - 120_000).toISOString(); + const grants = { + "peer-a": { + "crow-blog": { expires_at: null, granted_at: prior }, // active, until removed + "crow-projects": { expires_at: new Date(now - 60_000).toISOString(), granted_at: prior }, // expired + }, + "peer-b": { "crow-storage": { expires_at: null, granted_at: prior } }, + }; + const stub = { + async execute({ sql, args }) { + const key = args?.[0]; + if (/dashboard_settings_overrides/.test(sql)) { + if (key === "remote_exposed_tools_by_peer") return { rows: [{ value: JSON.stringify(grants) }] }; + return { rows: [] }; + } + if (/FROM dashboard_settings\b/.test(sql)) { + if (key === "remote_exposed_tools") return { rows: [{ value: JSON.stringify(["crow-memory"]) }] }; + return { rows: [] }; + } + return { rows: [] }; // pi_bot_defs + }, + }; + const exposedFor = async (peerId) => { + const cat = await getLocalCatalog(stub, { crowHome: "/tmp/nonexistent-crowhome", instanceId: "self", peerId }); + return Object.fromEntries(cat.tools.map((t) => [t.canonicalId, t.exposed])); + }; + + const a = await exposedFor("peer-a"); + assert.equal(a["crow-memory"], true, "the all-peer list applies to every peer"); + assert.equal(a["crow-blog"], true, "peer-a's own one-device grant is exposed to peer-a"); + assert.equal(a["crow-projects"], false, "an expired grant is not exposed"); + assert.equal(a["crow-storage"], false, "peer-b's grant is invisible to peer-a"); + + const b = await exposedFor("peer-b"); + assert.equal(b["crow-memory"], true); + assert.equal(b["crow-storage"], true, "peer-b sees its own grant"); + assert.equal(b["crow-blog"], false, "peer-a's grant is invisible to peer-b"); + + // No verified peer (the local/settings view): the all-peer list only. + const local = await getLocalCatalog(stub, { crowHome: "/tmp/nonexistent-crowhome", instanceId: "self" }); + const exposedIds = local.tools.filter((t) => t.exposed).map((t) => t.canonicalId); + assert.deepEqual(exposedIds, ["crow-memory"], "no peer's grants leak into a peerless catalog"); +}); + // Skills panel option C, PR 2: peers are not advertised a skill this Crow // switched off (it would not load). test("catalog skills leave out a skill switched off on this instance", async () => { diff --git a/tests/fix-it-detector.test.js b/tests/fix-it-detector.test.js index bbaf07586..34d369e36 100644 --- a/tests/fix-it-detector.test.js +++ b/tests/fix-it-detector.test.js @@ -2,6 +2,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { resolveFriendlyName } from "../servers/gateway/fix-it/friendly-names.js"; import detector from "../servers/gateway/fix-it/detectors/remote-exposure.js"; +import { dedupKeyFor, legacyDedupKeyFor } from "../servers/gateway/fix-it/peer-card-policy.js"; +import { pairingTag } from "../servers/shared/peer-id.js"; test("friendly-name map: known id, addon fallback, raw fallback", () => { assert.equal(resolveFriendlyName("funkwhale"), "Music"); @@ -11,34 +13,153 @@ test("friendly-name map: known id, addon fallback, raw fallback", () => { assert.equal(resolveFriendlyName(null), "this feature"); }); -function fakeStore() { - const items = []; - return { items, upsertItem: async (i) => { items.push(i); return { id: items.length, notify: true }; }, resolveByKey: async () => {} }; +function fakeStore(items = []) { + const created = []; + const dupes = []; + const suppressed = []; + const resolved = []; + return { + items, created, dupes, suppressed, resolved, + listBySource: async () => items, + upsertItem: async (i) => { created.push(i); return { id: created.length, notify: true }; }, + recordDuplicate: async (id) => { dupes.push(id); }, + recordSuppressed: async (info) => { suppressed.push(info); }, + resolveByKey: async () => {}, + markResolved: async (id) => { resolved.push(id); }, + }; } -test("detector creates one card with friendly title + Allow remedy", async () => { +test("detector creates one card scoped to the requesting peer, with the three durations", async () => { const s = fakeStore(); await detector.onEvent("peer-exposure:denied", { capability: "funkwhale", requestingInstance: "peer-1", requestingInstanceName: "Glasses", toolName: "fw_play" }, s); - assert.equal(s.items.length, 1); - const it = s.items[0]; + assert.equal(s.created.length, 1); + const it = s.created[0]; assert.equal(it.source, "remote-exposure"); - assert.equal(it.dedupKey, "expose:funkwhale:peer-1"); + // N1 (review round 1): the separator is `|`, not `:` — both the capability and + // the peer id may legitimately contain a colon (peer ids: [A-Za-z0-9._:-]). + assert.equal(it.dedupKey, dedupKeyFor("peer-1", "funkwhale")); + assert.equal(it.dedupKey, "expose|funkwhale|peer-1"); + assert.notEqual(it.dedupKey, legacyDedupKeyFor("peer-1", "funkwhale")); assert.match(it.title, /Glasses/); assert.match(it.title, /Music/); assert.equal(it.severity, "warn"); - assert.deepEqual(it.remedies, [{ label: "Allow", actionId: "expose-capability", args: { capability: "funkwhale" }, kind: "instant" }]); + // N1b-8: no more one-click "expose to everyone". Every remedy names the ONE + // peer (its verified id) and a duration; 30 days is the default (first), and + // each duration has its own actionId so the button the operator clicks is the + // duration that is granted (the POST carries only action_id). + assert.deepEqual(it.remedies.map((r) => r.label), [ + "Allow for 30 days", "Allow for 7 days", "Allow until I remove it", + ]); + assert.deepEqual(it.remedies.map((r) => r.actionId), [ + "expose-capability-30d", "expose-capability-7d", "expose-capability-forever", + ]); + assert.ok(it.remedies.every((r) => r.kind === "instant")); + assert.ok(it.remedies.every((r) => r.args.capability === "funkwhale" && r.args.requestingInstance === "peer-1")); + assert.deepEqual(it.remedies.map((r) => r.args.days), [30, 7, undefined]); + assert.equal(it.remedies[2].args.untilRemoved, true); assert.equal(it.context.toolName, "fw_play"); + assert.equal(it.context.requestingInstance, "peer-1"); + assert.equal(it.context.pairingTag, null, "no tag from the gate (here, none was supplied) is stored as null, never invented"); +}); + +// Fix round 3: the card carries the tag of the pairing the denial was verified +// under, and a pending card from ANOTHER pairing is stale — it can never be +// allowed (the remedy compares tags), so it must be answered instead of +// swallowing the new request. +test("a card records the gate's pairing tag; a malformed one is stored as null", async () => { + const tag = pairingTag("token-hash-a"); + let s = fakeStore(); + await detector.onEvent("peer-exposure:denied", { capability: "funkwhale", requestingInstance: "peer-1", pairingTag: tag }, s); + assert.equal(s.created[0].context.pairingTag, tag); + assert.match(s.created[0].context.pairingTag, /^[0-9a-f]{16}$/); + assert.doesNotMatch(JSON.stringify(s.created[0].context), /token-hash-a/, "never the raw hash"); + + for (const bad of ["", "not-a-tag", "ABCDEF0123456789", 42, { tag }]) { + s = fakeStore(); + await detector.onEvent("peer-exposure:denied", { capability: "funkwhale", requestingInstance: "peer-1", pairingTag: bad }, s); + assert.equal(s.created[0].context.pairingTag, null, `bad tag ${JSON.stringify(bad)} is not stored`); + } +}); + +test("a pending card from another pairing is resolved, and the request creates a new card", async () => { + const stale = { + id: 21, dedupKey: dedupKeyFor("peer-1", "funkwhale"), status: "pending", count: 1, + context: { capability: "funkwhale", requestingInstance: "peer-1", pairingTag: pairingTag("old-token-hash") }, + createdAt: "2026-01-01 00:00:00", updatedAt: "2026-01-01 00:00:00", + }; + const s = fakeStore([stale]); + const fresh = pairingTag("new-token-hash"); + await detector.onEvent("peer-exposure:denied", + { capability: "funkwhale", requestingInstance: "peer-1", pairingTag: fresh }, s); + + assert.deepEqual(s.resolved, [21], "the stale card is answered, not bumped"); + assert.deepEqual(s.dupes, [], "and the request is not deduped onto it"); + assert.equal(s.created.length, 1, "a new card is created for the current pairing"); + assert.equal(s.created[0].context.pairingTag, fresh); }); test("detector ignores a null-capability denial", async () => { const s = fakeStore(); await detector.onEvent("peer-exposure:denied", { capability: null, requestingInstance: "peer-1" }, s); - assert.equal(s.items.length, 0); + assert.equal(s.created.length, 0); }); test("detector falls back to 'another device' when peer name absent", async () => { const s = fakeStore(); await detector.onEvent("peer-exposure:denied", { capability: "funkwhale", requestingInstance: "peer-1" }, s); - assert.match(s.items[0].title, /another device/); + assert.match(s.created[0].title, /another device/); +}); + +test("detector refuses a card for an identity that is not a verified peer id", async () => { + for (const requestingInstance of [undefined, null, "", "My Glasses", "../../etc", "a b", { id: "peer-1" }, 42]) { + const s = fakeStore(); + await detector.onEvent("peer-exposure:denied", { capability: "funkwhale", requestingInstance }, s); + assert.equal(s.created.length, 0, `no card for ${JSON.stringify(requestingInstance)}`); + } }); + +// B2 (review round 1): the gate could not match the peer's tool name to a +// capability of this instance. No card, and ONE bounded counter bucket. +test("detector counts an unknown capability in one bucket and raises no card", async () => { + const s = fakeStore(); + const p = { requestingInstance: "peer-junk", capabilityKnown: false, toolName: "crow_junk1" }; + await detector.onEvent("peer-exposure:denied", { ...p, capability: "crow-junk1" }, s); + await detector.onEvent("peer-exposure:denied", { ...p, capability: "crow-junk2" }, s); + await detector.onEvent("peer-exposure:denied", { ...p, capability: null }, s); + assert.equal(s.created.length, 0, "an unknown capability never makes a card"); + assert.deepEqual(s.suppressed.map((i) => i.capability), ["unknown", "unknown", "unknown"], + "every unknown name lands in the one bucket, never under the peer's string"); + assert.ok(s.suppressed.every((i) => i.reason === "unknown_capability")); +}); + +// SF1 (review round 2): the policy finds the existing card under EITHER key +// shape, but the detector used to ignore a `dedupe` decision and fall through to +// `upsertItem` — which writes the NEW key and therefore inserted a SECOND open +// card for the same (capability, peer). +test("SF1: a repeat denial for a card opened under the OLD key bumps THAT card instead of inserting a duplicate", async () => { + const legacy = { + id: 7, dedupKey: legacyDedupKeyFor("peer-1", "funkwhale"), status: "pending", count: 1, + context: { capability: "funkwhale", requestingInstance: "peer-1" }, + createdAt: "2026-01-01 00:00:00", updatedAt: "2026-01-01 00:00:00", + }; + const s = fakeStore([legacy]); + await detector.onEvent("peer-exposure:denied", + { capability: "funkwhale", requestingInstance: "peer-1", toolName: "fw_play" }, s); + assert.equal(s.created.length, 0, "no upsert at all: an upsert here writes the new key and inserts a second card"); + assert.deepEqual(s.dupes, [7], "the repeat is recorded on the card that already exists"); + assert.deepEqual(s.suppressed, [], "a deduped request is not a suppressed one"); +}); + +test("SF1: a repeat denial for a card under the NEW key is also deduped onto that card", async () => { + const current = { + id: 11, dedupKey: dedupKeyFor("peer-1", "funkwhale"), status: "pending", count: 2, + context: { capability: "funkwhale", requestingInstance: "peer-1" }, + createdAt: "2026-01-01 00:00:00", updatedAt: "2026-01-01 00:00:00", + }; + const s = fakeStore([current]); + await detector.onEvent("peer-exposure:denied", + { capability: "funkwhale", requestingInstance: "peer-1" }, s); + assert.equal(s.created.length, 0); + assert.deepEqual(s.dupes, [11]); +}); \ No newline at end of file diff --git a/tests/fix-it-e2e.test.js b/tests/fix-it-e2e.test.js index ba1d2c779..210a0013f 100644 --- a/tests/fix-it-e2e.test.js +++ b/tests/fix-it-e2e.test.js @@ -5,8 +5,9 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createDbClient } from "../servers/db.js"; -import { enforcePeerExposure, getExposedCapabilities } from "../servers/gateway/peer-exposure.js"; +import { enforcePeerExposure, getExposedCapabilities, getPeerGrantedCapabilities } from "../servers/gateway/peer-exposure.js"; import * as store from "../servers/shared/fix-it/store.js"; +import { pairingTag } from "../servers/shared/peer-id.js"; let dir, db, idx; @@ -42,9 +43,23 @@ async function waitForPending(predicate, deadlineMs = 2000) { } } -test("denied call → card appears → remedy → next check allowed", async () => { +test("denied call → card appears → remedy → next check allowed for THAT peer only", async () => { + // Review round 2 (N-a): a card can only be allowed for a peer that is + // currently trusted here, and in production the card exists because that peer + // authenticated — so the fixture gives it a trusted row. Fix round 3: the row + // carries the bearer hash, and the card is bound to the tag of that pairing. + // Fix round 4: `instanceAuth.instance` carries that same hash, because that is + // what `validateInstanceToken` returns (it matched the bearer on the hash) and + // it is where the gate reads the pairing tag from — never a second row read. + await db.execute({ + sql: "INSERT OR REPLACE INTO crow_instances (id, name, crow_id, status, trusted, auth_token_hash) VALUES (?, ?, ?, 'active', 1, ?)", + args: ["peer-9", "Peer peer-9", "crow-test", "test-token-hash-peer-9"], + }); const connected = new Map([["funkwhale", { status: "connected", tools: [{ name: "fw_play" }] }]]); - const req = { instanceAuth: { instance: { id: "peer-9" } }, body: { method: "tools/call", params: { name: "fw_play" }, id: 1 } }; + const req = { + instanceAuth: { instance: { id: "peer-9", auth_token_hash: "test-token-hash-peer-9" } }, + body: { method: "tools/call", params: { name: "fw_play" }, id: 1 }, + }; // 1. Denied (nothing exposed) — uses the real emitFixIt (default emitFn). const denied = await enforcePeerExposure({ prefix: "tools", req, res: mkRes(), db, connectedServers: connected, auditFn: async () => {} }); @@ -53,14 +68,27 @@ test("denied call → card appears → remedy → next check allowed", async () // 2. The emit is fire-and-forget; poll deterministically until the card lands. const pending = await waitForPending((p) => p.length === 1); assert.equal(pending[0].context.capability, "funkwhale"); + assert.equal(pending[0].context.requestingInstance, "peer-9", "the card is scoped to the verified peer"); + assert.equal(pending[0].context.pairingTag, pairingTag("test-token-hash-peer-9"), + "and bound to the pairing the gate verified (fix round 3)"); // 3. Run the remedy via the action handler. const res2 = { redirectAfterPost: () => {} }; - await idx.handleFixItAction({ body: { action: "remedy", item_id: String(pending[0].id), action_id: "expose-capability" } }, res2, { db }); - assert.ok((await getExposedCapabilities(db)).has("funkwhale")); + await idx.handleFixItAction({ body: { action: "remedy", item_id: String(pending[0].id), action_id: "expose-capability-30d" } }, res2, { db }); + // N1b-8: the grant is per-peer, NOT an all-peer exposure. + assert.ok((await getPeerGrantedCapabilities(db, "peer-9")).has("funkwhale")); + assert.ok(!(await getExposedCapabilities(db)).has("funkwhale"), "one peer's Allow must not expose it to every peer"); assert.equal((await store.listPending(db)).length, 0); // card cleared - // 4. The next enforcement check for the same capability is now allowed. + // 4. The next enforcement check for the same capability is now allowed — for + // the peer that asked… const allowed = await enforcePeerExposure({ prefix: "tools", req, res: mkRes(), db, connectedServers: connected, auditFn: async () => {} }); assert.equal(allowed, true); + + // …and still refused for a different peer. + const other = { instanceAuth: { instance: { id: "peer-10", auth_token_hash: "test-token-hash-peer-10" } }, body: req.body }; + const otherRes = mkRes(); + const refused = await enforcePeerExposure({ prefix: "tools", req: other, res: otherRes, db, connectedServers: connected, auditFn: async () => {}, emitFn: async () => {} }); + assert.equal(refused, false); + assert.equal(otherRes._status, 403); }); diff --git a/tests/fix-it-index.test.js b/tests/fix-it-index.test.js index 19e6bdf1d..304b51903 100644 --- a/tests/fix-it-index.test.js +++ b/tests/fix-it-index.test.js @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { createDbClient } from "../servers/db.js"; import * as store from "../servers/shared/fix-it/store.js"; +import { pairingTag } from "../servers/shared/peer-id.js"; let dir, db, idx; @@ -21,6 +22,24 @@ before(async () => { after(() => { try { db.close(); } catch {} try { rmSync(dir, { recursive: true, force: true }); } catch {} }); beforeEach(async () => { await db.execute("DELETE FROM fix_it_items"); }); +/** + * Give `id` a crow_instances row that passes the peer-trust predicate. Review + * round 2 (N-a): the Allow remedy refuses a peer that is not trusted here, and in + * production only a peer that authenticated (i.e. has such a row) can raise a card. + * + * Fix round 3: the row carries the bearer hash, and a card is bound to the tag of + * the pairing that raised it — so the emit below publishes the tag the gate would. + */ +async function trustPeer(id) { + await db.execute({ + sql: "INSERT OR REPLACE INTO crow_instances (id, name, crow_id, status, trusted, auth_token_hash) VALUES (?, ?, ?, 'active', 1, ?)", + args: [id, `Peer ${id}`, "crow-test", `test-token-hash-${id}`], + }); +} + +/** The pairing tag the gate computes for a peer trusted by `trustPeer`. */ +const tagFor = (id) => pairingTag(`test-token-hash-${id}`); + test("emitFixIt turns a denial into one pending card (never throws)", async () => { await idx.emitFixIt(db, "peer-exposure:denied", { capability: "funkwhale", requestingInstance: "peer-x", toolName: "fw_play" }); const pending = await store.listPending(db); @@ -44,16 +63,22 @@ test("renderFixItCards renders a card with a CSRF field, or empty when none", as }); test("handleFixItAction runs the remedy and resolves the card", async () => { - await idx.emitFixIt(db, "peer-exposure:denied", { capability: "funkwhale", requestingInstance: "peer-x" }); + await trustPeer("peer-x"); + await idx.emitFixIt(db, "peer-exposure:denied", { + capability: "funkwhale", requestingInstance: "peer-x", pairingTag: tagFor("peer-x"), + }); const item = (await store.listPending(db))[0]; let redirected = null; - const req = { body: { action: "remedy", item_id: String(item.id), action_id: "expose-capability" } }; + const req = { body: { action: "remedy", item_id: String(item.id), action_id: "expose-capability-30d" } }; const res = { redirectAfterPost: (u) => { redirected = u; } }; await idx.handleFixItAction(req, res, { db }); assert.match(redirected, /flash=/); assert.equal((await store.getItem(db, item.id)).status, "resolved"); - const { getExposedCapabilities } = await import("../servers/gateway/peer-exposure.js"); - assert.ok((await getExposedCapabilities(db)).has("funkwhale")); + // N1b-8: the click grants the capability to the peer ON THE CARD, and does + // not add it to the all-trusted-peers allowlist. + const { getExposedCapabilities, getPeerGrantedCapabilities } = await import("../servers/gateway/peer-exposure.js"); + assert.ok((await getPeerGrantedCapabilities(db, "peer-x")).has("funkwhale")); + assert.ok(!(await getExposedCapabilities(db)).has("funkwhale")); }); test("handleFixItAction dismiss suppresses the card", async () => { @@ -63,3 +88,36 @@ test("handleFixItAction dismiss suppresses the card", async () => { await idx.handleFixItAction({ body: { action: "dismiss", item_id: String(item.id) } }, res, { db }); assert.equal((await store.listPending(db)).length, 0); }); + +// N1b-8 review round 1 (N2): a remedy must not run for a card that is no longer +// pending. Before this guard the handler trusted the POST: a stale page, a +// double submit or a replayed form re-ran the remedy (re-granting an allowance +// the operator had already answered, or granting one for a card they dismissed). +test("handleFixItAction refuses a remedy on a card that is no longer pending", async () => { + await idx.emitFixIt(db, "peer-exposure:denied", { capability: "funkwhale", requestingInstance: "peer-stale" }); + const item = (await store.listPending(db))[0]; + const req = { body: { action: "remedy", item_id: String(item.id), action_id: "expose-capability-30d" } }; + + // 1. Already resolved (someone clicked it, or the fix landed elsewhere). + await store.markResolved(db, item.id); + let redirected = null; + await idx.handleFixItAction(req, { redirectAfterPost: (u) => { redirected = u; } }, { db }); + assert.match(redirected, /flash=fixit_not_pending/, "the refusal says the card is not open"); + assert.equal((await store.getItem(db, item.id)).status, "resolved", "the card state is unchanged"); + + const { getPeerGrantedCapabilities, getExposedCapabilities } = await import("../servers/gateway/peer-exposure.js"); + assert.equal((await getPeerGrantedCapabilities(db, "peer-stale")).size, 0, "nothing was granted"); + assert.ok(!(await getExposedCapabilities(db)).has("funkwhale")); + + // 2. Dismissed is not pending either. + await idx.emitFixIt(db, "peer-exposure:denied", { capability: "crow-blog", capabilityKnown: true, requestingInstance: "peer-stale-2" }); + const dismissed = (await store.listPending(db))[0]; + await store.dismiss(db, dismissed.id, 7); + redirected = null; + await idx.handleFixItAction( + { body: { action: "remedy", item_id: String(dismissed.id), action_id: "expose-capability-30d" } }, + { redirectAfterPost: (u) => { redirected = u; } }, { db }, + ); + assert.match(redirected, /flash=fixit_not_pending/); + assert.equal((await getPeerGrantedCapabilities(db, "peer-stale-2")).size, 0, "a dismissed card grants nothing"); +}); diff --git a/tests/fix-it-remedy.test.js b/tests/fix-it-remedy.test.js index 0383f88ea..524db62a6 100644 --- a/tests/fix-it-remedy.test.js +++ b/tests/fix-it-remedy.test.js @@ -5,10 +5,18 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createDbClient } from "../servers/db.js"; -import { getExposedCapabilities } from "../servers/gateway/peer-exposure.js"; +import { + getExposedCapabilities, + getPeerGrantedCapabilities, + getPeerExposures, + isCapabilityExposedToPeer, +} from "../servers/gateway/peer-exposure.js"; +import { pairingTag } from "../servers/shared/peer-id.js"; let dir, db, remedy; +const DAY = 24 * 60 * 60 * 1000; + before(async () => { dir = mkdtempSync(join(tmpdir(), "fixit-remedy-")); execFileSync(process.execPath, ["scripts/init-db.js"], { @@ -21,24 +29,113 @@ before(async () => { after(() => { try { db.close(); } catch {} try { rmSync(dir, { recursive: true, force: true }); } catch {} }); -test("expose-capability adds the capability (idempotent), marks resolved", async () => { - const r1 = await remedy({ capability: "funkwhale" }, { db }); +/** + * A card's peer must still be a trusted peer for the Allow remedy (review round + * 2, N-a); in production only a peer that authenticated raises a card, so the + * tests that expect a grant give it a row. + * + * Fix round 3: the row also carries the bearer hash the card is bound to (the + * remedy compares `context.pairingTag` with the tag of the peer's CURRENT hash), + * and `cardFor` builds the card context the detector writes. + */ +async function trustPeer(...ids) { + for (const id of ids) { + await db.execute({ + sql: "INSERT OR REPLACE INTO crow_instances (id, name, crow_id, status, trusted, auth_token_hash) VALUES (?, ?, ?, 'active', 1, ?)", + args: [id, `Peer ${id}`, "crow-test", tokenHashFor(id)], + }); + } +} + +const tokenHashFor = (id) => `test-token-hash-${id}`; + +/** The stored card a denial from `id` under its current pairing produces. */ +const cardFor = (id) => ({ context: { requestingInstance: id, pairingTag: pairingTag(tokenHashFor(id)) } }); + +test("expose-capability grants the capability to the requesting peer ONLY (idempotent), marks resolved", async () => { + await trustPeer("peer-a"); + const r1 = await remedy({ capability: "funkwhale", requestingInstance: "peer-a", days: 30 }, { db, item: cardFor("peer-a") }); assert.equal(r1.resolved, true); - let exposed = await getExposedCapabilities(db); - assert.ok(exposed.has("funkwhale")); - const r2 = await remedy({ capability: "funkwhale" }, { db }); // idempotent + const granted = await getPeerGrantedCapabilities(db, "peer-a"); + assert.ok(granted.has("funkwhale")); + // Idempotent: a second identical click re-grants, it does not duplicate. + const r2 = await remedy({ capability: "funkwhale", requestingInstance: "peer-a", days: 30 }, { db, item: cardFor("peer-a") }); assert.equal(r2.resolved, true); - exposed = await getExposedCapabilities(db); - assert.equal([...exposed].filter((x) => x === "funkwhale").length, 1); + assert.deepEqual([...((await getPeerExposures(db)).get("peer-a").keys())], ["funkwhale"]); + // N1b-8: the all-trusted-peers allowlist is never touched by a card. + assert.ok(!(await getExposedCapabilities(db)).has("funkwhale"), "a card must not expose the capability to every peer"); + // …and no other peer is granted by it. + assert.equal(await isCapabilityExposedToPeer(db, "funkwhale", "peer-b"), false); }); -test("blank capability → not resolved", async () => { - const r = await remedy({ capability: "" }, { db }); - assert.equal(r.resolved, false); +test("no verified requesting peer → refused, and NOTHING is exposed to every peer", async () => { + const before = await getExposedCapabilities(db); + for (const requestingInstance of [undefined, null, "", "Some Device", { id: "peer-a" }]) { + const r = await remedy({ capability: "crow-memory", requestingInstance }, { db }); + assert.equal(r.resolved, false, `refused for ${JSON.stringify(requestingInstance)}`); + } + const after = await getExposedCapabilities(db); + assert.deepEqual([...after].sort(), [...before].sort(), "the all-peer allowlist is unchanged"); + assert.ok(!after.has("crow-memory"), "fail closed: never widened to all peers"); +}); + +test("a blank capability or a peer that is not trusted here → refused, nothing written", async () => { + const blank = await remedy({ capability: "", requestingInstance: "peer-a" }, { db }); + assert.equal(blank.resolved, false); + + // Review round 2 (N-a): a peer whose row fails the one trust predicate + // (`trusted = 1 AND status IN ('active','offline')`) is refused, with a reason. + await db.execute({ + sql: "INSERT OR REPLACE INTO crow_instances (id, name, crow_id, status, trusted) VALUES (?, ?, ?, 'revoked', 1)", + args: ["peer-revoked", "Revoked Peer", "crow-test"], + }); + const revoked = await remedy({ capability: "crow-memory", requestingInstance: "peer-revoked", days: 30 }, { db }); + assert.equal(revoked.resolved, false); + assert.match(revoked.message, /trusted peer/); + assert.equal(await isCapabilityExposedToPeer(db, "crow-memory", "peer-revoked"), false); + assert.equal((await getPeerExposures(db)).has("peer-revoked"), false); }); test("a federation peer's proxy key (instance-) is never exposed (queue item 38)", async () => { - const r = await remedy({ capability: "instance-p2" }, { db }); + const r = await remedy({ capability: "instance-p2", requestingInstance: "peer-a" }, { db }); assert.equal(r.resolved, false); assert.ok(!(await getExposedCapabilities(db)).has("instance-p2")); + assert.equal(await isCapabilityExposedToPeer(db, "instance-p2", "peer-a"), false); +}); + +test("duration: 7 days expires, untilRemoved never does; the grant records expires_at", async () => { + await trustPeer("peer-7"); + const now = Date.now(); + await remedy({ capability: "crow-blog", requestingInstance: "peer-7", days: 7 }, { db, item: cardFor("peer-7") }); + const grant = (await getPeerExposures(db)).get("peer-7").get("crow-blog"); + assert.ok(grant.expiresAt && Date.parse(grant.expiresAt) > now + 6 * DAY); + assert.equal(await isCapabilityExposedToPeer(db, "crow-blog", "peer-7", { now: now + 6 * DAY }), true); + assert.equal(await isCapabilityExposedToPeer(db, "crow-blog", "peer-7", { now: now + 8 * DAY }), false); + + await remedy({ capability: "crow-storage", requestingInstance: "peer-7", untilRemoved: true }, { db, item: cardFor("peer-7") }); + const forever = (await getPeerExposures(db)).get("peer-7").get("crow-storage"); + assert.equal(forever.expiresAt, null, "until I remove it stores no expiry"); + assert.equal(await isCapabilityExposedToPeer(db, "crow-storage", "peer-7", { now: now + 3650 * DAY }), true); }); + +// Fix round 3: a card is bound to the PAIRING that raised it. A card with no tag +// (written before this round) or one from a different pairing refuses, and a +// refusal never widens: these all used to grant the peer. +test("a card from another pairing (or with no tag at all) refuses, and grants nothing", async () => { + await trustPeer("peer-tag"); + const other = { context: { requestingInstance: "peer-tag", pairingTag: pairingTag("some-other-pairing") } }; + const mismatched = await remedy({ capability: "funkwhale", requestingInstance: "peer-tag", days: 30 }, { db, item: other }); + assert.equal(mismatched.resolved, false, "a card from a previous pairing is refused"); + assert.match(mismatched.message, /pairing has changed/); + + const untagged = await remedy({ capability: "funkwhale", requestingInstance: "peer-tag", days: 30 }, { db, item: { context: { requestingInstance: "peer-tag" } } }); + assert.equal(untagged.resolved, false, "a card written before tags existed is refused"); + assert.match(untagged.message, /pairing has changed/); + + const noCard = await remedy({ capability: "funkwhale", requestingInstance: "peer-tag", days: 30 }, { db }); + assert.equal(noCard.resolved, false, "no card context at all is refused too"); + assert.match(noCard.message, /pairing has changed/); + + assert.equal((await getPeerGrantedCapabilities(db, "peer-tag")).size, 0, "none of the refusals wrote an allowance"); + assert.equal((await getExposedCapabilities(db)).has("funkwhale"), false, "and none widened it to every peer"); +}); \ No newline at end of file diff --git a/tests/peer-exposure-scoped.test.js b/tests/peer-exposure-scoped.test.js new file mode 100644 index 000000000..2f0c26f66 --- /dev/null +++ b/tests/peer-exposure-scoped.test.js @@ -0,0 +1,1152 @@ +/** + * N1b-8 (security audit): one peer's request must not, with one click, expose a + * capability to every peer forever. + * + * What these tests pin down, against the PRE-FIX code: + * - before: the "Allow" card added the capability to `remote_exposed_tools` + * (all trusted peers) and nothing was timed, so a second peer was allowed + * by the same click and the exposure never expired; + * - before: the card had ONE remedy with no peer and no duration + * (`args: {capability}`), so there was no way to scope or expire a grant; + * - before: every denial became a card (no per-peer cap, no 24 h window) and + * a resolved card was simply reopened, so a peer could flood the Nest; + * - before: the card carried whatever `requestingInstance` the event payload + * had, and the payload was built from `req.instanceAuth` — correct, but + * nothing recorded the peer as the identity the grant is keyed by, so the + * sentence "this card is for that verified id" was untestable. + */ +import { test, before, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createDbClient } from "../servers/db.js"; +import { + enforcePeerExposure, + getExposedCapabilities, + getPeerExposures, + getPeerGrantedCapabilities, + isCapabilityExposedToPeer, + grantPeerCapability, +} from "../servers/gateway/peer-exposure.js"; +import { writeSetting } from "../servers/gateway/dashboard/settings/registry.js"; +import * as store from "../servers/shared/fix-it/store.js"; +import { + decidePeerExposureCard, + dedupKeyFor, + legacyDedupKeyFor, + MAX_OPEN_CARDS_PER_PEER, + NEW_CARD_WINDOW_MS, +} from "../servers/gateway/fix-it/peer-card-policy.js"; +import { + getSuppressedRequests, + recordSuppressedRequest, + SUPPRESSED_COUNTER_MAX_ENTRIES, + SUPPRESSED_COUNTER_WINDOW_MS, +} from "../servers/gateway/fix-it/peer-card-counters.js"; +import { resolveCalledCanonicalId } from "../servers/gateway/peer-exposure.js"; +import { getLocalCapabilityIds } from "../servers/gateway/capability-ids.js"; +import { pairingTag } from "../servers/shared/peer-id.js"; + +let dir, db, idx, remedy, section; + +const DAY = 24 * 60 * 60 * 1000; +const CONNECTED = new Map([["funkwhale", { status: "connected", tools: [{ name: "fw_play" }] }]]); +const CALL = { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "fw_play" } }; + +before(async () => { + dir = mkdtempSync(join(tmpdir(), "peer-exposure-scoped-")); + execFileSync(process.execPath, ["scripts/init-db.js"], { + env: { ...process.env, CROW_DATA_DIR: dir }, stdio: "pipe", cwd: join(import.meta.dirname, ".."), + }); + process.env.CROW_DATA_DIR = dir; + db = createDbClient(); + idx = await import("../servers/gateway/fix-it/index.js"); + remedy = (await import("../servers/gateway/fix-it/remedies/expose-capability.js")).default; + section = (await import("../servers/gateway/dashboard/settings/sections/remote-exposure.js")).default; +}); +after(() => { try { db.close(); } catch {} try { rmSync(dir, { recursive: true, force: true }); } catch {} }); + +/** Every piece of state these tests touch, back to a clean slate. */ +async function reset() { + await db.execute("DELETE FROM fix_it_items"); + for (const key of [ + "remote_exposed_tools", + "remote_exposed_tools_by_peer", + "peer_exposure_card_suppressed", + ]) { + await db.execute({ sql: "DELETE FROM dashboard_settings WHERE key = ?", args: [key] }); + await db.execute({ sql: "DELETE FROM dashboard_settings_overrides WHERE key = ?", args: [key] }); + } +} +beforeEach(reset); + +function mkRes() { + return { + _status: null, _json: null, headersSent: false, + status(c) { this._status = c; return this; }, + json(o) { this._json = o; this.headersSent = true; return this; }, + }; +} + +/** + * A peer request as the gate sees it in production: `instanceAuth.instance` is + * the row `validateInstanceToken` returned — which matched on `auth_token_hash`, + * so the VERIFIED instance always carries that hash (fix round 4: the gate reads + * the pairing tag from it and nowhere else). + */ +const peerReq = (peerId, body = CALL, extra = {}) => ({ + ...extra, + instanceAuth: { instance: { id: peerId, auth_token_hash: tokenHashFor(peerId) } }, + body, +}); + +/** The bearer hash this instance would have stored for `id` (any stable string). */ +const tokenHashFor = (id) => `test-token-hash-${id}`; + +/** The pairing tag the gate computes from that row's hash. */ +const tagFor = (id) => pairingTag(tokenHashFor(id)); + +/** The stored card a denial from `id` under its current pairing produces. */ +const cardFor = (id) => ({ context: { requestingInstance: id, pairingTag: tagFor(id) } }); + +/** + * Give `ids` a crow_instances row that passes the one trust predicate + * (`trusted = 1 AND status IN ('active','offline')`). Review round 2 (N-a): the + * Allow remedy refuses a peer that is not trusted HERE, and in production a card + * can only be raised by a peer that authenticated — i.e. one that has such a + * row. Tests that expect a click to grant must say so. + * + * Fix round 3: the row carries the bearer HASH too, because a card is bound to + * the pairing it was raised under (`context.pairingTag`). In production that + * hash is what `validateInstanceToken` matched the request against; here it is + * a deterministic stand-in, and `emitDenied` publishes its tag the way the gate + * does. + */ +async function trustPeer(...ids) { + for (const id of ids) { + await db.execute({ + sql: "INSERT OR REPLACE INTO crow_instances (id, name, crow_id, status, trusted, auth_token_hash) VALUES (?, ?, ?, 'active', 1, ?)", + args: [id, `Peer ${id}`, "crow-test", tokenHashFor(id)], + }); + } +} + +/** + * Publish a denial exactly as the gate does — the verified id AND the tag of the + * pairing it was verified under. A card raised without a tag can never be + * allowed (fix round 3), which is why every click-the-card fixture goes through + * here. + */ +async function emitDenied(peerId, capability, extra = {}) { + await idx.emitFixIt(db, "peer-exposure:denied", { + capability, requestingInstance: peerId, pairingTag: tagFor(peerId), ...extra, + }); +} + +/** Run the real gate (no exposure overrides) for one peer. */ +async function gate(peerId, { now, body = CALL, emit = false } = {}) { + const res = mkRes(); + const ok = await enforcePeerExposure({ + prefix: "tools", + req: peerReq(peerId, body), + res, db, connectedServers: CONNECTED, + now, + auditFn: async () => {}, + // Card emission is off unless a test wants the real detector (it is + // fire-and-forget and would race the assertions). + emitFn: emit ? undefined : async () => {}, + }); + return { ok, res }; +} + +/** + * Run the real gate on the `/router` mount for a PEER-CHOSEN tool name — the + * attack surface of B2. `emitFn` lets a test collect the (fire-and-forget) + * emit promises so it can await the detector rather than sleep. + */ +async function gateRouter(peerId, toolName, { emitFn } = {}) { + const res = mkRes(); + const ok = await enforcePeerExposure({ + prefix: "router", + req: peerReq(peerId, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: toolName } }), + res, db, connectedServers: CONNECTED, + auditFn: async () => {}, + emitFn: emitFn || (async () => {}), + }); + return { ok, res }; +} + +/** Collect the real emitFixIt promises the gate fires without awaiting. */ +function emitCollector() { + const pending = []; + pending.fn = (d, ev, payload) => { + const p = idx.emitFixIt(d, ev, payload); + pending.push(p); + return p; + }; + pending.settle = () => Promise.all(pending); + return pending; +} + +async function waitForPending(predicate, deadlineMs = 2000) { + const start = Date.now(); + for (;;) { + const pending = await store.listPending(db); + if (predicate(pending)) return pending; + if (Date.now() - start > deadlineMs) { + assert.fail(`card did not materialize within ${deadlineMs}ms (saw ${pending.length})`); + } + await new Promise((r) => setTimeout(r, 10)); + } +} + +async function clickAllow(card, actionId = "expose-capability-30d") { + let redirected = null; + await idx.handleFixItAction( + { body: { action: "remedy", item_id: String(card.id), action_id: actionId } }, + { redirectAfterPost: (u) => { redirected = u; } }, + { db }, + ); + return redirected; +} + +// --------------------------------------------------------------------------- +// 1. Allow grants only the requesting peer +// --------------------------------------------------------------------------- + +test("Allow grants only the requesting peer: a second peer is still refused", async () => { + await trustPeer("peer-a"); + // peer-a is denied, a card is raised for it. + assert.equal((await gate("peer-a")).ok, false); + await emitDenied("peer-a", "funkwhale", { toolName: "fw_play" }); + const card = (await store.listPending(db))[0]; + assert.equal(card.context.requestingInstance, "peer-a"); + + // One click on Allow. + await clickAllow(card); + + // The peer that asked is now allowed… + assert.equal((await gate("peer-a")).ok, true); + // …and every other peer is still refused. + const other = await gate("peer-b"); + assert.equal(other.ok, false); + assert.equal(other.res._status, 403); + assert.equal(other.res._json.error.code, -32001); + // The grant is per-peer on disk, and the all-peer list is untouched. + assert.deepEqual([...(await getPeerGrantedCapabilities(db, "peer-a"))], ["funkwhale"]); + assert.deepEqual([...((await getPeerExposures(db)).keys())], ["peer-a"]); + assert.equal((await getExposedCapabilities(db)).has("funkwhale"), false); +}); + +test("per-peer grants are read at check time: revoking one peer's grant re-denies it and nobody else", async () => { + await grantPeerCapability(db, { peerId: "peer-r", capability: "funkwhale", expiresAt: null }); + assert.equal((await gate("peer-r")).ok, true); + const { removePeerGrant } = await import("../servers/gateway/peer-exposure.js"); + await removePeerGrant(db, { peerId: "peer-r", capability: "funkwhale" }); + assert.equal((await gate("peer-r")).ok, false); +}); + +// --------------------------------------------------------------------------- +// 2. Expiry (time injected) +// --------------------------------------------------------------------------- + +test("an expired grant is refused at check time; a live one still works (time injected)", async () => { + const t0 = Date.now(); + await trustPeer("peer-e"); + await remedy({ capability: "funkwhale", requestingInstance: "peer-e", days: 30 }, { db, item: cardFor("peer-e") }); + assert.equal(await isCapabilityExposedToPeer(db, "funkwhale", "peer-e", { now: t0 + 29 * DAY }), true); + assert.equal(await isCapabilityExposedToPeer(db, "funkwhale", "peer-e", { now: t0 + 31 * DAY }), false); + + // The GATE honours the injected clock too (not just the storage helper). + assert.equal((await gate("peer-e", { now: t0 + 29 * DAY })).ok, true); + assert.equal((await gate("peer-e", { now: t0 + 31 * DAY })).ok, false); +}); + +test('"until I remove it" never expires', async () => { + const t0 = Date.now(); + await trustPeer("peer-f"); + await remedy({ capability: "funkwhale", requestingInstance: "peer-f", untilRemoved: true }, { db, item: cardFor("peer-f") }); + assert.equal((await getPeerExposures(db)).get("peer-f").get("funkwhale").expiresAt, null); + assert.equal(await isCapabilityExposedToPeer(db, "funkwhale", "peer-f", { now: t0 + 3650 * DAY }), true); + assert.equal((await gate("peer-f", { now: t0 + 3650 * DAY })).ok, true); +}); + +test("an expired grant is still stored (Settings can renew or remove it) and a malformed expiry fails closed", async () => { + const t0 = Date.now(); + await trustPeer("peer-g"); + await remedy({ capability: "funkwhale", requestingInstance: "peer-g", days: 7 }, { db, item: cardFor("peer-g") }); + assert.ok((await getPeerExposures(db)).get("peer-g").get("funkwhale"), "expired grants are kept, not pruned"); + assert.equal(await isCapabilityExposedToPeer(db, "funkwhale", "peer-g", { now: t0 + 8 * DAY }), false); + + // A hand-edited/garbled expires_at never means "forever". + await grantPeerCapability(db, { peerId: "peer-g", capability: "crow-memory", expiresAt: null }); + const raw = JSON.parse(await (await db.execute({ + sql: "SELECT value FROM dashboard_settings_overrides WHERE key = 'remote_exposed_tools_by_peer'", + args: [], + })).rows[0].value); + raw["peer-g"]["crow-memory"].expires_at = "not-a-date"; + await writeSetting(db, "remote_exposed_tools_by_peer", JSON.stringify(raw), { scope: "local" }); + assert.equal(await isCapabilityExposedToPeer(db, "crow-memory", "peer-g"), false); +}); + +test("Settings lists each one-device allowance with its peer and expiry, and renews/removes it", async () => { + const t0 = Date.now(); + await trustPeer("peer-abc123"); + await remedy({ capability: "funkwhale", requestingInstance: "peer-abc123", days: 1 }, { db, item: cardFor("peer-abc123") }); + // Age the grant past its expiry to prove the expired state is shown. + const stored = (await getPeerExposures(db)).get("peer-abc123").get("funkwhale"); + const past = new Date(t0 - DAY).toISOString(); + await grantPeerCapability(db, { peerId: "peer-abc123", capability: "funkwhale", expiresAt: past }); + assert.ok(stored, "grant recorded"); + + const html = await section.render({ db }); + assert.match(html, /Per-device allowances/); + assert.match(html, /peer-abc123/, "the peer is shown"); + assert.match(html, /expired/, "an expired allowance is visible as expired"); + assert.match(html, /value="renew_peer_exposure"/); + assert.match(html, /value="remove_peer_exposure"/); + + // Renew (+30 days) → active again. + let redirected = null; + const res = { redirectAfterPost: (u) => { redirected = u; } }; + assert.equal(await section.handleAction({ + req: { body: { action: "renew_peer_exposure", peer: "peer-abc123", cap: "funkwhale" } }, res, db, + action: "renew_peer_exposure", + }), true); + assert.match(redirected, /section=remote-exposure/); + assert.equal(await isCapabilityExposedToPeer(db, "funkwhale", "peer-abc123"), true); + + // Remove → gone from storage and from the page. + assert.equal(await section.handleAction({ + req: { body: { action: "remove_peer_exposure", peer: "peer-abc123", cap: "funkwhale" } }, res, db, + action: "remove_peer_exposure", + }), true); + assert.equal(await isCapabilityExposedToPeer(db, "funkwhale", "peer-abc123"), false); + const after = await section.render({ db }); + assert.doesNotMatch(after, /peer-abc123/); +}); + +// --------------------------------------------------------------------------- +// 3. Existing all-peer exposures keep working, untouched +// --------------------------------------------------------------------------- + +test("an existing all-trusted-peers exposure still allows EVERY peer (not narrowed, not migrated)", async () => { + await writeSetting(db, "remote_exposed_tools", JSON.stringify(["funkwhale"]), { scope: "local" }); + assert.equal((await gate("peer-1")).ok, true); + assert.equal((await gate("peer-2")).ok, true); + // A per-peer grant elsewhere does not narrow it. + await grantPeerCapability(db, { peerId: "peer-3", capability: "crow-memory", expiresAt: null }); + assert.equal((await gate("peer-1")).ok, true); + assert.deepEqual([...((await getExposedCapabilities(db)))], ["funkwhale"]); +}); + +test("a card Allow leaves the all-peer list byte-for-byte unchanged", async () => { + await trustPeer("peer-z"); + await writeSetting(db, "remote_exposed_tools", JSON.stringify(["crow-memory", "crow-blog"]), { scope: "local" }); + const before = (await db.execute({ sql: "SELECT value FROM dashboard_settings_overrides WHERE key = 'remote_exposed_tools'", args: [] })).rows[0].value; + + await emitDenied("peer-z", "funkwhale"); + await clickAllow((await store.listPending(db))[0]); + const after = (await db.execute({ sql: "SELECT value FROM dashboard_settings_overrides WHERE key = 'remote_exposed_tools'", args: [] })).rows[0].value; + assert.equal(after, before, "the all-peer setting is not written by a card"); + assert.deepEqual([...((await getExposedCapabilities(db)))].sort(), ["crow-blog", "crow-memory"]); + // peer-z got the scoped grant; nobody else did. + assert.equal(await isCapabilityExposedToPeer(db, "funkwhale", "peer-z"), true); + assert.equal(await isCapabilityExposedToPeer(db, "funkwhale", "peer-y"), false); +}); + +// --------------------------------------------------------------------------- +// 4. Rate limits + dedupe +// --------------------------------------------------------------------------- + +test("at most 3 open Allow cards per peer; further requests are counted, not carded", async () => { + for (const capability of ["funkwhale", "crow-memory", "crow-blog"]) { + await idx.emitFixIt(db, "peer-exposure:denied", { capability, requestingInstance: "peer-flood" }); + } + assert.equal((await store.listPending(db)).length, MAX_OPEN_CARDS_PER_PEER); + + await idx.emitFixIt(db, "peer-exposure:denied", { capability: "crow-storage", requestingInstance: "peer-flood" }); + const pending = await store.listPending(db); + assert.equal(pending.length, MAX_OPEN_CARDS_PER_PEER, "the 4th capability creates no card"); + assert.ok(!pending.some((p) => p.context.capability === "crow-storage")); + + const counted = await getSuppressedRequests(db); + assert.equal(counted.get("peer-flood|crow-storage")?.count, 1, "the refused request is recorded, not dropped silently"); + assert.equal(counted.get("peer-flood|crow-storage")?.reason, "open_card_cap"); + + // Another peer is unaffected by peer-flood's cap (the limit is PER peer). + await idx.emitFixIt(db, "peer-exposure:denied", { capability: "crow-storage", requestingInstance: "peer-quiet" }); + assert.equal((await store.listPending(db)).length, MAX_OPEN_CARDS_PER_PEER + 1); +}); + +test("a repeated identical request is deduped into the existing card (count bumped, one card)", async () => { + await idx.emitFixIt(db, "peer-exposure:denied", { capability: "funkwhale", requestingInstance: "peer-dup", toolName: "fw_play" }); + await idx.emitFixIt(db, "peer-exposure:denied", { capability: "funkwhale", requestingInstance: "peer-dup", toolName: "fw_play" }); + const pending = await store.listPending(db); + assert.equal(pending.length, 1, "still exactly one card"); + assert.equal(pending[0].count, 2, "the repeat is counted on that card"); + assert.equal(pending[0].dedupKey, dedupKeyFor("peer-dup", "funkwhale")); +}); + +test("only ONE new card per peer per capability per 24 h; a resolved card is counted, then reopens after the window", async () => { + await idx.emitFixIt(db, "peer-exposure:denied", { capability: "funkwhale", requestingInstance: "peer-24h" }); + const card = (await store.listPending(db))[0]; + await store.markResolved(db, card.id); + + // Same request again inside the window: no new card, the request is counted. + await idx.emitFixIt(db, "peer-exposure:denied", { capability: "funkwhale", requestingInstance: "peer-24h" }); + assert.equal((await store.listPending(db)).length, 0, "no new card inside 24 h"); + const counted = await getSuppressedRequests(db); + assert.equal(counted.get("peer-24h|funkwhale")?.reason, "same_capability_within_24h"); + assert.equal((await store.getItem(db, card.id)).count, 2, "recorded on the answered card"); + + // Past the window the request earns a fresh card. + await db.execute({ sql: "UPDATE fix_it_items SET updated_at = datetime('now','-2 days') WHERE id = ?", args: [card.id] }); + await idx.emitFixIt(db, "peer-exposure:denied", { capability: "funkwhale", requestingInstance: "peer-24h" }); + assert.equal((await store.listPending(db)).length, 1, "after 24 h the request may raise a card again"); +}); + +test("the 24 h window is measured with the clock the caller injects (policy is pure)", () => { + const t0 = Date.parse("2026-01-01T00:00:00.000Z"); + const answered = [{ + id: 7, dedupKey: dedupKeyFor("peer-p", "funkwhale"), status: "resolved", count: 1, + context: { capability: "funkwhale", requestingInstance: "peer-p" }, + createdAt: "2025-12-31 23:00:00", updatedAt: "2026-01-01 00:00:00", + }]; + assert.deepEqual( + decidePeerExposureCard({ items: answered, peerId: "peer-p", capability: "funkwhale", now: t0 + 1000 }), + { action: "count", itemId: 7, reason: "same_capability_within_24h" }, + ); + assert.deepEqual( + decidePeerExposureCard({ items: answered, peerId: "peer-p", capability: "funkwhale", now: t0 + NEW_CARD_WINDOW_MS + 1000 }), + { action: "create" }, + ); + assert.deepEqual( + decidePeerExposureCard({ items: answered, peerId: "other-peer", capability: "funkwhale", now: t0 }), + { action: "create" }, + "another peer's answered card does not block this one", + ); +}); + +// --------------------------------------------------------------------------- +// 5. The card records the VERIFIED peer id, never a name from the request body +// --------------------------------------------------------------------------- + +test("the card is keyed by the verified peer id, not by anything the request body claims", async () => { + await trustPeer("verified-peer"); + const spoof = { + jsonrpc: "2.0", id: 9, method: "tools/call", + params: { name: "fw_play", _meta: { requestingInstance: "victim-id", peerName: "Victim Device", requestedBy: "Victim Device" } }, + requestingInstance: "victim-id", + requestingInstanceName: "Victim Device", + peerName: "Victim Device", + instance: { id: "victim-id" }, + }; + const res = mkRes(); + const denied = await enforcePeerExposure({ + prefix: "tools", + req: { + ...peerReq("verified-peer", spoof, { + headers: { "x-peer-name": "Victim Device", "x-instance-id": "victim-id" }, + }), + peerName: "Victim Device", + instanceAuth: { + instance: { id: "verified-peer", auth_token_hash: tokenHashFor("verified-peer") }, + peerName: "Victim Device", + }, + }, + res, db, connectedServers: CONNECTED, auditFn: async () => {}, + }); + assert.equal(denied, false); + const pending = await waitForPending((p) => p.length === 1); + const card = pending[0]; + + assert.equal(card.context.requestingInstance, "verified-peer"); + assert.equal(card.dedupKey, dedupKeyFor("verified-peer", "funkwhale", tagFor("verified-peer"))); + assert.ok(card.remedies.every((r) => r.args.requestingInstance === "verified-peer")); + assert.doesNotMatch(JSON.stringify(card), /Victim Device/); + assert.doesNotMatch(JSON.stringify(card), /victim-id/); + + // Clicking Allow grants the verified peer, never the body-claimed identity. + await clickAllow(card); + assert.equal(await isCapabilityExposedToPeer(db, "funkwhale", "verified-peer"), true); + assert.equal(await isCapabilityExposedToPeer(db, "funkwhale", "victim-id"), false); + assert.equal((await getPeerExposures(db)).has("victim-id"), false); +}); + +test("each card button grants its own duration (30 days / 7 days / until removed)", async () => { + await trustPeer("peer-b7"); + await emitDenied("peer-b7", "funkwhale"); + let card = (await store.listPending(db))[0]; + assert.deepEqual(card.remedies.map((r) => r.actionId), + ["expose-capability-30d", "expose-capability-7d", "expose-capability-forever"]); + + await clickAllow(card, "expose-capability-7d"); + const seven = (await getPeerExposures(db)).get("peer-b7").get("funkwhale"); + const days = (Date.parse(seven.expiresAt) - Date.now()) / DAY; + assert.ok(days > 6.9 && days < 7.1, `7-day button grants 7 days, got ${days}`); + + await emitDenied("peer-b7", "crow-memory"); + card = (await store.listPending(db)).find((p) => p.context.capability === "crow-memory"); + await clickAllow(card, "expose-capability-forever"); + assert.equal((await getPeerExposures(db)).get("peer-b7").get("crow-memory").expiresAt, null); + assert.equal((await getPeerExposures(db)).get("peer-b7").get("funkwhale").expiresAt, seven.expiresAt, "the other grant is untouched"); +}); + +test("the per-peer store is local-only: it never syncs to other instances", async () => { + const { isSyncable } = await import("../servers/gateway/dashboard/settings/sync-allowlist.js"); + assert.equal(isSyncable("remote_exposed_tools_by_peer"), false); + assert.equal(isSyncable("peer_exposure_card_suppressed"), false); + + await grantPeerCapability(db, { peerId: "peer-local", capability: "funkwhale" }); + const globalRow = await db.execute({ sql: "SELECT 1 FROM dashboard_settings WHERE key = 'remote_exposed_tools_by_peer'", args: [] }); + assert.equal(globalRow.rows.length, 0, "no global (sync-emitted) row"); + const overrideRow = await db.execute({ sql: "SELECT 1 FROM dashboard_settings_overrides WHERE key = 'remote_exposed_tools_by_peer'", args: [] }); + assert.ok(overrideRow.rows.length > 0, "stored as this instance's local override"); +}); + +test("a remedy whose card carries no verified peer id refuses instead of widening", async () => { + await db.execute("DELETE FROM fix_it_items"); + const r = await remedy({ capability: "funkwhale", requestingInstance: "Victim Device" }, { db }); + assert.equal(r.resolved, false); + assert.equal((await getExposedCapabilities(db)).has("funkwhale"), false); + assert.equal((await getPeerExposures(db)).size, 0); +}); + +// --------------------------------------------------------------------------- +// 6. Review round 1 — B1: the decide-then-upsert step is serialised per peer +// --------------------------------------------------------------------------- + +// The six core capabilities of any instance, plus addon-shaped ids a box with +// addons would have. Every one of them is an id the gate would have matched +// against the catalog before the event reached the detector. +const TWENTY_CAPABILITIES = [ + ...getLocalCapabilityIds(), + "funkwhale", "trello", "notion", "slack", "canvas", "weather", "calendar", + "contacts", "gallery", "podcast", "rss", "maps", "translate", "recipes", +]; + +test("B1: 20 concurrent denials for ONE peer make exactly 3 cards; the rest are counted", async () => { + const lock = await import("../servers/gateway/fix-it/peer-card-lock.js"); + assert.equal(TWENTY_CAPABILITIES.length, 20, "20 distinct capabilities"); + assert.ok(MAX_OPEN_CARDS_PER_PEER < 20); + + // All 20 fire-and-forget events are in flight together — the exact shape that + // used to let every one of them see "no open cards yet" and create a card. + const droppedBefore = lock._peerLockOverflow(); + await Promise.all(TWENTY_CAPABILITIES.map((capability) => idx.emitFixIt(db, "peer-exposure:denied", { + capability, capabilityKnown: true, requestingInstance: "peer-burst", + }))); + + const pending = await store.listPending(db); + assert.equal(pending.length, MAX_OPEN_CARDS_PER_PEER, `expected ${MAX_OPEN_CARDS_PER_PEER} pending cards`); + assert.equal(new Set(pending.map((p) => p.context.capability)).size, MAX_OPEN_CARDS_PER_PEER, "all different capabilities"); + + const counted = await getSuppressedRequests(db); + const total = [...counted.values()].reduce((n, v) => n + v.count, 0); + // Round 2 (N-b): the per-peer queue is bounded, so an event that arrives with + // the queue full is dropped before it touches the db (no card, no counter) and + // counted in memory. Every event is still accounted for: + // cards + db-counted + dropped === 20. + const dropped = lock._peerLockOverflow() - droppedBefore; + assert.equal(pending.length + total + dropped, TWENTY_CAPABILITIES.length, + `3 cards + ${total} counted + ${dropped} dropped must be the 20 events`); + assert.ok([...counted.values()].every((v) => v.reason === "open_card_cap" && v.count === 1)); + assert.equal(counted.size, total, "one entry per refused capability"); + assert.equal(lock._peerLockSize(), 0, "the per-peer chain is dropped once its tail settles"); +}); + +// --- Review round 2, N-b: the per-peer queue is bounded --------------------- + +test("N-b: 100 concurrent events from one peer never have more than the cap waiting, and the overflow is counted", async () => { + const lock = await import("../servers/gateway/fix-it/peer-card-lock.js"); + assert.ok(Number.isInteger(lock.MAX_QUEUED_TASKS_PER_KEY) && lock.MAX_QUEUED_TASKS_PER_KEY > 0, + "the per-key queue has a named cap"); + + // (1) The lock itself. One task holds the key while 100 more arrive: the ones + // beyond the cap are refused AT ARRIVAL (never queued, never run). + lock._resetPeerLockForTest(); + let release; + const gate = new Promise((r) => { release = r; }); + let started = 0; + const runs = [lock.withPeerLock("peer-queue", async () => { started += 1; await gate; })]; + for (let i = 0; i < 100; i++) { + runs.push(lock.withPeerLock("peer-queue", async () => { started += 1; })); + } + await new Promise((r) => setTimeout(r, 5)); // let the blocker take the key + assert.equal(started, 1, "one task at a time per peer, as before"); + const droppedAtArrival = lock._peerLockOverflow(); + release(); + await Promise.all(runs); + assert.equal(started, lock.MAX_QUEUED_TASKS_PER_KEY, + `at most ${lock.MAX_QUEUED_TASKS_PER_KEY} tasks ever wait/run for one peer (1 ran + 15 queued)`); + assert.equal(droppedAtArrival, 101 - lock.MAX_QUEUED_TASKS_PER_KEY, + "the 85 events that arrived while the queue was full were dropped and counted in memory"); + assert.equal(lock._peerLockOverflow(), 101 - lock.MAX_QUEUED_TASKS_PER_KEY); + assert.equal(lock._peerLockSize(), 0, "the key is dropped once its tail settles"); + + // (2) Through the real event path: 100 distinct capabilities from one peer. + // A drop costs NO db work, so the cards AND the counters stay bounded. + const HUNDRED = Array.from({ length: 100 }, (_, i) => `cap-${i}`); + const droppedBefore = lock._peerLockOverflow(); + await Promise.all(HUNDRED.map((capability) => idx.emitFixIt(db, "peer-exposure:denied", { + capability, capabilityKnown: true, requestingInstance: "peer-hundred", + }))); + const dropped = lock._peerLockOverflow() - droppedBefore; + const pending = await store.listPending(db); + const counted = await getSuppressedRequests(db); + const total = [...counted.values()].reduce((n, v) => n + v.count, 0); + assert.equal(pending.length, MAX_OPEN_CARDS_PER_PEER, "the open-card cap still holds"); + assert.ok(dropped > 0, "the flood did overflow the per-peer queue (100 events, cap 16)"); + assert.equal(pending.length + total + dropped, HUNDRED.length, + `3 cards + ${total} counted + ${dropped} dropped must be the 100 events`); + assert.equal(lock._peerLockSize(), 0); +}); + +// --------------------------------------------------------------------------- +// 7. Review round 1 — B2: the peer does not choose the capability id +// --------------------------------------------------------------------------- + +test("B2: 200 junk tool names raise no card and leave ONE counter entry for the peer", async () => { + const emits = emitCollector(); + const calls = []; + for (let i = 0; i < 200; i++) calls.push(gateRouter("peer-junk", `crow_junk${i}`, { emitFn: emits.fn })); + // …plus two names that are not even id-shaped (no tail, absurdly long). + calls.push(gateRouter("peer-junk", `crow_${"a".repeat(300)}`, { emitFn: emits.fn })); + calls.push(gateRouter("peer-junk", "crow_", { emitFn: emits.fn })); + + const results = await Promise.all(calls); + assert.equal(results.length, 202); + assert.ok(results.every((r) => r.ok === false), "every junk name is refused"); + await emits.settle(); + + assert.equal((await store.listPending(db)).length, 0, "no card for a capability this instance does not have"); + const counted = await getSuppressedRequests(db); + assert.equal(counted.size, 1, "one bucket, not one entry per junk name"); + assert.equal(counted.get("peer-junk|unknown")?.count, 202); + assert.equal(counted.get("peer-junk|unknown")?.reason, "unknown_capability"); + assert.deepEqual((await getPeerExposures(db)).size, 0); +}); + +test("B2: crow_Memory and crow_memory are ONE capability (the case variant is not unknown)", async () => { + assert.equal(resolveCalledCanonicalId("router", { method: "tools/call", params: { name: "crow_Memory" } }), "crow-memory"); + assert.equal(resolveCalledCanonicalId("router", { method: "tools/call", params: { name: "crow_memory" } }), "crow-memory"); + // The tail keeps its underscores (review round 2, N-f: the comment in + // capability-ids.js said `crow-store-memory`, which is not what it returns). + assert.equal(resolveCalledCanonicalId("router", { method: "tools/call", params: { name: "crow_store_memory" } }), "crow-store_memory"); + + const emits = emitCollector(); + await Promise.all([ + gateRouter("peer-case", "crow_Memory", { emitFn: emits.fn }), + gateRouter("peer-case", "crow_memory", { emitFn: emits.fn }), + ]); + await emits.settle(); + + const pending = await store.listPending(db); + assert.equal(pending.length, 1, "both spellings land on the same card"); + assert.equal(pending[0].context.capability, "crow-memory"); + assert.equal(pending[0].count, 2, "the second spelling was deduped, not carded again"); + assert.equal((await getSuppressedRequests(db)).size, 0, "nothing was counted as unknown"); +}); + +test("B2: the suppressed-counter map is capped and pruned", async () => { + const now = Date.now(); + const seeded = Object.create(null); + for (let i = 0; i < SUPPRESSED_COUNTER_MAX_ENTRIES + 120; i++) { + seeded[`peer-seed${i}|crow-memory`] = { count: 1, last_at: new Date(now - 1000).toISOString(), reason: "open_card_cap" }; + } + await writeSetting(db, "peer_exposure_card_suppressed", JSON.stringify(seeded), { scope: "local" }); + await recordSuppressedRequest(db, { peerId: "peer-fresh", capability: "crow-blog", reason: "open_card_cap", now }); + const after = await getSuppressedRequests(db); + assert.equal(after.size, SUPPRESSED_COUNTER_MAX_ENTRIES, "the map never exceeds the cap"); + assert.ok(after.has("peer-fresh|crow-blog"), "the newest entry survives; the oldest are evicted"); + + // An entry whose window has passed is pruned on the next write. + const stale = Object.create(null); + stale["peer-stale|crow-memory"] = { + count: 5, last_at: new Date(now - SUPPRESSED_COUNTER_WINDOW_MS - 1000).toISOString(), reason: "open_card_cap", + }; + await writeSetting(db, "peer_exposure_card_suppressed", JSON.stringify(stale), { scope: "local" }); + await recordSuppressedRequest(db, { peerId: "peer-fresh", capability: "crow-blog", reason: "open_card_cap", now }); + assert.equal((await getSuppressedRequests(db)).has("peer-stale|crow-memory"), false, "an expired window is pruned"); +}); + +// --------------------------------------------------------------------------- +// 8. Review round 1 — S1: a card written by the OLD detector +// --------------------------------------------------------------------------- + +test("S1 (fix round 3): an old-shape Allow card with NO pairing tag refuses instead of granting", async () => { + const peer = "peer-old"; + await trustPeer(peer); + // Exactly what the pre-scoping detector wrote: one button, no duration, the + // capability in the remedy args and the peer only in the card CONTEXT. It also + // predates the pairing tag, so nothing in it says WHICH pairing asked. + await store.upsertItem(db, { + source: "remote-exposure", + dedupKey: legacyDedupKeyFor(peer, "funkwhale"), + title: "Another device tried to use Music", + why: "Allow it?", + severity: "warn", + remedies: [{ label: "Allow", actionId: "expose-capability", kind: "instant", args: { capability: "funkwhale" } }], + context: { capability: "funkwhale", requestingInstance: peer, toolName: "fw_play" }, + }); + const card = (await store.listPending(db))[0]; + assert.equal(card.remedies[0].actionId, "expose-capability"); + assert.equal(card.context.pairingTag, undefined, "the legacy card predates the tag"); + + // Fix round 3 changed this from a grant to a refusal: without a tag there is + // no way to tell the pairing that asked from a re-pair under the same id, and + // a refusal is the safe default. + const refused = await remedy({ capability: "funkwhale", days: 30 }, { db, item: card }); + assert.equal(refused.resolved, false, "a card with no pairing tag grants nothing"); + assert.match(refused.message, /pairing has changed/); + assert.equal((await getPeerExposures(db)).size, 0, "no allowance was written, for this peer or any other"); + assert.equal((await getExposedCapabilities(db)).has("funkwhale"), false, "and never the all-peer list"); + assert.equal((await gate(peer)).ok, false, "the peer that (once) asked is still refused"); + + // The card still occupies its rate-limit slot while it is pending: the same + // request bumps THAT card (a pending card is deduped, never re-carded), so it + // never multiplies in the Nest. + await idx.emitFixIt(db, "peer-exposure:denied", { capability: "funkwhale", capabilityKnown: true, requestingInstance: peer }); + const after = await store.listPending(db); + assert.equal(after.length, 1, "still exactly one card for the same (capability, peer)"); + assert.equal(after[0].id, card.id, "the card that was already open is the one that was bumped"); +}); + +// --------------------------------------------------------------------------- +// 9. Review round 1 — N1: the dedupe key is unambiguous and old keys still read +// --------------------------------------------------------------------------- + +test("N1: the dedupe key separator cannot appear in either part, and old keys still dedupe", () => { + // Peer ids are [A-Za-z0-9._:-] (so they CAN contain ':'), which is what made + // `expose::` ambiguous. + assert.notEqual(dedupKeyFor("peer:c", "crow-memory"), legacyDedupKeyFor("peer:c", "crow-memory")); + assert.equal(dedupKeyFor("peer:c", "crow-memory"), "expose|crow-memory|peer:c"); + + // A card opened with the old key shape is still recognised as the same + // (capability, peer) — the fallback looks at the key, not only the context. + const items = [{ + id: 3, dedupKey: legacyDedupKeyFor("peer-c", "funkwhale"), status: "pending", count: 1, + context: { requestingInstance: "peer-c" }, + createdAt: "2026-01-01 00:00:00", updatedAt: "2026-01-01 00:00:00", + }]; + assert.deepEqual( + decidePeerExposureCard({ items, peerId: "peer-c", capability: "funkwhale" }), + { action: "dedupe", itemId: 3 }, + ); +}); + +// --------------------------------------------------------------------------- +// 10. Review round 1 — S3: revoking a peer drops its one-device allowances +// --------------------------------------------------------------------------- + +test("S3: revoking a peer removes its grants; re-pairing the same id starts clean", async () => { + const { revokePeer } = await import("../servers/sharing/revoke-peer.js"); + await db.execute({ + sql: "INSERT OR REPLACE INTO crow_instances (id, name, crow_id, status, trusted) VALUES (?, ?, ?, ?, ?)", + args: ["peer-revoked", "Peer To Revoke", "crow-other", "active", 1], + }); + await grantPeerCapability(db, { peerId: "peer-revoked", capability: "funkwhale", expiresAt: null }); + await grantPeerCapability(db, { peerId: "peer-revoked", capability: "crow-blog", expiresAt: null }); + await grantPeerCapability(db, { peerId: "peer-bystander", capability: "funkwhale", expiresAt: null }); + assert.equal((await gate("peer-revoked")).ok, true); + + const r = await revokePeer(db, "peer-revoked", { instanceSyncManager: null }); + assert.equal(r.ok, true); + + assert.equal((await getPeerExposures(db)).has("peer-revoked"), false, "every grant for the revoked peer is gone"); + assert.deepEqual([...((await getPeerExposures(db)).keys())], ["peer-bystander"], "another peer's grant is untouched"); + assert.equal((await gate("peer-revoked")).ok, false, "and the revoked peer is refused"); + + // Re-pair with the same id (a revoke→re-pair reuses it): the row comes back, + // the allowances do not. + await db.execute({ + sql: "UPDATE crow_instances SET status = 'active', auth_token_hash = 'reissued', trusted = 1 WHERE id = ?", + args: ["peer-revoked"], + }); + assert.equal((await getPeerGrantedCapabilities(db, "peer-revoked")).size, 0, "no grant survives the re-pair"); + assert.equal((await gate("peer-revoked")).ok, false, "the re-paired peer must be allowed again from scratch"); +}); + +test("S3: a revoke written by ANOTHER process (row only) also drops the grants on the next sweep", async () => { + const { reconcilePeerCreds } = await import("../servers/gateway/peer-trust-sweeper.js"); + await db.execute({ + sql: "INSERT OR REPLACE INTO crow_instances (id, name, crow_id, status, trusted) VALUES (?, ?, ?, ?, ?)", + args: ["peer-elsewhere", "Peer Revoked Elsewhere", "crow-third", "active", 1], + }); + await grantPeerCapability(db, { peerId: "peer-elsewhere", capability: "funkwhale", expiresAt: null }); + assert.equal((await gate("peer-elsewhere")).ok, true); + + // The stdio MCP door flips the row and the gateway only sees the row change. + await db.execute({ sql: "UPDATE crow_instances SET status = 'revoked' WHERE id = ?", args: ["peer-elsewhere"] }); + await reconcilePeerCreds(db); + + assert.equal((await getPeerExposures(db)).has("peer-elsewhere"), false, "the sweep drops the revoked peer's allowances"); + await db.execute({ sql: "UPDATE crow_instances SET status = 'active', trusted = 1 WHERE id = ?", args: ["peer-elsewhere"] }); + assert.equal((await gate("peer-elsewhere")).ok, false, "re-paired, but no allowance came back"); +}); + +// --------------------------------------------------------------------------- +// 11. Review round 2 — SF1: an old-key card must not gain a duplicate +// --------------------------------------------------------------------------- + +// The policy already matched the pre-round-1 key (`expose::`), but the +// DETECTOR ignored the `dedupe` verdict and fell through to `upsertItem` under +// the NEW key — which inserted a second open card for the same request. Policy +// tests alone could not see that; this one goes through the real event path. +test("SF1: a repeat denial for a pre-round-1 card bumps THAT card and leaves exactly one open card", async () => { + const peer = "peer-sf1"; + await trustPeer(peer); + await store.upsertItem(db, { + source: "remote-exposure", + dedupKey: legacyDedupKeyFor(peer, "funkwhale"), + title: "Another device tried to use Music", + why: "Allow it?", + severity: "warn", + remedies: [{ label: "Allow", actionId: "expose-capability", kind: "instant", args: { capability: "funkwhale" } }], + context: { capability: "funkwhale", requestingInstance: peer, toolName: "fw_play" }, + }); + const seeded = (await store.listPending(db))[0]; + assert.equal(seeded.dedupKey, legacyDedupKeyFor(peer, "funkwhale")); + + // The same peer asks again, through the real event path. + await idx.emitFixIt(db, "peer-exposure:denied", { capability: "funkwhale", capabilityKnown: true, requestingInstance: peer, toolName: "fw_play" }); + + const pending = await store.listPending(db); + assert.equal(pending.length, 1, "ONE open card — the repeat must not insert a second row under the new key"); + assert.equal(pending[0].id, seeded.id, "the card that was already open is the one that was bumped"); + assert.equal(pending[0].dedupKey, legacyDedupKeyFor(peer, "funkwhale"), "its old key shape is preserved"); + assert.equal(pending[0].count, seeded.count + 1, "and its count records the repeat"); + const rows = await db.execute({ + sql: "SELECT COUNT(*) AS n FROM fix_it_items WHERE source = ? AND status = 'pending'", + args: ["remote-exposure"], + }); + assert.equal(Number(rows.rows[0].n), 1, "exactly one pending row in the table, not just in the pending view"); +}); + +// --------------------------------------------------------------------------- +// 12. Review round 2 — N-a: a revoke answers the peer's cards, and a card for +// a peer that is no longer trusted cannot grant anything +// --------------------------------------------------------------------------- + +test("N-a: revoke answers the peer's open card; re-pairing the same id does not make that card clickable again", async () => { + const { revokePeer, PEER_EXPOSURE_CARD_SOURCE } = await import("../servers/sharing/revoke-peer.js"); + // The revoke module keeps the detector's source as a literal; if either moves, + // the cards stop being answered and this test says so. + const detector = (await import("../servers/gateway/fix-it/detectors/remote-exposure.js")).default; + assert.equal(PEER_EXPOSURE_CARD_SOURCE, detector.source); + + const peer = "peer-na"; + await trustPeer(peer); + await idx.emitFixIt(db, "peer-exposure:denied", { capability: "funkwhale", requestingInstance: peer, toolName: "fw_play" }); + const card = (await store.listPending(db))[0]; + assert.equal(card.context.requestingInstance, peer); + + const r = await revokePeer(db, peer, { instanceSyncManager: null }); + assert.equal(r.ok, true); + assert.equal((await store.getItem(db, card.id)).status, "resolved", "the revoke answers the card it left behind"); + assert.equal((await store.listPending(db)).length, 0, "nothing is left asking for a peer that is gone"); + + // Re-pair under the SAME id (a revoke→re-pair reuses it): the row is trusted + // again, so the remedy's trust check would pass — the answered card must not. + await db.execute({ + sql: "UPDATE crow_instances SET status = 'active', auth_token_hash = 'reissued', trusted = 1 WHERE id = ?", + args: [peer], + }); + const redirected = await clickAllow(card); + assert.match(redirected, /flash=fixit_not_pending/, "the stale card refuses"); + assert.equal((await getPeerGrantedCapabilities(db, peer)).size, 0, "and grants nothing"); + assert.equal((await getPeerExposures(db)).size, 0, "no allowance was written at all"); +}); + +test("N-a: the Allow remedy refuses for a peer that is not a currently trusted peer", async () => { + const peer = "peer-na-row"; + await trustPeer(peer); + await idx.emitFixIt(db, "peer-exposure:denied", { capability: "funkwhale", requestingInstance: peer }); + const card = (await store.listPending(db))[0]; + assert.ok(card, "the card exists while the peer is trusted"); + + // A revoke written by ANOTHER process lands on the row only: the card is still + // pending here, so the remedy itself must refuse. + await db.execute({ sql: "UPDATE crow_instances SET status = 'revoked' WHERE id = ?", args: [peer] }); + const revoked = await remedy({ capability: "funkwhale", requestingInstance: peer, days: 30 }, { db, item: card }); + assert.equal(revoked.resolved, false, "a revoked peer is refused"); + assert.match(revoked.message, /trusted peer/, "and the refusal says why"); + assert.equal((await getPeerGrantedCapabilities(db, peer)).size, 0, "nothing granted"); + assert.equal((await getPeerExposures(db)).size, 0); + + // Paused (an owner's reversible sync hold) and untrusted rows are not trusted + // either, and neither is an id with no row at all. + await db.execute({ sql: "UPDATE crow_instances SET status = 'paused', trusted = 1 WHERE id = ?", args: [peer] }); + assert.equal((await remedy({ capability: "funkwhale", requestingInstance: peer }, { db })).resolved, false, "paused refused"); + await db.execute({ sql: "UPDATE crow_instances SET status = 'active', trusted = 0 WHERE id = ?", args: [peer] }); + assert.equal((await remedy({ capability: "funkwhale", requestingInstance: peer }, { db })).resolved, false, "untrusted refused"); + const neverPaired = await remedy({ capability: "funkwhale", requestingInstance: "peer-never-paired" }, { db }); + assert.equal(neverPaired.resolved, false, "a peer with no row is not a trusted peer"); + assert.match(neverPaired.message, /trusted peer/); + assert.equal((await getPeerExposures(db)).size, 0, "no allowance was written by any of them"); + + // Fix round 3: the peer's row is made trusted again — exactly what a revoke→ + // re-pair under the SAME id does — so the trust check alone cannot save us. + // This card was raised by a pairing that is gone (it carries no tag, which is + // what every card written before this round carries), so the remedy refuses. + await db.execute({ sql: "UPDATE crow_instances SET status = 'active', trusted = 1, auth_token_hash = ? WHERE id = ?", args: ["reissued", peer] }); + const repaired = await remedy({ capability: "funkwhale", requestingInstance: peer, days: 30 }, { db, item: card }); + assert.equal(repaired.resolved, false, "a card from the old pairing is refused after a re-pair"); + assert.match(repaired.message, /pairing has changed/); + assert.equal((await getPeerGrantedCapabilities(db, peer)).size, 0, "nothing granted"); + assert.equal((await getPeerExposures(db)).size, 0, "no allowance was written by the re-paired id either"); +}); + +// --------------------------------------------------------------------------- +// 13. Fix round 3 — the card is bound to the PAIRING it was raised under +// --------------------------------------------------------------------------- + +// `auth_token_hash` changes on every re-pair (NULL on revoke, a new hash on +// enroll/repair) and on every rotation, but the instance ID does NOT: a +// revoke→re-pair reuses it, and `peerTrust` passes for the re-paired row. An ID +// alone therefore cannot tell "the device that asked" from "a later device +// wearing the same id". The card records `context.pairingTag` (a one-way tag of +// the hash that verified the request) and the remedy compares it with the tag of +// the peer's CURRENT hash. + +test("the card stores a 16-hex pairing tag and never the peer's bearer hash", async () => { + const peer = "peer-tag"; + await trustPeer(peer); + await emitDenied(peer, "funkwhale", { toolName: "fw_play" }); + const card = (await store.listPending(db))[0]; + + assert.equal(card.context.pairingTag, tagFor(peer), "the tag of the pairing that asked"); + assert.match(card.context.pairingTag, /^[0-9a-f]{16}$/); + // The stored hash is a live credential: the card carries a one-way tag only. + assert.equal(card.context.auth_token_hash, undefined, "no raw hash field on the card"); + assert.ok(!JSON.stringify(card.context).includes(tokenHashFor(peer)), "no raw hash anywhere in the card context"); + + // The helper itself: no hash → no tag (never a fixed/empty-string tag), and + // two pairings never share one. + assert.equal(pairingTag(null), null); + assert.equal(pairingTag(undefined), null); + assert.equal(pairingTag(""), null); + assert.notEqual(pairingTag("hash-a"), pairingTag("hash-b")); + assert.equal(pairingTag("hash-a"), pairingTag("hash-a"), "stable for the same pairing"); +}); + +test("same pairing: a card raised under the current pairing still grants, exactly as before", async () => { + const peer = "peer-same"; + await trustPeer(peer); + await emitDenied(peer, "funkwhale"); + const card = (await store.listPending(db))[0]; + + assert.equal((await remedy({ capability: "funkwhale", requestingInstance: peer, days: 30 }, { db, item: card })).resolved, true); + assert.equal((await getPeerGrantedCapabilities(db, peer)).has("funkwhale"), true); + assert.equal((await getExposedCapabilities(db)).has("funkwhale"), false, "still never the all-peer list"); +}); + +test("a denial queued when a revoke sweeps is carded afterwards, and cannot grant the re-paired id", async () => { + const { revokePeer } = await import("../servers/sharing/revoke-peer.js"); + const lock = await import("../servers/gateway/fix-it/peer-card-lock.js"); + const peer = "peer-race"; + await trustPeer(peer); // pairing H1 + + // Hold the per-peer queue so the denial is decided AFTER the revoke's card + // sweep — which does not take this lock, so it cannot see the card either. + let release; + const heldOpen = new Promise((r) => { release = r; }); + const blocker = lock.withPeerLock(peer, async () => { await heldOpen; }); + const queued = emitDenied(peer, "funkwhale"); // tag(H1), as the gate would send + await new Promise((r) => setTimeout(r, 25)); // let it reach the lock + + assert.equal((await revokePeer(db, peer, { instanceSyncManager: null })).ok, true); + assert.equal((await store.listPending(db)).length, 0, "no card existed when the revoke swept"); + + // Re-pair under the SAME id with a NEW bearer hash: a different pairing. + await db.execute({ + sql: "UPDATE crow_instances SET status = 'active', trusted = 1, auth_token_hash = ? WHERE id = ?", + args: ["repaired-token-hash", peer], + }); + + release(); + await blocker; + await queued; + + const pending = await store.listPending(db); + assert.equal(pending.length, 1, "the queued denial creates its card after the sweep, as before"); + assert.equal(pending[0].context.pairingTag, tagFor(peer), "tagged with the OLD pairing it was raised under"); + + // `peerTrust` passes now (the row is active/trusted again) — the pairing tag is + // what refuses the click. + const outcome = await remedy({ capability: "funkwhale", requestingInstance: peer, days: 30 }, { db, item: pending[0] }); + assert.equal(outcome.resolved, false, "the old pairing's card grants nothing after the re-pair"); + assert.match(outcome.message, /pairing has changed/); + assert.equal((await getPeerGrantedCapabilities(db, peer)).size, 0, "no grant for the re-paired id"); + assert.equal((await getPeerExposures(db)).size, 0, "no allowance was written at all"); +}); + +test("a row-only revoke is answered by the next sweep, like the grant drop", async () => { + const { reconcilePeerCreds } = await import("../servers/gateway/peer-trust-sweeper.js"); + const peer = "peer-rowonly-card"; + await trustPeer(peer); + await emitDenied(peer, "funkwhale"); + const card = (await store.listPending(db))[0]; + assert.equal(card.status, "pending"); + + // The stdio MCP door flips the row; this gateway only sees the row change. + await db.execute({ sql: "UPDATE crow_instances SET status = 'revoked' WHERE id = ?", args: [peer] }); + await reconcilePeerCreds(db); + + assert.equal((await store.getItem(db, card.id)).status, "resolved", "the sweep answers the peer's open card"); + assert.equal((await store.listPending(db)).length, 0, "nothing is left asking for a peer that is gone"); +}); + +test("row-only revoke, then a re-pair with a new hash: the old pending card is refused", async () => { + const peer = "peer-rowonly-repair"; + await trustPeer(peer); + await emitDenied(peer, "funkwhale"); + const card = (await store.listPending(db))[0]; + + // Revoked elsewhere, and the sweeper has not run: the card is still pending + // when the same id pairs again with a new hash. + await db.execute({ sql: "UPDATE crow_instances SET status = 'revoked' WHERE id = ?", args: [peer] }); + await db.execute({ + sql: "UPDATE crow_instances SET status = 'active', trusted = 1, auth_token_hash = ? WHERE id = ?", + args: ["repaired-token-hash-2", peer], + }); + + const outcome = await remedy({ capability: "funkwhale", requestingInstance: peer, days: 30 }, { db, item: card }); + assert.equal(outcome.resolved, false, "the card that outlived the revoke cannot grant the re-paired id"); + assert.match(outcome.message, /pairing has changed/); + assert.equal((await getPeerGrantedCapabilities(db, peer)).size, 0, "nothing granted"); + assert.equal((await getPeerExposures(db)).size, 0, "no allowance was written"); +}); + +test("a pending card from an old pairing does not swallow the new pairing's request", async () => { + const peer = "peer-stale-tag"; + await trustPeer(peer); // pairing H1 + await emitDenied(peer, "funkwhale"); + const old = (await store.listPending(db))[0]; + assert.equal(old.context.pairingTag, tagFor(peer)); + + // Re-pair: a new hash, so a new pairing tag on the next denial. + const newHash = "repaired-token-hash-3"; + await db.execute({ sql: "UPDATE crow_instances SET auth_token_hash = ? WHERE id = ?", args: [newHash, peer] }); + await idx.emitFixIt(db, "peer-exposure:denied", { + capability: "funkwhale", requestingInstance: peer, pairingTag: pairingTag(newHash), + }); + + assert.equal((await store.getItem(db, old.id)).status, "resolved", "the stale card is answered, not reused"); + const pending = await store.listPending(db); + assert.equal(pending.length, 1, "exactly one card is open"); + assert.notEqual(pending[0].id, old.id, "and it is a NEW card, not the old one"); + assert.equal(pending[0].context.pairingTag, pairingTag(newHash), "tagged with the current pairing"); + + // The new card is a real, clickable request. + await clickAllow(pending[0]); + assert.equal((await getPeerGrantedCapabilities(db, peer)).has("funkwhale"), true); +}); + +// --------------------------------------------------------------------------- +// 14. Fix round 4 — the tag comes from the VERIFIED instance, and a sweep +// reads the card table once +// --------------------------------------------------------------------------- + +// `validateInstanceToken` matched the request's bearer against +// `auth_token_hash` and returned that row, so the verified instance DOES carry +// the hash. Fix round 4 removed the by-id re-read that used to stand in for it: +// after a revoke→re-pair that re-read could tag an OLD pairing's request with +// the NEW pairing's hash, which is exactly the card the remedy would accept. + +/** A peer request whose VERIFIED row carries `hash` (null = carries none). */ +const reqVerifiedAs = (id, hash) => ({ + instanceAuth: { instance: hash === null ? { id } : { id, auth_token_hash: hash } }, + body: CALL, +}); + +async function denyWithVerifiedHash(id, hash) { + const res = mkRes(); + const ok = await enforcePeerExposure({ + prefix: "tools", req: reqVerifiedAs(id, hash), res, + db, connectedServers: CONNECTED, auditFn: async () => {}, + }); + assert.equal(ok, false, "the call is still denied (nothing is exposed)"); + assert.equal(res._status, 403); + const card = (await waitForPending((p) => p.length === 1))[0]; + return card; +} + +test("the card's tag is the VERIFIED pairing's, even when the current row holds another hash", async () => { + const peer = "peer-verified-pairing"; + await trustPeer(peer); // the row now holds tokenHashFor(peer) — "H2" + const H1 = "hash-of-the-pairing-that-actually-asked"; + + const card = await denyWithVerifiedHash(peer, H1); + assert.equal(card.context.requestingInstance, peer); + assert.equal(card.context.pairingTag, pairingTag(H1), "tagged with the pairing the request was verified under"); + assert.notEqual(card.context.pairingTag, tagFor(peer), "NOT with the hash a later re-read of the row would return"); + + // The remedy compares the card's tag with the tag of the CURRENT row (H2): + // they differ, so the click refuses — the H1 device is not the H2 device. + const outcome = await remedy({ capability: "funkwhale", requestingInstance: peer, days: 30 }, { db, item: card }); + assert.equal(outcome.resolved, false, "the card outlived its pairing and is refused"); + assert.match(outcome.message, /pairing has changed/); + assert.equal((await getPeerGrantedCapabilities(db, peer)).size, 0, "nothing granted"); + assert.equal((await getPeerExposures(db)).size, 0, "no allowance was written"); +}); + +test("a verified instance with no hash raises an UNTAGGED card, and the remedy refuses it", async () => { + const peer = "peer-no-verified-hash"; + await trustPeer(peer); // the row HAS a hash: the removed fallback would have used it + + const card = await denyWithVerifiedHash(peer, null); + assert.equal(card.context.pairingTag, null, "no hash on the verified row → no tag, never the current row's"); + + const outcome = await remedy({ capability: "funkwhale", requestingInstance: peer, days: 30 }, { db, item: card }); + assert.equal(outcome.resolved, false, "fail closed: an untagged card grants nothing"); + assert.match(outcome.message, /pairing has changed/); + assert.equal((await getPeerGrantedCapabilities(db, peer)).size, 0, "nothing granted"); + assert.equal((await getPeerExposures(db)).size, 0, "no allowance was written"); +}); + +/** A Fix-it store stub that counts the reads a sweep makes. */ +function countingStore(items = []) { + const calls = { listBySource: 0, markResolved: 0 }; + return { + calls, + listBySource: async () => { calls.listBySource += 1; return items; }, + markResolved: async () => { calls.markResolved += 1; }, + }; +} + +test("a sweep lists the peer-exposure cards ONCE however many revoked peers there are", async () => { + const { reconcilePeerCreds } = await import("../servers/gateway/peer-trust-sweeper.js"); + // Revoked rows are never deleted, so a per-row `listBySource` grows without + // bound (50 revokes → 50 reads every 10 s). + for (let i = 0; i < 50; i++) { + const id = `peer-revoked-bulk-${i}`; + await db.execute({ + sql: "INSERT OR REPLACE INTO crow_instances (id, name, crow_id, status, trusted) VALUES (?, ?, ?, 'revoked', 0)", + args: [id, `Revoked ${i}`, "crow-test"], + }); + } + assert.equal((await store.listPending(db)).length, 0, "nothing is pending in this fixture"); + + const cards = countingStore([]); + await reconcilePeerCreds(db, { fixItStore: cards }); + assert.equal(cards.calls.listBySource, 1, "one card read per sweep, not one per revoked row"); + assert.equal(cards.calls.markResolved, 0, "nothing pending → no card work at all"); +}); + +test("a sweep resolves a pending card for a revoked peer, from the one read it made", async () => { + const { reconcilePeerCreds } = await import("../servers/gateway/peer-trust-sweeper.js"); + const peer = "peer-revoked-bulk-card"; + await trustPeer(peer); + await emitDenied(peer, "funkwhale"); + const card = (await store.listPending(db))[0]; + + await db.execute({ sql: "UPDATE crow_instances SET status = 'revoked' WHERE id = ?", args: [peer] }); + const cards = countingStore([card]); + await reconcilePeerCreds(db, { fixItStore: cards }); + assert.equal(cards.calls.listBySource, 1, "still one read per sweep"); + assert.equal(cards.calls.markResolved, 1, "the revoked peer's card is the one that was answered"); +}); \ No newline at end of file