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/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
@@ -0,0 +1,2 @@
1
+ // shadow-attest-core/batch — batch attestation entry point
2
+ export * from "./attestation-batch.js";
package/chain.js ADDED
@@ -0,0 +1,2 @@
1
+ // shadow-attest-core/chain — hash chain primitives
2
+ export * from "./attestation-chain.js";