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/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
|
+
}
|