shadow-attest-core 2.0.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/README.md +141 -0
- package/anchors.js +1321 -0
- package/attestation-batch.js +230 -0
- package/attestation-chain.js +125 -0
- package/attestation.js +653 -0
- package/batch.js +2 -0
- package/chain.js +2 -0
- package/index.js +57 -0
- package/package.json +59 -0
- package/session.js +557 -0
- package/store-file.js +65 -0
- package/verify-chain.js +5 -0
package/attestation.js
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
// lib/attestation.js
|
|
2
|
+
// ──────────────────────────────────────────────────────────────────
|
|
3
|
+
// AEX-style multi-hop attestation for /api/deliberate + /api/loan-council.
|
|
4
|
+
//
|
|
5
|
+
// Ships 2026-07-02 based on AEX (arXiv:2603.14283, June 2026) which
|
|
6
|
+
// specifies a signed top-level attestation object binding request-
|
|
7
|
+
// commitment ↔ output-commitment. Also covers the failure mode from
|
|
8
|
+
// "Auditing Model Substitution in LLM APIs" (arXiv:2504.04715) —
|
|
9
|
+
// API providers can silently swap models; only cryptographic
|
|
10
|
+
// attestation catches it.
|
|
11
|
+
//
|
|
12
|
+
// Defense layers
|
|
13
|
+
// --------------
|
|
14
|
+
// Shadow already ships:
|
|
15
|
+
// - Hash-chain audit log (SHA-256 chain across decisions)
|
|
16
|
+
// - Prompt SHA-256 pin per response
|
|
17
|
+
// - Model ID field per response
|
|
18
|
+
//
|
|
19
|
+
// What this module adds:
|
|
20
|
+
// - SHA-256 commitment of the FULL request payload (loan + policy)
|
|
21
|
+
// - SHA-256 commitment of the FULL response body (verdict + voices)
|
|
22
|
+
// - Signature over both commitments + model_id + completed_at_utc
|
|
23
|
+
//
|
|
24
|
+
// Signature modes
|
|
25
|
+
// ---------------
|
|
26
|
+
// Two modes supported. The mode is stamped into the attestation
|
|
27
|
+
// object so a downstream auditor knows how to verify.
|
|
28
|
+
//
|
|
29
|
+
// **hmac (default, back-compat)** — HMAC-SHA-256 with a symmetric
|
|
30
|
+
// server_secret. Anyone with the secret can BOTH sign and verify.
|
|
31
|
+
// Simple but requires the bank auditor to hold Shadow's server
|
|
32
|
+
// secret (which also means they could forge — not cleanly
|
|
33
|
+
// separable). Use for dev + internal audits.
|
|
34
|
+
//
|
|
35
|
+
// **ed25519 (recommended for procurement)** — asymmetric public-
|
|
36
|
+
// key signature. Shadow holds the private key, bank auditor holds
|
|
37
|
+
// only the public key. Bank can VERIFY but cannot FORGE. This is
|
|
38
|
+
// the posture procurement teams want because "who can sign" and
|
|
39
|
+
// "who can verify" are cleanly separated.
|
|
40
|
+
//
|
|
41
|
+
// Ed25519 was chosen because: (1) 256-bit private key = same
|
|
42
|
+
// footprint as HMAC secret, (2) 64-byte signature, (3) fast
|
|
43
|
+
// (~50µs sign / ~200µs verify on M1), (4) shipped in Node's stdlib
|
|
44
|
+
// crypto module since v12, no dep needed. RFC 8032.
|
|
45
|
+
//
|
|
46
|
+
// Verification
|
|
47
|
+
// ------------
|
|
48
|
+
// Auditor recomputes both commitments from persisted request +
|
|
49
|
+
// response bodies, recomputes the signature using either the
|
|
50
|
+
// shared secret (hmac) or the public key (ed25519), compares
|
|
51
|
+
// against the stored signature. Any mismatch = record was tampered
|
|
52
|
+
// OR a silent model swap happened OR key material rotated without
|
|
53
|
+
// re-signing.
|
|
54
|
+
//
|
|
55
|
+
// Key rotation
|
|
56
|
+
// ------------
|
|
57
|
+
// The signature carries a `keyId` so multiple keys can co-exist
|
|
58
|
+
// (grace period for rotation). Auditor picks the right key by
|
|
59
|
+
// keyId. Rotate at least yearly per NIST SP 800-57 §5.2.
|
|
60
|
+
//
|
|
61
|
+
// Ref
|
|
62
|
+
// ---
|
|
63
|
+
// - arXiv:2603.14283 AEX: Non-Intrusive Multi-Hop Attestation for LLM APIs
|
|
64
|
+
// - arXiv:2504.04715 Auditing Model Substitution in LLM APIs
|
|
65
|
+
// - RFC 8032 Edwards-Curve Digital Signature Algorithm (EdDSA)
|
|
66
|
+
// - NIST SP 800-57 Part 1 §5.2 key rotation cadence
|
|
67
|
+
|
|
68
|
+
import {
|
|
69
|
+
createHash, createHmac,
|
|
70
|
+
createPrivateKey, createPublicKey,
|
|
71
|
+
sign as cryptoSign, verify as cryptoVerify,
|
|
72
|
+
} from "node:crypto";
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
// Deploy-time secret. In production this comes from an env var or
|
|
76
|
+
// KMS. For dev the default is fine because the signature is only
|
|
77
|
+
// verifiable by whoever holds the secret — a dev signature won't
|
|
78
|
+
// match a prod verifier.
|
|
79
|
+
const DEFAULT_SECRET =
|
|
80
|
+
process.env.SHADOW_ATTESTATION_SECRET
|
|
81
|
+
|| "dev-shadow-attestation-secret-DO-NOT-USE-IN-PROD";
|
|
82
|
+
|
|
83
|
+
const DEFAULT_KEY_ID = process.env.SHADOW_ATTESTATION_KEY_ID || "dev-v1";
|
|
84
|
+
|
|
85
|
+
const ATTESTATION_VERSION = "aex-attestation/v1";
|
|
86
|
+
|
|
87
|
+
// Signature modes shipped in v1. The mode is stamped into the
|
|
88
|
+
// attestation object so a downstream auditor knows which verifier
|
|
89
|
+
// path to use. See module docstring for tradeoffs.
|
|
90
|
+
export const SIGNATURE_MODES = Object.freeze({
|
|
91
|
+
HMAC: "hmac-sha256",
|
|
92
|
+
ED25519: "ed25519",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Default mode. HMAC keeps existing callers back-compat. Ops teams
|
|
96
|
+
// running procurement pilots should flip to ed25519 via env var:
|
|
97
|
+
// SHADOW_ATTESTATION_MODE=ed25519
|
|
98
|
+
// SHADOW_ATTESTATION_ED25519_PRIVATE_KEY=<PEM or base64 raw>
|
|
99
|
+
// SHADOW_ATTESTATION_KEY_ID=<rotation-tag>
|
|
100
|
+
const DEFAULT_MODE = process.env.SHADOW_ATTESTATION_MODE === "ed25519"
|
|
101
|
+
? SIGNATURE_MODES.ED25519
|
|
102
|
+
: SIGNATURE_MODES.HMAC;
|
|
103
|
+
|
|
104
|
+
// Ed25519 key material from env — nullable; we only fail if callers
|
|
105
|
+
// actually request ed25519 mode without providing a key.
|
|
106
|
+
const DEFAULT_ED25519_PRIVATE_PEM =
|
|
107
|
+
process.env.SHADOW_ATTESTATION_ED25519_PRIVATE_KEY || null;
|
|
108
|
+
const DEFAULT_ED25519_PUBLIC_PEM =
|
|
109
|
+
process.env.SHADOW_ATTESTATION_ED25519_PUBLIC_KEY || null;
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Compute SHA-256 hex digest of a value. Objects are canonicalized
|
|
114
|
+
* via JSON.stringify with sorted keys so signatures are stable
|
|
115
|
+
* regardless of key ordering.
|
|
116
|
+
*/
|
|
117
|
+
export function commitmentOf(value) {
|
|
118
|
+
const canonical = canonicalize(value);
|
|
119
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Canonicalize a value for deterministic hashing. Objects → sorted-
|
|
125
|
+
* key JSON. Arrays → element-wise canonicalization. Primitives → as-is.
|
|
126
|
+
*
|
|
127
|
+
* This matters because JSON.stringify({a:1,b:2}) !== JSON.stringify({b:2,a:1})
|
|
128
|
+
* — two functionally-equivalent responses would otherwise produce
|
|
129
|
+
* two different commitments.
|
|
130
|
+
*/
|
|
131
|
+
export function canonicalize(value) {
|
|
132
|
+
if (value === null || typeof value !== "object") {
|
|
133
|
+
return JSON.stringify(value);
|
|
134
|
+
}
|
|
135
|
+
if (Array.isArray(value)) {
|
|
136
|
+
return "[" + value.map(canonicalize).join(",") + "]";
|
|
137
|
+
}
|
|
138
|
+
const keys = Object.keys(value).sort();
|
|
139
|
+
return "{" + keys.map((k) =>
|
|
140
|
+
JSON.stringify(k) + ":" + canonicalize(value[k]),
|
|
141
|
+
).join(",") + "}";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Compose the canonical signing payload string. Both hmac and
|
|
147
|
+
* ed25519 modes sign THE SAME BYTES, so an attestation object can
|
|
148
|
+
* (in principle) be dual-signed for a rotation window. The mode
|
|
149
|
+
* string is included so a hmac-signed payload can't be reused as
|
|
150
|
+
* ed25519 material (domain separation).
|
|
151
|
+
*
|
|
152
|
+
* `dictionaryHash` is optional (v1.5.8+). When absent, the field is
|
|
153
|
+
* omitted from the payload so old (pre-v1.5.8) attestations continue
|
|
154
|
+
* to verify byte-for-byte. When present, the hash of the signed
|
|
155
|
+
* reason-code dictionary at decision time is bound into the signature
|
|
156
|
+
* so any post-hoc dictionary tampering breaks verification.
|
|
157
|
+
*/
|
|
158
|
+
function _signingPayload({ mode, requestCommitment, outputCommitment,
|
|
159
|
+
modelId, completedAtUtc, previousHash, keyId,
|
|
160
|
+
dictionaryHash, citationRegistrySha256,
|
|
161
|
+
proxySchemaSha256, originalContentHash,
|
|
162
|
+
policyInvarianceScoreSha256,
|
|
163
|
+
adverseActionNoticeSha256,
|
|
164
|
+
samplingSeedCommitmentSha256,
|
|
165
|
+
evidencePartitionSchemeSha256,
|
|
166
|
+
heterogeneityCommitmentSha256,
|
|
167
|
+
claimTypeSha256,
|
|
168
|
+
bianCoverageSha256,
|
|
169
|
+
eticasTaxonomySha256,
|
|
170
|
+
siveFixtureSetSha256,
|
|
171
|
+
calibrationRankingSplitSha256 }) {
|
|
172
|
+
const parts = [
|
|
173
|
+
ATTESTATION_VERSION,
|
|
174
|
+
mode,
|
|
175
|
+
requestCommitment,
|
|
176
|
+
outputCommitment,
|
|
177
|
+
modelId,
|
|
178
|
+
completedAtUtc,
|
|
179
|
+
previousHash || "",
|
|
180
|
+
keyId,
|
|
181
|
+
];
|
|
182
|
+
// Only append when the caller signed with a dictionary hash. This
|
|
183
|
+
// keeps every pre-v1.5.8 attestation verifying unchanged: they were
|
|
184
|
+
// signed without this field, and verification recomputes the payload
|
|
185
|
+
// without it because the attestation object doesn't carry it.
|
|
186
|
+
if (dictionaryHash) parts.push(dictionaryHash);
|
|
187
|
+
// v1.5.18: same back-compat append-only pattern as dictionaryHash.
|
|
188
|
+
if (citationRegistrySha256) parts.push(citationRegistrySha256);
|
|
189
|
+
// v1.5.19: same conditional append pattern. Bank counsel pins
|
|
190
|
+
// proxy_schema_sha256 in procurement contract so post-hoc edits
|
|
191
|
+
// to the ECOA §701 blocklist (e.g. quietly softening from hard-block
|
|
192
|
+
// to advisory on a class the bank finds inconvenient) break Ed25519
|
|
193
|
+
// verification.
|
|
194
|
+
if (proxySchemaSha256) parts.push(proxySchemaSha256);
|
|
195
|
+
// v1.5.20: Pattern C original_content_hash. Same conditional
|
|
196
|
+
// append-only pattern. When Shadow ships CCR (compressed content
|
|
197
|
+
// retrieval) mode later, MCP tool responses will carry
|
|
198
|
+
// {summary_120w, ccr_hash, retrieve_via} where output_commitment
|
|
199
|
+
// covers only the summary. original_content_hash then covers the
|
|
200
|
+
// PRE-COMPRESSION original so bank counsel can verify the summary
|
|
201
|
+
// was derived from what the bank actually saw. Pre-v1.5.20
|
|
202
|
+
// attestations sign without this field and verify unchanged; the
|
|
203
|
+
// scaffold ships now so v1.5.20+ callers who opt-in are ready.
|
|
204
|
+
if (originalContentHash) parts.push(originalContentHash);
|
|
205
|
+
// v1.5.23: append-only pattern continues. When the caller provides
|
|
206
|
+
// a Judge Card SHA-256 (per arXiv:2605.06161 Policy Invariance
|
|
207
|
+
// Score protocol), it binds into the signing payload. Bank counsel
|
|
208
|
+
// pins the Judge Card hash in procurement contracts so any post-
|
|
209
|
+
// hoc metric relaxation (e.g. quietly widening the ambiguity
|
|
210
|
+
// signal list to raise scores) breaks Ed25519 verification.
|
|
211
|
+
if (policyInvarianceScoreSha256) parts.push(policyInvarianceScoreSha256);
|
|
212
|
+
// v1.5.24: append-only pattern continues. When the caller binds a
|
|
213
|
+
// GAICF-compatible adverse-action notice hash (per Wang et al
|
|
214
|
+
// arXiv:2607.04103 layer 3), it flows into the signing payload
|
|
215
|
+
// so any post-hoc softening of the notice text breaks Ed25519
|
|
216
|
+
// verification. Bank counsel pins the notice hash in the
|
|
217
|
+
// procurement contract alongside the verdict hash.
|
|
218
|
+
if (adverseActionNoticeSha256) parts.push(adverseActionNoticeSha256);
|
|
219
|
+
// v1.5.28 (arXiv:2606.16121): append the sampling-seed commitment
|
|
220
|
+
// when the caller binds it. Same append-only pattern. Detects
|
|
221
|
+
// silent seed / temperature substitution between council calls.
|
|
222
|
+
if (samplingSeedCommitmentSha256) parts.push(samplingSeedCommitmentSha256);
|
|
223
|
+
// v1.5.30 (arXiv:2607.01661): append the evidence-partition scheme
|
|
224
|
+
// hash. Same append-only pattern. Detects silent partition-scheme
|
|
225
|
+
// swap between decisions (e.g. quietly widening compliance's field
|
|
226
|
+
// list to include credit_score would break correlated-vote
|
|
227
|
+
// defense; the hash change makes it detectable).
|
|
228
|
+
if (evidencePartitionSchemeSha256) parts.push(evidencePartitionSchemeSha256);
|
|
229
|
+
// v1.5.32 (arXiv:2606.19826): append the heterogeneous-debate
|
|
230
|
+
// enforcement commitment. Same append-only pattern. Detects silent
|
|
231
|
+
// post-hoc relaxation of the min-providers requirement (e.g. quietly
|
|
232
|
+
// lowering min_providers from 2 to 1 to permit a single-provider
|
|
233
|
+
// deployment, which would fail the adversarial-peer defense).
|
|
234
|
+
if (heterogeneityCommitmentSha256) parts.push(heterogeneityCommitmentSha256);
|
|
235
|
+
// v1.5.37 (arXiv:2605.20312 Pramana): append the typed-claim
|
|
236
|
+
// envelope hash. Same append-only pattern. Detects silent post-hoc
|
|
237
|
+
// reclassification of a claim (e.g. downgrading an inference-class
|
|
238
|
+
// claim to perception-class to skip seed-commitment verification
|
|
239
|
+
// at audit time).
|
|
240
|
+
if (claimTypeSha256) parts.push(claimTypeSha256);
|
|
241
|
+
// v1.5.39 (arXiv:2607.01740 BIAN meta-benchmark): append the
|
|
242
|
+
// persona→BIAN coverage map hash. Same append-only pattern. Detects
|
|
243
|
+
// silent widening of a persona's claimed BIAN domain (e.g. quietly
|
|
244
|
+
// asserting Compliance Officer covers "Fraud Detection" when it
|
|
245
|
+
// does not) which would let a bank claim BIAN coverage it cannot
|
|
246
|
+
// actually deliver.
|
|
247
|
+
if (bianCoverageSha256) parts.push(bianCoverageSha256);
|
|
248
|
+
// v1.5.40 (arXiv:2607.02201 Eticas AI Risk Taxonomy v2.0.0):
|
|
249
|
+
// append the Eticas subcategory map hash. Same append-only pattern.
|
|
250
|
+
// Detects silent widening of a Shadow test's claimed Eticas
|
|
251
|
+
// subcategory coverage (e.g. claiming "adversarial-peer-defense"
|
|
252
|
+
// without shipping the underlying heterogeneous-debate test).
|
|
253
|
+
if (eticasTaxonomySha256) parts.push(eticasTaxonomySha256);
|
|
254
|
+
// v1.5.41 (arXiv:2607.00910 SIVE): append the internal-consistency
|
|
255
|
+
// fixture set hash. Same append-only pattern. Silent weakening of
|
|
256
|
+
// a fixture (e.g. lowering obvious_deny FICO from 500 to 620 to
|
|
257
|
+
// make a broken council pass its consistency test) breaks Ed25519
|
|
258
|
+
// verification.
|
|
259
|
+
if (siveFixtureSetSha256) parts.push(siveFixtureSetSha256);
|
|
260
|
+
// v1.5.42 (arXiv:2605.27712 Prefix-Safe Bayesian Belief Tracking):
|
|
261
|
+
// append the calibration-vs-ranking split hash. Same append-only
|
|
262
|
+
// pattern. Silent swap of either calibrated_p or ranking_score
|
|
263
|
+
// (e.g. quietly lowering calibrated_p to skirt a Brier audit
|
|
264
|
+
// threshold) breaks Ed25519 verification.
|
|
265
|
+
if (calibrationRankingSplitSha256) parts.push(calibrationRankingSplitSha256);
|
|
266
|
+
return parts.join("|");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Build a signed attestation object for a Shadow decision.
|
|
272
|
+
*
|
|
273
|
+
* @param {object} params
|
|
274
|
+
* @param {object} params.request — the exact input the caller sent
|
|
275
|
+
* (loan dict + policy + any other config)
|
|
276
|
+
* @param {object} params.response — the exact response body Shadow
|
|
277
|
+
* is about to return (verdict + voices + risk_packet + etc.).
|
|
278
|
+
* IMPORTANT: pass this BEFORE embedding the attestation object
|
|
279
|
+
* itself — else you're hashing the attestation into itself.
|
|
280
|
+
* @param {string} params.modelId — e.g. "claude-sonnet-4-6"
|
|
281
|
+
* @param {string} [params.completedAtUtc] — ISO 8601. Defaults to now.
|
|
282
|
+
* @param {string} [params.previousHash] — SHA-256 of previous
|
|
283
|
+
* response's attestation. Enables hash-chain. Optional (first
|
|
284
|
+
* response has no previous).
|
|
285
|
+
* @param {string} [params.mode] — SIGNATURE_MODES.HMAC (default,
|
|
286
|
+
* back-compat) or SIGNATURE_MODES.ED25519 (recommended for
|
|
287
|
+
* procurement — asymmetric).
|
|
288
|
+
* @param {string} [params.secret] — HMAC mode only. Override for tests.
|
|
289
|
+
* @param {string|KeyObject} [params.privateKey] — Ed25519 mode only.
|
|
290
|
+
* PEM string OR a Node KeyObject. Falls back to
|
|
291
|
+
* SHADOW_ATTESTATION_ED25519_PRIVATE_KEY env var.
|
|
292
|
+
* @param {string} [params.keyId] — override for tests
|
|
293
|
+
* @returns {{
|
|
294
|
+
* version: string,
|
|
295
|
+
* mode: string, // "hmac-sha256" | "ed25519"
|
|
296
|
+
* request_commitment: string, // sha256 hex
|
|
297
|
+
* output_commitment: string, // sha256 hex
|
|
298
|
+
* model_id: string,
|
|
299
|
+
* completed_at_utc: string,
|
|
300
|
+
* previous_hash: string|null,
|
|
301
|
+
* key_id: string,
|
|
302
|
+
* signature: string, // hex (hmac) or base64 (ed25519)
|
|
303
|
+
* }}
|
|
304
|
+
*/
|
|
305
|
+
export function buildAttestation(params) {
|
|
306
|
+
const {
|
|
307
|
+
request,
|
|
308
|
+
response,
|
|
309
|
+
modelId,
|
|
310
|
+
completedAtUtc = new Date().toISOString(),
|
|
311
|
+
previousHash = null,
|
|
312
|
+
mode = DEFAULT_MODE,
|
|
313
|
+
secret = DEFAULT_SECRET,
|
|
314
|
+
privateKey = DEFAULT_ED25519_PRIVATE_PEM,
|
|
315
|
+
keyId = DEFAULT_KEY_ID,
|
|
316
|
+
dictionaryHash = null, // v1.5.8+: hash of the signed reason-code dictionary at decision time
|
|
317
|
+
citationRegistrySha256 = null, // v1.5.18+: hash of the CFR citation registry at decision time
|
|
318
|
+
proxySchemaSha256 = null, // v1.5.19+: hash of the ECOA §701 protected-classes schema at decision time
|
|
319
|
+
originalContentHash = null, // v1.5.20+: SHA-256 of pre-compression original when CCR mode is active
|
|
320
|
+
policyInvarianceScoreSha256 = null, // v1.5.23+: SHA-256 of the Judge Card artifact (arXiv:2605.06161)
|
|
321
|
+
adverseActionNoticeSha256 = null, // v1.5.24+: SHA-256 of the bilingual §1002.9(b)(2) adverse-action notice (arXiv:2607.04103 layer 3)
|
|
322
|
+
samplingSeedCommitmentSha256 = null, // v1.5.28+: SHA-256 of the sampling-seed commitment (arXiv:2606.16121 invisible-manipulation defense)
|
|
323
|
+
evidencePartitionSchemeSha256 = null, // v1.5.30+: SHA-256 of the InfoDelphi evidence-partition scheme (arXiv:2607.01661)
|
|
324
|
+
heterogeneityCommitmentSha256 = null, // v1.5.32+: SHA-256 of the heterogeneous-debate enforcement commitment (arXiv:2606.19826)
|
|
325
|
+
claimTypeSha256 = null, // v1.5.37+: SHA-256 of the typed-claim envelope (arXiv:2605.20312 Pramana)
|
|
326
|
+
bianCoverageSha256 = null, // v1.5.39+: SHA-256 of the persona→BIAN coverage map (arXiv:2607.01740)
|
|
327
|
+
eticasTaxonomySha256 = null, // v1.5.40+: SHA-256 of the Eticas AI Risk Taxonomy v2 map (arXiv:2607.02201)
|
|
328
|
+
siveFixtureSetSha256 = null, // v1.5.41+: SHA-256 of the SIVE fixture set (arXiv:2607.00910)
|
|
329
|
+
calibrationRankingSplitSha256 = null, // v1.5.42+: SHA-256 of the calibration-ranking-split (arXiv:2605.27712)
|
|
330
|
+
} = params;
|
|
331
|
+
|
|
332
|
+
if (!request) throw new Error("buildAttestation: request required");
|
|
333
|
+
if (!response) throw new Error("buildAttestation: response required");
|
|
334
|
+
if (!modelId) throw new Error("buildAttestation: modelId required");
|
|
335
|
+
if (mode !== SIGNATURE_MODES.HMAC && mode !== SIGNATURE_MODES.ED25519) {
|
|
336
|
+
throw new Error(`buildAttestation: unsupported mode "${mode}"`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const requestCommitment = commitmentOf(request);
|
|
340
|
+
const outputCommitment = commitmentOf(response);
|
|
341
|
+
|
|
342
|
+
const signingPayload = _signingPayload({
|
|
343
|
+
mode, requestCommitment, outputCommitment,
|
|
344
|
+
modelId, completedAtUtc, previousHash, keyId, dictionaryHash,
|
|
345
|
+
citationRegistrySha256, proxySchemaSha256, originalContentHash,
|
|
346
|
+
policyInvarianceScoreSha256, adverseActionNoticeSha256,
|
|
347
|
+
samplingSeedCommitmentSha256, evidencePartitionSchemeSha256,
|
|
348
|
+
heterogeneityCommitmentSha256, claimTypeSha256, bianCoverageSha256,
|
|
349
|
+
eticasTaxonomySha256, siveFixtureSetSha256,
|
|
350
|
+
calibrationRankingSplitSha256,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
let signature;
|
|
354
|
+
if (mode === SIGNATURE_MODES.HMAC) {
|
|
355
|
+
signature = createHmac("sha256", secret)
|
|
356
|
+
.update(signingPayload)
|
|
357
|
+
.digest("hex");
|
|
358
|
+
} else {
|
|
359
|
+
// Ed25519
|
|
360
|
+
if (!privateKey) {
|
|
361
|
+
throw new Error(
|
|
362
|
+
"buildAttestation: ed25519 mode requires privateKey (or " +
|
|
363
|
+
"SHADOW_ATTESTATION_ED25519_PRIVATE_KEY env var)",
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
const keyObj = _asPrivateKey(privateKey);
|
|
367
|
+
// Node's crypto.sign with algorithm=null does Ed25519 (which
|
|
368
|
+
// is a pure-EdDSA scheme, not a hash-and-sign).
|
|
369
|
+
signature = cryptoSign(null, Buffer.from(signingPayload), keyObj)
|
|
370
|
+
.toString("base64");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
version: ATTESTATION_VERSION,
|
|
375
|
+
mode,
|
|
376
|
+
request_commitment: requestCommitment,
|
|
377
|
+
output_commitment: outputCommitment,
|
|
378
|
+
model_id: modelId,
|
|
379
|
+
completed_at_utc: completedAtUtc,
|
|
380
|
+
previous_hash: previousHash,
|
|
381
|
+
key_id: keyId,
|
|
382
|
+
// v1.5.8+: only present when the caller bound a dictionary hash.
|
|
383
|
+
// Old attestations omit this field entirely, keeping wire back-compat.
|
|
384
|
+
...(dictionaryHash ? { dictionary_hash: dictionaryHash } : {}),
|
|
385
|
+
// v1.5.18+: same back-compat append-only pattern. Bank counsel
|
|
386
|
+
// pins citation_registry_sha256 in the procurement contract; any
|
|
387
|
+
// post-hoc registry edit breaks verification.
|
|
388
|
+
...(citationRegistrySha256 ? { citation_registry_sha256: citationRegistrySha256 } : {}),
|
|
389
|
+
// v1.5.19+: same append-only pattern for the ECOA §701 protected-
|
|
390
|
+
// classes schema hash. Post-hoc softening of the blocklist breaks
|
|
391
|
+
// verification.
|
|
392
|
+
...(proxySchemaSha256 ? { proxy_schema_sha256: proxySchemaSha256 } : {}),
|
|
393
|
+
// v1.5.20+: Pattern C original_content_hash scaffold. Populated
|
|
394
|
+
// only when Shadow ships CCR compression mode (deferred). Present
|
|
395
|
+
// as append-only field so v1.5.20+ callers who opt in are already
|
|
396
|
+
// wire-compatible with future v1.5.21+ CCR implementations.
|
|
397
|
+
...(originalContentHash ? { original_content_hash: originalContentHash } : {}),
|
|
398
|
+
// v1.5.23+: Judge Card SHA-256 per Weng et al arXiv:2605.06161.
|
|
399
|
+
// Same append-only pattern. Bank counsel pins the value in
|
|
400
|
+
// procurement contracts so a downstream metric-relaxation
|
|
401
|
+
// (widening the ambiguity signal list, softening thresholds)
|
|
402
|
+
// breaks Ed25519 verification.
|
|
403
|
+
...(policyInvarianceScoreSha256 ? { policy_invariance_score_sha256: policyInvarianceScoreSha256 } : {}),
|
|
404
|
+
// v1.5.24+: adverse-action notice hash per Wang et al 2607.04103
|
|
405
|
+
// layer 3. Same append-only pattern. Bank counsel pins the notice
|
|
406
|
+
// hash in the procurement contract so post-hoc notice edits break
|
|
407
|
+
// Ed25519 verification.
|
|
408
|
+
...(adverseActionNoticeSha256 ? { adverse_action_notice_sha256: adverseActionNoticeSha256 } : {}),
|
|
409
|
+
// v1.5.28+: sampling-seed commitment per arXiv:2606.16121.
|
|
410
|
+
// Same append-only pattern. Post-hoc edit of the seed / model /
|
|
411
|
+
// temperature that Shadow requested breaks Ed25519 verification.
|
|
412
|
+
...(samplingSeedCommitmentSha256 ? { sampling_seed_commitment_sha256: samplingSeedCommitmentSha256 } : {}),
|
|
413
|
+
// v1.5.30+: evidence-partition scheme hash per arXiv:2607.01661.
|
|
414
|
+
...(evidencePartitionSchemeSha256 ? { evidence_partition_scheme_sha256: evidencePartitionSchemeSha256 } : {}),
|
|
415
|
+
// v1.5.32+: heterogeneity commitment per arXiv:2606.19826. Same
|
|
416
|
+
// append-only pattern. Post-hoc lowering of min_providers (silently
|
|
417
|
+
// permitting a single-provider deployment that fails the
|
|
418
|
+
// adversarial-peer defense) breaks Ed25519 verification.
|
|
419
|
+
...(heterogeneityCommitmentSha256 ? { heterogeneity_commitment_sha256: heterogeneityCommitmentSha256 } : {}),
|
|
420
|
+
// v1.5.37+: typed-claim envelope hash per arXiv:2605.20312 Pramana.
|
|
421
|
+
// Same append-only pattern. Post-hoc silent reclassification of
|
|
422
|
+
// the claim (e.g. downgrading INFERENCE to PERCEPTION to skip
|
|
423
|
+
// seed-commitment verification) breaks Ed25519 verification.
|
|
424
|
+
...(claimTypeSha256 ? { claim_type_sha256: claimTypeSha256 } : {}),
|
|
425
|
+
// v1.5.39+: BIAN coverage map hash per arXiv:2607.01740. Same
|
|
426
|
+
// append-only pattern. Silent widening of a persona's claimed
|
|
427
|
+
// BIAN domain breaks Ed25519 verification.
|
|
428
|
+
...(bianCoverageSha256 ? { bian_coverage_sha256: bianCoverageSha256 } : {}),
|
|
429
|
+
// v1.5.40+: Eticas Taxonomy map hash per arXiv:2607.02201. Same
|
|
430
|
+
// append-only pattern. Silent widening of a Shadow test's
|
|
431
|
+
// claimed Eticas subcategory coverage breaks Ed25519 verification.
|
|
432
|
+
...(eticasTaxonomySha256 ? { eticas_taxonomy_sha256: eticasTaxonomySha256 } : {}),
|
|
433
|
+
// v1.5.41+: SIVE fixture set hash per arXiv:2607.00910. Same
|
|
434
|
+
// append-only pattern. Silent weakening of a fixture breaks
|
|
435
|
+
// Ed25519 verification.
|
|
436
|
+
...(siveFixtureSetSha256 ? { sive_fixture_set_sha256: siveFixtureSetSha256 } : {}),
|
|
437
|
+
// v1.5.42+: calibration-vs-ranking split hash per arXiv:2605.27712.
|
|
438
|
+
// Silent swap of either output breaks Ed25519 verification.
|
|
439
|
+
...(calibrationRankingSplitSha256 ? { calibration_ranking_split_sha256: calibrationRankingSplitSha256 } : {}),
|
|
440
|
+
signature,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
function _asPrivateKey(input) {
|
|
446
|
+
if (typeof input === "object" && input.asymmetricKeyType) return input;
|
|
447
|
+
// PEM string or raw base64 → coerce to KeyObject.
|
|
448
|
+
const str = String(input);
|
|
449
|
+
if (str.includes("BEGIN")) {
|
|
450
|
+
return createPrivateKey({ key: str, format: "pem" });
|
|
451
|
+
}
|
|
452
|
+
// Assume raw 32-byte seed base64-encoded; wrap into DER PKCS8
|
|
453
|
+
// (Node requires that form when constructing an Ed25519 key from
|
|
454
|
+
// a raw seed). This helper preserves callers that pass a raw seed.
|
|
455
|
+
const raw = Buffer.from(str, "base64");
|
|
456
|
+
if (raw.length !== 32) {
|
|
457
|
+
throw new Error(
|
|
458
|
+
`_asPrivateKey: raw Ed25519 seed must be 32 bytes, got ${raw.length}`,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
// PKCS8 wrapper for Ed25519: SEQUENCE { 0, AlgorithmId, OCTET STRING }
|
|
462
|
+
const pkcs8 = Buffer.concat([
|
|
463
|
+
Buffer.from("302e020100300506032b657004220420", "hex"),
|
|
464
|
+
raw,
|
|
465
|
+
]);
|
|
466
|
+
return createPrivateKey({ key: pkcs8, format: "der", type: "pkcs8" });
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
function _asPublicKey(input) {
|
|
471
|
+
if (typeof input === "object" && input.asymmetricKeyType) return input;
|
|
472
|
+
const str = String(input);
|
|
473
|
+
if (str.includes("BEGIN")) {
|
|
474
|
+
return createPublicKey({ key: str, format: "pem" });
|
|
475
|
+
}
|
|
476
|
+
// Raw 32-byte public key base64 → SPKI DER wrapper
|
|
477
|
+
const raw = Buffer.from(str, "base64");
|
|
478
|
+
if (raw.length !== 32) {
|
|
479
|
+
throw new Error(
|
|
480
|
+
`_asPublicKey: raw Ed25519 public key must be 32 bytes, got ${raw.length}`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
const spki = Buffer.concat([
|
|
484
|
+
Buffer.from("302a300506032b6570032100", "hex"),
|
|
485
|
+
raw,
|
|
486
|
+
]);
|
|
487
|
+
return createPublicKey({ key: spki, format: "der", type: "spki" });
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Verify a signed attestation against a persisted request + response.
|
|
493
|
+
* Returns a diagnostic object; ok=false means the record is tampered
|
|
494
|
+
* OR a silent model swap happened OR the wrong key material was used.
|
|
495
|
+
*
|
|
496
|
+
* @param {object} attestation — the signed object built earlier
|
|
497
|
+
* @param {object} originalRequest — persisted request payload
|
|
498
|
+
* @param {object} originalResponse — persisted response body (with
|
|
499
|
+
* attestation field removed — pass response.body minus attestation)
|
|
500
|
+
* @param {object} [keys] — key material for verification
|
|
501
|
+
* @param {string} [keys.secret] — HMAC secret (for hmac-sha256 mode)
|
|
502
|
+
* @param {string|KeyObject} [keys.publicKey] — Ed25519 public key
|
|
503
|
+
* (PEM string, base64 raw 32-byte, or Node KeyObject). Falls back
|
|
504
|
+
* to SHADOW_ATTESTATION_ED25519_PUBLIC_KEY env var.
|
|
505
|
+
* @returns {{ok: boolean, reason: string, checks: object}}
|
|
506
|
+
*
|
|
507
|
+
* Legacy positional call verifyAttestation(attestation, req, res, secretString)
|
|
508
|
+
* still works — a bare string 4th arg is treated as `secret`.
|
|
509
|
+
*/
|
|
510
|
+
export function verifyAttestation(attestation, originalRequest,
|
|
511
|
+
originalResponse, keys = {}) {
|
|
512
|
+
const checks = {};
|
|
513
|
+
|
|
514
|
+
if (!attestation || typeof attestation !== "object") {
|
|
515
|
+
return { ok: false, reason: "attestation missing or malformed", checks };
|
|
516
|
+
}
|
|
517
|
+
if (attestation.version !== ATTESTATION_VERSION) {
|
|
518
|
+
return {
|
|
519
|
+
ok: false,
|
|
520
|
+
reason: `unsupported attestation version: ${attestation.version}`,
|
|
521
|
+
checks,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Legacy positional signature: bare string means hmac secret.
|
|
526
|
+
const legacySecret = typeof keys === "string" ? keys : null;
|
|
527
|
+
const secret = legacySecret ?? keys.secret ?? DEFAULT_SECRET;
|
|
528
|
+
const publicKey = keys.publicKey ?? DEFAULT_ED25519_PUBLIC_PEM;
|
|
529
|
+
|
|
530
|
+
// Verify request commitment
|
|
531
|
+
const expectedRequest = commitmentOf(originalRequest);
|
|
532
|
+
checks.request_commitment_match =
|
|
533
|
+
expectedRequest === attestation.request_commitment;
|
|
534
|
+
if (!checks.request_commitment_match) {
|
|
535
|
+
return {
|
|
536
|
+
ok: false,
|
|
537
|
+
reason: "request commitment mismatch — record was tampered",
|
|
538
|
+
checks,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Verify output commitment
|
|
543
|
+
const expectedOutput = commitmentOf(originalResponse);
|
|
544
|
+
checks.output_commitment_match =
|
|
545
|
+
expectedOutput === attestation.output_commitment;
|
|
546
|
+
if (!checks.output_commitment_match) {
|
|
547
|
+
return {
|
|
548
|
+
ok: false,
|
|
549
|
+
reason: "output commitment mismatch — response was tampered",
|
|
550
|
+
checks,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Dispatch signature verification by mode.
|
|
555
|
+
// Missing mode field → treat as hmac (v0 back-compat with older
|
|
556
|
+
// attestations built before the mode field existed).
|
|
557
|
+
const mode = attestation.mode ?? SIGNATURE_MODES.HMAC;
|
|
558
|
+
const signingPayload = _signingPayload({
|
|
559
|
+
mode,
|
|
560
|
+
requestCommitment: attestation.request_commitment,
|
|
561
|
+
outputCommitment: attestation.output_commitment,
|
|
562
|
+
modelId: attestation.model_id,
|
|
563
|
+
completedAtUtc: attestation.completed_at_utc,
|
|
564
|
+
previousHash: attestation.previous_hash,
|
|
565
|
+
keyId: attestation.key_id,
|
|
566
|
+
// v1.5.8+: dictionary_hash is included in the signing payload only
|
|
567
|
+
// when the attestation object itself carries it. This preserves
|
|
568
|
+
// wire back-compat: pre-v1.5.8 attestations verify unchanged.
|
|
569
|
+
dictionaryHash: attestation.dictionary_hash,
|
|
570
|
+
// v1.5.18+: same conditional pattern for citation_registry_sha256.
|
|
571
|
+
citationRegistrySha256: attestation.citation_registry_sha256,
|
|
572
|
+
// v1.5.19+: same conditional pattern for proxy_schema_sha256.
|
|
573
|
+
proxySchemaSha256: attestation.proxy_schema_sha256,
|
|
574
|
+
// v1.5.20+: same conditional pattern for original_content_hash.
|
|
575
|
+
originalContentHash: attestation.original_content_hash,
|
|
576
|
+
// v1.5.23+: same conditional pattern for policy_invariance_score_sha256.
|
|
577
|
+
policyInvarianceScoreSha256: attestation.policy_invariance_score_sha256,
|
|
578
|
+
// v1.5.24+: same conditional pattern for adverse_action_notice_sha256.
|
|
579
|
+
adverseActionNoticeSha256: attestation.adverse_action_notice_sha256,
|
|
580
|
+
// v1.5.28+: same conditional pattern for sampling_seed_commitment_sha256.
|
|
581
|
+
samplingSeedCommitmentSha256: attestation.sampling_seed_commitment_sha256,
|
|
582
|
+
// v1.5.30+: same conditional pattern for evidence_partition_scheme_sha256.
|
|
583
|
+
evidencePartitionSchemeSha256: attestation.evidence_partition_scheme_sha256,
|
|
584
|
+
// v1.5.32+: same conditional pattern for heterogeneity_commitment_sha256.
|
|
585
|
+
heterogeneityCommitmentSha256: attestation.heterogeneity_commitment_sha256,
|
|
586
|
+
// v1.5.37+: same conditional pattern for claim_type_sha256.
|
|
587
|
+
claimTypeSha256: attestation.claim_type_sha256,
|
|
588
|
+
// v1.5.39+: same conditional pattern for bian_coverage_sha256.
|
|
589
|
+
bianCoverageSha256: attestation.bian_coverage_sha256,
|
|
590
|
+
// v1.5.40+: same conditional pattern for eticas_taxonomy_sha256.
|
|
591
|
+
eticasTaxonomySha256: attestation.eticas_taxonomy_sha256,
|
|
592
|
+
// v1.5.41+: same conditional pattern for sive_fixture_set_sha256.
|
|
593
|
+
siveFixtureSetSha256: attestation.sive_fixture_set_sha256,
|
|
594
|
+
// v1.5.42+: same conditional pattern for calibration_ranking_split_sha256.
|
|
595
|
+
calibrationRankingSplitSha256: attestation.calibration_ranking_split_sha256,
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
if (mode === SIGNATURE_MODES.HMAC) {
|
|
599
|
+
const expectedSignature = createHmac("sha256", secret)
|
|
600
|
+
.update(signingPayload)
|
|
601
|
+
.digest("hex");
|
|
602
|
+
checks.signature_match = expectedSignature === attestation.signature;
|
|
603
|
+
if (!checks.signature_match) {
|
|
604
|
+
return {
|
|
605
|
+
ok: false,
|
|
606
|
+
reason:
|
|
607
|
+
"signature mismatch — either the wrong server_secret was used, " +
|
|
608
|
+
"or the model_id / completed_at_utc were tampered with silently.",
|
|
609
|
+
checks,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
} else if (mode === SIGNATURE_MODES.ED25519) {
|
|
613
|
+
if (!publicKey) {
|
|
614
|
+
return {
|
|
615
|
+
ok: false,
|
|
616
|
+
reason:
|
|
617
|
+
"ed25519 verification requires publicKey (or " +
|
|
618
|
+
"SHADOW_ATTESTATION_ED25519_PUBLIC_KEY env var)",
|
|
619
|
+
checks,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
const keyObj = _asPublicKey(publicKey);
|
|
623
|
+
const sigBuf = Buffer.from(attestation.signature, "base64");
|
|
624
|
+
checks.signature_match = cryptoVerify(
|
|
625
|
+
null, Buffer.from(signingPayload), keyObj, sigBuf,
|
|
626
|
+
);
|
|
627
|
+
if (!checks.signature_match) {
|
|
628
|
+
return {
|
|
629
|
+
ok: false,
|
|
630
|
+
reason:
|
|
631
|
+
"ed25519 signature mismatch — either the wrong public key was used, " +
|
|
632
|
+
"or the attestation was signed by a different private key, or the " +
|
|
633
|
+
"model_id / completed_at_utc were tampered with silently.",
|
|
634
|
+
checks,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
} else {
|
|
638
|
+
return {
|
|
639
|
+
ok: false,
|
|
640
|
+
reason: `unsupported attestation signature mode "${mode}"`,
|
|
641
|
+
checks,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
return {
|
|
646
|
+
ok: true,
|
|
647
|
+
reason: "attestation verified",
|
|
648
|
+
checks,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
export { ATTESTATION_VERSION };
|
package/batch.js
ADDED
package/chain.js
ADDED