|
| 1 | +// LoopOver federated fleet intelligence (#1970) — OPT-IN, peer bundle IMPORT + trust-gating (#6480). |
| 2 | +// |
| 3 | +// This is the RECEIVING side: it decides whether a bundle pulled by the transport client |
| 4 | +// (src/orb/federated-collector.ts, #6479) may be folded into local calibration or the peer-median benchmark |
| 5 | +// (#6481) at all. The export side is #6478 (src/orb/federated-bundle.ts). |
| 6 | +// |
| 7 | +// The trust model is #6477's DESIGN DECISION, implemented here exactly as specified and deliberately NOT |
| 8 | +// redesigned. Its two poisoning-resistance layers, and where each one actually lives: |
| 9 | +// 1. ALLOWLIST — only a peer whose verification key the operator explicitly added to |
| 10 | +// `federatedIntelligence.peerKeys` is ever considered. That is enforced HERE, and it is why a Sybil |
| 11 | +// attack is self-limiting by construction: forging peers requires the RECEIVING operator to have added |
| 12 | +// the attacker's keys themselves. Mirrors MCP_READ_REPO_ALLOWLIST's posture: explicit operator config, |
| 13 | +// fail closed when unset, never auto-discovery and never a PKI. |
| 14 | +// 2. MEDIAN, NOT MEAN — a bounded number of outliers cannot drag a median arbitrarily, unlike a mean. That |
| 15 | +// layer needs no code here: the fleet aggregation this feeds already medians (src/orb/analytics.ts:92), |
| 16 | +// so it holds by construction. Re-implementing it in this module would fork the definition #6481's |
| 17 | +// comparison depends on. |
| 18 | +// |
| 19 | +// #6477 explicitly rejected building a reputation/decay/scoring system for trust, so there is deliberately no |
| 20 | +// per-peer score, no anomaly heuristic, and no retroactive poisoned-bundle detection here: an operator who |
| 21 | +// discovers a bad peer removes its key from the allowlist. Adding any of those would be inventing a mechanism |
| 22 | +// that design pass considered and turned down. |
| 23 | +import { canonicalizeFederatedBundleBody, FEDERATED_BUNDLE_SCHEMA_VERSION, type FederatedSignalBundle, type FederatedSignalBundleBody } from "./federated-bundle"; |
| 24 | +import { timingSafeEqualHex } from "../utils/crypto"; |
| 25 | +import type { FocusManifest } from "../signals/focus-manifest"; |
| 26 | +import { createHmac } from "node:crypto"; |
| 27 | + |
| 28 | +/** Why a bundle was not folded in. Every rejection carries one of these, so a rejection is always traceable to |
| 29 | + * a specific rule rather than vanishing silently (#6480 requires rejections be operator-visible). */ |
| 30 | +export type FederatedRejectionReason = |
| 31 | + /** The operator never opted in — nothing inbound is processed at all. */ |
| 32 | + | "not_opted_in" |
| 33 | + /** `peerKeys` is empty: the operator trusts no peer yet, so nothing can verify. Fail closed. */ |
| 34 | + | "no_trusted_peers" |
| 35 | + /** Not a bundle shape this build understands — never guessed at, per FEDERATED_BUNDLE_SCHEMA_VERSION. */ |
| 36 | + | "unsupported_schema_version" |
| 37 | + /** Structurally malformed: a field the signature covers is missing or the wrong type. */ |
| 38 | + | "malformed" |
| 39 | + /** No allowlisted key reproduces the signature: either an untrusted peer or a tampered body. These are |
| 40 | + * deliberately ONE reason — with a detached HMAC the receiver cannot distinguish them, and pretending |
| 41 | + * otherwise would report a distinction this scheme cannot actually make. */ |
| 42 | + | "untrusted_or_tampered"; |
| 43 | + |
| 44 | +/** One rejected bundle, reduced to what an operator can act on without leaking bundle contents. */ |
| 45 | +export interface FederatedRejection { |
| 46 | + /** The claimed instance handle, or null when the bundle was too malformed to read one. Opaque, not identity. */ |
| 47 | + instanceId: string | null; |
| 48 | + reason: FederatedRejectionReason; |
| 49 | +} |
| 50 | + |
| 51 | +export interface FederatedImportResult { |
| 52 | + /** Bundles that passed every gate and may be folded into calibration / the peer median. */ |
| 53 | + accepted: FederatedSignalBundle[]; |
| 54 | + /** Every bundle that did not, with the rule that stopped it. */ |
| 55 | + rejected: FederatedRejection[]; |
| 56 | +} |
| 57 | + |
| 58 | +/** Sink for rejection visibility. Defaults to console.warn so a rejection is never silently dropped even when |
| 59 | + * a caller passes no logger — #6480 forbids a silent drop as explicitly as it forbids silent acceptance. */ |
| 60 | +export type FederatedImportLogger = (rejection: FederatedRejection) => void; |
| 61 | + |
| 62 | +type ManifestSlice = Pick<FocusManifest, "federatedIntelligence">; |
| 63 | + |
| 64 | +/** Is peer IMPORT armed? Opt-in (`enabled`) is necessary but NOT sufficient: an operator who turned on the |
| 65 | + * export and configured no peer keys imports nothing, because trust is explicit and there is no default peer. |
| 66 | + * Kept separate from isFederatedIntelligenceEnabled (the export's gate) precisely so enabling the export can |
| 67 | + * never, by itself, start admitting inbound data. */ |
| 68 | +export function isFederatedImportEnabled(manifest: ManifestSlice | null | undefined): boolean { |
| 69 | + const config = manifest?.federatedIntelligence; |
| 70 | + return config?.enabled === true && config.peerKeys.length > 0; |
| 71 | +} |
| 72 | + |
| 73 | +/** Does `bundle` carry every signature-covered field, with the right type? Guards the canonicalization below: |
| 74 | + * an absent field would otherwise serialize as `undefined` and silently change the signed bytes. */ |
| 75 | +function isBundleBodyShaped(bundle: FederatedSignalBundle): boolean { |
| 76 | + const numeric = (value: unknown): boolean => typeof value === "number" && Number.isFinite(value); |
| 77 | + const nullableNumeric = (value: unknown): boolean => value === null || numeric(value); |
| 78 | + return ( |
| 79 | + typeof bundle.instanceId === "string" && |
| 80 | + typeof bundle.generatedAt === "string" && |
| 81 | + typeof bundle.signature === "string" && |
| 82 | + numeric(bundle.windowDays) && |
| 83 | + numeric(bundle.decided) && |
| 84 | + numeric(bundle.reversalRate) && |
| 85 | + numeric(bundle.slopRate) && |
| 86 | + numeric(bundle.copycatRate) && |
| 87 | + nullableNumeric(bundle.mergePrecision) && |
| 88 | + nullableNumeric(bundle.closePrecision) && |
| 89 | + nullableNumeric(bundle.fpRate) && |
| 90 | + nullableNumeric(bundle.fnRate) && |
| 91 | + nullableNumeric(bundle.cycleP50Ms) && |
| 92 | + nullableNumeric(bundle.cycleP95Ms) |
| 93 | + ); |
| 94 | +} |
| 95 | + |
| 96 | +/** Strip the detached signature back off, so the body is canonicalized over exactly the fields the sender |
| 97 | + * signed. Rebuilt field-by-field rather than by deleting `signature` from a copy: the canonical form is a |
| 98 | + * fixed key list, so an extra property a peer appended can never reach the signed bytes. */ |
| 99 | +function toBody(bundle: FederatedSignalBundle): FederatedSignalBundleBody { |
| 100 | + return { |
| 101 | + schemaVersion: bundle.schemaVersion, |
| 102 | + instanceId: bundle.instanceId, |
| 103 | + generatedAt: bundle.generatedAt, |
| 104 | + windowDays: bundle.windowDays, |
| 105 | + decided: bundle.decided, |
| 106 | + mergePrecision: bundle.mergePrecision, |
| 107 | + closePrecision: bundle.closePrecision, |
| 108 | + fpRate: bundle.fpRate, |
| 109 | + fnRate: bundle.fnRate, |
| 110 | + reversalRate: bundle.reversalRate, |
| 111 | + cycleP50Ms: bundle.cycleP50Ms, |
| 112 | + cycleP95Ms: bundle.cycleP95Ms, |
| 113 | + slopRate: bundle.slopRate, |
| 114 | + copycatRate: bundle.copycatRate, |
| 115 | + }; |
| 116 | +} |
| 117 | + |
| 118 | +/** |
| 119 | + * Does `bundle`'s signature verify against ANY key the operator allowlisted? |
| 120 | + * |
| 121 | + * Every candidate key is tried because the HMAC is detached and carries no key hint — the bundle says which |
| 122 | + * INSTANCE it claims to be from, but `instanceId` is unauthenticated until a key verifies, so selecting a key |
| 123 | + * by it would trust the attacker-controlled field to pick its own verifier. |
| 124 | + * |
| 125 | + * The comparison is timing-safe (timingSafeEqualHex), and the loop deliberately does NOT early-exit on a match: |
| 126 | + * it verifies against all keys and ORs the results, so total work does not depend on WHICH key matched. |
| 127 | + */ |
| 128 | +export function verifyFederatedBundle(bundle: FederatedSignalBundle, peerKeys: readonly string[]): boolean { |
| 129 | + const canonical = canonicalizeFederatedBundleBody(toBody(bundle)); |
| 130 | + let verified = false; |
| 131 | + for (const key of peerKeys) { |
| 132 | + const expected = createHmac("sha256", key).update(canonical).digest("hex"); |
| 133 | + if (timingSafeEqualHex(bundle.signature, expected)) verified = true; |
| 134 | + } |
| 135 | + return verified; |
| 136 | +} |
| 137 | + |
| 138 | +/** Apply every gate to a single bundle. Returns null when it may be folded in, or the reason it may not. */ |
| 139 | +function rejectionFor(bundle: FederatedSignalBundle, peerKeys: readonly string[]): FederatedRejectionReason | null { |
| 140 | + if (bundle?.schemaVersion !== FEDERATED_BUNDLE_SCHEMA_VERSION) return "unsupported_schema_version"; |
| 141 | + if (!isBundleBodyShaped(bundle)) return "malformed"; |
| 142 | + if (!verifyFederatedBundle(bundle, peerKeys)) return "untrusted_or_tampered"; |
| 143 | + return null; |
| 144 | +} |
| 145 | + |
| 146 | +/** |
| 147 | + * Trust-gate a batch of pulled peer bundles, returning only those an operator's own config says to trust. |
| 148 | + * |
| 149 | + * FAIL-SAFE: this is a pure function the gate never consults — it reads no DB, makes no network call, and |
| 150 | + * returns a value rather than mutating anything, so neither a rejected nor a malformed bundle can reach this |
| 151 | + * instance's own review/merge behavior. That is the structural version of #6480's fail-safe requirement: there |
| 152 | + * is no path from here to a gate decision, rather than a guard that could be forgotten. |
| 153 | + */ |
| 154 | +export function importPeerBundles( |
| 155 | + manifest: ManifestSlice | null | undefined, |
| 156 | + bundles: readonly FederatedSignalBundle[], |
| 157 | + opts: { log?: FederatedImportLogger } = {}, |
| 158 | +): FederatedImportResult { |
| 159 | + const log = opts.log ?? defaultRejectionLogger; |
| 160 | + const reject = (instanceId: string | null, reason: FederatedRejectionReason): FederatedRejection => { |
| 161 | + const rejection: FederatedRejection = { instanceId, reason }; |
| 162 | + log(rejection); |
| 163 | + return rejection; |
| 164 | + }; |
| 165 | + |
| 166 | + const config = manifest?.federatedIntelligence; |
| 167 | + // Opted out and no-trusted-peers are reported per bundle rather than once: an operator watching the log for |
| 168 | + // "why did nothing import?" needs the answer attached to the bundles that were actually dropped. |
| 169 | + if (config?.enabled !== true) { |
| 170 | + return { accepted: [], rejected: bundles.map((bundle) => reject(instanceIdOf(bundle), "not_opted_in")) }; |
| 171 | + } |
| 172 | + if (config.peerKeys.length === 0) { |
| 173 | + return { accepted: [], rejected: bundles.map((bundle) => reject(instanceIdOf(bundle), "no_trusted_peers")) }; |
| 174 | + } |
| 175 | + |
| 176 | + const accepted: FederatedSignalBundle[] = []; |
| 177 | + const rejected: FederatedRejection[] = []; |
| 178 | + for (const bundle of bundles) { |
| 179 | + const reason = rejectionFor(bundle, config.peerKeys); |
| 180 | + if (reason === null) accepted.push(bundle); |
| 181 | + else rejected.push(reject(instanceIdOf(bundle), reason)); |
| 182 | + } |
| 183 | + return { accepted, rejected }; |
| 184 | +} |
| 185 | + |
| 186 | +/** The claimed handle, or null when the bundle is too malformed to carry one. Unauthenticated until a |
| 187 | + * signature verifies — only ever used to label a log line, never to select a key or a trust decision. */ |
| 188 | +function instanceIdOf(bundle: FederatedSignalBundle): string | null { |
| 189 | + return typeof bundle?.instanceId === "string" ? bundle.instanceId : null; |
| 190 | +} |
| 191 | + |
| 192 | +/** Operator-visible by default. Logs the reason and the opaque instance handle only — never bundle contents, |
| 193 | + * never a peer key, so a rejection is diagnosable without the log becoming a place secrets leak. */ |
| 194 | +function defaultRejectionLogger(rejection: FederatedRejection): void { |
| 195 | + console.warn(`[federated-import] rejected peer bundle (instance=${rejection.instanceId ?? "unknown"}): ${rejection.reason}`); |
| 196 | +} |
0 commit comments