@xaccefy/pi-casefile 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/oob-oracle.ts DELETED
@@ -1,279 +0,0 @@
1
- /**
2
- * Operator-owned OOB oracle client — Tier 1 of docs/poc-trust-model.md.
3
- *
4
- * Blind/OOB classes (SSRF, blind XSS, XXE, DNS exfil) cannot be confirmed by
5
- * response differentials: the effect lands on a callback channel. The trust
6
- * model requires the judge to own the evidence channel and the secret:
7
- *
8
- * - The HARNESS generates a per-run random token and provisions a callback
9
- * domain embedding it via the operator-run oracle service. The value does
10
- * not exist when the PoC script is written, so it cannot be pre-printed.
11
- * - The PoC causes the TARGET to interact with that domain; the oracle's own
12
- * interaction log — read back by the harness, never by the PoC — is the
13
- * evidence.
14
- * - Differential shape: the target run gets one token, the control run a
15
- * DIFFERENT token. Proof requires target-token interactions AND zero
16
- * control-token interactions.
17
- *
18
- * Source separation (the PoC must not be able to fake the interaction):
19
- * - Hard guarantee (three-box model): only when the operator attests the
20
- * network topology separates PoC egress from oracle reachability via
21
- * PI_OOB_SOURCE_SEPARATED=1. Without it, verification stays diagnostic
22
- * and the ledger gate refuses promotion.
23
- * - Self-interaction filtering: interactions originating from PI_OOB_SELF_IPS
24
- * (the sandbox/host egress addresses) are rejected as self-caused and never
25
- * counted as target hits.
26
- *
27
- * Everything fails closed: no oracle configured -> OOB promotion impossible.
28
- */
29
-
30
- import { randomBytes } from "node:crypto";
31
-
32
- export type OobInteraction = {
33
- protocol?: string;
34
- src_ip?: string;
35
- ts?: string;
36
- raw?: string;
37
- };
38
-
39
- export type OobOracleConfig = {
40
- baseUrl: string;
41
- bearer?: string;
42
- sourceSeparated: boolean;
43
- selfIps: string[];
44
- };
45
-
46
- /** Read the operator's oracle configuration; error text explains what's missing. */
47
- export function readOobOracleConfig(env: NodeJS.ProcessEnv = process.env): {
48
- config?: OobOracleConfig;
49
- error?: string;
50
- } {
51
- const raw = (env.PI_OOB_ORACLE_URL ?? "").trim();
52
- if (!raw) {
53
- return {
54
- error:
55
- "no OOB oracle configured. Set PI_OOB_ORACLE_URL to an operator-run oracle service " +
56
- "(POST /provision {token} -> {domain}; GET /interactions?token= -> {interactions}). " +
57
- "Declare PI_OOB_SOURCE_SEPARATED=1 only when the network topology truly prevents the " +
58
- "PoC sandbox from reaching the oracle directly.",
59
- };
60
- }
61
- let baseUrl: URL;
62
- try {
63
- baseUrl = new URL(raw);
64
- } catch {
65
- return { error: `PI_OOB_ORACLE_URL is not a valid URL: ${raw}` };
66
- }
67
- if (baseUrl.protocol !== "http:" && baseUrl.protocol !== "https:") {
68
- return { error: `PI_OOB_ORACLE_URL must be http(s), got ${baseUrl.protocol}` };
69
- }
70
- const selfIps = (env.PI_OOB_SELF_IPS ?? "")
71
- .split(",")
72
- .map((v) => v.trim())
73
- .filter(Boolean);
74
- return {
75
- config: {
76
- baseUrl: raw.replace(/\/+$/, ""),
77
- bearer: env.PI_OOB_ORACLE_TOKEN?.trim() || undefined,
78
- sourceSeparated: env.PI_OOB_SOURCE_SEPARATED === "1",
79
- selfIps,
80
- },
81
- };
82
- }
83
-
84
- type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
85
-
86
- let oracleFetchForTest: FetchLike | undefined;
87
-
88
- /** Test seam; production uses global fetch. */
89
- export function setOobOracleFetchForTest(impl: FetchLike | undefined): void {
90
- oracleFetchForTest = impl;
91
- }
92
-
93
- function makeToken(): string {
94
- return randomBytes(16).toString("hex");
95
- }
96
-
97
- async function oracleFetch(
98
- config: OobOracleConfig,
99
- path: string,
100
- init?: RequestInit,
101
- ): Promise<Response> {
102
- const headers: Record<string, string> = {
103
- accept: "application/json",
104
- ...((init?.headers as Record<string, string>) ?? {}),
105
- };
106
- if (config.bearer) headers.authorization = `Bearer ${config.bearer}`;
107
- const fetchImpl = oracleFetchForTest ?? fetch;
108
- // Bounded: an unresponsive oracle must fail the run, not hang the tool.
109
- return fetchImpl(`${config.baseUrl}${path}`, {
110
- ...init,
111
- headers,
112
- signal: AbortSignal.timeout(30_000),
113
- });
114
- }
115
-
116
- export type ProvisionedCallback = {
117
- /** Harness-generated secret embedded in the provisioned domain. */
118
- token: string;
119
- /** Domain the payload must make the target interact with. */
120
- domain: string;
121
- };
122
-
123
- /**
124
- * Provision one callback identity. The token is generated HERE (harness owns
125
- * the secret); the oracle returns a domain that embeds it.
126
- */
127
- export async function provisionCallback(config: OobOracleConfig): Promise<ProvisionedCallback> {
128
- const token = makeToken();
129
- let res: Response;
130
- try {
131
- res = await oracleFetch(config, "/provision", {
132
- method: "POST",
133
- headers: { "content-type": "application/json" },
134
- body: JSON.stringify({ token }),
135
- });
136
- } catch (e) {
137
- throw new Error(`OOB oracle unreachable (${config.baseUrl}): ${(e as Error).message}`);
138
- }
139
- if (!res.ok) {
140
- throw new Error(`OOB oracle /provision failed: HTTP ${res.status}`);
141
- }
142
- let body: { domain?: unknown };
143
- try {
144
- body = (await res.json()) as { domain?: unknown };
145
- } catch {
146
- throw new Error("OOB oracle /provision returned a non-JSON body");
147
- }
148
- if (typeof body.domain !== "string" || !body.domain.includes(token)) {
149
- throw new Error(
150
- "OOB oracle /provision returned a domain that does not embed the harness token — refusing an oracle that invents its own secrets",
151
- );
152
- }
153
- return { token, domain: body.domain };
154
- }
155
-
156
- export type OobPollResult = {
157
- interactions: OobInteraction[];
158
- /** Interactions NOT counted (self-IP matches) — recorded honestly. */
159
- selfInteractions: OobInteraction[];
160
- };
161
-
162
- /** Fetch (not wait-and-retry — callers own the polling loop) current interactions for a token. */
163
- export async function fetchInteractions(
164
- config: OobOracleConfig,
165
- token: string,
166
- ): Promise<OobPollResult> {
167
- let res: Response;
168
- try {
169
- res = await oracleFetch(config, `/interactions?token=${encodeURIComponent(token)}`);
170
- } catch (e) {
171
- throw new Error(`OOB oracle unreachable (${config.baseUrl}): ${(e as Error).message}`);
172
- }
173
- if (!res.ok) {
174
- throw new Error(`OOB oracle /interactions failed: HTTP ${res.status}`);
175
- }
176
- let body: { interactions?: unknown };
177
- try {
178
- body = (await res.json()) as { interactions?: unknown };
179
- } catch {
180
- throw new Error("OOB oracle /interactions returned a non-JSON body");
181
- }
182
- if (!Array.isArray(body.interactions)) {
183
- throw new Error("OOB oracle /interactions response missing interactions array");
184
- }
185
- const all = body.interactions.filter(
186
- (i): i is OobInteraction => typeof i === "object" && i !== null,
187
- );
188
- const self = config.selfIps;
189
- // Fail closed on provenance: an interaction WITHOUT a source IP cannot be
190
- // attributed to the target (a PoC could fabricate one on any channel it
191
- // reaches), so it is never counted as a hit — surfaced separately instead.
192
- const interactions = all.filter(
193
- (i) => typeof i.src_ip === "string" && i.src_ip.length > 0 && !self.includes(i.src_ip),
194
- );
195
- const selfInteractions = all.filter(
196
- (i) => !i.src_ip || (typeof i.src_ip === "string" && self.includes(i.src_ip)),
197
- );
198
- return { interactions, selfInteractions };
199
- }
200
-
201
- /**
202
- * Poll for interactions on both run tokens. Once ANY token observes an
203
- * interaction, polling continues for a full settle window so a DELAYED
204
- * control-token hit (the false-positive shape: target fires at t=1s, a
205
- * cheating/self-caused control hit lands at t=3s after an early exit)
206
- * cannot be missed. Evaluation happens after settle or at deadline.
207
- */
208
- export async function verifyOobDifferential(
209
- opts: {
210
- targetToken: string;
211
- controlToken: string;
212
- pollMs?: number;
213
- intervalMs?: number;
214
- /** Keep polling this long after the first observed interaction. */
215
- settleMs?: number;
216
- },
217
- env: NodeJS.ProcessEnv = process.env,
218
- ): Promise<{ verification: import("./ledger.ts").OobVerification }> {
219
- const { config, error } = readOobOracleConfig(env);
220
- if (!config) throw new Error(error ?? "OOB oracle not configured");
221
- // Env overrides are operator tuning: clamp to sane minimums so a typo like
222
- // PI_OOB_POLL_MS=-5 cannot produce an inverted deadline or a zero window.
223
- const pollMs = opts.pollMs ?? Math.max(Number(env.PI_OOB_POLL_MS) || 30_000, 1_000);
224
- const intervalMs = opts.intervalMs ?? Math.max(Number(env.PI_OOB_INTERVAL_MS) || 2_000, 100);
225
- const settleMs =
226
- opts.settleMs ?? Math.max(Number(env.PI_OOB_SETTLE_MS) || Math.max(intervalMs, 5_000), 250);
227
- const deadline = Date.now() + pollMs;
228
-
229
- let target: OobPollResult = { interactions: [], selfInteractions: [] };
230
- let control: OobPollResult = { interactions: [], selfInteractions: [] };
231
- let firstHitAt: number | undefined;
232
- for (;;) {
233
- target = await fetchInteractions(config, opts.targetToken);
234
- control = await fetchInteractions(config, opts.controlToken);
235
- if (target.interactions.length > 0 || control.interactions.length > 0) {
236
- firstHitAt ??= Date.now();
237
- // Settled: kept watching past the first hit long enough to catch
238
- // trailing control hits.
239
- if (Date.now() - firstHitAt >= settleMs) break;
240
- }
241
- const now = Date.now();
242
- if (now >= deadline) break;
243
- await new Promise((r) => setTimeout(r, Math.min(intervalMs, deadline - now)));
244
- }
245
-
246
- const notes: string[] = [];
247
- if (target.selfInteractions.length + control.selfInteractions.length > 0) {
248
- notes.push(
249
- `${target.selfInteractions.length} target-run / ${control.selfInteractions.length} ` +
250
- "control-run unattributed-or-self-source interaction(s) rejected (missing src_ip or PI_OOB_SELF_IPS)",
251
- );
252
- }
253
- notes.push(
254
- config.sourceSeparated
255
- ? "operator attests source separation (PI_OOB_SOURCE_SEPARATED=1)"
256
- : "source separation NOT attested — diagnostic only",
257
- );
258
- // Tokens are stored raw in the ledger (see PendingConfirmation.oobTokens);
259
- // an oracle reachable without a bearer token makes those tokens pollable by
260
- // anyone with ledger read access. Surface it where the operator's attention
261
- // already is — the verification note.
262
- if (!config.bearer) {
263
- notes.push(
264
- "oracle has no PI_OOB_ORACLE_TOKEN — stored run tokens are pollable by anyone with oracle network access; set a bearer token or restrict the oracle endpoint",
265
- );
266
- }
267
-
268
- return {
269
- verification: {
270
- attempted: true,
271
- targetHits: target.interactions.length,
272
- controlHits: control.interactions.length,
273
- sourceSeparated: config.sourceSeparated,
274
- note:
275
- `target-token ${target.interactions.length} interaction(s), control-token ${control.interactions.length}` +
276
- (notes.length ? `; ${notes.join("; ")}` : ""),
277
- },
278
- };
279
- }