Skip to content

Commit 729254a

Browse files
feat(orb): trust-gate inbound federated bundles per the #6477 key-trust design (#6649)
* feat(orb): trust-gate inbound federated bundles per the #6477 key-trust design (#6480) Add the receiving side of federated fleet intelligence: verify a pulled peer bundle's HMAC against the operator's explicit peerKeys allowlist before it can be folded into local calibration or the peer-median benchmark, and log every rejection with the rule that stopped it. Implements #6477's decision exactly: explicit operator-configured allowlist (no auto-discovery, no PKI) plus median-not-mean aggregation, which the existing fleet analytics already provides. No reputation/decay/scoring system, which that design pass considered and deliberately turned down. Closes #6480 * test(orb): add peerKeys to the export/transport manifest fixtures (#6480) vitest does not typecheck, so both fixtures passed at runtime while tsc failed on the new required field. Each notes why its own side never reads peerKeys. Also cover defaultRejectionLogger's unknown-handle fallback, the last partial branch in the new module. --------- Co-authored-by: luciferlive112116 <291889058+luciferlive112116@users.noreply.github.com>
1 parent 0c8845f commit 729254a

8 files changed

Lines changed: 539 additions & 7 deletions

File tree

.loopover.yml.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1328,3 +1328,12 @@ settings:
13281328
# # uses, so an http:// or localhost/private-range endpoint is dropped with a warning.
13291329
# collectorUrl: https://collector.example.org/v1/federated
13301330
# collectorMode: both # push | pull | both. Default: both.
1331+
# # WHICH PEERS YOU TRUST (#6480). A pulled bundle is only ever folded into your calibration if its signature
1332+
# # verifies against one of these keys -- trust is explicit and operator-configured, exactly like
1333+
# # MCP_READ_REPO_ALLOWLIST: there is no auto-discovery, no PKI, and no default peer. Unset or empty means you
1334+
# # trust no peer, so EVERY inbound bundle is rejected; `enabled: true` on its own never starts importing.
1335+
# # Each entry is a peer's 64-char hex verification key, which that operator gives you out of band. This is
1336+
# # shared verification material, not a password: treat it the way you treat the rest of this file.
1337+
# # To stop trusting a peer, remove its key -- that is the whole revocation story, by design.
1338+
# peerKeys:
1339+
# - 0000000000000000000000000000000000000000000000000000000000000000

config/examples/loopover.full.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1342,3 +1342,12 @@ settings:
13421342
# # uses, so an http:// or localhost/private-range endpoint is dropped with a warning.
13431343
# collectorUrl: https://collector.example.org/v1/federated
13441344
# collectorMode: both # push | pull | both. Default: both.
1345+
# # WHICH PEERS YOU TRUST (#6480). A pulled bundle is only ever folded into your calibration if its signature
1346+
# # verifies against one of these keys -- trust is explicit and operator-configured, exactly like
1347+
# # MCP_READ_REPO_ALLOWLIST: there is no auto-discovery, no PKI, and no default peer. Unset or empty means you
1348+
# # trust no peer, so EVERY inbound bundle is rejected; `enabled: true` on its own never starts importing.
1349+
# # Each entry is a peer's 64-char hex verification key, which that operator gives you out of band. This is
1350+
# # shared verification material, not a password: treat it the way you treat the rest of this file.
1351+
# # To stop trusting a peer, remove its key -- that is the whole revocation story, by design.
1352+
# peerKeys:
1353+
# - 0000000000000000000000000000000000000000000000000000000000000000

packages/loopover-engine/src/focus-manifest.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,10 @@ export type FocusManifestPrReconciliationConfig = {
476476
export const FEDERATED_COLLECTOR_MODES = ["push", "pull", "both"] as const;
477477
export type FederatedCollectorMode = (typeof FEDERATED_COLLECTOR_MODES)[number];
478478

479+
/** A peer verification key: 64 hex chars — the exact shape generateAnonSecret produces and signFederatedBundle
480+
* consumes as its HMAC key, so an operator can only allowlist something that could actually verify a bundle. */
481+
const FEDERATED_PEER_KEY = /^[0-9a-f]{64}$/;
482+
479483
export type FocusManifestFederatedIntelligenceConfig = {
480484
present: boolean;
481485
enabled: boolean;
@@ -490,6 +494,14 @@ export type FocusManifestFederatedIntelligenceConfig = {
490494
collectorUrl: string | null;
491495
/** Which directions the client may use against `collectorUrl`. Null ⇒ `both`. */
492496
collectorMode: FederatedCollectorMode | null;
497+
/**
498+
* The operator's explicit allowlist of peer verification keys (#6477's key-trust decision, consumed by
499+
* #6480's import path). A pulled bundle is only ever considered if its signature verifies against one of
500+
* these keys — trust is operator-configured, exactly like `MCP_READ_REPO_ALLOWLIST`, never auto-discovered
501+
* and never a PKI. Empty ⇒ no peer is trusted ⇒ every inbound bundle is rejected (fail closed), which is
502+
* also the default, so an operator who opts into the export alone never silently starts importing.
503+
*/
504+
peerKeys: string[];
493505
};
494506

495507
/**
@@ -1257,6 +1269,7 @@ const EMPTY_FEDERATED_INTELLIGENCE_CONFIG: FocusManifestFederatedIntelligenceCon
12571269
enabled: false,
12581270
collectorUrl: null,
12591271
collectorMode: null,
1272+
peerKeys: [],
12601273
};
12611274

12621275
const EMPTY_MANIFEST: FocusManifest = {
@@ -2194,7 +2207,30 @@ function parseFederatedIntelligenceConfig(value: JsonValue | undefined, warnings
21942207
const enabled = normalizeOptionalBoolean(record.enabled, "federatedIntelligence.enabled", warnings) ?? false;
21952208
const collectorUrl = parseFederatedCollectorUrl(record.collectorUrl, warnings);
21962209
const collectorMode = normalizeOptionalEnum(record.collectorMode, "federatedIntelligence.collectorMode", FEDERATED_COLLECTOR_MODES, warnings);
2197-
return { present: true, enabled, collectorUrl, collectorMode };
2210+
const peerKeys = parseFederatedPeerKeys(record.peerKeys, warnings);
2211+
return { present: true, enabled, collectorUrl, collectorMode, peerKeys };
2212+
}
2213+
2214+
/** Parse `federatedIntelligence.peerKeys` (#6480) — the operator's explicit peer-trust allowlist from #6477's
2215+
* design. Each entry must be a 64-char hex key, the shape signFederatedBundle's HMAC key already has; a
2216+
* malformed entry is dropped with a warning rather than throwing, matching every sibling list field. Dropping
2217+
* rather than failing closed on the whole list is deliberate and safe in this direction: a dropped key can only
2218+
* ever REMOVE a peer's bundles from consideration, never admit an untrusted one. */
2219+
function parseFederatedPeerKeys(value: JsonValue | undefined, warnings: string[]): string[] {
2220+
const raw = normalizeStringList(value, "federatedIntelligence.peerKeys", warnings);
2221+
const keys: string[] = [];
2222+
const seen = new Set<string>();
2223+
for (const entry of raw) {
2224+
const key = entry.toLowerCase();
2225+
if (!FEDERATED_PEER_KEY.test(key)) {
2226+
warnings.push(`Manifest "federatedIntelligence.peerKeys" entry is not a 64-character hex key; ignoring it.`);
2227+
continue;
2228+
}
2229+
if (seen.has(key)) continue; // first occurrence wins, like normalizeAutoCloseExemptLogins
2230+
seen.add(key);
2231+
keys.push(key);
2232+
}
2233+
return keys;
21982234
}
21992235

22002236
/** Parse `federatedIntelligence.collectorUrl` (#6479) — validated at CONFIG-READ time against the same
@@ -2216,7 +2252,12 @@ function parseFederatedCollectorUrl(value: JsonValue | undefined, warnings: stri
22162252
* configured. */
22172253
export function federatedIntelligenceConfigToJson(config: FocusManifestFederatedIntelligenceConfig): JsonValue {
22182254
if (!config.present) return null;
2219-
return { enabled: config.enabled, collectorUrl: config.collectorUrl, collectorMode: config.collectorMode };
2255+
return {
2256+
enabled: config.enabled,
2257+
collectorUrl: config.collectorUrl,
2258+
collectorMode: config.collectorMode,
2259+
peerKeys: [...config.peerKeys],
2260+
};
22202261
}
22212262

22222263
function normalizeOptionalEnum<T extends string>(value: JsonValue | undefined, field: string, allowed: readonly T[], warnings: string[]): T | null {

src/orb/federated-import.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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+
}

test/unit/federated-bundle.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ function manifest(enabled: boolean | undefined): Pick<FocusManifest, "federatedI
7373
federatedIntelligence: {
7474
present: enabled !== undefined,
7575
enabled: enabled ?? false,
76+
// The EXPORT never reads peerKeys — that allowlist gates the import side (#6480) only.
77+
peerKeys: [],
7678
collectorUrl: null,
7779
collectorMode: null,
7880
},

test/unit/federated-collector.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ function manifest(
5353
federatedIntelligence: {
5454
present: o.present ?? true,
5555
enabled: o.enabled ?? true,
56+
// The TRANSPORT never reads peerKeys — pulled bundles are trust-gated downstream by #6480's import.
57+
peerKeys: [],
5658
collectorUrl: o.collectorUrl === undefined ? URL_OK : o.collectorUrl,
5759
collectorMode: o.collectorMode ?? null,
5860
},

0 commit comments

Comments
 (0)