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.
@@ -0,0 +1,230 @@
1
+ // Batch Ed25519 attestation signing — v1.5.12.
2
+ //
3
+ // Motivation: for banks processing 10,000+ loan files per day, per-decision
4
+ // Ed25519 signing scales linearly (one sign() per decision), but verifiers
5
+ // walking the audit chain want O(1) verification per batch: sign a single
6
+ // signature over the SHA-256 of concatenated per-decision hashes.
7
+ //
8
+ // Design constraint: batch signing MUST NOT weaken per-decision integrity.
9
+ // Every decision still has its own hash + previous_hash chain + dictionary_hash.
10
+ // The batch signature is a *summary* signature over that chain, letting a
11
+ // bank auditor verify N=10,000 decisions in one Ed25519 verification instead
12
+ // of N. If the bank cares about tamper detection for a single decision, they
13
+ // still walk the chain — this is O(N). Batch verification is the "SIEM
14
+ // aggregate check runs once per hour" path.
15
+ //
16
+ // Refs:
17
+ // - RFC 8032 Edwards-Curve Digital Signature Algorithm
18
+ // - Shadow v1.5.10 hash-chain verifier (lib/attestation-chain.js)
19
+ // - Shadow v1.5.8 dictionary_hash binding (lib/attestation.js)
20
+
21
+ import {
22
+ createHash,
23
+ createPrivateKey,
24
+ createPublicKey,
25
+ sign as cryptoSign,
26
+ verify as cryptoVerify,
27
+ } from "node:crypto";
28
+ import { computeAttestationHash } from "./attestation-chain.js";
29
+
30
+ // Ed25519 key helpers — self-contained so this file has no cross-file test coupling.
31
+ // PKCS8 wrapper for Ed25519: SEQUENCE { 0, AlgorithmId(OID 1.3.101.112), OCTET STRING }
32
+ const ED25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
33
+ // SPKI wrapper for Ed25519: SEQUENCE { AlgorithmId, BIT STRING }
34
+ const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
35
+
36
+ function _asPrivateKey(input) {
37
+ if (typeof input === "object" && input !== null && input.type === "private") {
38
+ return input; // already a KeyObject
39
+ }
40
+ if (typeof input === "string") {
41
+ if (input.startsWith("-----BEGIN")) {
42
+ return createPrivateKey({ key: input, format: "pem" });
43
+ }
44
+ // Assume hex-encoded 32-byte seed
45
+ const raw = Buffer.from(input, "hex");
46
+ if (raw.length !== 32) {
47
+ throw new Error(`_asPrivateKey: raw Ed25519 seed must be 32 bytes, got ${raw.length}`);
48
+ }
49
+ return createPrivateKey({
50
+ key: Buffer.concat([ED25519_PKCS8_PREFIX, raw]),
51
+ format: "der",
52
+ type: "pkcs8",
53
+ });
54
+ }
55
+ throw new Error("_asPrivateKey: unsupported input type");
56
+ }
57
+
58
+ function _asPublicKey(input) {
59
+ if (typeof input === "object" && input !== null && input.type === "public") {
60
+ return input;
61
+ }
62
+ if (typeof input === "string") {
63
+ if (input.startsWith("-----BEGIN")) {
64
+ return createPublicKey({ key: input, format: "pem" });
65
+ }
66
+ const raw = Buffer.from(input, "hex");
67
+ if (raw.length !== 32) {
68
+ throw new Error(`_asPublicKey: raw Ed25519 public key must be 32 bytes, got ${raw.length}`);
69
+ }
70
+ return createPublicKey({
71
+ key: Buffer.concat([ED25519_SPKI_PREFIX, raw]),
72
+ format: "der",
73
+ type: "spki",
74
+ });
75
+ }
76
+ throw new Error("_asPublicKey: unsupported input type");
77
+ }
78
+
79
+ /**
80
+ * Batch signing schema:
81
+ *
82
+ * {
83
+ * batch_id: string,
84
+ * attestation_count: number,
85
+ * first_decision_hash: string (SHA-256 hex),
86
+ * last_decision_hash: string (SHA-256 hex),
87
+ * root_hash: string (SHA-256 hex of concatenated per-decision hashes),
88
+ * batch_signature: string (Ed25519 hex signature over root_hash + batch_id),
89
+ * key_id: string,
90
+ * signed_at_utc: string (ISO 8601),
91
+ * }
92
+ *
93
+ * root_hash = SHA-256(
94
+ * attestation[0].hash || attestation[1].hash || ... || attestation[N-1].hash
95
+ * )
96
+ *
97
+ * batch_signature = Ed25519_sign(privateKey,
98
+ * batch_id || "|" || root_hash || "|" || attestation_count
99
+ * )
100
+ */
101
+
102
+ const CANONICAL_SEP = "|";
103
+
104
+ /**
105
+ * Compute the root hash over a sequence of attestation hashes.
106
+ * @param {Array<{hash: string}>} decisionHashes — output of computeAttestationHash per decision
107
+ * @returns {string} SHA-256 hex root hash
108
+ */
109
+ export function computeBatchRootHash(decisionHashes) {
110
+ if (!Array.isArray(decisionHashes) || decisionHashes.length === 0) {
111
+ throw new Error(
112
+ "computeBatchRootHash requires a non-empty array of decision hashes",
113
+ );
114
+ }
115
+ const hasher = createHash("sha256");
116
+ for (const item of decisionHashes) {
117
+ if (typeof item !== "string" || item.length !== 64) {
118
+ throw new Error(
119
+ `computeBatchRootHash requires 64-char hex hashes, got: ${JSON.stringify(item)}`,
120
+ );
121
+ }
122
+ hasher.update(item, "hex");
123
+ }
124
+ return hasher.digest("hex");
125
+ }
126
+
127
+ /**
128
+ * Sign a batch of attestations with Ed25519.
129
+ * @param {Array<object>} attestations — array of attestation objects from buildAttestation()
130
+ * @param {object} params
131
+ * @param {string|Buffer|KeyObject} params.privateKey — Ed25519 private key
132
+ * @param {string} params.keyId — key id used
133
+ * @param {string} [params.batchId] — optional batch identifier; auto-generated if absent
134
+ * @returns {object} batch signature record
135
+ */
136
+ export function batchSignAttestations(attestations, { privateKey, keyId, batchId } = {}) {
137
+ if (!Array.isArray(attestations) || attestations.length === 0) {
138
+ throw new Error("batchSignAttestations requires a non-empty array of attestations");
139
+ }
140
+ if (!privateKey) {
141
+ throw new Error("batchSignAttestations requires Ed25519 privateKey");
142
+ }
143
+ if (!keyId) {
144
+ throw new Error("batchSignAttestations requires keyId (per RFC 8032 key rotation)");
145
+ }
146
+
147
+ const decisionHashes = attestations.map((a) => computeAttestationHash(a));
148
+ const rootHash = computeBatchRootHash(decisionHashes);
149
+ const resolvedBatchId = batchId ?? `batch-${new Date().toISOString().replace(/[:.]/g, "-")}`;
150
+ const attestationCount = attestations.length;
151
+
152
+ const signingPayload = [resolvedBatchId, rootHash, String(attestationCount)].join(CANONICAL_SEP);
153
+
154
+ const key = _asPrivateKey(privateKey);
155
+ const signature = cryptoSign(null, Buffer.from(signingPayload, "utf-8"), key).toString("hex");
156
+
157
+ return {
158
+ batch_id: resolvedBatchId,
159
+ attestation_count: attestationCount,
160
+ first_decision_hash: decisionHashes[0],
161
+ last_decision_hash: decisionHashes[decisionHashes.length - 1],
162
+ root_hash: rootHash,
163
+ batch_signature: signature,
164
+ key_id: keyId,
165
+ signed_at_utc: new Date().toISOString(),
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Verify a batch signature record.
171
+ * @param {object} batchRecord — output of batchSignAttestations
172
+ * @param {Array<object>} attestations — the same array of attestations
173
+ * @param {object} params
174
+ * @param {string|Buffer|KeyObject} params.publicKey — Ed25519 public key
175
+ * @returns {{ok: boolean, reason?: string, checks: object}}
176
+ */
177
+ export function batchVerifyAttestations(batchRecord, attestations, { publicKey } = {}) {
178
+ const checks = {};
179
+
180
+ if (!batchRecord || typeof batchRecord !== "object") {
181
+ return { ok: false, reason: "batchRecord is missing or not an object", checks };
182
+ }
183
+ if (!Array.isArray(attestations)) {
184
+ return { ok: false, reason: "attestations must be an array", checks };
185
+ }
186
+ if (!publicKey) {
187
+ return { ok: false, reason: "batchVerifyAttestations requires Ed25519 publicKey", checks };
188
+ }
189
+
190
+ checks.attestation_count_match = attestations.length === batchRecord.attestation_count;
191
+ if (!checks.attestation_count_match) {
192
+ return {
193
+ ok: false,
194
+ reason: `attestation count mismatch — batch says ${batchRecord.attestation_count}, got ${attestations.length}`,
195
+ checks,
196
+ };
197
+ }
198
+
199
+ const decisionHashes = attestations.map((a) => computeAttestationHash(a));
200
+ const rootHash = computeBatchRootHash(decisionHashes);
201
+ checks.root_hash_match = rootHash === batchRecord.root_hash;
202
+ if (!checks.root_hash_match) {
203
+ return {
204
+ ok: false,
205
+ reason: "root_hash mismatch — one or more attestations were reordered or tampered",
206
+ checks,
207
+ };
208
+ }
209
+
210
+ const signingPayload = [
211
+ batchRecord.batch_id,
212
+ batchRecord.root_hash,
213
+ String(batchRecord.attestation_count),
214
+ ].join(CANONICAL_SEP);
215
+
216
+ const key = _asPublicKey(publicKey);
217
+ const signatureBytes = Buffer.from(batchRecord.batch_signature, "hex");
218
+ const signatureValid = cryptoVerify(null, Buffer.from(signingPayload, "utf-8"), key, signatureBytes);
219
+
220
+ checks.signature_valid = signatureValid;
221
+ if (!signatureValid) {
222
+ return {
223
+ ok: false,
224
+ reason: "batch signature is invalid — batch_id, root_hash, or attestation_count were tampered",
225
+ checks,
226
+ };
227
+ }
228
+
229
+ return { ok: true, checks };
230
+ }
@@ -0,0 +1,125 @@
1
+ // lib/attestation-chain.js
2
+ // ──────────────────────────────────────────────────────────────────
3
+ // Hash-chain audit trail for Shadow attestations.
4
+ //
5
+ // Every attestation carries a `previous_hash` field that binds it to
6
+ // the SHA-256 of the previous attestation in the deployment's audit
7
+ // log. This module ships the canonical computation + a chain-integrity
8
+ // verifier so a bank auditor can hand a JSONL log to a verifier and
9
+ // receive a machine-readable "chain intact / broken at index N" verdict.
10
+ //
11
+ // Why chain-of-signatures matters
12
+ // -------------------------------
13
+ // Signature verification proves a single attestation was signed by the
14
+ // right key. A chain proves the SEQUENCE was never reordered, no records
15
+ // were inserted retroactively, and no records were silently deleted.
16
+ // This is the hardest evidence to forge — forging one link forces
17
+ // re-signing every subsequent one.
18
+ //
19
+ // Ships v1.5.10 (2026-07-05). Deployed alongside `POST /api/verify-chain`
20
+ // and `bin/verify-chain.mjs`.
21
+
22
+ import { createHash } from "node:crypto";
23
+ import { canonicalize } from "./attestation.js";
24
+
25
+ /**
26
+ * SHA-256 hex of the canonicalized attestation object. This is what the
27
+ * NEXT attestation's `previous_hash` must equal.
28
+ *
29
+ * We include the full attestation object (including the signature) so
30
+ * that forging a link requires resigning the entire tail — the chain
31
+ * is only as strong as the private key, but any tail modification
32
+ * cascades forward.
33
+ */
34
+ export function computeAttestationHash(attestation) {
35
+ if (!attestation || typeof attestation !== "object") {
36
+ throw new Error("computeAttestationHash: attestation must be an object");
37
+ }
38
+ return createHash("sha256").update(canonicalize(attestation)).digest("hex");
39
+ }
40
+
41
+ /**
42
+ * Verify a chain of attestations. Walks the array left→right and asserts
43
+ * every element's `previous_hash` matches SHA-256 of the previous element.
44
+ *
45
+ * @param {Array<object>} attestations — ordered chronologically
46
+ * @returns {{
47
+ * ok: boolean,
48
+ * length: number,
49
+ * broken_at_index: number|null,
50
+ * reason: string,
51
+ * links_verified: number,
52
+ * }}
53
+ *
54
+ * Design notes:
55
+ * - Empty array → ok: true, length: 0 (nothing to verify).
56
+ * - Single-element chain → ok: true iff element.previous_hash is
57
+ * null/empty (first entry has no predecessor).
58
+ * - Mid-chain break: reports the FIRST broken index. Callers can
59
+ * partition the log at that point (pre-break is intact, post-break
60
+ * is compromised).
61
+ * - Does NOT re-verify signatures. That's `verifyAttestation()`'s job.
62
+ * Chain verification is a separate, cheaper primitive.
63
+ */
64
+ export function verifyChain(attestations) {
65
+ if (!Array.isArray(attestations)) {
66
+ return {
67
+ ok: false,
68
+ length: 0,
69
+ broken_at_index: null,
70
+ reason: "attestations must be an array",
71
+ links_verified: 0,
72
+ };
73
+ }
74
+
75
+ if (attestations.length === 0) {
76
+ return {
77
+ ok: true,
78
+ length: 0,
79
+ broken_at_index: null,
80
+ reason: "empty chain — nothing to verify",
81
+ links_verified: 0,
82
+ };
83
+ }
84
+
85
+ // First element must have null / empty previous_hash — it's the genesis.
86
+ const first = attestations[0];
87
+ if (first.previous_hash && first.previous_hash !== "") {
88
+ return {
89
+ ok: false,
90
+ length: attestations.length,
91
+ broken_at_index: 0,
92
+ reason:
93
+ "first attestation carries a previous_hash but has no predecessor " +
94
+ "in the supplied chain — a prior entry was deleted or the chain " +
95
+ "was truncated",
96
+ links_verified: 0,
97
+ };
98
+ }
99
+
100
+ let linksVerified = 0;
101
+ for (let i = 1; i < attestations.length; i++) {
102
+ const expected = computeAttestationHash(attestations[i - 1]);
103
+ if (attestations[i].previous_hash !== expected) {
104
+ return {
105
+ ok: false,
106
+ length: attestations.length,
107
+ broken_at_index: i,
108
+ reason:
109
+ `chain broken at index ${i}: previous_hash does not match ` +
110
+ `SHA-256 of attestations[${i - 1}]. Either the prior entry was ` +
111
+ `edited, an entry was inserted, or a deletion left a gap.`,
112
+ links_verified: linksVerified,
113
+ };
114
+ }
115
+ linksVerified++;
116
+ }
117
+
118
+ return {
119
+ ok: true,
120
+ length: attestations.length,
121
+ broken_at_index: null,
122
+ reason: `chain intact — ${linksVerified} link(s) verified across ${attestations.length} attestation(s)`,
123
+ links_verified: linksVerified,
124
+ };
125
+ }