privateer-agent 0.12.18 → 0.12.20

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.
@@ -1,193 +0,0 @@
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
- }