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/index.js ADDED
@@ -0,0 +1,57 @@
1
+ // packages/attest-core/index.js
2
+ // ─────────────────────────────────────────────────────────────────
3
+ // shadow-attest-core — public API contract surface.
4
+ //
5
+ // v2.0.0 (2026-07-10): source files now physically live in this
6
+ // directory. lib/attestation.js and lib/attestation-chain.js are
7
+ // back-compat shims that re-export from here.
8
+ //
9
+ // A dedicated CI test (test/attest-core-contract.test.js) verifies:
10
+ // 1. Every symbol re-exported below actually resolves.
11
+ // 2. None of the source files reachable from this entry point
12
+ // import any LLM SDK (anthropic, openai, google-genai, etc).
13
+ //
14
+ // ─────────────────────────────────────────────────────────────────
15
+
16
+ export {
17
+ ATTESTATION_VERSION,
18
+ SIGNATURE_MODES,
19
+ buildAttestation,
20
+ verifyAttestation,
21
+ } from "./attestation.js";
22
+
23
+ export {
24
+ computeAttestationHash,
25
+ } from "./attestation-chain.js";
26
+
27
+ // v3 M1.2: streaming evidence bundle API + crash-recovery.
28
+ export {
29
+ EVENT_TYPES,
30
+ createSession,
31
+ appendEvent,
32
+ sealSession,
33
+ sealPartialBundle,
34
+ recoverSession,
35
+ verifyBundle,
36
+ } from "./session.js";
37
+ export { createFileStore, listSessionFiles } from "./store-file.js";
38
+
39
+ // v3 M3 sprint 1 + 2 + 3: external anchoring (RFC 3161 TSA + Sigstore Rekor).
40
+ export {
41
+ TRUST_LEVELS,
42
+ trustLevelRank,
43
+ buildTimestampRequest,
44
+ parseTimestampResponse,
45
+ requestTimestamp,
46
+ verifyRfc3161Anchor,
47
+ verifyCmsSignature,
48
+ validateCmsCertChain,
49
+ buildRekorHashedrekordEntry,
50
+ submitRekorEntry,
51
+ canonicalizeJson,
52
+ extractRekorPayloadHash,
53
+ rekorLeafHash,
54
+ verifyInclusionProof,
55
+ verifyRekorSet,
56
+ verifyRekorAnchor,
57
+ } from "./anchors.js";
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "shadow-attest-core",
3
+ "version": "2.0.0",
4
+ "description": "Zero-LLM-dep cryptographic evidence primitives for AI decision attestation and agent session recording. Ed25519 signing, hash chain, batch attestation, and streaming session bundle API. Independently verifiable offline.",
5
+ "license": "MIT",
6
+ "author": "Alex Xiaoyu Ji <xji1@mail.yu.edu>",
7
+ "type": "module",
8
+ "main": "index.js",
9
+ "exports": {
10
+ ".": "./index.js",
11
+ "./attestation": "./index.js",
12
+ "./batch": "./batch.js",
13
+ "./chain": "./chain.js",
14
+ "./session": "./session.js",
15
+ "./anchors": "./anchors.js",
16
+ "./verify-chain": "./verify-chain.js"
17
+ },
18
+ "files": [
19
+ "anchors.js",
20
+ "attestation.js",
21
+ "attestation-batch.js",
22
+ "attestation-chain.js",
23
+ "batch.js",
24
+ "chain.js",
25
+ "index.js",
26
+ "session.js",
27
+ "store-file.js",
28
+ "verify-chain.js",
29
+ "README.md"
30
+ ],
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
34
+ "keywords": [
35
+ "attestation",
36
+ "ed25519",
37
+ "hash-chain",
38
+ "evidence",
39
+ "tamper-evident",
40
+ "audit",
41
+ "eu-ai-act",
42
+ "article-12",
43
+ "agent-recording",
44
+ "flight-recorder"
45
+ ],
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/alex-jb/shadow-mentor.git",
49
+ "directory": "packages/attest-core"
50
+ },
51
+ "homepage": "https://github.com/alex-jb/shadow-mentor#readme",
52
+ "bugs": {
53
+ "url": "https://github.com/alex-jb/shadow-mentor/issues"
54
+ },
55
+ "publishConfig": {
56
+ "access": "public",
57
+ "provenance": true
58
+ }
59
+ }
package/session.js ADDED
@@ -0,0 +1,557 @@
1
+ // packages/attest-core/session.js
2
+ // ─────────────────────────────────────────────────────────────────
3
+ // Streaming session API for v3 evidence bundles.
4
+ //
5
+ // Contract: createSession() → appendEvent() (many) → sealSession().
6
+ // Each append extends the hash chain in memory; sealSession signs
7
+ // the batch root and returns a bundle matching spec/evidence-bundle.schema.json.
8
+ //
9
+ // v3 M1.2 shipped in-memory primitive. Crash-recovery via an optional
10
+ // JSONL append store (2026-07-10): pass `store` to createSession and every
11
+ // header/event/seal write is durable. On process crash, `recoverSession`
12
+ // reads the store back and returns a session that can be either resumed
13
+ // (if it wasn't sealed) or sealed post-hoc via `sealPartialBundle` to
14
+ // produce a verifiable evidence bundle covering everything up to the
15
+ // crash. Store interface is minimal: {appendLine(text), readLines()}.
16
+ // Default FileStore lives in packages/attest-core/store-file.js.
17
+ //
18
+ // Design constraints (mirror spec/EVIDENCE_BUNDLE.md):
19
+ // - Event type enum is frozen at 13 kinds.
20
+ // - Payload content is hashed here; the payload_ref points to a
21
+ // content-addressed store the caller manages. If the caller passes no
22
+ // store adapter, payload_ref is set to a `sha256:<hex>` self-reference.
23
+ // - Chain seed for seq=0 is sha256(canonicalized header).
24
+ // - Session is mutable until sealed; sealed sessions throw on any append.
25
+
26
+ import {
27
+ createHash,
28
+ createPrivateKey,
29
+ createPublicKey,
30
+ randomFillSync,
31
+ sign as cryptoSign,
32
+ verify as cryptoVerify,
33
+ } from "node:crypto";
34
+ import { canonicalize } from "./attestation.js";
35
+ import { TRUST_LEVELS, trustLevelRank, verifyRfc3161Anchor, verifyRekorAnchor } from "./anchors.js";
36
+
37
+ // Frozen event enum. If this list changes, bump bundle_version.
38
+ export const EVENT_TYPES = Object.freeze([
39
+ "session_start",
40
+ "user_message",
41
+ "model_call",
42
+ "model_output",
43
+ "tool_call",
44
+ "tool_result",
45
+ "file_read",
46
+ "file_write",
47
+ "shell_exec",
48
+ "network_request",
49
+ "human_approval",
50
+ "error",
51
+ "session_end",
52
+ ]);
53
+
54
+ const ACTOR_TYPES = Object.freeze(["agent", "user", "model", "tool", "system"]);
55
+
56
+ const BUNDLE_VERSION = 1;
57
+ const SPEC_VERSION = "shadow-evidence/v1";
58
+
59
+ const ATTEST_CORE_VERSION = "2.0.0";
60
+
61
+ function sha256Hex(bytes) {
62
+ return createHash("sha256").update(bytes).digest("hex");
63
+ }
64
+
65
+ function canonicalBytes(value) {
66
+ return Buffer.from(canonicalize(value));
67
+ }
68
+
69
+ // Chain-seed hash MUST be stable across the session lifecycle. The
70
+ // header's `session_ended_at_utc` is filled in at seal time, so we
71
+ // exclude it from the seed hash. Both createSession and verifyBundle
72
+ // use this same normalization.
73
+ function headerSeedHash(header) {
74
+ const normalized = { ...header, session_ended_at_utc: null };
75
+ return sha256Hex(canonicalBytes(normalized));
76
+ }
77
+
78
+ // `payload_ref` is a hint about where the content-addressed payload lives.
79
+ // Its value is EXCLUDED from the signed record shape so the operator can
80
+ // null it out during a GDPR erasure without invalidating the chain. The
81
+ // authenticator is `payload_hash`, which stays in the signed shape.
82
+ function signedShape(event) {
83
+ const { payload_ref, ...rest } = event;
84
+ return rest;
85
+ }
86
+
87
+ /**
88
+ * Create a new evidence-bundle session.
89
+ *
90
+ * @param {object} params
91
+ * @param {object} params.agent — { name, version, identity_ref? }
92
+ * @param {Array<object>} params.models — model manifest per header schema
93
+ * @param {object} params.environmentFingerprint — { os, node_version, hostname_hash? }
94
+ * @param {string} params.keyId — signing key identifier (opaque to this module)
95
+ * @param {string|object} params.privateKey — Ed25519 private key (PEM string or KeyObject)
96
+ * @param {string} [params.sessionId] — override; default is a random 128-bit id
97
+ * @param {string} [params.startedAtUtc] — override; default is new Date().toISOString()
98
+ * @param {string} [params.signingAlgorithm] — "ed25519" (default) or "hmac-sha256" (future)
99
+ * @param {object} [params.attestCoreVersion] — override for tests
100
+ * @param {object} [params.store] — durable append store {appendLine, readLines}. When provided, every header/event/seal write is persisted synchronously; a mid-session crash leaves a JSONL log that recoverSession can rebuild into a verifiable partial bundle.
101
+ * @returns {object} session state
102
+ */
103
+ export function createSession(params) {
104
+ const {
105
+ agent,
106
+ models = [],
107
+ environmentFingerprint,
108
+ keyId,
109
+ privateKey,
110
+ sessionId,
111
+ startedAtUtc,
112
+ signingAlgorithm = "ed25519",
113
+ attestCoreVersion = ATTEST_CORE_VERSION,
114
+ store = null,
115
+ } = params ?? {};
116
+
117
+ if (!agent || typeof agent !== "object") throw new Error("createSession: agent required");
118
+ if (!agent.name || !agent.version) throw new Error("createSession: agent.name and agent.version required");
119
+ if (!environmentFingerprint) throw new Error("createSession: environmentFingerprint required");
120
+ if (!environmentFingerprint.os || !environmentFingerprint.node_version) {
121
+ throw new Error("createSession: environmentFingerprint.os and .node_version required");
122
+ }
123
+ if (!keyId) throw new Error("createSession: keyId required");
124
+ if (!privateKey) throw new Error("createSession: privateKey required");
125
+ if (signingAlgorithm !== "ed25519") {
126
+ throw new Error(`createSession: signingAlgorithm "${signingAlgorithm}" not yet implemented`);
127
+ }
128
+
129
+ const header = {
130
+ session_id: sessionId ?? cryptoRandomHex(32),
131
+ session_started_at_utc: startedAtUtc ?? new Date().toISOString(),
132
+ session_ended_at_utc: null,
133
+ agent: {
134
+ name: agent.name,
135
+ version: agent.version,
136
+ identity_ref: agent.identity_ref ?? null,
137
+ },
138
+ models: models.map(m => ({
139
+ model_id: m.model_id,
140
+ provider: m.provider ?? null,
141
+ sampling_params_hash: m.sampling_params_hash ?? null,
142
+ })),
143
+ environment_fingerprint: {
144
+ os: environmentFingerprint.os,
145
+ node_version: environmentFingerprint.node_version,
146
+ hostname_hash: environmentFingerprint.hostname_hash ?? null,
147
+ },
148
+ schema_versions: {
149
+ bundle: BUNDLE_VERSION,
150
+ attest_core: attestCoreVersion,
151
+ },
152
+ };
153
+
154
+ const headerHash = headerSeedHash(header);
155
+
156
+ const session = {
157
+ _sealed: false,
158
+ header,
159
+ events: [],
160
+ _headerHash: headerHash,
161
+ _lastEventHash: headerHash, // seed prev_hash for seq=0
162
+ _signing: {
163
+ algorithm: signingAlgorithm,
164
+ keyId,
165
+ privateKey: normalizePrivateKey(privateKey),
166
+ },
167
+ _store: store,
168
+ };
169
+
170
+ if (store) {
171
+ store.appendLine(JSON.stringify({ kind: "header", header }));
172
+ }
173
+
174
+ return session;
175
+ }
176
+
177
+ /**
178
+ * Append an event to the session. Extends the hash chain in place.
179
+ *
180
+ * @param {object} session — from createSession()
181
+ * @param {object} event
182
+ * @param {string} event.event_type — must be in EVENT_TYPES
183
+ * @param {string} event.actor — must be in ACTOR_TYPES
184
+ * @param {*} [event.payload] — arbitrary JSON-serializable; will be canonicalized + hashed
185
+ * @param {string} [event.payload_ref] — override the default self-reference
186
+ * @param {object} [event.extensions] — additive metadata bag
187
+ * @param {string} [event.ts_utc] — override; default is new Date().toISOString()
188
+ * @returns {object} the appended event (frozen copy)
189
+ */
190
+ export function appendEvent(session, event) {
191
+ if (!session || typeof session !== "object") throw new Error("appendEvent: session required");
192
+ if (session._sealed) throw new Error("appendEvent: session already sealed");
193
+ if (!event || typeof event !== "object") throw new Error("appendEvent: event required");
194
+ if (!EVENT_TYPES.includes(event.event_type)) {
195
+ throw new Error(`appendEvent: unknown event_type "${event.event_type}"`);
196
+ }
197
+ if (!ACTOR_TYPES.includes(event.actor)) {
198
+ throw new Error(`appendEvent: unknown actor "${event.actor}"`);
199
+ }
200
+
201
+ const payload = event.payload ?? {};
202
+ const payloadHash = sha256Hex(canonicalBytes(payload));
203
+ const payloadRef = event.payload_ref === null
204
+ ? null
205
+ : (event.payload_ref ?? `sha256:${payloadHash}`);
206
+
207
+ const record = {
208
+ seq: session.events.length,
209
+ ts_utc: event.ts_utc ?? new Date().toISOString(),
210
+ event_type: event.event_type,
211
+ actor: event.actor,
212
+ payload_hash: payloadHash,
213
+ payload_ref: payloadRef,
214
+ prev_hash: session._lastEventHash,
215
+ extensions: event.extensions ?? {},
216
+ };
217
+
218
+ const ownHash = sha256Hex(canonicalBytes(signedShape(record)));
219
+ session.events.push(record);
220
+ session._lastEventHash = ownHash;
221
+
222
+ if (session._store) {
223
+ session._store.appendLine(JSON.stringify({ kind: "event", event: record }));
224
+ }
225
+
226
+ return Object.freeze({ ...record });
227
+ }
228
+
229
+ /**
230
+ * Seal the session — computes batch root, signs it, and returns the bundle.
231
+ * The session becomes immutable; further appendEvent calls throw.
232
+ *
233
+ * @param {object} session
234
+ * @param {object} [options]
235
+ * @param {string} [options.endedAtUtc] — override; default is new Date().toISOString()
236
+ * @param {boolean} [options.omitSessionEnd] — if true, don't auto-append a session_end event
237
+ * @returns {object} evidence bundle matching spec/evidence-bundle.schema.json
238
+ */
239
+ export function sealSession(session, options = {}) {
240
+ if (!session || typeof session !== "object") throw new Error("sealSession: session required");
241
+ if (session._sealed) throw new Error("sealSession: session already sealed");
242
+
243
+ const endedAtUtc = options.endedAtUtc ?? new Date().toISOString();
244
+
245
+ if (!options.omitSessionEnd) {
246
+ const alreadyEnded = session.events.length > 0 &&
247
+ session.events[session.events.length - 1].event_type === "session_end";
248
+ if (!alreadyEnded) {
249
+ appendEvent(session, {
250
+ event_type: "session_end",
251
+ actor: "system",
252
+ payload: {
253
+ event_count: session.events.length,
254
+ session_duration_ms: Date.parse(endedAtUtc) - Date.parse(session.header.session_started_at_utc),
255
+ },
256
+ ts_utc: endedAtUtc,
257
+ });
258
+ }
259
+ }
260
+
261
+ session.header.session_ended_at_utc = endedAtUtc;
262
+
263
+ // Recompute event own-hashes to compute batch_root. This is O(N).
264
+ // We keep every event's own hash internally so the batch root is
265
+ // sha256 of the concatenation.
266
+ const eventHashes = session.events.map(e => sha256Hex(canonicalBytes(signedShape(e))));
267
+ const batchRoot = sha256Hex(Buffer.concat(eventHashes.map(h => Buffer.from(h, "hex"))));
268
+
269
+ const signature = signEd25519(session._signing.privateKey, Buffer.from(batchRoot, "hex"));
270
+
271
+ const bundle = {
272
+ bundle_version: BUNDLE_VERSION,
273
+ spec_version: SPEC_VERSION,
274
+ header: session.header,
275
+ events: session.events,
276
+ batch_root: batchRoot,
277
+ signatures: [
278
+ {
279
+ algorithm: session._signing.algorithm,
280
+ key_id: session._signing.keyId,
281
+ signature,
282
+ signed_at_utc: new Date().toISOString(),
283
+ },
284
+ ],
285
+ };
286
+
287
+ session._sealed = true;
288
+
289
+ if (session._store) {
290
+ session._store.appendLine(JSON.stringify({
291
+ kind: "seal",
292
+ batch_root: batchRoot,
293
+ signatures: bundle.signatures,
294
+ session_ended_at_utc: endedAtUtc,
295
+ }));
296
+ }
297
+
298
+ return bundle;
299
+ }
300
+
301
+ /**
302
+ * Recover a session from a durable store after a crash. Reads all lines,
303
+ * reconstructs the in-memory session state, and returns it. The returned
304
+ * session is either:
305
+ * - resumable (session._sealed === false, seal line was never written):
306
+ * more appendEvent + sealSession calls will work as normal.
307
+ * - already sealed (seal line found): sealSession will throw, but the
308
+ * bundle already exists on disk and can be reassembled by the caller.
309
+ *
310
+ * Recovery does NOT require the caller to pass agent/models/environment
311
+ * again — those are read from the header line. The caller MUST pass the
312
+ * signing privateKey (matching the header.schema_versions.attest_core
313
+ * and header.session_id) so future events can sign, and so
314
+ * sealPartialBundle can produce a valid signature.
315
+ *
316
+ * @param {object} params
317
+ * @param {object} params.store — same store shape as createSession
318
+ * @param {string|object} params.privateKey — Ed25519 private key for future signs
319
+ * @returns {object} recovered session state
320
+ */
321
+ export function recoverSession(params) {
322
+ const { store, privateKey } = params ?? {};
323
+ if (!store) throw new Error("recoverSession: store required");
324
+ if (!privateKey) throw new Error("recoverSession: privateKey required");
325
+
326
+ const lines = store.readLines();
327
+ if (!Array.isArray(lines) || lines.length === 0) {
328
+ throw new Error("recoverSession: store is empty");
329
+ }
330
+
331
+ let header = null;
332
+ const events = [];
333
+ let sealLine = null;
334
+
335
+ for (const line of lines) {
336
+ if (!line || !line.trim()) continue;
337
+ let obj;
338
+ try {
339
+ obj = JSON.parse(line);
340
+ } catch (err) {
341
+ throw new Error(`recoverSession: malformed line: ${err.message}`);
342
+ }
343
+ if (obj.kind === "header") {
344
+ if (header) throw new Error("recoverSession: duplicate header line");
345
+ header = obj.header;
346
+ } else if (obj.kind === "event") {
347
+ events.push(obj.event);
348
+ } else if (obj.kind === "seal") {
349
+ sealLine = obj;
350
+ }
351
+ // Unknown kinds are ignored so future additive extensions are safe.
352
+ }
353
+
354
+ if (!header) throw new Error("recoverSession: no header line found");
355
+
356
+ // Reconstruct chain state by walking the persisted events.
357
+ let lastHash = headerSeedHash(header);
358
+ for (const ev of events) {
359
+ lastHash = sha256Hex(canonicalBytes(signedShape(ev)));
360
+ }
361
+
362
+ const session = {
363
+ _sealed: sealLine !== null,
364
+ header,
365
+ events,
366
+ _headerHash: headerSeedHash(header),
367
+ _lastEventHash: lastHash,
368
+ _signing: {
369
+ algorithm: "ed25519",
370
+ keyId: sealLine?.signatures?.[0]?.key_id ??
371
+ header.recovered_from_key_id ?? "recovered",
372
+ privateKey: normalizePrivateKey(privateKey),
373
+ },
374
+ _store: store,
375
+ _recoveredSeal: sealLine,
376
+ };
377
+
378
+ return session;
379
+ }
380
+
381
+ /**
382
+ * Produce a signed evidence bundle from a session that never received a
383
+ * session_end (e.g. the process crashed). Unlike sealSession, this does
384
+ * NOT auto-append session_end — the caller acknowledges the record is
385
+ * partial. The bundle verifies against the chain up to the crash, and
386
+ * bundle.header.session_ended_at_utc is set to null so an auditor sees
387
+ * the partial-record posture.
388
+ *
389
+ * @param {object} session — recovered session
390
+ * @param {object} [options]
391
+ * @param {string} [options.keyId] — override key_id in the signature block
392
+ */
393
+ export function sealPartialBundle(session, options = {}) {
394
+ if (!session || typeof session !== "object") throw new Error("sealPartialBundle: session required");
395
+ if (session._sealed) throw new Error("sealPartialBundle: session already sealed");
396
+ if (session.events.length === 0) throw new Error("sealPartialBundle: no events to seal");
397
+
398
+ const eventHashes = session.events.map(e => sha256Hex(canonicalBytes(signedShape(e))));
399
+ const batchRoot = sha256Hex(Buffer.concat(eventHashes.map(h => Buffer.from(h, "hex"))));
400
+ const signature = signEd25519(session._signing.privateKey, Buffer.from(batchRoot, "hex"));
401
+
402
+ const keyId = options.keyId ?? session._signing.keyId;
403
+ session.header.session_ended_at_utc = null; // partial → no clean end time
404
+
405
+ const bundle = {
406
+ bundle_version: BUNDLE_VERSION,
407
+ spec_version: SPEC_VERSION,
408
+ header: session.header,
409
+ events: session.events,
410
+ batch_root: batchRoot,
411
+ signatures: [
412
+ {
413
+ algorithm: session._signing.algorithm,
414
+ key_id: keyId,
415
+ signature,
416
+ signed_at_utc: new Date().toISOString(),
417
+ },
418
+ ],
419
+ };
420
+
421
+ session._sealed = true;
422
+
423
+ if (session._store) {
424
+ session._store.appendLine(JSON.stringify({
425
+ kind: "seal",
426
+ batch_root: batchRoot,
427
+ signatures: bundle.signatures,
428
+ session_ended_at_utc: null,
429
+ partial: true,
430
+ }));
431
+ }
432
+
433
+ return bundle;
434
+ }
435
+
436
+ // ── helpers ──────────────────────────────────────────────────
437
+
438
+ function normalizePrivateKey(pk) {
439
+ if (typeof pk === "string") {
440
+ return createPrivateKey(pk);
441
+ }
442
+ return pk;
443
+ }
444
+
445
+ function signEd25519(privateKeyObject, bytes) {
446
+ const sig = cryptoSign(null, bytes, privateKeyObject);
447
+ return sig.toString("base64url");
448
+ }
449
+
450
+ function cryptoRandomHex(byteLen) {
451
+ const buf = Buffer.alloc(byteLen);
452
+ return randomFillSync(buf).toString("hex");
453
+ }
454
+
455
+ /**
456
+ * Verify an evidence bundle. Recomputes the header hash, walks the event
457
+ * chain, recomputes each event's own hash, checks prev_hash linkage,
458
+ * recomputes the batch root, and verifies the signature.
459
+ *
460
+ * @param {object} bundle
461
+ * @param {object} params
462
+ * @param {string|object} params.publicKey — Ed25519 public key (PEM or KeyObject)
463
+ * @param {boolean|"structural"|"full"} [params.checkAnchors] — anchor verification mode:
464
+ * - false (default): don't verify anchors; trustLevel stays SELF_SIGNED
465
+ * - true or "structural": messageImprint match only for TSA anchors, body-hash
466
+ * match for Rekor anchors; elevates to TIME_ANCHORED_STRUCTURAL / LOG_ANCHORED_STRUCTURAL
467
+ * - "full": also verifies CMS SignedData signature (TSA) and inclusion proof
468
+ * + SET (Rekor); elevates to TIME_ANCHORED / LOG_ANCHORED on success, falls
469
+ * back to structural on partial failure with a diagnostic reason
470
+ * @param {string|object} [params.rekorPubKey] — required for Rekor "full" mode.
471
+ * Fetch current key: `curl https://rekor.sigstore.dev/api/v1/log/publicKey`
472
+ * @returns {{ok: boolean, reason?: string, failedSeq?: number, trustLevel?: string, anchors?: object[]}}
473
+ */
474
+ export function verifyBundle(bundle, params) {
475
+ const { publicKey, checkAnchors = false, rekorPubKey, caTrustStorePem } = params ?? {};
476
+ if (!publicKey) return { ok: false, reason: "publicKey required" };
477
+ if (!bundle || typeof bundle !== "object") return { ok: false, reason: "bundle required" };
478
+ if (bundle.bundle_version !== BUNDLE_VERSION) {
479
+ return { ok: false, reason: `unsupported bundle_version ${bundle.bundle_version}` };
480
+ }
481
+ if (!Array.isArray(bundle.events)) return { ok: false, reason: "events must be array" };
482
+ if (!Array.isArray(bundle.signatures) || bundle.signatures.length === 0) {
483
+ return { ok: false, reason: "signatures required" };
484
+ }
485
+
486
+ const headerHash = headerSeedHash(bundle.header);
487
+ let expectedPrev = headerHash;
488
+ const eventHashes = [];
489
+
490
+ for (let i = 0; i < bundle.events.length; i++) {
491
+ const ev = bundle.events[i];
492
+ if (ev.seq !== i) return { ok: false, reason: `seq gap at index ${i}`, failedSeq: i };
493
+ if (ev.prev_hash !== expectedPrev) {
494
+ return { ok: false, reason: "prev_hash mismatch", failedSeq: i };
495
+ }
496
+ const own = sha256Hex(canonicalBytes(signedShape(ev)));
497
+ eventHashes.push(own);
498
+ expectedPrev = own;
499
+ }
500
+
501
+ const batchRoot = sha256Hex(Buffer.concat(eventHashes.map(h => Buffer.from(h, "hex"))));
502
+ if (batchRoot !== bundle.batch_root) {
503
+ return { ok: false, reason: "batch_root mismatch" };
504
+ }
505
+
506
+ const sig = bundle.signatures[0];
507
+ if (sig.algorithm !== "ed25519") {
508
+ return { ok: false, reason: `unsupported signature algorithm "${sig.algorithm}"` };
509
+ }
510
+ const keyObj = typeof publicKey === "string" ? createPublicKey(publicKey) : publicKey;
511
+ const sigOk = cryptoVerify(
512
+ null,
513
+ Buffer.from(batchRoot, "hex"),
514
+ keyObj,
515
+ Buffer.from(sig.signature, "base64url"),
516
+ );
517
+ if (!sigOk) return { ok: false, reason: "signature verification failed" };
518
+
519
+ // v3 M3 sprint 1 + 2: report trust level. Default SELF_SIGNED. If the
520
+ // caller opts into checkAnchors, walk external_anchors and elevate.
521
+ // - "structural" / true: messageImprint match only → TIME_ANCHORED_STRUCTURAL
522
+ // - "full": also CMS SignedData signature verify → TIME_ANCHORED on success
523
+ let trustLevel = TRUST_LEVELS.SELF_SIGNED;
524
+ const anchorResults = [];
525
+ const wantFull = checkAnchors === "full";
526
+ const anchorsEnabled = checkAnchors === true || checkAnchors === "structural" || wantFull;
527
+
528
+ if (anchorsEnabled && Array.isArray(bundle.external_anchors) && bundle.external_anchors.length > 0) {
529
+ for (const anchor of bundle.external_anchors) {
530
+ let r;
531
+ if (anchor.kind === "rfc3161-tsa") {
532
+ r = verifyRfc3161Anchor({
533
+ anchor,
534
+ expectedBatchRootHex: batchRoot,
535
+ verifyCms: wantFull,
536
+ caTrustStorePem,
537
+ });
538
+ } else if (anchor.kind === "rekor") {
539
+ r = verifyRekorAnchor({
540
+ anchor,
541
+ expectedBatchRootHex: batchRoot,
542
+ verifyFull: wantFull,
543
+ rekorPubKey,
544
+ });
545
+ } else {
546
+ anchorResults.push({ kind: anchor.kind, ok: false, reason: "anchor kind not yet supported" });
547
+ continue;
548
+ }
549
+ anchorResults.push({ kind: anchor.kind, ...r });
550
+ if (r.ok && r.trustLevel && trustLevelRank(r.trustLevel) > trustLevelRank(trustLevel)) {
551
+ trustLevel = r.trustLevel;
552
+ }
553
+ }
554
+ }
555
+
556
+ return { ok: true, trustLevel, anchors: anchorResults };
557
+ }