privateer-agent 0.9.3 → 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.
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The agent's Nostr identity.
3
+ *
4
+ * A Nostr secret key is not a bot token. It is a PERMANENT identity: it cannot be
5
+ * rotated without becoming a different participant, everything it ever signed stays
6
+ * attributable to it, and there is no issuer to revoke it. So it gets the same
7
+ * treatment as the terminal identity key — minted locally, written 0600, and never
8
+ * sent anywhere — rather than living in plaintext config.json beside the revocable
9
+ * platform bot tokens.
10
+ *
11
+ * DEFAULT PATH: the agent generates its own keypair and reports the npub, which the
12
+ * user pastes into their Buzz workspace so an Owner can add it as a Bot member. The
13
+ * secret never crosses a wire, not even the sealed app→terminal channel.
14
+ *
15
+ * IMPORT PATH: a user who already has a Buzz identity can send an nsec through the
16
+ * app's sealed-secret flow; importBuzzKey() moves it into the 0600 file so it stops
17
+ * living in config.json.
18
+ *
19
+ * Construction mirrors crypto/terminalKey.ts deliberately, including the 0600
20
+ * TOCTOU-avoiding write and the process-lifetime cache.
21
+ */
22
+
23
+ import { readFileSync, writeFileSync, chmodSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { schnorr } from "@noble/curves/secp256k1";
26
+ import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
27
+ import { globalDir } from "../config/paths.ts";
28
+ import { npubEncode, npubDecode, nsecEncode, nsecDecode } from "./bech32.ts";
29
+
30
+ interface BuzzKeyFile {
31
+ v: 1;
32
+ secretKey: string; // base64, 32 raw bytes — never leaves this machine
33
+ }
34
+
35
+ export interface BuzzIdentity {
36
+ secretHex: string;
37
+ pubkeyHex: string;
38
+ npub: string;
39
+ }
40
+
41
+ function keyPath(): string {
42
+ return join(globalDir(), "buzz-key.json");
43
+ }
44
+
45
+ let cached: BuzzIdentity | undefined;
46
+
47
+ // ── pure ────────────────────────────────────────────────────────────────────────
48
+
49
+ /** A fresh 32-byte secp256k1 secret key. */
50
+ export function generateSecretKey(): Uint8Array {
51
+ return schnorr.utils.randomSecretKey();
52
+ }
53
+
54
+ /** The 32-byte x-only public key for a secret, as lowercase hex — a Nostr pubkey. */
55
+ export function publicKeyHex(secret: Uint8Array | string): string {
56
+ return bytesToHex(schnorr.getPublicKey(typeof secret === "string" ? hexToBytes(secret) : secret));
57
+ }
58
+
59
+ /**
60
+ * Normalize a configured identity to lowercase hex.
61
+ *
62
+ * Allowlists are written by humans, who will paste whichever form Buzz showed them —
63
+ * so accept both npub and raw hex and store one canonical form. Returns undefined
64
+ * for anything that isn't a valid 32-byte key, so a typo'd entry fails closed
65
+ * (dropped from the allowlist) rather than silently matching nothing forever.
66
+ */
67
+ export function toHexPubkey(npubOrHex: string): string | undefined {
68
+ const s = npubOrHex.trim();
69
+ if (/^[0-9a-fA-F]{64}$/.test(s)) return s.toLowerCase();
70
+ if (s.startsWith("npub1")) {
71
+ try {
72
+ return bytesToHex(npubDecode(s));
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ }
77
+ return undefined;
78
+ }
79
+
80
+ /** Accept either an nsec or raw hex secret; throws on anything else. */
81
+ export function secretFromNsec(nsecOrHex: string): Uint8Array {
82
+ const s = nsecOrHex.trim();
83
+ if (/^[0-9a-fA-F]{64}$/.test(s)) return hexToBytes(s.toLowerCase());
84
+ if (s.startsWith("nsec1")) return nsecDecode(s);
85
+ throw new Error("expected an nsec1… key or 64 hex characters");
86
+ }
87
+
88
+ function identityFrom(secret: Uint8Array): BuzzIdentity {
89
+ const pubkeyHex = publicKeyHex(secret);
90
+ return { secretHex: bytesToHex(secret), pubkeyHex, npub: npubEncode(hexToBytes(pubkeyHex)) };
91
+ }
92
+
93
+ // ── persisted ───────────────────────────────────────────────────────────────────
94
+
95
+ function persist(secret: Uint8Array): BuzzIdentity {
96
+ const file: BuzzKeyFile = { v: 1, secretKey: Buffer.from(secret).toString("base64") };
97
+ // 0600 from creation — see terminalKey.ts for why `mode` plus a follow-up chmod.
98
+ writeFileSync(keyPath(), JSON.stringify(file), { mode: 0o600 });
99
+ try {
100
+ chmodSync(keyPath(), 0o600);
101
+ } catch {
102
+ /* best effort — e.g. non-POSIX FS */
103
+ }
104
+ cached = identityFrom(secret);
105
+ return cached;
106
+ }
107
+
108
+ /** Load the persisted identity, minting and persisting one on first use. */
109
+ export function loadOrCreateBuzzKey(): BuzzIdentity {
110
+ if (cached) return cached;
111
+ try {
112
+ const parsed = JSON.parse(readFileSync(keyPath(), "utf8")) as BuzzKeyFile;
113
+ if (parsed?.v === 1 && parsed.secretKey) {
114
+ const buf = Buffer.from(parsed.secretKey, "base64");
115
+ if (buf.length === 32) {
116
+ cached = identityFrom(new Uint8Array(buf));
117
+ return cached;
118
+ }
119
+ }
120
+ } catch {
121
+ /* missing or malformed → mint a fresh keypair below */
122
+ }
123
+ return persist(generateSecretKey());
124
+ }
125
+
126
+ /**
127
+ * Adopt an existing identity, replacing any current one.
128
+ *
129
+ * Called when a user supplies an nsec through the app: the value arrives sealed,
130
+ * lands here, and the caller then deletes it from config.json so the only copy on
131
+ * disk is the 0600 file.
132
+ */
133
+ export function importBuzzKey(nsecOrHex: string): BuzzIdentity {
134
+ return persist(secretFromNsec(nsecOrHex));
135
+ }
136
+
137
+ /**
138
+ * Every textual form of the persisted secret, for the outbound redactor.
139
+ *
140
+ * The agent can read its own key file — it's a file on the machine it operates —
141
+ * so without this it could quote its permanent identity into a public channel.
142
+ * Both encodings are returned because either could plausibly appear in output.
143
+ * Non-minting: no key file means nothing to redact.
144
+ */
145
+ export function buzzRedactionSecrets(): string[] {
146
+ const secretHex = cached?.secretHex ?? readPersistedSecretHex();
147
+ if (!secretHex) return [];
148
+ return [secretHex, nsecEncode(hexToBytes(secretHex))];
149
+ }
150
+
151
+ function readPersistedSecretHex(): string | undefined {
152
+ try {
153
+ const parsed = JSON.parse(readFileSync(keyPath(), "utf8")) as BuzzKeyFile;
154
+ const buf = Buffer.from(parsed?.secretKey ?? "", "base64");
155
+ if (parsed?.v === 1 && buf.length === 32) return bytesToHex(new Uint8Array(buf));
156
+ } catch {
157
+ /* not configured yet */
158
+ }
159
+ return undefined;
160
+ }
161
+
162
+ /**
163
+ * Read the persisted npub WITHOUT minting one.
164
+ *
165
+ * Side-effect-free by design: the app lists every platform, configured or not, and
166
+ * merely rendering an empty Buzz card must not conjure a permanent identity.
167
+ */
168
+ export function peekBuzzNpub(): string | undefined {
169
+ if (cached) return cached.npub;
170
+ const secretHex = readPersistedSecretHex();
171
+ return secretHex ? identityFrom(hexToBytes(secretHex)).npub : undefined;
172
+ }
@@ -0,0 +1,60 @@
1
+ // NIP-10 threading and NIP-27 mention helpers — reading meaning out of an event's
2
+ // positional tag arrays, and building the tags for a reply.
3
+ //
4
+ // Pure and adapter-agnostic: the Buzz adapter uses these, but nothing here is
5
+ // Buzz-specific.
6
+
7
+ import type { Tag } from "./event.ts";
8
+
9
+ /**
10
+ * Extract a reply's thread position from its "e" tags.
11
+ *
12
+ * Two encodings exist in the wild and both must be handled:
13
+ * MARKERED (current) ["e", <id>, <relay>, "root"] / [… "reply"]
14
+ * POSITIONAL (legacy) the FIRST "e" tag is the root, the LAST is the direct
15
+ * parent; with exactly one, it is both.
16
+ * A markered tag anywhere wins — mixing the two is malformed, and trusting the
17
+ * explicit marker is the safer read.
18
+ */
19
+ export function threadRefs(tags: Tag[]): { root?: string; reply?: string } {
20
+ const eTags = tags.filter((t) => t[0] === "e" && typeof t[1] === "string" && t[1].length > 0);
21
+ if (eTags.length === 0) return {};
22
+
23
+ const markered = eTags.filter((t) => t[3] === "root" || t[3] === "reply");
24
+ if (markered.length > 0) {
25
+ return {
26
+ root: markered.find((t) => t[3] === "root")?.[1],
27
+ reply: markered.find((t) => t[3] === "reply")?.[1],
28
+ };
29
+ }
30
+
31
+ // Legacy positional form.
32
+ if (eTags.length === 1) return { root: eTags[0][1], reply: eTags[0][1] };
33
+ return { root: eTags[0][1], reply: eTags[eTags.length - 1][1] };
34
+ }
35
+
36
+ /** Every pubkey this event tags — i.e. everyone it @-mentions. */
37
+ export function pTags(tags: Tag[]): string[] {
38
+ return tags.filter((t) => t[0] === "p" && typeof t[1] === "string" && t[1].length > 0).map((t) => t[1]);
39
+ }
40
+
41
+ /** Blossom content hashes attached to this event (["x", <sha256>]). */
42
+ export function xTags(tags: Tag[]): string[] {
43
+ return tags.filter((t) => t[0] === "x" && typeof t[1] === "string" && t[1].length > 0).map((t) => t[1]);
44
+ }
45
+
46
+ /**
47
+ * Build the tags for a reply.
48
+ *
49
+ * `root` is the thread root and `parent` the message being answered; when only one
50
+ * is known, pass it as both — a reply that marks a root but no parent reads as a
51
+ * top-level post to most clients. Mentioned pubkeys are deduped, since duplicate "p"
52
+ * tags inflate relay-side mention indexes for no benefit.
53
+ */
54
+ export function replyTags(root?: string, parent?: string, mentionPubkeys: string[] = []): Tag[] {
55
+ const out: Tag[] = [];
56
+ if (root) out.push(["e", root, "", "root"]);
57
+ if (parent && parent !== root) out.push(["e", parent, "", "reply"]);
58
+ for (const pk of [...new Set(mentionPubkeys)]) out.push(["p", pk]);
59
+ return out;
60
+ }
@@ -1,5 +1,7 @@
1
1
  import { resolve, isAbsolute, relative, dirname, join, basename } from "node:path";
2
2
  import { realpathSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { fileURLToPath } from "node:url";
3
5
  import { isProtectedPath } from "./protected.ts";
4
6
  import type { PermissionRequest } from "./gate.ts";
5
7
 
@@ -42,8 +44,57 @@ function realBase(abs: string): string {
42
44
  }
43
45
  }
44
46
 
47
+ // ⚠️ SECURITY-CRITICAL: this MUST resolve a path string to exactly the same place
48
+ // Pi's file tools do, or the gate judges one file and the tool touches another.
49
+ //
50
+ // Pi resolves every tool path through `resolveToCwd` → `resolvePath` →
51
+ // `normalizePath` (node_modules/@earendil-works/pi-coding-agent/dist/utils/paths.js
52
+ // and dist/core/tools/path-utils.js), which EXPANDS `~`, STRIPS a leading `@`, and
53
+ // converts `file://` URLs. `node:path` treats all three as RELATIVE, so a classifier
54
+ // using bare resolve() computed `<cwd>/~/.ssh/authorized_keys` — comfortably "inside"
55
+ // the working directory — while `write` truly targeted `$HOME/.ssh/authorized_keys`.
56
+ // That made `outside` false and `protected` false (isProtectedPath matches basenames,
57
+ // and `authorized_keys` isn't one), so:
58
+ // • reads returned null from classifyToolCall — NO gate at all, in every posture
59
+ // including plan/readonly, at the default read-only tool ceiling; and
60
+ // • writes auto-allowed under acceptEdits / bypass / no-quarter.
61
+ // The adversary here is the MODEL (prompt injection), not the user, so "the operator
62
+ // could read that file anyway" does not apply.
63
+ //
64
+ // Pi does not export normalizePath (package exports are "." and "./rpc-entry" only),
65
+ // so it is mirrored here. KEEP IN SYNC — tests/classifyPathParity.test.ts asserts this
66
+ // function agrees with Pi's own resolver, so a Pi upgrade that changes normalization
67
+ // fails the suite instead of silently reopening the hole.
68
+ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
69
+
70
+ function normalizeLikePi(input: string, opts: { unicodeSpaces?: boolean; stripAt?: boolean } = {}): string {
71
+ let s = input;
72
+ if (opts.unicodeSpaces) s = s.replace(UNICODE_SPACES, " ");
73
+ if (opts.stripAt && s.startsWith("@")) s = s.slice(1);
74
+ const home = homedir();
75
+ if (s === "~") return home;
76
+ if (s.startsWith("~/") || (process.platform === "win32" && s.startsWith("~\\"))) return join(home, s.slice(2));
77
+ if (/^file:\/\//.test(s)) {
78
+ // Pi lets fileURLToPath throw here, which fails the tool call. We must not throw
79
+ // (that would break the gate), so fall back to the raw string: it then resolves
80
+ // inside cwd, but the tool errors on the same input, so there is no divergence
81
+ // a caller can exploit.
82
+ try {
83
+ return fileURLToPath(s);
84
+ } catch {
85
+ return s;
86
+ }
87
+ }
88
+ return s;
89
+ }
90
+
45
91
  function resolveInCwd(cwd: string, p: string): string {
46
- return realBase(isAbsolute(p) ? p : resolve(cwd, p));
92
+ // Mirrors resolvePath(): the TARGET gets the tools' options
93
+ // ({normalizeUnicodeSpaces, stripAtPrefix}); the BASE gets normalizePath's defaults
94
+ // (tilde/file:// only), because Pi normalizes baseDir with no options.
95
+ const target = normalizeLikePi(p, { unicodeSpaces: true, stripAt: true });
96
+ const base = normalizeLikePi(cwd);
97
+ return realBase(isAbsolute(target) ? resolve(target) : resolve(base, target));
47
98
  }
48
99
 
49
100
  function isInsideDir(root: string, abs: string): boolean {
@@ -90,8 +141,17 @@ function unknownTarget(toolName: string, kind: "write" | "edit"): PermissionRequ
90
141
  // machine: no gate regardless of arguments. Tunable — the conservative default for
91
142
  // anything NOT listed here is to ask (see below). TODO(verify) against Pi's full
92
143
  // builtin tool catalog as it's enumerated in Phase 5.
144
+ // `ask_user_question` (rpiv-ask-user-question, shimmed by the launcher) is here on
145
+ // purpose: it is a QUESTION PUT TO THE USER — it renders a dialog and returns what the
146
+ // human picked. It touches nothing, sends nothing, and the human is already in the loop
147
+ // by construction. Gating it would fall through to the unknown-tool branch below, which
148
+ // classifies as bash-kind: a pointless "Run ask_user_question" prompt in default mode,
149
+ // and an outright DENY in plan/readonly — the very posture where a model most needs to
150
+ // ask instead of guess. Headless surfaces need no guard either: the tool self-checks
151
+ // ctx.hasUI and returns an error result when there's no one to ask.
93
152
  const NON_GATED = new Set([
94
153
  "todo", "todowrite", "todo_write", "todoread", "think", "plan_note",
154
+ "ask_user_question",
95
155
  ]);
96
156
 
97
157
  // Read-ish builtins: gated ONLY when the target resolves outside scope.
@@ -114,10 +114,37 @@ export function loadCachedCatalogIds(): string[] {
114
114
  }
115
115
  }
116
116
 
117
+ // Whether the account channel can actually SERVE a catalog model right now.
118
+ //
119
+ // `phala/*` is sealed-only: it runs through the sealed blind relay and nowhere else.
120
+ // The server's cleartext `/api/agent/v1` has no Phala route and rejects the id
121
+ // outright (verified live 2026-07-31: 400 "phala/… is not a valid model ID"), because
122
+ // Phala models are the Sealed tier by design — the server is not meant to be able to
123
+ // read them. But `/api/models` advertises them to every client regardless of whether
124
+ // that client can reach the sealed path, so they were pickable and then failed on the
125
+ // first prompt. Offering a model we know cannot answer is worse than a shorter list.
126
+ //
127
+ // The condition is the SHIM, not the flag. Sealed mode being enabled only means we
128
+ // intend to seal; `phala/*` is unservable until the loopback shim is actually
129
+ // listening, because that is what its per-model baseUrl points at (see modelEntry).
130
+ // With the flag now defaulting on, "enabled but the shim failed to bind" is a state a
131
+ // user can really land in, and it must not re-offer models that would 400.
132
+ //
133
+ // `tinfoil/*` is deliberately NOT filtered: the cleartext path serves it fine (sealed
134
+ // mode only upgrades the badge from unconfirmed to verified), so it stays either way.
135
+ export function isServableAccountModel(id: string): boolean {
136
+ if (!id.startsWith("phala/")) return true;
137
+ return sealedEnabled() && sealedShimBase() !== null;
138
+ }
139
+
117
140
  // The ids to register synchronously at load. DEFAULT_MODELS FIRST and always: the account
118
141
  // default has to be index 0 both because it must always resolve and because Pi clones the
119
142
  // provider's first/default model when it synthesizes a custom model id
120
143
  // (model-resolver.js buildFallbackModel).
144
+ //
145
+ // Returns the server's list as cached, unfiltered — accountProviderConfig decides what
146
+ // is servable at each registration, so a model dropped now (shim not up yet) can be
147
+ // re-offered by a later re-registration without the cache being rewritten.
121
148
  export function seedCatalogIds(): string[] {
122
149
  const ids = [...DEFAULT_MODELS];
123
150
  const seen = new Set(ids);
@@ -202,7 +229,9 @@ export async function fetchAccountCatalog(): Promise<AccountModelInfo[]> {
202
229
  .map((m) => (m.modelId ? { id: m.modelId, tier: normalizeTier(m.privacy?.tier, m.modelId) } : null))
203
230
  .filter((x): x is AccountModelInfo => !!x);
204
231
  // 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.
232
+ // seed ids on disk and read back as though it were the catalog. Both the cache and
233
+ // the returned list are the server's UNFILTERED offer; servability is decided at
234
+ // registration (accountProviderConfig), which re-evaluates it every time.
206
235
  if (parsed.length) saveCachedCatalogIds(parsed.map((p) => p.id));
207
236
  infos = parsed.length ? parsed : fallback();
208
237
  }
@@ -402,14 +431,15 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
402
431
  const att = await attestSealed(sealedProvider);
403
432
  return att.ok ? { tier: "tee-verified" } : { tier: "tee-unverified", error: att.error };
404
433
  }
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).
434
+ // Honest labelling for the non-NEAR enclaves when we are NOT sealing — sealed mode
435
+ // explicitly disabled (PRIVATEER_SEALED=0), or on but the shim never came up. Tinfoil
436
+ // and Phala publish real attestations, but the server proxies the inference in
437
+ // cleartext, so from here we cannot bind a quote to the connection actually carrying
438
+ // our tokens — only the account's word that it did. That's `tee-unverified` (yellow
439
+ // "confidential compute, unconfirmed"), never the green tee-verified we reserve for a
440
+ // quote we checked ourselves. Re-enable sealed mode for the verified shield, or set
441
+ // TINFOIL_API_KEY and run `tinfoil/*` direct (pi-privacy attests client-side over the
442
+ // TLS binding).
413
443
  if (!modelId.startsWith("near/")) {
414
444
  return { tier: "tee-unverified" };
415
445
  }
@@ -429,11 +459,13 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
429
459
  }
430
460
  }
431
461
 
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).
462
+ // A model entry, with a per-model baseUrl override once the sealed shim is listening:
463
+ // `tinfoil/*` and `phala/*` then route through the loopback shim (which seals to the
464
+ // blind relay) instead of the cleartext `/api/agent/v1` proxy. Everything else keeps the
465
+ // provider baseUrl. Until the shim is up (or with sealed mode disabled) `tinfoil/*` falls
466
+ // back to the cleartext path and the badge stays honestly `tee-unverified` (see
467
+ // accountPosture); `phala/*` has no cleartext path at all and is not registered in that
468
+ // state (see isServableAccountModel).
437
469
  function modelEntry(id: string) {
438
470
  const base = seedModel(id);
439
471
  const provider = sealedEnabled() ? sealedProviderFor(id) : null;
@@ -448,7 +480,13 @@ export function accountProviderConfig(ids: string[]): Record<string, unknown> {
448
480
  baseUrl: `${serverBaseUrl()}/api/agent/v1`,
449
481
  api: "openai-completions",
450
482
  oauth: privateerOAuthProvider,
451
- models: ids.map(modelEntry),
483
+ // Filter HERE rather than at the catalog, so callers keep passing the server's
484
+ // full list and every registration re-evaluates servability against the CURRENT
485
+ // shim state. That is what lets the post-shim re-registration in makeAccountProvider
486
+ // put `phala/*` back: had the ids been filtered upstream, the sealed-only models
487
+ // would have been dropped from `lastIds` before the shim ever finished starting and
488
+ // nothing would have brought them back.
489
+ models: ids.filter(isServableAccountModel).map(modelEntry),
452
490
  };
453
491
  }
454
492
 
@@ -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
+ }