privateer-agent 0.10.0 → 0.12.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.
@@ -37,7 +37,7 @@ import {
37
37
 
38
38
  // Seed/fallback catalog: registered synchronously so the account provider has real
39
39
  // models the instant it loads (before the live /api/models fetch resolves) — in
40
- // particular the default, tinfoil/glm-5-2, resolves at startup without a "model not
40
+ // particular ACCOUNT_DEFAULT_MODEL_ID resolves at startup without a "model not
41
41
  // found" warning, which matters more than ever now that a signed-OUT terminal also
42
42
  // launches on it. The first two entries are the TEE tiers (Tinfoil, then NEAR); the
43
43
  // rest are the familiar names. Also the fallback list if the live listing is
@@ -46,6 +46,11 @@ import {
46
46
  const DEFAULT_MODELS = [
47
47
  ACCOUNT_DEFAULT_MODEL_ID,
48
48
  ACCOUNT_NEAR_MODEL_ID,
49
+ // The default until 2026-08-01 (see TINFOIL_MODEL_ID). It stays in the floor so a
50
+ // user who saved it as their own default still resolves it synchronously at launch,
51
+ // rather than falling through to "first model with configured auth" — the BYO dead
52
+ // end this seed list exists to prevent.
53
+ "tinfoil/glm-5-2",
49
54
  "anthropic/claude-opus-5",
50
55
  "anthropic/claude-sonnet-5",
51
56
  "openai/gpt-5.6-sol",
@@ -56,7 +61,9 @@ function seedModel(id: string) {
56
61
  return {
57
62
  id,
58
63
  name: id,
59
- reasoning: false,
64
+ // reasoning + how to steer it, for the enclave models where we verified the
65
+ // control shape live; `reasoning: false` (Pi's "not a thinking model") for the rest.
66
+ ...(thinkingProfile(id) ?? { reasoning: false as const }),
60
67
  input: ["text"] as ("text" | "image")[],
61
68
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
62
69
  contextWindow: 128000,
@@ -64,6 +71,83 @@ function seedModel(id: string) {
64
71
  };
65
72
  }
66
73
 
74
+ // ── Thinking control ─────────────────────────────────────────────────────────
75
+ //
76
+ // Every account model used to register with `reasoning: false`, and that one field
77
+ // silently pinned the whole catalog to maximum thinking. Pi gates EVERY
78
+ // thinking-control branch on `model.reasoning` (pi-ai api/openai-completions.js
79
+ // buildParams) and AgentSession.cycleThinkingLevel() returns undefined without it. So
80
+ // Privateer sent no thinking parameter at all — a thinking model ran at whatever its
81
+ // server-side default was, forever — and the user's thinking toggle was inert.
82
+ //
83
+ // What that cost, measured live against the account channel on 2026-08-01 with
84
+ // "Write a haiku about the sea": the default model emitted 77 reasoning deltas and
85
+ // ZERO content, spending all 300 tokens thinking. The same prompt with thinking off
86
+ // answered in 18 tokens / 1.8s.
87
+ //
88
+ // Annotating a model is a promise that the dial actually moves, so ONLY shapes
89
+ // verified against the live enclave appear below. Pi's default level is "medium", so
90
+ // nothing here turns thinking off behind the user's back — it makes the toggle real.
91
+ interface ThinkingProfile {
92
+ reasoning: true;
93
+ thinkingLevelMap?: Record<string, string | null>;
94
+ compat?: { thinkingFormat: string };
95
+ }
96
+
97
+ // The vLLM chat-template family (GLM, Qwen). Honours
98
+ // `chat_template_kwargs.enable_thinking`, which is exactly what Pi's
99
+ // "qwen-chat-template" format emits. Verified — enable_thinking=false → 0 reasoning
100
+ // deltas and a direct answer, true → thinking restored, neither errors — on
101
+ // tinfoil/glm-5-2, near/zai-org/GLM-5.1-FP8, near/Qwen/Qwen3.6-35B-A3B-FP8 and
102
+ // phala/z-ai/glm-5.2.
103
+ //
104
+ // The switch is binary (there is no effort dial), so publish exactly two levels
105
+ // instead of five that all mean "on": a null in thinkingLevelMap marks a level
106
+ // unsupported and pi-ai's getSupportedThinkingLevels drops it.
107
+ const CHAT_TEMPLATE_THINKING: ThinkingProfile = {
108
+ reasoning: true,
109
+ thinkingLevelMap: { minimal: null, low: null, high: null, xhigh: null },
110
+ compat: { thinkingFormat: "qwen-chat-template" },
111
+ };
112
+
113
+ // gpt-oss (harmony) is the other way round: it IGNORES chat_template_kwargs and
114
+ // honours `reasoning_effort` — which is Pi's default format for our baseUrl, so this
115
+ // profile deliberately carries no compat override. Verified on tinfoil/gpt-oss-120b:
116
+ // low → 9 reasoning deltas, high → 61.
117
+ //
118
+ // Harmony has no "none", so "off" is pinned to the floor rather than left unset —
119
+ // unset would send nothing and let the model fall back to its own default (medium),
120
+ // i.e. an "off" that thinks harder than "low". This is the toggle's lowest setting,
121
+ // not silence.
122
+ const REASONING_EFFORT_THINKING: ThinkingProfile = {
123
+ reasoning: true,
124
+ thinkingLevelMap: { off: "low", minimal: "low", xhigh: null },
125
+ };
126
+
127
+ // Only the TEE prefixes are annotated. Those are enclaves we drive directly and can
128
+ // probe. The rest of the catalog is proxied to a third-party gateway whose thinking
129
+ // shape we have NOT verified from here, and an unsupported parameter fails the whole
130
+ // turn — decisively worse than a turn that thinks too much. They keep the old
131
+ // behaviour exactly.
132
+ //
133
+ // Two deliberate omissions inside the TEE set: `*-instruct` ids are the
134
+ // non-thinking variants, and tinfoil/kimi-k2-6 reasons but ignored BOTH levers when
135
+ // probed, so annotating it would hand the user a dial connected to nothing.
136
+ const TEE_MODEL = /^(tinfoil|phala|near)\//;
137
+
138
+ export function thinkingProfile(id: string): ThinkingProfile | null {
139
+ if (!TEE_MODEL.test(id)) return null;
140
+ if (/instruct/i.test(id)) return null;
141
+ const profile = /gpt-oss/i.test(id)
142
+ ? REASONING_EFFORT_THINKING
143
+ : /glm|qwen/i.test(id)
144
+ ? CHAT_TEMPLATE_THINKING
145
+ : null;
146
+ // Hand out a COPY. These entries end up on hundreds of registered models, and a
147
+ // shared nested object is one careless mutation away from retuning the whole catalog.
148
+ return profile && { ...profile, thinkingLevelMap: { ...profile.thinkingLevelMap }, ...(profile.compat ? { compat: { ...profile.compat } } : {}) };
149
+ }
150
+
67
151
  // ── Catalog cache ────────────────────────────────────────────────────────────
68
152
  //
69
153
  // The live catalog (241 models and counting) can only be registered once the network
@@ -114,10 +198,37 @@ export function loadCachedCatalogIds(): string[] {
114
198
  }
115
199
  }
116
200
 
201
+ // Whether the account channel can actually SERVE a catalog model right now.
202
+ //
203
+ // `phala/*` is sealed-only: it runs through the sealed blind relay and nowhere else.
204
+ // The server's cleartext `/api/agent/v1` has no Phala route and rejects the id
205
+ // outright (verified live 2026-07-31: 400 "phala/… is not a valid model ID"), because
206
+ // Phala models are the Sealed tier by design — the server is not meant to be able to
207
+ // read them. But `/api/models` advertises them to every client regardless of whether
208
+ // that client can reach the sealed path, so they were pickable and then failed on the
209
+ // first prompt. Offering a model we know cannot answer is worse than a shorter list.
210
+ //
211
+ // The condition is the SHIM, not the flag. Sealed mode being enabled only means we
212
+ // intend to seal; `phala/*` is unservable until the loopback shim is actually
213
+ // listening, because that is what its per-model baseUrl points at (see modelEntry).
214
+ // With the flag now defaulting on, "enabled but the shim failed to bind" is a state a
215
+ // user can really land in, and it must not re-offer models that would 400.
216
+ //
217
+ // `tinfoil/*` is deliberately NOT filtered: the cleartext path serves it fine (sealed
218
+ // mode only upgrades the badge from unconfirmed to verified), so it stays either way.
219
+ export function isServableAccountModel(id: string): boolean {
220
+ if (!id.startsWith("phala/")) return true;
221
+ return sealedEnabled() && sealedShimBase() !== null;
222
+ }
223
+
117
224
  // The ids to register synchronously at load. DEFAULT_MODELS FIRST and always: the account
118
225
  // default has to be index 0 both because it must always resolve and because Pi clones the
119
226
  // provider's first/default model when it synthesizes a custom model id
120
227
  // (model-resolver.js buildFallbackModel).
228
+ //
229
+ // Returns the server's list as cached, unfiltered — accountProviderConfig decides what
230
+ // is servable at each registration, so a model dropped now (shim not up yet) can be
231
+ // re-offered by a later re-registration without the cache being rewritten.
121
232
  export function seedCatalogIds(): string[] {
122
233
  const ids = [...DEFAULT_MODELS];
123
234
  const seen = new Set(ids);
@@ -202,7 +313,9 @@ export async function fetchAccountCatalog(): Promise<AccountModelInfo[]> {
202
313
  .map((m) => (m.modelId ? { id: m.modelId, tier: normalizeTier(m.privacy?.tier, m.modelId) } : null))
203
314
  .filter((x): x is AccountModelInfo => !!x);
204
315
  // Cache only a real LIVE listing — never the fallback, which would freeze the six
205
- // seed ids on disk and read back as though it were the catalog.
316
+ // seed ids on disk and read back as though it were the catalog. Both the cache and
317
+ // the returned list are the server's UNFILTERED offer; servability is decided at
318
+ // registration (accountProviderConfig), which re-evaluates it every time.
206
319
  if (parsed.length) saveCachedCatalogIds(parsed.map((p) => p.id));
207
320
  infos = parsed.length ? parsed : fallback();
208
321
  }
@@ -367,7 +480,7 @@ const TEE_PREFIXES = ["near/", "tinfoil/", "phala/"];
367
480
  // Which privacy channel an account model routes through: confidential compute (TEE)
368
481
  // for the prefixes above, else a server-side ZDR channel. Ported from tree-cli
369
482
  // resolve.ts, then widened — it used to say `near/` only, which quietly labelled the
370
- // default model (tinfoil/glm-5-2, a TEE model) as a mere ZDR policy claim.
483
+ // then-default model (tinfoil/glm-5-2, a TEE model) as a mere ZDR policy claim.
371
484
  export function privateerChannel(modelId: string): "tee" | "zdr" {
372
485
  return TEE_PREFIXES.some((p) => modelId.startsWith(p)) ? "tee" : "zdr";
373
486
  }
@@ -402,14 +515,15 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
402
515
  const att = await attestSealed(sealedProvider);
403
516
  return att.ok ? { tier: "tee-verified" } : { tier: "tee-unverified", error: att.error };
404
517
  }
405
- // Honest labelling for the non-NEAR enclaves without sealed mode. Tinfoil and Phala
406
- // publish real attestations, but the server proxies the inference in cleartext, so
407
- // from here we cannot bind a quote to the connection actually carrying our tokens —
408
- // only the account's word that it did. That's `tee-unverified` (yellow "confidential
409
- // compute, unconfirmed"), never the green tee-verified we reserve for a quote we
410
- // checked ourselves. Turn on sealed mode (PRIVATEER_SEALED=1) for the verified
411
- // shield, or set TINFOIL_API_KEY and run `tinfoil/*` direct (pi-privacy attests
412
- // client-side over the TLS binding).
518
+ // Honest labelling for the non-NEAR enclaves when we are NOT sealing — sealed mode
519
+ // explicitly disabled (PRIVATEER_SEALED=0), or on but the shim never came up. Tinfoil
520
+ // and Phala publish real attestations, but the server proxies the inference in
521
+ // cleartext, so from here we cannot bind a quote to the connection actually carrying
522
+ // our tokens — only the account's word that it did. That's `tee-unverified` (yellow
523
+ // "confidential compute, unconfirmed"), never the green tee-verified we reserve for a
524
+ // quote we checked ourselves. Re-enable sealed mode for the verified shield, or set
525
+ // TINFOIL_API_KEY and run `tinfoil/*` direct (pi-privacy attests client-side over the
526
+ // TLS binding).
413
527
  if (!modelId.startsWith("near/")) {
414
528
  return { tier: "tee-unverified" };
415
529
  }
@@ -429,11 +543,13 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
429
543
  }
430
544
  }
431
545
 
432
- // A model entry, with a per-model baseUrl override once the EHBP shim is listening:
433
- // `tinfoil/*` then route through the loopback shim (which seals to the blind relay)
434
- // instead of the cleartext `/api/agent/v1` proxy. Everything else keeps the provider
435
- // baseUrl. Until the shim is up (or when sealed mode is off) sealed models fall back to
436
- // the cleartext path — and the badge stays honestly `tee-unverified` (see accountPosture).
546
+ // A model entry, with a per-model baseUrl override once the sealed shim is listening:
547
+ // `tinfoil/*` and `phala/*` then route through the loopback shim (which seals to the
548
+ // blind relay) instead of the cleartext `/api/agent/v1` proxy. Everything else keeps the
549
+ // provider baseUrl. Until the shim is up (or with sealed mode disabled) `tinfoil/*` falls
550
+ // back to the cleartext path and the badge stays honestly `tee-unverified` (see
551
+ // accountPosture); `phala/*` has no cleartext path at all and is not registered in that
552
+ // state (see isServableAccountModel).
437
553
  function modelEntry(id: string) {
438
554
  const base = seedModel(id);
439
555
  const provider = sealedEnabled() ? sealedProviderFor(id) : null;
@@ -448,7 +564,13 @@ export function accountProviderConfig(ids: string[]): Record<string, unknown> {
448
564
  baseUrl: `${serverBaseUrl()}/api/agent/v1`,
449
565
  api: "openai-completions",
450
566
  oauth: privateerOAuthProvider,
451
- models: ids.map(modelEntry),
567
+ // Filter HERE rather than at the catalog, so callers keep passing the server's
568
+ // full list and every registration re-evaluates servability against the CURRENT
569
+ // shim state. That is what lets the post-shim re-registration in makeAccountProvider
570
+ // put `phala/*` back: had the ids been filtered upstream, the sealed-only models
571
+ // would have been dropped from `lastIds` before the shim ever finished starting and
572
+ // nothing would have brought them back.
573
+ models: ids.filter(isServableAccountModel).map(modelEntry),
452
574
  };
453
575
  }
454
576
 
@@ -460,7 +582,7 @@ export function accountProviderConfig(ids: string[]): Record<string, unknown> {
460
582
  // REPLACES a provider's model list and its request config, so whichever registration
461
583
  // lands last wins — and pi extensions are discovered with an unsorted readdirSync, which
462
584
  // on a typical box puts privateer-privacy after privateer-account. The account channel's
463
- // whole catalog was then replaced by that one model, so the default `tinfoil/glm-5-2` no
585
+ // whole catalog was then replaced by that one model, so the account default no
464
586
  // longer resolved ("not found for provider privateer. Using custom model id") and the
465
587
  // synthesized model inherited the PUBLIC endpoint instead of `/api/agent/v1`.
466
588
  //
@@ -15,13 +15,29 @@ import { join } from "node:path";
15
15
  import { hasCredentials } from "../auth/privateer.ts";
16
16
  import { agentDir } from "../config/paths.ts";
17
17
 
18
- // Tinfoil's most capable chat model, and Privateer's default everywhere. Tinfoil runs
19
- // GLM 5.2 inside an attestable TEE (the serving enclave's quote is published and the
20
- // live TLS key is bound to it), which is the strongest privacy tier we offer — so the
21
- // most capable model on that tier is what a privacy-first agent should boot on.
18
+ // A capable Tinfoil chat model, and Privateer's default everywhere. Tinfoil runs it
19
+ // inside an attestable TEE (the serving enclave's quote is published and the live TLS
20
+ // key is bound to it), which is the strongest privacy tier we offer — so a capable
21
+ // model on that tier is what a privacy-first agent should boot on.
22
22
  // One definition, three consumers: this resolver, providers/account.ts's seed catalog,
23
23
  // and bin/privateer-launch.mjs (which mirrors the id — keep them in step).
24
- export const TINFOIL_MODEL_ID = "tinfoil/glm-5-2";
24
+ //
25
+ // It was `tinfoil/glm-5-2` until 2026-08-01, and the swap is a LATENCY decision, not a
26
+ // capability one. Measured over 22 requests spaced 20s apart on the account channel,
27
+ // glm-5-2 stalled before its first token on 9 of them — 33s to 98s each, with the
28
+ // model demonstrably warm 20 seconds earlier, so it is contention in that deployment
29
+ // rather than a cold start anything here can warm up. kimi-k2-6 and gpt-oss-120b, same
30
+ // enclave provider, same tier, same transport, stalled 0 times in 20 (medians 1.2s and
31
+ // 1.0s). The same run reproduced glm-5-2's stalls on BOTH the sealed and the cleartext
32
+ // path, which is what rules out the shim, the relay and the proxy as the cause.
33
+ //
34
+ // Two consequences worth knowing when revisiting this: glm-5-2 remains in the live
35
+ // catalog and is one pick away for anyone who wants it, and kimi-k2-6 reasons on every
36
+ // turn with no working off switch (see thinkingProfile in providers/account.ts — its
37
+ // levers were probed and none of them moved the reasoning volume). Its reasoning is
38
+ // short and it always reaches an answer, which is why that is acceptable here and was
39
+ // not for glm-5-2.
40
+ export const TINFOIL_MODEL_ID = "tinfoil/kimi-k2-6";
25
41
 
26
42
  // Same model, reached two ways:
27
43
  // - TINFOIL_DEFAULT_SPEC — direct to inference.tinfoil.sh with the user's own
@@ -63,6 +79,31 @@ export interface ResolveDefaultModelOptions {
63
79
  env?: NodeJS.ProcessEnv;
64
80
  // Override the signed-in check (testing). Defaults to hasCredentials().
65
81
  signedIn?: boolean;
82
+ // The user's saved Pi default ("provider/id"). undefined (the default) reads it
83
+ // from agentDir()/settings.json; null skips it entirely — resolveSignedInModel
84
+ // uses null because the sign-in TARGET must stay the confidential model (the
85
+ // decision to stay on a saved pick is made explicitly at its call sites, where
86
+ // the account channel still gets armed either way).
87
+ saved?: string | null;
88
+ }
89
+
90
+ // The user's own persisted model pick: Pi writes defaultProvider + defaultModel to
91
+ // agentDir()/settings.json on EVERY interactive switch (AgentSession.setModel →
92
+ // setDefaultModelAndProvider — both the built-in selector and pi-privacy's /models
93
+ // picker land there). That makes it the strongest non-env signal of deliberate
94
+ // intent we have, so resolveDefaultModel ranks it right after PRIVATEER_MODEL.
95
+ // Returns "provider/id", or null when either half is missing.
96
+ export function savedPiDefaultSpec(): string | null {
97
+ try {
98
+ const raw = readFileSync(join(agentDir(), "settings.json"), "utf8").trim();
99
+ if (!raw) return null;
100
+ const s = JSON.parse(raw) as Record<string, unknown>;
101
+ const provider = typeof s.defaultProvider === "string" ? s.defaultProvider.trim() : "";
102
+ const modelId = typeof s.defaultModel === "string" ? s.defaultModel.trim() : "";
103
+ return provider && modelId ? `${provider}/${modelId}` : null;
104
+ } catch {
105
+ return null;
106
+ }
66
107
  }
67
108
 
68
109
  // Resolve the model spec ("provider/id") to use when no model is named. Pure and
@@ -71,10 +112,13 @@ export interface ResolveDefaultModelOptions {
71
112
  // so the launcher, the REPL, and the next-launch seed all agree):
72
113
  // 1. explicit user choice (config/channel) — deliberate, always wins
73
114
  // 2. PRIVATEER_MODEL env — dev/global override
74
- // 3. Tinfoil key present → Tinfoil GLM 5.2 — strongest (client-attested) privacy
75
- // 4. signed into Privateer → the same model over the subscription
76
- // 5. a BYO provider whose key is present — anthropic, openai, openrouter
77
- // 6. nothing at all → the account default anyway — so the failure names Privateer
115
+ // 3. the SAVED Pi default (settings.json) — the model the user last picked
116
+ // interactively; Pi persists every switch, so honoring it here is what makes a
117
+ // /models pick actually stick across launches on every entry point
118
+ // 4. Tinfoil key present → the Tinfoil default — strongest (client-attested) privacy
119
+ // 5. signed into Privateer → the same model over the subscription
120
+ // 6. a BYO provider whose key is present — anthropic, openai, openrouter
121
+ // 7. nothing at all → the account default anyway — so the failure names Privateer
78
122
  // and /login is the visible fix, instead of a keyless OpenRouter dead end
79
123
  export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): string {
80
124
  const env = opts.env ?? process.env;
@@ -85,6 +129,9 @@ export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): stri
85
129
  const fromEnv = env.PRIVATEER_MODEL?.trim();
86
130
  if (fromEnv) return fromEnv;
87
131
 
132
+ const saved = opts.saved === undefined ? savedPiDefaultSpec() : opts.saved;
133
+ if (saved) return saved;
134
+
88
135
  // Privacy-first: a Tinfoil key means we can run verifiable TEE inference right now,
89
136
  // which we prefer even over the account's NEAR channel — same order the launcher uses.
90
137
  if (env.TINFOIL_API_KEY?.trim()) return TINFOIL_DEFAULT_SPEC;
@@ -110,8 +157,11 @@ export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): stri
110
157
  // model sign-in should activate RIGHT AWAY: Tinfoil GLM 5.2, direct when a Tinfoil key
111
158
  // is present and over the subscription otherwise — no BYO key needed.
112
159
  // PRIVATEER_MODEL still wins — a deliberate override is never stomped.
160
+ // `saved: null` on purpose: this is the sign-in TARGET, and the target is always the
161
+ // confidential model. Whether to actually move a session that sits on a deliberate
162
+ // saved pick is decided at the call sites (which arm the account channel either way).
113
163
  export function resolveSignedInModel(env: NodeJS.ProcessEnv = process.env): string {
114
- return resolveDefaultModel({ env, signedIn: true });
164
+ return resolveDefaultModel({ env, signedIn: true, saved: null });
115
165
  }
116
166
 
117
167
  // Split a "provider/id" spec on its first slash (model ids themselves contain "/", so
@@ -135,7 +185,12 @@ function splitSpec(spec: string): { provider: string; modelId: string } | null {
135
185
  // `defaultModel` key), so a deliberate /model choice is never stomped. Best-effort —
136
186
  // any read/parse/write failure is swallowed; a missing seed just means the user picks
137
187
  // a model once via /model. Returns the spec written, or null if we left it alone.
138
- export function ensurePiDefaultModel(spec: string = ACCOUNT_DEFAULT_SPEC): string | null {
188
+ //
189
+ // The default seed is resolveSignedInModel(), not the static account spec: now that
190
+ // the launcher HONORS a saved default (omits --model when one exists), seeding
191
+ // `privateer/…` for a Tinfoil-keyed user would demote them from direct
192
+ // client-attested inference to the subscription proxy on every later launch.
193
+ export function ensurePiDefaultModel(spec: string = resolveSignedInModel()): string | null {
139
194
  const parts = splitSpec(spec);
140
195
  if (!parts) return null;
141
196
  const settingsPath = join(agentDir(), "settings.json");
@@ -21,3 +21,19 @@ Everything else is byte-for-byte upstream. The crypto runs on `globalThis.crypto
21
21
  (Node ≥ 22) these are all native — **no polyfills needed** (unlike the treeview RN app,
22
22
  which bridges them via `react-native-quick-crypto`). Re-pull from upstream to update;
23
23
  re-apply only the `.js`-extension strip.
24
+
25
+ ## What lives OUTSIDE this directory (and why)
26
+ `../reportBinding.ts` — `verifyAciReportBinding`, the algorithm dispatch for §10.1
27
+ checks 2–6. Callers use it instead of importing `verifyReportBinding` from here.
28
+
29
+ Upstream's verifier is Web-Crypto-only, so it throws `UnsupportedAlgorithmError` on
30
+ an `ecdsa-secp256k1` keyset endorsement — which §4.3 explicitly permits alongside
31
+ ed25519, and which the deployed `inference.phala.com` gateway actually uses. Rather
32
+ than patch this tree (and re-patch it on every re-pull), the dispatch sits outside:
33
+ ed25519 delegates here verbatim, secp256k1 takes a parallel path over `@noble/curves`,
34
+ and any other algorithm still throws. Nothing here changed, so the re-pull recipe
35
+ above stays exactly the `.js`-extension strip.
36
+
37
+ If upstream ever adds secp256k1 (or a check 7) to `report.ts`, collapse
38
+ `reportBinding.ts` back to a straight re-export — `tests/phalaReportBinding.test.ts`
39
+ pins the behaviour either way.
@@ -0,0 +1,193 @@
1
+ // Algorithm dispatch for the ACI report-binding checks (§10.1 checks 2–6).
2
+ //
3
+ // The ACI spec (§4.3) allows the keyset endorsement to be signed with EITHER
4
+ // `ed25519` OR `ecdsa-secp256k1` — the former because "every primitive in it is
5
+ // available in the Web Crypto API", the latter for "clients in the EVM/dstack
6
+ // ecosystem". Upstream's reference TS verifier implements only the Web Crypto half:
7
+ // `verifySignature` throws `UnsupportedAlgorithmError` on secp256k1
8
+ // (aci-verifier/crypto.ts), and `verifyReportBinding` propagates that.
9
+ //
10
+ // The deployed gateway (inference.phala.com) signs with `ecdsa-secp256k1`, so the
11
+ // vendored verifier can never attest it — a limit of upstream's CLIENT, not of the
12
+ // spec or the gateway. Verified live 2026-07-31: attestation fetched, endorsement
13
+ // rejected with UnsupportedAlgorithmError.
14
+ //
15
+ // This module owns the dispatch so `aci-verifier/` stays byte-for-byte upstream
16
+ // (see its VENDORED.md — only the `.js`-extension strip diverges, and re-pulls stay
17
+ // mechanical):
18
+ // ed25519 → delegate to the vendored verifyReportBinding, verbatim
19
+ // ecdsa-secp256k1 → the same checks 2–6, with check 5 done over @noble/curves
20
+ // anything else → still throws (never a silent pass)
21
+ //
22
+ // The secp256k1 path deliberately mirrors report.ts check-for-check, in the same
23
+ // order and with the same check names, so a caller cannot tell which path ran.
24
+
25
+ import { secp256k1 } from "@noble/curves/secp256k1.js";
26
+ import {
27
+ verifyReportBinding,
28
+ computeWorkloadId,
29
+ computeKeysetDigest,
30
+ computeReportData,
31
+ keysetEndorsementPayload,
32
+ sha256,
33
+ fromHex,
34
+ UnsupportedAlgorithmError,
35
+ type AttestationReport,
36
+ type Check,
37
+ type ReportVerification,
38
+ type ReportBindingOptions,
39
+ } from "./aci-verifier/index.ts";
40
+
41
+ const ED25519 = "ed25519";
42
+ const SECP256K1 = "ecdsa-secp256k1";
43
+
44
+ /**
45
+ * Verify a report's cryptographic bindings for `nonce` (§10.1 checks 2–6),
46
+ * dispatching on the algorithm the attested identity key declares. Drop-in
47
+ * replacement for the vendored `verifyReportBinding`: same arguments, same result
48
+ * shape, same "a failed check is `ok: false`, never thrown" contract.
49
+ *
50
+ * Like upstream, this is the crypto-binding half only — compose it with a hardware
51
+ * quote verifier (phalaSeal.ts `verifyHardwareQuote`) for Level 2.
52
+ */
53
+ export async function verifyAciReportBinding(
54
+ report: AttestationReport,
55
+ nonce: string | null | undefined,
56
+ options: ReportBindingOptions = {},
57
+ ): Promise<ReportVerification> {
58
+ const algo = report.attestation.workload_keyset.workload_identity.public_key.algo;
59
+ if (algo === ED25519) return verifyReportBinding(report, nonce, options);
60
+ if (algo !== SECP256K1) {
61
+ // Same fail-closed posture as upstream: an algorithm we cannot check is a
62
+ // refusal, not a pass.
63
+ throw new UnsupportedAlgorithmError(algo, "keyset endorsement (§4.3)");
64
+ }
65
+ return verifySecp256k1ReportBinding(report, nonce, options);
66
+ }
67
+
68
+ async function verifySecp256k1ReportBinding(
69
+ report: AttestationReport,
70
+ nonce: string | null | undefined,
71
+ options: ReportBindingOptions,
72
+ ): Promise<ReportVerification> {
73
+ const now = options.now ?? Math.floor(Date.now() / 1000);
74
+ const checks: Check[] = [];
75
+
76
+ const keyset = report.attestation.workload_keyset;
77
+ const identityKey = keyset.workload_identity.public_key;
78
+
79
+ // Check 2: workload_id == digest of the identity public key in the report's keyset.
80
+ const workloadId = await computeWorkloadId(identityKey);
81
+ pushEqual(checks, "workload_id", report.workload_id, workloadId);
82
+
83
+ // Check 3: workload_keyset_digest == digest of the report's keyset.
84
+ const workloadKeysetDigest = await computeKeysetDigest(keyset);
85
+ pushEqual(checks, "workload_keyset_digest", report.workload_keyset_digest, workloadKeysetDigest);
86
+
87
+ // Check 4 (binding half): report_data == the §4.4 statement digest for this nonce.
88
+ // The hardware-evidence-binds-report_data half is verifyHardwareQuote's job.
89
+ const expectedReportData = await computeReportData(workloadId, workloadKeysetDigest, nonce);
90
+ pushEqual(checks, "report_data", report.attestation.report_data, expectedReportData);
91
+
92
+ // Check 5: keyset endorsement verifies under the identity key, algo matching.
93
+ const endorsement = report.attestation.keyset_endorsement;
94
+ if (endorsement.algo !== identityKey.algo) {
95
+ checks.push({
96
+ name: "keyset_endorsement",
97
+ ok: false,
98
+ detail: `endorsement.algo "${endorsement.algo}" != identity key algo "${identityKey.algo}"`,
99
+ });
100
+ } else {
101
+ const ok = await verifySecp256k1(
102
+ identityKey.public_key,
103
+ endorsement.value,
104
+ keysetEndorsementPayload(workloadKeysetDigest),
105
+ );
106
+ checks.push({
107
+ name: "keyset_endorsement",
108
+ ok,
109
+ ...(ok ? {} : { detail: "endorsement signature failed under identity key" }),
110
+ });
111
+ }
112
+
113
+ // Check 6: freshness. Nonce binding is check 4; here bound the epoch and, when
114
+ // the profile trusts it, the declared validity window.
115
+ const notAfter = keyset.keyset_epoch.not_after;
116
+ const epochOk = now < notAfter;
117
+ checks.push({
118
+ name: "keyset_epoch.not_after",
119
+ ok: epochOk,
120
+ ...(epochOk ? {} : { detail: `now ${now} >= not_after ${notAfter}` }),
121
+ });
122
+ if (options.trustPlatformClock) {
123
+ const freshness = report.attestation.freshness;
124
+ const fetchedAt = freshness?.fetched_at;
125
+ const staleAfter = freshness?.stale_after;
126
+ const windowOk =
127
+ typeof fetchedAt === "number" &&
128
+ typeof staleAfter === "number" &&
129
+ fetchedAt <= now &&
130
+ now < staleAfter;
131
+ checks.push({
132
+ name: "freshness_window",
133
+ ok: windowOk,
134
+ ...(windowOk ? {} : { detail: `now ${now} outside [${fetchedAt}, ${staleAfter})` }),
135
+ });
136
+ }
137
+
138
+ return { ok: checks.every((c) => c.ok), checks, workloadId, workloadKeysetDigest };
139
+ }
140
+
141
+ /**
142
+ * §4.3 secp256k1 endorsement: a 64-byte `r || s` signature over
143
+ * `sha256(payload bytes)`. Returns false on malformed input rather than throwing —
144
+ * a bad signature is a failed check, not an exception.
145
+ *
146
+ * NOT the §8.5 *receipt* shape, which is a 65-byte recoverable `r || s || v` and
147
+ * where the spec says 64-byte signatures MUST be rejected. Different shapes; easy
148
+ * to conflate if this ever grows a receipt path.
149
+ */
150
+ async function verifySecp256k1(
151
+ publicKeyHex: string,
152
+ signatureHex: string,
153
+ payload: Uint8Array,
154
+ ): Promise<boolean> {
155
+ try {
156
+ const sig = fromHex(signatureHex);
157
+ if (sig.length !== 64) return false; // r||s only; DER / recoverable forms are not §4.3
158
+ const msgHash = await sha256(payload);
159
+ return secp256k1.verify(sig, msgHash, publicKey(publicKeyHex), {
160
+ prehash: false, // we hand it the sha256 digest, per §4.3
161
+ // Accept high-s as well as low-s. ECDSA malleability is meaningless for a
162
+ // signature over a FIXED payload — an attacker who can flip s already has a
163
+ // valid endorsement and still cannot sign a different keyset digest. Leaving
164
+ // the default on would reject ~half of otherwise-valid endorsements from any
165
+ // signer that doesn't normalize, as an intermittent attestation failure.
166
+ lowS: false,
167
+ });
168
+ } catch {
169
+ return false;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Identity key bytes. §7.1 pins secp256k1 public keys as 65-byte uncompressed SEC1
175
+ * and requires that "the 64-byte uncompressed form without the `0x04` prefix MUST be
176
+ * accepted and treated as the same key" — so restore the prefix when it's absent.
177
+ * The live gateway sends the 65-byte form; do NOT prefix that one again.
178
+ */
179
+ function publicKey(hex: string): Uint8Array {
180
+ const raw = fromHex(hex);
181
+ if (raw.length === 64) {
182
+ const sec1 = new Uint8Array(65);
183
+ sec1[0] = 0x04;
184
+ sec1.set(raw, 1);
185
+ return sec1;
186
+ }
187
+ return raw;
188
+ }
189
+
190
+ function pushEqual(checks: Check[], name: string, actual: string, expected: string): void {
191
+ const ok = actual === expected;
192
+ checks.push({ name, ok, ...(ok ? {} : { detail: `report ${actual} != recomputed ${expected}` }) });
193
+ }
@@ -13,7 +13,7 @@
13
13
  // no polyfills, unlike the RN app.
14
14
  //
15
15
  // Two-layer attestation, fail-secure:
16
- // (1) verifyReportBinding — the report's crypto binding (keyset digest,
16
+ // (1) verifyAciReportBinding — the report's crypto binding (keyset digest,
17
17
  // report_data == statement(nonce), endorsement sig). Self-attesting alone.
18
18
  // (2) verifyHardwareQuote — the hardware root: @phala/dcap-qvl verifies the TDX quote
19
19
  // against Intel collateral and binds the quote's report_data to (1)'s statement
@@ -22,7 +22,6 @@
22
22
 
23
23
  import type { Report } from "@phala/dcap-qvl";
24
24
  import {
25
- verifyReportBinding,
26
25
  openE2eeChannel,
27
26
  toHex,
28
27
  fromHex,
@@ -30,6 +29,11 @@ import {
30
29
  type ReportVerification,
31
30
  type E2eeChannel,
32
31
  } from "./phala/aci-verifier/index.ts";
32
+ // Not the vendored verifyReportBinding directly: the deployed gateway signs its
33
+ // keyset endorsement with ecdsa-secp256k1, which upstream's Web-Crypto-only verifier
34
+ // refuses. This wrapper delegates ed25519 to it unchanged and adds the secp256k1 arm
35
+ // the spec allows (§4.3), leaving aci-verifier/ pristine for re-pulls.
36
+ import { verifyAciReportBinding } from "./phala/reportBinding.ts";
33
37
  import { serverBaseUrl } from "../auth/privateer.ts";
34
38
 
35
39
  const DEFAULT_ACCEPTABLE_TCB = ["UpToDate"];
@@ -94,7 +98,7 @@ async function establishAttestation(): Promise<VerifiedAttestation> {
94
98
  if (!res.ok) throw new Error(`phala attestation HTTP ${res.status}`);
95
99
  const report = (await res.json()) as AttestationReport;
96
100
 
97
- const verification = await verifyReportBinding(report, nonce);
101
+ const verification = await verifyAciReportBinding(report, nonce);
98
102
  if (!verification.ok) {
99
103
  const failed = verification.checks.filter((c) => !c.ok).map((c) => c.name).join(", ");
100
104
  throw new Error(`phala attestation binding failed: ${failed}`);