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/anchors.js
ADDED
|
@@ -0,0 +1,1321 @@
|
|
|
1
|
+
// packages/attest-core/anchors.js
|
|
2
|
+
// ─────────────────────────────────────────────────────────────────
|
|
3
|
+
// v3 M3 sprint 1 — external anchoring.
|
|
4
|
+
//
|
|
5
|
+
// RFC 3161 Time-Stamp Protocol (TSA) client + verifier. Given a Shadow
|
|
6
|
+
// evidence-bundle batch_root, requestTimestamp() posts a TimeStampReq to
|
|
7
|
+
// a TSA and returns an anchor object embeddable in bundle.external_anchors.
|
|
8
|
+
// verifyRfc3161Anchor() parses the returned TimeStampToken, checks that
|
|
9
|
+
// the messageImprint matches the expected batch_root, extracts genTime.
|
|
10
|
+
//
|
|
11
|
+
// Design constraints:
|
|
12
|
+
// - Zero external dependencies. All ASN.1 DER encoding + decoding is
|
|
13
|
+
// implemented inline against the specific structures RFC 3161 uses
|
|
14
|
+
// (TimeStampReq / TimeStampResp / TSTInfo). This keeps the attest-core
|
|
15
|
+
// zero-LLM-dep contract clean and makes bank-side security review
|
|
16
|
+
// simpler (no third-party crypto library to audit).
|
|
17
|
+
// - The verifier checks structural integrity (messageImprint match +
|
|
18
|
+
// genTime extraction). Sprint 1 does NOT verify the TSA's signature
|
|
19
|
+
// on the token — that requires full CMS SignedData parsing +
|
|
20
|
+
// certificate chain validation. Sprint 2 lands that. Until then, the
|
|
21
|
+
// trust level for a bundle with an anchor is reported as
|
|
22
|
+
// TIME_ANCHORED_STRUCTURAL, not TIME_ANCHORED. This is honest posture,
|
|
23
|
+
// not marketing.
|
|
24
|
+
// - No default TSA URL hard-coded to a specific vendor. The caller
|
|
25
|
+
// picks; documented free-tier options live in the module docstring.
|
|
26
|
+
// Freetsa.org (freetsa.org/tsr) is the widely-tested community
|
|
27
|
+
// default; DigiCert and Sectigo publish TSAs at published URLs. Any
|
|
28
|
+
// RFC 3161-compliant TSA works.
|
|
29
|
+
//
|
|
30
|
+
// References:
|
|
31
|
+
// - RFC 3161 (Time-Stamp Protocol / TSP)
|
|
32
|
+
// - RFC 5652 (Cryptographic Message Syntax)
|
|
33
|
+
// - RFC 5280 (X.509 certificate profile) — for sprint 2 signature check
|
|
34
|
+
|
|
35
|
+
import { createHash, createPublicKey, verify as cryptoVerify, X509Certificate } from "node:crypto";
|
|
36
|
+
|
|
37
|
+
// ── Trust-level enum ──────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
// The verifier reports one of these on every bundle it inspects. Never
|
|
40
|
+
// present SELF_SIGNED as more than it is; the language discipline in
|
|
41
|
+
// docs/AUTONOMOUS_SESSION_RULES.md rule 3 applies here transitively.
|
|
42
|
+
export const TRUST_LEVELS = Object.freeze({
|
|
43
|
+
SELF_SIGNED: "SELF_SIGNED",
|
|
44
|
+
// Sprint 1 exits: chain intact + at least one RFC 3161 anchor whose
|
|
45
|
+
// messageImprint matches the bundle's batch_root. Structural check
|
|
46
|
+
// only; TSA signature not yet verified.
|
|
47
|
+
TIME_ANCHORED_STRUCTURAL: "TIME_ANCHORED_STRUCTURAL",
|
|
48
|
+
// Sprint 2 target: chain intact + RFC 3161 anchor whose CMS SignedData
|
|
49
|
+
// signature verifies against a trusted CA chain. Not exposed until
|
|
50
|
+
// sprint 2 ships; declared here so callers can pattern-match on the
|
|
51
|
+
// full enum today.
|
|
52
|
+
TIME_ANCHORED: "TIME_ANCHORED",
|
|
53
|
+
// Sprint 3 exits: chain intact + Rekor entry body's payloadHash matches
|
|
54
|
+
// the bundle's batch_root. Log-operator received the entry; inclusion
|
|
55
|
+
// proof + SET signature not yet verified.
|
|
56
|
+
LOG_ANCHORED_STRUCTURAL: "LOG_ANCHORED_STRUCTURAL",
|
|
57
|
+
// Sprint 3 target: chain intact + Rekor inclusion proof against the
|
|
58
|
+
// published tree head + SET signature verified with a caller-supplied
|
|
59
|
+
// Rekor public key. Publicly witnessed — a bundle at this level cannot
|
|
60
|
+
// be silently rewritten by a compromised operator alone.
|
|
61
|
+
LOG_ANCHORED: "LOG_ANCHORED",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Rekor trust rank: SELF < TIME_STRUCTURAL < LOG_STRUCTURAL < TIME_ANCHORED
|
|
65
|
+
// < LOG_ANCHORED. LOG_STRUCTURAL ranks above TIME_STRUCTURAL because a
|
|
66
|
+
// public log accepting an entry is a stronger claim than an unverified
|
|
67
|
+
// TSA response blob (which could be an operator-signed replay).
|
|
68
|
+
const TRUST_RANK = new Map([
|
|
69
|
+
[TRUST_LEVELS.SELF_SIGNED, 0],
|
|
70
|
+
[TRUST_LEVELS.TIME_ANCHORED_STRUCTURAL, 1],
|
|
71
|
+
[TRUST_LEVELS.LOG_ANCHORED_STRUCTURAL, 2],
|
|
72
|
+
[TRUST_LEVELS.TIME_ANCHORED, 3],
|
|
73
|
+
[TRUST_LEVELS.LOG_ANCHORED, 4],
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
export function trustLevelRank(level) {
|
|
77
|
+
return TRUST_RANK.get(level) ?? 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── ASN.1 DER helpers ─────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
// DER tag constants for the subset RFC 3161 requires.
|
|
83
|
+
const TAG_INTEGER = 0x02;
|
|
84
|
+
const TAG_BIT_STRING = 0x03;
|
|
85
|
+
const TAG_OCTET_STRING = 0x04;
|
|
86
|
+
const TAG_NULL = 0x05;
|
|
87
|
+
const TAG_OID = 0x06;
|
|
88
|
+
const TAG_GENERALIZED_TIME = 0x18;
|
|
89
|
+
const TAG_SEQUENCE = 0x30;
|
|
90
|
+
const TAG_SET = 0x31;
|
|
91
|
+
|
|
92
|
+
// OIDs used in RFC 3161 + related CMS structures.
|
|
93
|
+
const OID_SHA256 = "2.16.840.1.101.3.4.2.1";
|
|
94
|
+
const OID_SHA1 = "1.3.14.3.2.26";
|
|
95
|
+
const OID_SIGNED_DATA = "1.2.840.113549.1.7.2";
|
|
96
|
+
const OID_TSTINFO = "1.2.840.113549.1.9.16.1.4";
|
|
97
|
+
|
|
98
|
+
const HASH_ALG_BY_OID = new Map([
|
|
99
|
+
[OID_SHA256, { name: "sha256", digestLen: 32 }],
|
|
100
|
+
[OID_SHA1, { name: "sha1", digestLen: 20 }],
|
|
101
|
+
]);
|
|
102
|
+
const OID_BY_HASH_ALG = new Map([
|
|
103
|
+
["sha256", OID_SHA256],
|
|
104
|
+
["sha1", OID_SHA1],
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
// CMS signed-attribute OIDs (RFC 5652 §11).
|
|
108
|
+
const OID_CONTENT_TYPE_ATTR = "1.2.840.113549.1.9.3";
|
|
109
|
+
const OID_MESSAGE_DIGEST_ATTR = "1.2.840.113549.1.9.4";
|
|
110
|
+
const OID_SIGNING_TIME_ATTR = "1.2.840.113549.1.9.5";
|
|
111
|
+
|
|
112
|
+
// Signature algorithm OIDs the TSA might use to sign its token.
|
|
113
|
+
// Node's crypto.verify() infers the algorithm class from the key object;
|
|
114
|
+
// we pass the digest name it expects (e.g. "sha256"). Ed25519 is a special
|
|
115
|
+
// case — its verify call uses `null` as the digest name.
|
|
116
|
+
const SIG_ALG_HANDLERS = new Map([
|
|
117
|
+
// RSA-SSA PKCS#1 v1.5
|
|
118
|
+
["1.2.840.113549.1.1.11", { digest: "sha256", kind: "rsa" }], // sha256WithRSAEncryption
|
|
119
|
+
["1.2.840.113549.1.1.12", { digest: "sha384", kind: "rsa" }], // sha384WithRSAEncryption
|
|
120
|
+
["1.2.840.113549.1.1.13", { digest: "sha512", kind: "rsa" }], // sha512WithRSAEncryption
|
|
121
|
+
["1.2.840.113549.1.1.5", { digest: "sha1", kind: "rsa" }], // sha1WithRSAEncryption (legacy)
|
|
122
|
+
["1.2.840.113549.1.1.1", { digest: null, kind: "rsa" }], // rsaEncryption — digest per signedAttrs
|
|
123
|
+
// ECDSA
|
|
124
|
+
["1.2.840.10045.4.3.2", { digest: "sha256", kind: "ecdsa" }], // ecdsa-with-SHA256
|
|
125
|
+
["1.2.840.10045.4.3.3", { digest: "sha384", kind: "ecdsa" }], // ecdsa-with-SHA384
|
|
126
|
+
["1.2.840.10045.4.3.4", { digest: "sha512", kind: "ecdsa" }], // ecdsa-with-SHA512
|
|
127
|
+
// EdDSA
|
|
128
|
+
["1.3.101.112", { digest: null, kind: "ed25519" }],
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Encode a non-negative integer as a DER-encoded length prefix.
|
|
133
|
+
* Short form (0-127) is one byte; long form is 0x80 | N followed by N
|
|
134
|
+
* big-endian bytes.
|
|
135
|
+
*/
|
|
136
|
+
function derEncodeLength(n) {
|
|
137
|
+
if (n < 0) throw new Error("derEncodeLength: negative");
|
|
138
|
+
if (n < 128) return Buffer.from([n]);
|
|
139
|
+
const bytes = [];
|
|
140
|
+
let x = n;
|
|
141
|
+
while (x > 0) { bytes.unshift(x & 0xff); x >>>= 8; }
|
|
142
|
+
return Buffer.from([0x80 | bytes.length, ...bytes]);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Encode a single DER TLV given tag + payload bytes.
|
|
147
|
+
*/
|
|
148
|
+
function derEncodeTLV(tag, payload) {
|
|
149
|
+
return Buffer.concat([Buffer.from([tag]), derEncodeLength(payload.length), payload]);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Encode a positive integer as INTEGER (two's-complement, minimal, with
|
|
154
|
+
* a leading 0x00 pad if the high bit would otherwise be set).
|
|
155
|
+
*/
|
|
156
|
+
function derEncodeInteger(n) {
|
|
157
|
+
if (n < 0) throw new Error("derEncodeInteger: negative not supported");
|
|
158
|
+
const bytes = [];
|
|
159
|
+
if (n === 0) bytes.push(0);
|
|
160
|
+
else {
|
|
161
|
+
let x = n;
|
|
162
|
+
while (x > 0) { bytes.unshift(x & 0xff); x = Math.floor(x / 256); }
|
|
163
|
+
if (bytes[0] & 0x80) bytes.unshift(0);
|
|
164
|
+
}
|
|
165
|
+
return derEncodeTLV(TAG_INTEGER, Buffer.from(bytes));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Encode a dotted OID string as OBJECT IDENTIFIER TLV.
|
|
170
|
+
*/
|
|
171
|
+
function derEncodeOid(oid) {
|
|
172
|
+
const parts = oid.split(".").map(Number);
|
|
173
|
+
if (parts.length < 2) throw new Error(`derEncodeOid: malformed "${oid}"`);
|
|
174
|
+
const bytes = [40 * parts[0] + parts[1]];
|
|
175
|
+
for (let i = 2; i < parts.length; i++) {
|
|
176
|
+
let v = parts[i];
|
|
177
|
+
const chunk = [];
|
|
178
|
+
do { chunk.unshift(v & 0x7f); v >>>= 7; } while (v > 0);
|
|
179
|
+
for (let j = 0; j < chunk.length - 1; j++) chunk[j] |= 0x80;
|
|
180
|
+
bytes.push(...chunk);
|
|
181
|
+
}
|
|
182
|
+
return derEncodeTLV(TAG_OID, Buffer.from(bytes));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function derEncodeOctetString(bytes) {
|
|
186
|
+
return derEncodeTLV(TAG_OCTET_STRING, Buffer.from(bytes));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function derEncodeNull() {
|
|
190
|
+
return Buffer.from([TAG_NULL, 0x00]);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function derEncodeSequence(...children) {
|
|
194
|
+
return derEncodeTLV(TAG_SEQUENCE, Buffer.concat(children));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Encode an AlgorithmIdentifier (SEQUENCE { OID, NULL parameters }).
|
|
199
|
+
*/
|
|
200
|
+
function derEncodeAlgorithmIdentifier(oid) {
|
|
201
|
+
return derEncodeSequence(derEncodeOid(oid), derEncodeNull());
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── DER parser (walk-oriented) ────────────────────────────────
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Read a length prefix at `pos`. Returns { length, bytesRead }.
|
|
208
|
+
*/
|
|
209
|
+
function derReadLength(buf, pos) {
|
|
210
|
+
const b0 = buf[pos];
|
|
211
|
+
if (b0 < 128) return { length: b0, bytesRead: 1 };
|
|
212
|
+
const n = b0 & 0x7f;
|
|
213
|
+
if (n === 0) throw new Error("derReadLength: indefinite form not supported");
|
|
214
|
+
if (n > 4) throw new Error("derReadLength: length too big");
|
|
215
|
+
let len = 0;
|
|
216
|
+
for (let i = 0; i < n; i++) len = (len << 8) | buf[pos + 1 + i];
|
|
217
|
+
return { length: len, bytesRead: 1 + n };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Read a TLV at `pos`. Returns { tag, length, contentStart, next }.
|
|
222
|
+
*/
|
|
223
|
+
function derReadTLV(buf, pos) {
|
|
224
|
+
const tag = buf[pos];
|
|
225
|
+
const { length, bytesRead } = derReadLength(buf, pos + 1);
|
|
226
|
+
const contentStart = pos + 1 + bytesRead;
|
|
227
|
+
return { tag, length, contentStart, next: contentStart + length };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Parse an INTEGER at `pos` as a Number (throws if it doesn't fit).
|
|
232
|
+
*/
|
|
233
|
+
function derReadInteger(buf, pos) {
|
|
234
|
+
const tlv = derReadTLV(buf, pos);
|
|
235
|
+
if (tlv.tag !== TAG_INTEGER) throw new Error(`derReadInteger: expected INTEGER, got 0x${tlv.tag.toString(16)}`);
|
|
236
|
+
const content = buf.slice(tlv.contentStart, tlv.next);
|
|
237
|
+
if (content.length > 6) throw new Error("derReadInteger: value too big to fit in Number safely");
|
|
238
|
+
let n = 0;
|
|
239
|
+
for (const b of content) n = n * 256 + b;
|
|
240
|
+
return { value: n, next: tlv.next };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Parse an OID at `pos` as a dotted string.
|
|
245
|
+
*/
|
|
246
|
+
function derReadOid(buf, pos) {
|
|
247
|
+
const tlv = derReadTLV(buf, pos);
|
|
248
|
+
if (tlv.tag !== TAG_OID) throw new Error(`derReadOid: expected OID, got 0x${tlv.tag.toString(16)}`);
|
|
249
|
+
const content = buf.slice(tlv.contentStart, tlv.next);
|
|
250
|
+
if (content.length === 0) throw new Error("derReadOid: empty");
|
|
251
|
+
const parts = [Math.floor(content[0] / 40), content[0] % 40];
|
|
252
|
+
let v = 0;
|
|
253
|
+
for (let i = 1; i < content.length; i++) {
|
|
254
|
+
v = (v << 7) | (content[i] & 0x7f);
|
|
255
|
+
if ((content[i] & 0x80) === 0) { parts.push(v); v = 0; }
|
|
256
|
+
}
|
|
257
|
+
return { value: parts.join("."), next: tlv.next };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function derReadOctetString(buf, pos) {
|
|
261
|
+
const tlv = derReadTLV(buf, pos);
|
|
262
|
+
if (tlv.tag !== TAG_OCTET_STRING) throw new Error(`derReadOctetString: expected OCTET STRING, got 0x${tlv.tag.toString(16)}`);
|
|
263
|
+
return { value: buf.slice(tlv.contentStart, tlv.next), next: tlv.next };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Parse a GeneralizedTime at `pos`. Returns ISO 8601 UTC string.
|
|
268
|
+
* Formats accepted: YYYYMMDDHHMMSS[.fff]Z (RFC 5280 §4.1.2.5.2 profile).
|
|
269
|
+
*/
|
|
270
|
+
function derReadGeneralizedTime(buf, pos) {
|
|
271
|
+
const tlv = derReadTLV(buf, pos);
|
|
272
|
+
if (tlv.tag !== TAG_GENERALIZED_TIME) throw new Error(`derReadGeneralizedTime: expected GeneralizedTime, got 0x${tlv.tag.toString(16)}`);
|
|
273
|
+
const s = buf.slice(tlv.contentStart, tlv.next).toString("utf8");
|
|
274
|
+
const m = s.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\.\d+)?Z$/);
|
|
275
|
+
if (!m) throw new Error(`derReadGeneralizedTime: malformed "${s}"`);
|
|
276
|
+
const [, y, mo, d, h, mi, se, frac] = m;
|
|
277
|
+
return { value: `${y}-${mo}-${d}T${h}:${mi}:${se}${frac ?? ""}Z`, next: tlv.next };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── RFC 3161 message construction ─────────────────────────────
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Build a DER-encoded TimeStampReq for a given hex-encoded digest.
|
|
284
|
+
* @param {object} params
|
|
285
|
+
* @param {string} params.digestHex — hex-encoded hash of the batch root
|
|
286
|
+
* @param {string} [params.hashAlgorithm] — "sha256" (default) or "sha1"
|
|
287
|
+
* @param {boolean} [params.certReq] — request the TSA include its cert; default true
|
|
288
|
+
* @returns {Buffer} DER-encoded TimeStampReq
|
|
289
|
+
*/
|
|
290
|
+
export function buildTimestampRequest(params) {
|
|
291
|
+
const {
|
|
292
|
+
digestHex,
|
|
293
|
+
hashAlgorithm = "sha256",
|
|
294
|
+
certReq = true,
|
|
295
|
+
} = params ?? {};
|
|
296
|
+
if (!digestHex || typeof digestHex !== "string") {
|
|
297
|
+
throw new Error("buildTimestampRequest: digestHex required (hex string)");
|
|
298
|
+
}
|
|
299
|
+
const oid = OID_BY_HASH_ALG.get(hashAlgorithm);
|
|
300
|
+
if (!oid) throw new Error(`buildTimestampRequest: unsupported hashAlgorithm "${hashAlgorithm}"`);
|
|
301
|
+
const digest = Buffer.from(digestHex, "hex");
|
|
302
|
+
const expectedLen = HASH_ALG_BY_OID.get(oid).digestLen;
|
|
303
|
+
if (digest.length !== expectedLen) {
|
|
304
|
+
throw new Error(`buildTimestampRequest: digest is ${digest.length}B, expected ${expectedLen}B for ${hashAlgorithm}`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const messageImprint = derEncodeSequence(
|
|
308
|
+
derEncodeAlgorithmIdentifier(oid),
|
|
309
|
+
derEncodeOctetString(digest),
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
// Nonce ≥ 0. We use a small deterministic-ish value; TSAs replay the
|
|
313
|
+
// nonce back so the caller can pair request↔response. If a caller
|
|
314
|
+
// wants unpredictability they can pass their own DER-crafted request.
|
|
315
|
+
const nonce = derEncodeInteger(Math.floor(Math.random() * 0xffff) + 1);
|
|
316
|
+
const certReqTLV = Buffer.from([0x01, 0x01, certReq ? 0xff : 0x00]);
|
|
317
|
+
|
|
318
|
+
return derEncodeSequence(
|
|
319
|
+
derEncodeInteger(1), // version
|
|
320
|
+
messageImprint,
|
|
321
|
+
nonce,
|
|
322
|
+
certReqTLV,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── RFC 3161 response parsing ─────────────────────────────────
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Parse a DER-encoded TimeStampResp. Extracts the top-level status and,
|
|
330
|
+
* if the token is present, the embedded TSTInfo fields relevant to
|
|
331
|
+
* verification (messageImprint + genTime + serialNumber).
|
|
332
|
+
*
|
|
333
|
+
* @param {Buffer|Uint8Array} bytes — DER-encoded TimeStampResp
|
|
334
|
+
* @returns {{status: {statusCode: number, statusString?: string}, tstInfo?: {messageImprintAlgorithm: string, messageImprintHash: Buffer, genTimeIso: string, serialNumber: number, tokenBytes: Buffer}}}
|
|
335
|
+
*/
|
|
336
|
+
export function parseTimestampResponse(bytes) {
|
|
337
|
+
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
|
|
338
|
+
const outer = derReadTLV(buf, 0);
|
|
339
|
+
if (outer.tag !== TAG_SEQUENCE) {
|
|
340
|
+
throw new Error(`parseTimestampResponse: expected SEQUENCE, got 0x${outer.tag.toString(16)}`);
|
|
341
|
+
}
|
|
342
|
+
let pos = outer.contentStart;
|
|
343
|
+
|
|
344
|
+
// PKIStatusInfo ::= SEQUENCE { status INTEGER, ... }
|
|
345
|
+
const statusInfo = derReadTLV(buf, pos);
|
|
346
|
+
if (statusInfo.tag !== TAG_SEQUENCE) throw new Error("parseTimestampResponse: PKIStatusInfo not SEQUENCE");
|
|
347
|
+
const statusInt = derReadInteger(buf, statusInfo.contentStart);
|
|
348
|
+
const status = { statusCode: statusInt.value };
|
|
349
|
+
pos = statusInfo.next;
|
|
350
|
+
|
|
351
|
+
if (pos >= outer.next) return { status };
|
|
352
|
+
|
|
353
|
+
// TimeStampToken is a ContentInfo (SEQUENCE) with contentType = signedData.
|
|
354
|
+
const contentInfo = derReadTLV(buf, pos);
|
|
355
|
+
if (contentInfo.tag !== TAG_SEQUENCE) {
|
|
356
|
+
throw new Error("parseTimestampResponse: TimeStampToken not SEQUENCE");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Walk contentInfo → signedData → encapContentInfo → eContent → TSTInfo.
|
|
360
|
+
// Structure:
|
|
361
|
+
// ContentInfo ::= SEQUENCE {
|
|
362
|
+
// contentType OBJECT IDENTIFIER (signedData 1.2.840.113549.1.7.2),
|
|
363
|
+
// content [0] EXPLICIT ANY
|
|
364
|
+
// }
|
|
365
|
+
// SignedData ::= SEQUENCE {
|
|
366
|
+
// version INTEGER, digestAlgorithms SET, encapContentInfo SEQUENCE, ...
|
|
367
|
+
// }
|
|
368
|
+
// EncapsulatedContentInfo ::= SEQUENCE {
|
|
369
|
+
// eContentType OBJECT IDENTIFIER (id-ct-TSTInfo 1.2.840.113549.1.9.16.1.4),
|
|
370
|
+
// eContent [0] EXPLICIT OCTET STRING (TSTInfo DER-encoded)
|
|
371
|
+
// }
|
|
372
|
+
const contentTypeOid = derReadOid(buf, contentInfo.contentStart);
|
|
373
|
+
if (contentTypeOid.value !== OID_SIGNED_DATA) {
|
|
374
|
+
throw new Error(`parseTimestampResponse: expected signedData OID, got ${contentTypeOid.value}`);
|
|
375
|
+
}
|
|
376
|
+
// content [0] EXPLICIT — tag 0xa0, contains the SignedData SEQUENCE.
|
|
377
|
+
const contentExplicit = derReadTLV(buf, contentTypeOid.next);
|
|
378
|
+
if (contentExplicit.tag !== 0xa0) {
|
|
379
|
+
throw new Error(`parseTimestampResponse: expected [0] EXPLICIT tag, got 0x${contentExplicit.tag.toString(16)}`);
|
|
380
|
+
}
|
|
381
|
+
const signedData = derReadTLV(buf, contentExplicit.contentStart);
|
|
382
|
+
if (signedData.tag !== TAG_SEQUENCE) {
|
|
383
|
+
throw new Error("parseTimestampResponse: SignedData not SEQUENCE");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Walk into SignedData: version, digestAlgorithms, encapContentInfo,
|
|
387
|
+
// [0] IMPLICIT certificates (optional), [1] IMPLICIT crls (optional),
|
|
388
|
+
// signerInfos SET.
|
|
389
|
+
let sdPos = signedData.contentStart;
|
|
390
|
+
// Skip version (INTEGER).
|
|
391
|
+
const sdVersion = derReadTLV(buf, sdPos);
|
|
392
|
+
sdPos = sdVersion.next;
|
|
393
|
+
// Skip digestAlgorithms (SET).
|
|
394
|
+
const sdDigestAlgs = derReadTLV(buf, sdPos);
|
|
395
|
+
sdPos = sdDigestAlgs.next;
|
|
396
|
+
|
|
397
|
+
// encapContentInfo — SEQUENCE { eContentType OID, eContent [0] EXPLICIT OCTET STRING }
|
|
398
|
+
const encap = derReadTLV(buf, sdPos);
|
|
399
|
+
if (encap.tag !== TAG_SEQUENCE) throw new Error("parseTimestampResponse: encapContentInfo not SEQUENCE");
|
|
400
|
+
sdPos = encap.next;
|
|
401
|
+
const eContentType = derReadOid(buf, encap.contentStart);
|
|
402
|
+
if (eContentType.value !== OID_TSTINFO) {
|
|
403
|
+
throw new Error(`parseTimestampResponse: expected TSTInfo OID, got ${eContentType.value}`);
|
|
404
|
+
}
|
|
405
|
+
const eContentExplicit = derReadTLV(buf, eContentType.next);
|
|
406
|
+
if (eContentExplicit.tag !== 0xa0) {
|
|
407
|
+
throw new Error("parseTimestampResponse: expected [0] EXPLICIT for eContent");
|
|
408
|
+
}
|
|
409
|
+
const tstInfoOctet = derReadTLV(buf, eContentExplicit.contentStart);
|
|
410
|
+
if (tstInfoOctet.tag !== TAG_OCTET_STRING) {
|
|
411
|
+
throw new Error("parseTimestampResponse: expected OCTET STRING wrapping TSTInfo");
|
|
412
|
+
}
|
|
413
|
+
const tstInfoBytes = buf.slice(tstInfoOctet.contentStart, tstInfoOctet.next);
|
|
414
|
+
const tokenBytes = buf.slice(contentInfo.contentStart - (contentInfo.contentStart - pos), contentInfo.next);
|
|
415
|
+
|
|
416
|
+
// Extract CMS pieces needed for signature verification. Certificates,
|
|
417
|
+
// CRLs, and signerInfos live after encapContentInfo in SignedData.
|
|
418
|
+
const certificatesDer = [];
|
|
419
|
+
let signerInfoBytes = null;
|
|
420
|
+
while (sdPos < signedData.next) {
|
|
421
|
+
const tlv = derReadTLV(buf, sdPos);
|
|
422
|
+
if (tlv.tag === 0xa0) {
|
|
423
|
+
// [0] IMPLICIT certificates — SET/SEQUENCE OF CertificateChoices.
|
|
424
|
+
// Sprint 4: extract ALL X.509 certificate SEQUENCEs so chain
|
|
425
|
+
// validation can walk leaf → intermediates → root. The first cert
|
|
426
|
+
// is treated as the signer's leaf (sprint 2 behavior preserved via
|
|
427
|
+
// certificatesDer[0]).
|
|
428
|
+
let cpos = tlv.contentStart;
|
|
429
|
+
while (cpos < tlv.next) {
|
|
430
|
+
const cert = derReadTLV(buf, cpos);
|
|
431
|
+
if (cert.tag === TAG_SEQUENCE) {
|
|
432
|
+
certificatesDer.push(buf.slice(cpos, cert.next));
|
|
433
|
+
}
|
|
434
|
+
cpos = cert.next;
|
|
435
|
+
}
|
|
436
|
+
} else if (tlv.tag === 0xa1) {
|
|
437
|
+
// [1] IMPLICIT crls — skip; sprint 2 does not consult CRLs.
|
|
438
|
+
} else if (tlv.tag === TAG_SET) {
|
|
439
|
+
// signerInfos SET OF SignerInfo. We take the first for sprint 2.
|
|
440
|
+
const firstSignerInfo = derReadTLV(buf, tlv.contentStart);
|
|
441
|
+
if (firstSignerInfo.tag === TAG_SEQUENCE) {
|
|
442
|
+
signerInfoBytes = buf.slice(tlv.contentStart, firstSignerInfo.next);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
sdPos = tlv.next;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Parse TSTInfo.
|
|
449
|
+
const tstInfoSeq = derReadTLV(tstInfoBytes, 0);
|
|
450
|
+
if (tstInfoSeq.tag !== TAG_SEQUENCE) throw new Error("parseTimestampResponse: TSTInfo not SEQUENCE");
|
|
451
|
+
|
|
452
|
+
let tpos = tstInfoSeq.contentStart;
|
|
453
|
+
const tVersion = derReadTLV(tstInfoBytes, tpos); tpos = tVersion.next;
|
|
454
|
+
const tPolicyOid = derReadOid(tstInfoBytes, tpos); tpos = tPolicyOid.next;
|
|
455
|
+
|
|
456
|
+
// messageImprint SEQUENCE { hashAlgorithm AlgorithmIdentifier, hashedMessage OCTET STRING }
|
|
457
|
+
const mi = derReadTLV(tstInfoBytes, tpos); tpos = mi.next;
|
|
458
|
+
if (mi.tag !== TAG_SEQUENCE) throw new Error("parseTimestampResponse: messageImprint not SEQUENCE");
|
|
459
|
+
const miAlgSeq = derReadTLV(tstInfoBytes, mi.contentStart);
|
|
460
|
+
const miAlgOid = derReadOid(tstInfoBytes, miAlgSeq.contentStart);
|
|
461
|
+
const miHashInfo = HASH_ALG_BY_OID.get(miAlgOid.value);
|
|
462
|
+
if (!miHashInfo) throw new Error(`parseTimestampResponse: unsupported messageImprint algorithm ${miAlgOid.value}`);
|
|
463
|
+
const miHashOctet = derReadOctetString(tstInfoBytes, miAlgSeq.next);
|
|
464
|
+
|
|
465
|
+
// serialNumber INTEGER
|
|
466
|
+
const serial = derReadInteger(tstInfoBytes, tpos); tpos = serial.next;
|
|
467
|
+
|
|
468
|
+
// genTime GeneralizedTime
|
|
469
|
+
const genTime = derReadGeneralizedTime(tstInfoBytes, tpos); tpos = genTime.next;
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
status,
|
|
473
|
+
tstInfo: {
|
|
474
|
+
messageImprintAlgorithm: miHashInfo.name,
|
|
475
|
+
messageImprintHash: miHashOctet.value,
|
|
476
|
+
genTimeIso: genTime.value,
|
|
477
|
+
serialNumber: serial.value,
|
|
478
|
+
tokenBytes,
|
|
479
|
+
// Sprint 2 pieces — may be null on synthetic TSRs used in tests.
|
|
480
|
+
tstInfoBytes,
|
|
481
|
+
certificateDer: certificatesDer[0] ?? null,
|
|
482
|
+
certificatesDer,
|
|
483
|
+
signerInfoBytes,
|
|
484
|
+
},
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ── CMS SignedData signature verification (sprint 2) ──────────
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Parse a SignerInfo TLV blob and return the fields needed to verify.
|
|
492
|
+
* @param {Buffer} buf — DER SignerInfo SEQUENCE bytes (outer SEQUENCE included)
|
|
493
|
+
* @returns {{digestAlgorithmOid: string, signedAttrsDerForSig: Buffer|null, messageDigestAttr: Buffer|null, signatureAlgorithmOid: string, signature: Buffer}}
|
|
494
|
+
*/
|
|
495
|
+
function parseSignerInfo(buf) {
|
|
496
|
+
const outer = derReadTLV(buf, 0);
|
|
497
|
+
if (outer.tag !== TAG_SEQUENCE) throw new Error("parseSignerInfo: outer not SEQUENCE");
|
|
498
|
+
let p = outer.contentStart;
|
|
499
|
+
|
|
500
|
+
// version INTEGER
|
|
501
|
+
const version = derReadTLV(buf, p); p = version.next;
|
|
502
|
+
// sid — either issuerAndSerialNumber SEQUENCE OR [0] IMPLICIT subjectKeyIdentifier.
|
|
503
|
+
const sid = derReadTLV(buf, p); p = sid.next;
|
|
504
|
+
// digestAlgorithm AlgorithmIdentifier
|
|
505
|
+
const dAlg = derReadTLV(buf, p); p = dAlg.next;
|
|
506
|
+
const dAlgOid = derReadOid(buf, dAlg.contentStart);
|
|
507
|
+
|
|
508
|
+
// signedAttrs [0] IMPLICIT SET OF Attribute — OPTIONAL
|
|
509
|
+
let signedAttrsDerForSig = null;
|
|
510
|
+
let messageDigestAttr = null;
|
|
511
|
+
const tlv1 = derReadTLV(buf, p);
|
|
512
|
+
if (tlv1.tag === 0xa0) {
|
|
513
|
+
// Re-encode as SET (0x31) with the same length + content per RFC 5652 §5.4
|
|
514
|
+
// "The IMPLICIT [0] tag in the signedAttrs is not used for the DER encoding,
|
|
515
|
+
// rather an EXPLICIT SET OF tag is used."
|
|
516
|
+
const setDer = Buffer.concat([
|
|
517
|
+
Buffer.from([TAG_SET]),
|
|
518
|
+
derEncodeLength(tlv1.length),
|
|
519
|
+
buf.slice(tlv1.contentStart, tlv1.next),
|
|
520
|
+
]);
|
|
521
|
+
signedAttrsDerForSig = setDer;
|
|
522
|
+
|
|
523
|
+
// Walk attributes to find messageDigest.
|
|
524
|
+
let apos = tlv1.contentStart;
|
|
525
|
+
while (apos < tlv1.next) {
|
|
526
|
+
const attr = derReadTLV(buf, apos);
|
|
527
|
+
if (attr.tag !== TAG_SEQUENCE) { apos = attr.next; continue; }
|
|
528
|
+
const attrOid = derReadOid(buf, attr.contentStart);
|
|
529
|
+
const attrValues = derReadTLV(buf, attrOid.next);
|
|
530
|
+
if (attrValues.tag !== TAG_SET) { apos = attr.next; continue; }
|
|
531
|
+
if (attrOid.value === OID_MESSAGE_DIGEST_ATTR) {
|
|
532
|
+
const digestOctet = derReadOctetString(buf, attrValues.contentStart);
|
|
533
|
+
messageDigestAttr = digestOctet.value;
|
|
534
|
+
}
|
|
535
|
+
apos = attr.next;
|
|
536
|
+
}
|
|
537
|
+
p = tlv1.next;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// signatureAlgorithm AlgorithmIdentifier
|
|
541
|
+
const sAlg = derReadTLV(buf, p); p = sAlg.next;
|
|
542
|
+
const sAlgOid = derReadOid(buf, sAlg.contentStart);
|
|
543
|
+
|
|
544
|
+
// signature OCTET STRING
|
|
545
|
+
const sig = derReadOctetString(buf, p);
|
|
546
|
+
|
|
547
|
+
return {
|
|
548
|
+
digestAlgorithmOid: dAlgOid.value,
|
|
549
|
+
signedAttrsDerForSig,
|
|
550
|
+
messageDigestAttr,
|
|
551
|
+
signatureAlgorithmOid: sAlgOid.value,
|
|
552
|
+
signature: sig.value,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Verify the CMS SignedData signature over a parsed TSTInfo (eContent),
|
|
558
|
+
* using the certificate embedded in the CMS structure. Returns { ok, reason? }.
|
|
559
|
+
*
|
|
560
|
+
* Scope note (sprint 2): the certificate is used AS-IS from the CMS; this
|
|
561
|
+
* function does NOT walk a trust chain to a root CA. Real deployments
|
|
562
|
+
* should pair this with an operator-managed trust store; see docs/THREAT_MODEL.md
|
|
563
|
+
* §6.3 for the honest posture around what a valid CMS signature does and
|
|
564
|
+
* does not imply.
|
|
565
|
+
*
|
|
566
|
+
* Sprint 4: caTrustStorePem[] enables real chain validation. When provided,
|
|
567
|
+
* this function walks leaf → embedded intermediates → a caller-supplied root
|
|
568
|
+
* CA and reports caChainValidated:true only if a full chain terminates at a
|
|
569
|
+
* trust-store root. Without a trust store, caChainValidated is null (the
|
|
570
|
+
* sprint 2 posture — signature verified against the embedded cert whose
|
|
571
|
+
* ownership is unproven).
|
|
572
|
+
*
|
|
573
|
+
* @param {object} params
|
|
574
|
+
* @param {Buffer} params.eContentBytes — the DER-encoded TSTInfo bytes
|
|
575
|
+
* @param {Buffer} [params.certificateDer] — legacy: the signer's X.509 leaf DER (sprint 2)
|
|
576
|
+
* @param {Buffer[]} [params.certificatesDer] — sprint 4: all embedded certs; [0] is the leaf
|
|
577
|
+
* @param {Buffer} params.signerInfoBytes — the SignerInfo SEQUENCE bytes
|
|
578
|
+
* @param {string[]} [params.caTrustStorePem] — sprint 4: PEM-encoded root CA certs
|
|
579
|
+
* @returns {{ok: boolean, reason?: string, signerSubject?: string, caChainValidated?: boolean|null, chainFailReason?: string, chainAnchorSubject?: string}}
|
|
580
|
+
*/
|
|
581
|
+
export function verifyCmsSignature(params) {
|
|
582
|
+
const {
|
|
583
|
+
eContentBytes,
|
|
584
|
+
certificateDer,
|
|
585
|
+
certificatesDer,
|
|
586
|
+
signerInfoBytes,
|
|
587
|
+
caTrustStorePem,
|
|
588
|
+
} = params ?? {};
|
|
589
|
+
if (!eContentBytes) return { ok: false, reason: "eContentBytes required" };
|
|
590
|
+
const allCerts = Array.isArray(certificatesDer) && certificatesDer.length > 0
|
|
591
|
+
? certificatesDer
|
|
592
|
+
: (certificateDer ? [certificateDer] : []);
|
|
593
|
+
if (allCerts.length === 0) return { ok: false, reason: "certificateDer or certificatesDer required" };
|
|
594
|
+
if (!signerInfoBytes) return { ok: false, reason: "signerInfoBytes required" };
|
|
595
|
+
|
|
596
|
+
let cert;
|
|
597
|
+
try {
|
|
598
|
+
cert = new X509Certificate(allCerts[0]);
|
|
599
|
+
} catch (err) {
|
|
600
|
+
return { ok: false, reason: `certificate parse failed: ${err.message}` };
|
|
601
|
+
}
|
|
602
|
+
const publicKey = cert.publicKey;
|
|
603
|
+
|
|
604
|
+
let signerInfo;
|
|
605
|
+
try {
|
|
606
|
+
signerInfo = parseSignerInfo(signerInfoBytes);
|
|
607
|
+
} catch (err) {
|
|
608
|
+
return { ok: false, reason: `SignerInfo parse failed: ${err.message}` };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const sigAlg = SIG_ALG_HANDLERS.get(signerInfo.signatureAlgorithmOid);
|
|
612
|
+
if (!sigAlg) return { ok: false, reason: `unsupported signatureAlgorithm ${signerInfo.signatureAlgorithmOid}` };
|
|
613
|
+
const digestInfo = HASH_ALG_BY_OID.get(
|
|
614
|
+
// Map the signer's digestAlgorithm OID.
|
|
615
|
+
signerInfo.digestAlgorithmOid,
|
|
616
|
+
);
|
|
617
|
+
if (!digestInfo) return { ok: false, reason: `unsupported digestAlgorithm ${signerInfo.digestAlgorithmOid}` };
|
|
618
|
+
|
|
619
|
+
// If signedAttrs is present, verify that the messageDigest attribute
|
|
620
|
+
// equals the hash of eContent, then sign over the DER-encoded SET
|
|
621
|
+
// (with tag 0x31, not the IMPLICIT [0] 0xa0).
|
|
622
|
+
let dataToVerify;
|
|
623
|
+
if (signerInfo.signedAttrsDerForSig) {
|
|
624
|
+
if (!signerInfo.messageDigestAttr) {
|
|
625
|
+
return { ok: false, reason: "signedAttrs present but messageDigest attribute missing" };
|
|
626
|
+
}
|
|
627
|
+
const expectedDigest = createHash(digestInfo.name).update(eContentBytes).digest();
|
|
628
|
+
if (!expectedDigest.equals(signerInfo.messageDigestAttr)) {
|
|
629
|
+
return { ok: false, reason: "signedAttrs.messageDigest does not match hash(eContent)" };
|
|
630
|
+
}
|
|
631
|
+
dataToVerify = signerInfo.signedAttrsDerForSig;
|
|
632
|
+
} else {
|
|
633
|
+
// No signedAttrs — sign eContent directly. This is rare for TSAs
|
|
634
|
+
// but allowed by RFC 5652.
|
|
635
|
+
dataToVerify = Buffer.from(eContentBytes);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const digestName = sigAlg.digest;
|
|
639
|
+
let sigOk;
|
|
640
|
+
try {
|
|
641
|
+
sigOk = cryptoVerify(
|
|
642
|
+
// Node picks the algorithm from the key type + this digest name.
|
|
643
|
+
// Ed25519 requires the digest name to be null.
|
|
644
|
+
digestName,
|
|
645
|
+
dataToVerify,
|
|
646
|
+
publicKey,
|
|
647
|
+
signerInfo.signature,
|
|
648
|
+
);
|
|
649
|
+
} catch (err) {
|
|
650
|
+
return { ok: false, reason: `cryptoVerify threw: ${err.message}` };
|
|
651
|
+
}
|
|
652
|
+
if (!sigOk) return { ok: false, reason: "CMS signature verification failed" };
|
|
653
|
+
|
|
654
|
+
// Sprint 4: optional CA chain validation. If a trust store is not
|
|
655
|
+
// supplied, caChainValidated is reported as null so callers can tell
|
|
656
|
+
// "operator did not opt in" apart from "opted in and passed".
|
|
657
|
+
let caChainValidated = null;
|
|
658
|
+
let chainFailReason;
|
|
659
|
+
let chainAnchorSubject;
|
|
660
|
+
if (Array.isArray(caTrustStorePem) && caTrustStorePem.length > 0) {
|
|
661
|
+
const chainResult = validateCmsCertChain({
|
|
662
|
+
leafCert: cert,
|
|
663
|
+
intermediateDers: allCerts.slice(1),
|
|
664
|
+
trustStorePems: caTrustStorePem,
|
|
665
|
+
});
|
|
666
|
+
caChainValidated = chainResult.ok;
|
|
667
|
+
if (chainResult.ok) {
|
|
668
|
+
chainAnchorSubject = chainResult.anchorSubject;
|
|
669
|
+
} else {
|
|
670
|
+
chainFailReason = chainResult.reason;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return {
|
|
675
|
+
ok: true,
|
|
676
|
+
signerSubject: cert.subject,
|
|
677
|
+
caChainValidated,
|
|
678
|
+
...(chainAnchorSubject ? { chainAnchorSubject } : {}),
|
|
679
|
+
...(chainFailReason ? { chainFailReason } : {}),
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Walk an X.509 certificate chain from a leaf, through embedded
|
|
685
|
+
* intermediates, terminating at a caller-supplied trust-store root.
|
|
686
|
+
*
|
|
687
|
+
* Node's X509Certificate.checkIssued() confirms name-chaining (issuer of
|
|
688
|
+
* the child equals subject of the parent); X509Certificate.verify(pub)
|
|
689
|
+
* confirms the child's signature was produced with the parent's key.
|
|
690
|
+
* Together those match the sub-check that RFC 5280 §6 formalizes.
|
|
691
|
+
*
|
|
692
|
+
* Sprint 4 scope: name-chain + signature-chain + validity-period check.
|
|
693
|
+
* Out of scope for this sprint: CRL/OCSP revocation, name constraints,
|
|
694
|
+
* policy processing, keyUsage/extKeyUsage enforcement. Callers who need
|
|
695
|
+
* revocation should pair this with an OCSP responder query at the sealing
|
|
696
|
+
* side.
|
|
697
|
+
*
|
|
698
|
+
* @param {object} params
|
|
699
|
+
* @param {X509Certificate} params.leafCert
|
|
700
|
+
* @param {Buffer[]} params.intermediateDers
|
|
701
|
+
* @param {string[]} params.trustStorePems
|
|
702
|
+
* @returns {{ok: true, anchorSubject: string, chainLength: number}|{ok: false, reason: string}}
|
|
703
|
+
*/
|
|
704
|
+
export function validateCmsCertChain(params) {
|
|
705
|
+
const { leafCert, intermediateDers = [], trustStorePems } = params ?? {};
|
|
706
|
+
if (!leafCert) return { ok: false, reason: "leafCert required" };
|
|
707
|
+
if (!Array.isArray(trustStorePems) || trustStorePems.length === 0) {
|
|
708
|
+
return { ok: false, reason: "trustStorePems must be a non-empty array" };
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
let intermediates;
|
|
712
|
+
try {
|
|
713
|
+
intermediates = intermediateDers.map((d) => new X509Certificate(d));
|
|
714
|
+
} catch (err) {
|
|
715
|
+
return { ok: false, reason: `intermediate parse failed: ${err.message}` };
|
|
716
|
+
}
|
|
717
|
+
let roots;
|
|
718
|
+
try {
|
|
719
|
+
roots = trustStorePems.map((pem) => new X509Certificate(pem));
|
|
720
|
+
} catch (err) {
|
|
721
|
+
return { ok: false, reason: `trust-store parse failed: ${err.message}` };
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const now = Date.now();
|
|
725
|
+
const seenFingerprints = new Set();
|
|
726
|
+
let current = leafCert;
|
|
727
|
+
let chainLength = 1;
|
|
728
|
+
|
|
729
|
+
for (let step = 0; step < 16; step++) {
|
|
730
|
+
const fp = current.fingerprint256;
|
|
731
|
+
if (seenFingerprints.has(fp)) {
|
|
732
|
+
return { ok: false, reason: "certificate loop detected" };
|
|
733
|
+
}
|
|
734
|
+
seenFingerprints.add(fp);
|
|
735
|
+
|
|
736
|
+
// Validity window check on every cert in the chain.
|
|
737
|
+
const notBefore = Date.parse(current.validFrom);
|
|
738
|
+
const notAfter = Date.parse(current.validTo);
|
|
739
|
+
if (Number.isFinite(notBefore) && now < notBefore) {
|
|
740
|
+
return { ok: false, reason: `cert not yet valid: ${current.subject}` };
|
|
741
|
+
}
|
|
742
|
+
if (Number.isFinite(notAfter) && now > notAfter) {
|
|
743
|
+
return { ok: false, reason: `cert expired: ${current.subject}` };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Termination: current cert issued by a root in the trust store.
|
|
747
|
+
for (const root of roots) {
|
|
748
|
+
// Self-signed root case: current fingerprint matches a trusted root.
|
|
749
|
+
if (root.fingerprint256 === current.fingerprint256) {
|
|
750
|
+
return { ok: true, anchorSubject: root.subject, chainLength };
|
|
751
|
+
}
|
|
752
|
+
if (current.checkIssued(root)) {
|
|
753
|
+
let signatureOk = false;
|
|
754
|
+
try { signatureOk = current.verify(root.publicKey); } catch { /* fall through */ }
|
|
755
|
+
if (signatureOk) {
|
|
756
|
+
return { ok: true, anchorSubject: root.subject, chainLength: chainLength + 1 };
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Walk to an embedded intermediate. Skip the current cert itself in
|
|
762
|
+
// case the leaf appears in the intermediates array too.
|
|
763
|
+
let parent = null;
|
|
764
|
+
for (const inter of intermediates) {
|
|
765
|
+
if (inter.fingerprint256 === current.fingerprint256) continue;
|
|
766
|
+
if (current.checkIssued(inter)) {
|
|
767
|
+
let signatureOk = false;
|
|
768
|
+
try { signatureOk = current.verify(inter.publicKey); } catch { /* fall through */ }
|
|
769
|
+
if (signatureOk) { parent = inter; break; }
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
if (!parent) {
|
|
773
|
+
return { ok: false, reason: `no trusted issuer for "${current.subject}"` };
|
|
774
|
+
}
|
|
775
|
+
current = parent;
|
|
776
|
+
chainLength++;
|
|
777
|
+
}
|
|
778
|
+
return { ok: false, reason: "chain exceeded 16 steps" };
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// ── HTTP client (Node built-in fetch) ─────────────────────────
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Post a TimeStampReq to a TSA and return a Shadow anchor object suitable
|
|
785
|
+
* for embedding in bundle.external_anchors[].
|
|
786
|
+
*
|
|
787
|
+
* @param {object} params
|
|
788
|
+
* @param {string} params.batchRootHex — the bundle.batch_root (hex sha256)
|
|
789
|
+
* @param {string} params.tsaUrl — TSA endpoint URL (must accept application/timestamp-query)
|
|
790
|
+
* @param {string} [params.hashAlgorithm] — "sha256" default
|
|
791
|
+
* @param {number} [params.timeoutMs] — default 15000
|
|
792
|
+
* @returns {Promise<{kind: "rfc3161-tsa", batch_root: string, anchor_ref: string, anchored_at_utc: string}>}
|
|
793
|
+
*/
|
|
794
|
+
export async function requestTimestamp(params) {
|
|
795
|
+
const {
|
|
796
|
+
batchRootHex,
|
|
797
|
+
tsaUrl,
|
|
798
|
+
hashAlgorithm = "sha256",
|
|
799
|
+
timeoutMs = 15000,
|
|
800
|
+
} = params ?? {};
|
|
801
|
+
if (!batchRootHex) throw new Error("requestTimestamp: batchRootHex required");
|
|
802
|
+
if (!tsaUrl) throw new Error("requestTimestamp: tsaUrl required");
|
|
803
|
+
|
|
804
|
+
// The messageImprint is a hash of the bundle's own batch_root (which is
|
|
805
|
+
// already a sha256). This produces sha256(sha256(batch)), which is what
|
|
806
|
+
// the TSA signs. On verify we re-hash the batch_root the same way and
|
|
807
|
+
// compare against the token's messageImprint.
|
|
808
|
+
const digest = createHash(hashAlgorithm).update(Buffer.from(batchRootHex, "hex")).digest();
|
|
809
|
+
const req = buildTimestampRequest({ digestHex: digest.toString("hex"), hashAlgorithm });
|
|
810
|
+
|
|
811
|
+
const controller = new AbortController();
|
|
812
|
+
const to = setTimeout(() => controller.abort(), timeoutMs);
|
|
813
|
+
let res;
|
|
814
|
+
try {
|
|
815
|
+
res = await fetch(tsaUrl, {
|
|
816
|
+
method: "POST",
|
|
817
|
+
headers: { "Content-Type": "application/timestamp-query" },
|
|
818
|
+
body: req,
|
|
819
|
+
signal: controller.signal,
|
|
820
|
+
});
|
|
821
|
+
} finally {
|
|
822
|
+
clearTimeout(to);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
if (!res.ok) {
|
|
826
|
+
throw new Error(`requestTimestamp: TSA returned HTTP ${res.status} from ${tsaUrl}`);
|
|
827
|
+
}
|
|
828
|
+
const respBytes = Buffer.from(await res.arrayBuffer());
|
|
829
|
+
const parsed = parseTimestampResponse(respBytes);
|
|
830
|
+
if (parsed.status.statusCode !== 0 && parsed.status.statusCode !== 1) {
|
|
831
|
+
// 0 = granted, 1 = grantedWithMods (both are success per RFC 3161).
|
|
832
|
+
throw new Error(`requestTimestamp: TSA statusCode ${parsed.status.statusCode}`);
|
|
833
|
+
}
|
|
834
|
+
if (!parsed.tstInfo) {
|
|
835
|
+
throw new Error("requestTimestamp: response missing timeStampToken");
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
return {
|
|
839
|
+
kind: "rfc3161-tsa",
|
|
840
|
+
batch_root: batchRootHex,
|
|
841
|
+
anchor_ref: respBytes.toString("base64url"),
|
|
842
|
+
anchored_at_utc: parsed.tstInfo.genTimeIso,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// ── Anchor verification ───────────────────────────────────────
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Verify an RFC 3161 anchor against an expected bundle batch_root.
|
|
850
|
+
*
|
|
851
|
+
* Sprint 1 default (verifyCms: false): structural verification only —
|
|
852
|
+
* checks that the messageImprint in the parsed TSTInfo equals
|
|
853
|
+
* sha256(batch_root_bytes) (or sha1, per the anchor's algorithm), and
|
|
854
|
+
* extracts genTime. Reports trustLevel TIME_ANCHORED_STRUCTURAL.
|
|
855
|
+
*
|
|
856
|
+
* Sprint 2 opt-in (verifyCms: true): additionally verifies the CMS
|
|
857
|
+
* SignedData signature over the TSTInfo using the certificate embedded
|
|
858
|
+
* in the token. Reports trustLevel TIME_ANCHORED on success; falls back
|
|
859
|
+
* to TIME_ANCHORED_STRUCTURAL on CMS-verify failure with a diagnostic
|
|
860
|
+
* reason. Does NOT walk a certificate trust chain to a root CA — the
|
|
861
|
+
* caller's operator must pair this with a trust store; see
|
|
862
|
+
* docs/THREAT_MODEL.md §6.3.
|
|
863
|
+
*
|
|
864
|
+
* @param {object} params
|
|
865
|
+
* @param {object} params.anchor — one entry from bundle.external_anchors
|
|
866
|
+
* @param {string} params.expectedBatchRootHex — bundle.batch_root
|
|
867
|
+
* @param {boolean} [params.verifyCms] — attempt full CMS signature verify
|
|
868
|
+
* @returns {{ok: true, genTimeIso: string, trustLevel: string, cmsSignerSubject?: string, cmsFailReason?: string} | {ok: false, reason: string}}
|
|
869
|
+
*/
|
|
870
|
+
export function verifyRfc3161Anchor(params) {
|
|
871
|
+
const { anchor, expectedBatchRootHex, verifyCms = false, caTrustStorePem } = params ?? {};
|
|
872
|
+
if (!anchor) return { ok: false, reason: "anchor required" };
|
|
873
|
+
if (anchor.kind !== "rfc3161-tsa") return { ok: false, reason: `wrong kind "${anchor.kind}"` };
|
|
874
|
+
if (!expectedBatchRootHex) return { ok: false, reason: "expectedBatchRootHex required" };
|
|
875
|
+
if (anchor.batch_root && anchor.batch_root !== expectedBatchRootHex) {
|
|
876
|
+
return { ok: false, reason: "anchor.batch_root does not match bundle.batch_root" };
|
|
877
|
+
}
|
|
878
|
+
if (!anchor.anchor_ref) return { ok: false, reason: "anchor.anchor_ref missing" };
|
|
879
|
+
|
|
880
|
+
let respBytes;
|
|
881
|
+
try {
|
|
882
|
+
respBytes = Buffer.from(anchor.anchor_ref, "base64url");
|
|
883
|
+
} catch (err) {
|
|
884
|
+
return { ok: false, reason: `anchor_ref base64url decode failed: ${err.message}` };
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
let parsed;
|
|
888
|
+
try {
|
|
889
|
+
parsed = parseTimestampResponse(respBytes);
|
|
890
|
+
} catch (err) {
|
|
891
|
+
return { ok: false, reason: `TimeStampResp parse failed: ${err.message}` };
|
|
892
|
+
}
|
|
893
|
+
if (parsed.status.statusCode !== 0 && parsed.status.statusCode !== 1) {
|
|
894
|
+
return { ok: false, reason: `TSA statusCode ${parsed.status.statusCode}` };
|
|
895
|
+
}
|
|
896
|
+
if (!parsed.tstInfo) return { ok: false, reason: "response missing timeStampToken" };
|
|
897
|
+
|
|
898
|
+
const expectedDigest = createHash(parsed.tstInfo.messageImprintAlgorithm)
|
|
899
|
+
.update(Buffer.from(expectedBatchRootHex, "hex"))
|
|
900
|
+
.digest();
|
|
901
|
+
if (!expectedDigest.equals(parsed.tstInfo.messageImprintHash)) {
|
|
902
|
+
return { ok: false, reason: "messageImprint does not match sha256(batch_root)" };
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Sprint 2: attempt CMS SignedData signature verification if requested
|
|
906
|
+
// AND the token contains the pieces needed (certificate + SignerInfo).
|
|
907
|
+
// Fall back to TIME_ANCHORED_STRUCTURAL on failure so the caller sees a
|
|
908
|
+
// clear diagnostic without the top-level verify going false.
|
|
909
|
+
if (verifyCms) {
|
|
910
|
+
if (!parsed.tstInfo.certificateDer || !parsed.tstInfo.signerInfoBytes) {
|
|
911
|
+
return {
|
|
912
|
+
ok: true,
|
|
913
|
+
genTimeIso: parsed.tstInfo.genTimeIso,
|
|
914
|
+
trustLevel: TRUST_LEVELS.TIME_ANCHORED_STRUCTURAL,
|
|
915
|
+
cmsFailReason: "token missing embedded certificate or SignerInfo",
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
const cmsResult = verifyCmsSignature({
|
|
919
|
+
eContentBytes: parsed.tstInfo.tstInfoBytes,
|
|
920
|
+
certificatesDer: parsed.tstInfo.certificatesDer,
|
|
921
|
+
signerInfoBytes: parsed.tstInfo.signerInfoBytes,
|
|
922
|
+
caTrustStorePem,
|
|
923
|
+
});
|
|
924
|
+
if (!cmsResult.ok) {
|
|
925
|
+
return {
|
|
926
|
+
ok: true,
|
|
927
|
+
genTimeIso: parsed.tstInfo.genTimeIso,
|
|
928
|
+
trustLevel: TRUST_LEVELS.TIME_ANCHORED_STRUCTURAL,
|
|
929
|
+
cmsFailReason: cmsResult.reason,
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
// Sprint 4: if a trust store was provided AND chain failed, honestly
|
|
933
|
+
// demote to STRUCTURAL with a chainFailReason. Sig verified against
|
|
934
|
+
// an untrusted cert is not stronger than "TSA-shaped bytes matched".
|
|
935
|
+
if (Array.isArray(caTrustStorePem) && caTrustStorePem.length > 0
|
|
936
|
+
&& cmsResult.caChainValidated !== true) {
|
|
937
|
+
return {
|
|
938
|
+
ok: true,
|
|
939
|
+
genTimeIso: parsed.tstInfo.genTimeIso,
|
|
940
|
+
trustLevel: TRUST_LEVELS.TIME_ANCHORED_STRUCTURAL,
|
|
941
|
+
cmsFailReason: cmsResult.chainFailReason ?? "CA chain validation failed",
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
return {
|
|
945
|
+
ok: true,
|
|
946
|
+
genTimeIso: parsed.tstInfo.genTimeIso,
|
|
947
|
+
trustLevel: TRUST_LEVELS.TIME_ANCHORED,
|
|
948
|
+
cmsSignerSubject: cmsResult.signerSubject,
|
|
949
|
+
caChainValidated: cmsResult.caChainValidated,
|
|
950
|
+
...(cmsResult.chainAnchorSubject ? { chainAnchorSubject: cmsResult.chainAnchorSubject } : {}),
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
return {
|
|
955
|
+
ok: true,
|
|
956
|
+
genTimeIso: parsed.tstInfo.genTimeIso,
|
|
957
|
+
trustLevel: TRUST_LEVELS.TIME_ANCHORED_STRUCTURAL,
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// ─────────────────────────────────────────────────────────────────
|
|
962
|
+
// v3 M3 sprint 3 — Sigstore Rekor transparency-log adapter.
|
|
963
|
+
// ─────────────────────────────────────────────────────────────────
|
|
964
|
+
//
|
|
965
|
+
// Rekor is a public append-only transparency log for signed artifacts
|
|
966
|
+
// (sigstore.dev). Submitting a Shadow evidence bundle's batch_root + signature
|
|
967
|
+
// to Rekor yields an entry that is publicly witnessed: even a compromised
|
|
968
|
+
// Shadow operator cannot silently rewrite history without also compromising
|
|
969
|
+
// the Rekor log itself (which is monitored by third parties).
|
|
970
|
+
//
|
|
971
|
+
// Structural verification (LOG_ANCHORED_STRUCTURAL): parse the Rekor entry
|
|
972
|
+
// body and confirm its embedded payload hash matches the bundle's batch_root.
|
|
973
|
+
// This proves "the operator submitted a matching hash to a log endpoint";
|
|
974
|
+
// it does NOT prove inclusion in the actual tree or authenticity of the
|
|
975
|
+
// response.
|
|
976
|
+
//
|
|
977
|
+
// Full verification (LOG_ANCHORED): also verify (a) the RFC 9162 inclusion
|
|
978
|
+
// proof against the tree root hash returned in the response, and (b) the
|
|
979
|
+
// Signed Entry Timestamp (SET) — a Rekor-signed statement binding
|
|
980
|
+
// (body, logID, logIndex, integratedTime) — using a caller-supplied Rekor
|
|
981
|
+
// public key. Fresh Rekor pubkey: `curl https://rekor.sigstore.dev/api/v1/log/publicKey`.
|
|
982
|
+
//
|
|
983
|
+
// Design constraints (same discipline as sprint 1/2):
|
|
984
|
+
// - Zero external dependencies. All Merkle math + JSON canonicalization
|
|
985
|
+
// implemented inline.
|
|
986
|
+
// - No hard-coded Rekor pubkey. Rotation is a real operational risk;
|
|
987
|
+
// caller must supply the current pubkey PEM. verifyRekorAnchor with
|
|
988
|
+
// mode:"full" throws a clear error if pubkey missing.
|
|
989
|
+
// - The client function submitRekorEntry does network I/O; callers must
|
|
990
|
+
// opt in. Tests use captured fixtures rather than live network calls
|
|
991
|
+
// (parallel to the freetsa test policy).
|
|
992
|
+
//
|
|
993
|
+
// References:
|
|
994
|
+
// - RFC 9162 Certificate Transparency 2.0 (tree hash format + inclusion proof)
|
|
995
|
+
// - Sigstore Rekor API v1 (github.com/sigstore/rekor/blob/main/openapi.yaml)
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Build the canonicalized hashedrekord v0.0.1 entry body for a bundle.
|
|
999
|
+
*
|
|
1000
|
+
* @param {object} params
|
|
1001
|
+
* @param {string} params.batchRootHex — bundle.batch_root (hex).
|
|
1002
|
+
* @param {string} params.signatureBase64 — bundle signature (base64).
|
|
1003
|
+
* @param {string} params.publicKeyPem — Ed25519 public key PEM used to sign.
|
|
1004
|
+
* @returns {{body: string, hashedrekord: object}} — body is base64 of canonical JSON.
|
|
1005
|
+
*/
|
|
1006
|
+
export function buildRekorHashedrekordEntry({ batchRootHex, signatureBase64, publicKeyPem }) {
|
|
1007
|
+
if (!batchRootHex || !signatureBase64 || !publicKeyPem) {
|
|
1008
|
+
throw new Error("buildRekorHashedrekordEntry: batchRootHex + signatureBase64 + publicKeyPem all required");
|
|
1009
|
+
}
|
|
1010
|
+
const hashedrekord = {
|
|
1011
|
+
apiVersion: "0.0.1",
|
|
1012
|
+
kind: "hashedrekord",
|
|
1013
|
+
spec: {
|
|
1014
|
+
data: {
|
|
1015
|
+
hash: { algorithm: "sha256", value: batchRootHex.toLowerCase() },
|
|
1016
|
+
},
|
|
1017
|
+
signature: {
|
|
1018
|
+
content: signatureBase64,
|
|
1019
|
+
publicKey: { content: Buffer.from(publicKeyPem, "utf8").toString("base64") },
|
|
1020
|
+
},
|
|
1021
|
+
},
|
|
1022
|
+
};
|
|
1023
|
+
const canonical = canonicalizeJson(hashedrekord);
|
|
1024
|
+
return { body: Buffer.from(canonical, "utf8").toString("base64"), hashedrekord };
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Submit a hashedrekord entry to a Rekor instance. Returns the anchor object
|
|
1029
|
+
* ready to embed in bundle.external_anchors.
|
|
1030
|
+
*
|
|
1031
|
+
* NETWORK — opt-in. Callers, not tests, invoke this.
|
|
1032
|
+
*
|
|
1033
|
+
* @param {object} params
|
|
1034
|
+
* @param {string} params.batchRootHex
|
|
1035
|
+
* @param {string} params.signatureBase64
|
|
1036
|
+
* @param {string} params.publicKeyPem — Ed25519 signer key (bundle signer).
|
|
1037
|
+
* @param {string} [params.rekorUrl] — default "https://rekor.sigstore.dev"
|
|
1038
|
+
* @param {number} [params.timeoutMs] — default 15000
|
|
1039
|
+
* @returns {Promise<object>} — anchor object with kind:"rekor"
|
|
1040
|
+
*/
|
|
1041
|
+
export async function submitRekorEntry({
|
|
1042
|
+
batchRootHex,
|
|
1043
|
+
signatureBase64,
|
|
1044
|
+
publicKeyPem,
|
|
1045
|
+
rekorUrl = "https://rekor.sigstore.dev",
|
|
1046
|
+
timeoutMs = 15000,
|
|
1047
|
+
}) {
|
|
1048
|
+
const { hashedrekord } = buildRekorHashedrekordEntry({
|
|
1049
|
+
batchRootHex,
|
|
1050
|
+
signatureBase64,
|
|
1051
|
+
publicKeyPem,
|
|
1052
|
+
});
|
|
1053
|
+
const abort = new AbortController();
|
|
1054
|
+
const t = setTimeout(() => abort.abort(), timeoutMs);
|
|
1055
|
+
let resp;
|
|
1056
|
+
try {
|
|
1057
|
+
resp = await fetch(`${rekorUrl.replace(/\/$/, "")}/api/v1/log/entries`, {
|
|
1058
|
+
method: "POST",
|
|
1059
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
1060
|
+
body: JSON.stringify(hashedrekord),
|
|
1061
|
+
signal: abort.signal,
|
|
1062
|
+
});
|
|
1063
|
+
} finally {
|
|
1064
|
+
clearTimeout(t);
|
|
1065
|
+
}
|
|
1066
|
+
if (!resp.ok) {
|
|
1067
|
+
const bodyText = await resp.text().catch(() => "");
|
|
1068
|
+
throw new Error(`Rekor POST failed: HTTP ${resp.status} ${bodyText.slice(0, 200)}`);
|
|
1069
|
+
}
|
|
1070
|
+
const respJson = await resp.json();
|
|
1071
|
+
const uuid = Object.keys(respJson)[0];
|
|
1072
|
+
if (!uuid) throw new Error("Rekor response missing entry uuid");
|
|
1073
|
+
const entry = respJson[uuid];
|
|
1074
|
+
return {
|
|
1075
|
+
kind: "rekor",
|
|
1076
|
+
logUrl: rekorUrl,
|
|
1077
|
+
uuid,
|
|
1078
|
+
logIndex: entry.logIndex,
|
|
1079
|
+
logID: entry.logID,
|
|
1080
|
+
integratedTime: entry.integratedTime,
|
|
1081
|
+
body: entry.body,
|
|
1082
|
+
inclusionProof: entry.verification?.inclusionProof ?? null,
|
|
1083
|
+
signedEntryTimestamp: entry.verification?.signedEntryTimestamp ?? null,
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* RFC 8785–ish canonical JSON stringify for the subset used here (objects
|
|
1089
|
+
* with string keys, strings, integers, arrays, booleans, null; no floats,
|
|
1090
|
+
* no Unicode surrogates). Keys sorted lexicographically at every level.
|
|
1091
|
+
*/
|
|
1092
|
+
export function canonicalizeJson(v) {
|
|
1093
|
+
if (v === null) return "null";
|
|
1094
|
+
if (typeof v === "boolean") return v ? "true" : "false";
|
|
1095
|
+
if (typeof v === "number") {
|
|
1096
|
+
if (!Number.isFinite(v)) throw new Error("canonicalizeJson: non-finite number");
|
|
1097
|
+
if (!Number.isInteger(v)) throw new Error("canonicalizeJson: only integers supported");
|
|
1098
|
+
return String(v);
|
|
1099
|
+
}
|
|
1100
|
+
if (typeof v === "string") return JSON.stringify(v);
|
|
1101
|
+
if (Array.isArray(v)) return "[" + v.map(canonicalizeJson).join(",") + "]";
|
|
1102
|
+
if (typeof v === "object") {
|
|
1103
|
+
const keys = Object.keys(v).sort();
|
|
1104
|
+
const parts = keys.map((k) => JSON.stringify(k) + ":" + canonicalizeJson(v[k]));
|
|
1105
|
+
return "{" + parts.join(",") + "}";
|
|
1106
|
+
}
|
|
1107
|
+
throw new Error(`canonicalizeJson: unsupported type ${typeof v}`);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Extract the payload SHA-256 hash from a base64-encoded Rekor hashedrekord
|
|
1112
|
+
* body. Returns null if the body is malformed or not a hashedrekord.
|
|
1113
|
+
*
|
|
1114
|
+
* @param {string} bodyBase64
|
|
1115
|
+
* @returns {{algorithm: string, hex: string}|null}
|
|
1116
|
+
*/
|
|
1117
|
+
export function extractRekorPayloadHash(bodyBase64) {
|
|
1118
|
+
try {
|
|
1119
|
+
const raw = Buffer.from(bodyBase64, "base64").toString("utf8");
|
|
1120
|
+
const parsed = JSON.parse(raw);
|
|
1121
|
+
if (parsed?.kind !== "hashedrekord") return null;
|
|
1122
|
+
const h = parsed?.spec?.data?.hash;
|
|
1123
|
+
if (!h?.algorithm || !h?.value) return null;
|
|
1124
|
+
return { algorithm: String(h.algorithm), hex: String(h.value).toLowerCase() };
|
|
1125
|
+
} catch {
|
|
1126
|
+
return null;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* RFC 9162 §2.1.3 inner-node hash: SHA-256(0x01 || left || right).
|
|
1132
|
+
*/
|
|
1133
|
+
function hashChildren(left, right) {
|
|
1134
|
+
return createHash("sha256").update(Buffer.from([0x01])).update(left).update(right).digest();
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* RFC 9162 §2.1.3 leaf hash: SHA-256(0x00 || raw_body_bytes).
|
|
1139
|
+
* Rekor's leaf-hash input is the base64-decoded body bytes.
|
|
1140
|
+
*/
|
|
1141
|
+
export function rekorLeafHash(bodyBase64) {
|
|
1142
|
+
const raw = Buffer.from(bodyBase64, "base64");
|
|
1143
|
+
return createHash("sha256").update(Buffer.from([0x00])).update(raw).digest();
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* Verify an RFC 9162 inclusion proof.
|
|
1148
|
+
*
|
|
1149
|
+
* Ported from the widely-audited pattern used by sigstore-js and
|
|
1150
|
+
* transparency-dev/merkle (Go). Returns true only when the reconstructed
|
|
1151
|
+
* root matches expectedRootHex AND the proof was fully consumed with the
|
|
1152
|
+
* subtree-size counter reaching zero.
|
|
1153
|
+
*
|
|
1154
|
+
* @param {object} params
|
|
1155
|
+
* @param {number} params.leafIndex — 0-based
|
|
1156
|
+
* @param {number} params.treeSize — must be > leafIndex
|
|
1157
|
+
* @param {string[]} params.hashesHex — sibling hashes bottom-up (hex)
|
|
1158
|
+
* @param {string} params.expectedRootHex — hex-encoded expected root
|
|
1159
|
+
* @param {Buffer} params.leafHash — 32-byte SHA-256 leaf hash
|
|
1160
|
+
* @returns {boolean}
|
|
1161
|
+
*/
|
|
1162
|
+
export function verifyInclusionProof({ leafIndex, treeSize, hashesHex, expectedRootHex, leafHash }) {
|
|
1163
|
+
if (!Number.isInteger(leafIndex) || !Number.isInteger(treeSize)) return false;
|
|
1164
|
+
if (leafIndex < 0 || treeSize <= 0 || leafIndex >= treeSize) return false;
|
|
1165
|
+
if (leafHash.length !== 32) return false;
|
|
1166
|
+
// Single-leaf tree: no siblings expected, leaf IS root.
|
|
1167
|
+
if (treeSize === 1) {
|
|
1168
|
+
return hashesHex.length === 0 && leafHash.equals(Buffer.from(expectedRootHex, "hex"));
|
|
1169
|
+
}
|
|
1170
|
+
let fn = leafIndex;
|
|
1171
|
+
let sn = treeSize - 1;
|
|
1172
|
+
let r = leafHash;
|
|
1173
|
+
for (const hHex of hashesHex) {
|
|
1174
|
+
const h = Buffer.from(hHex, "hex");
|
|
1175
|
+
if (h.length !== 32) return false;
|
|
1176
|
+
if (sn === 0) return false;
|
|
1177
|
+
if ((fn & 1) === 1 || fn === sn) {
|
|
1178
|
+
r = hashChildren(h, r);
|
|
1179
|
+
while ((fn & 1) === 0 && fn !== 0) {
|
|
1180
|
+
fn = Math.floor(fn / 2);
|
|
1181
|
+
sn = Math.floor(sn / 2);
|
|
1182
|
+
}
|
|
1183
|
+
} else {
|
|
1184
|
+
r = hashChildren(r, h);
|
|
1185
|
+
}
|
|
1186
|
+
fn = Math.floor(fn / 2);
|
|
1187
|
+
sn = Math.floor(sn / 2);
|
|
1188
|
+
}
|
|
1189
|
+
return sn === 0 && r.equals(Buffer.from(expectedRootHex, "hex"));
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* Verify the Rekor Signed Entry Timestamp (SET). The SET is an ECDSA
|
|
1194
|
+
* signature by Rekor over the canonical JSON of {body, integratedTime,
|
|
1195
|
+
* logID, logIndex}.
|
|
1196
|
+
*
|
|
1197
|
+
* @param {object} params
|
|
1198
|
+
* @param {object} params.anchor — must contain body/integratedTime/logID/logIndex/signedEntryTimestamp
|
|
1199
|
+
* @param {string|Buffer|KeyObject} params.rekorPubKey — PEM/KeyObject
|
|
1200
|
+
* @returns {{ok: boolean, reason?: string}}
|
|
1201
|
+
*/
|
|
1202
|
+
export function verifyRekorSet({ anchor, rekorPubKey }) {
|
|
1203
|
+
if (!anchor?.signedEntryTimestamp) {
|
|
1204
|
+
return { ok: false, reason: "anchor missing signedEntryTimestamp" };
|
|
1205
|
+
}
|
|
1206
|
+
const canonical = canonicalizeJson({
|
|
1207
|
+
body: anchor.body,
|
|
1208
|
+
integratedTime: anchor.integratedTime,
|
|
1209
|
+
logID: anchor.logID,
|
|
1210
|
+
logIndex: anchor.logIndex,
|
|
1211
|
+
});
|
|
1212
|
+
const sig = Buffer.from(anchor.signedEntryTimestamp, "base64");
|
|
1213
|
+
let keyObj;
|
|
1214
|
+
try {
|
|
1215
|
+
keyObj = rekorPubKey && typeof rekorPubKey === "object" && rekorPubKey.type
|
|
1216
|
+
? rekorPubKey
|
|
1217
|
+
: createPublicKey(rekorPubKey);
|
|
1218
|
+
} catch (err) {
|
|
1219
|
+
return { ok: false, reason: `Rekor pubkey parse failed: ${err.message}` };
|
|
1220
|
+
}
|
|
1221
|
+
const ok = cryptoVerify("sha256", Buffer.from(canonical, "utf8"), keyObj, sig);
|
|
1222
|
+
return ok ? { ok: true } : { ok: false, reason: "SET signature verification failed" };
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* Verify a Rekor anchor object. Two modes:
|
|
1227
|
+
* - Structural (default): confirm entry body payload-hash matches
|
|
1228
|
+
* expectedBatchRootHex. Elevates to LOG_ANCHORED_STRUCTURAL.
|
|
1229
|
+
* - Full: also verify inclusion proof + SET signature with rekorPubKey.
|
|
1230
|
+
* Elevates to LOG_ANCHORED on success. On partial failure, falls back
|
|
1231
|
+
* to LOG_ANCHORED_STRUCTURAL with a diagnostic.
|
|
1232
|
+
*
|
|
1233
|
+
* @param {object} params
|
|
1234
|
+
* @param {object} params.anchor
|
|
1235
|
+
* @param {string} params.expectedBatchRootHex
|
|
1236
|
+
* @param {boolean} [params.verifyFull]
|
|
1237
|
+
* @param {string|Buffer|KeyObject} [params.rekorPubKey] — required if verifyFull
|
|
1238
|
+
* @returns {{ok: boolean, reason?: string, trustLevel?: string, ...}}
|
|
1239
|
+
*/
|
|
1240
|
+
export function verifyRekorAnchor({ anchor, expectedBatchRootHex, verifyFull = false, rekorPubKey } = {}) {
|
|
1241
|
+
if (!anchor || anchor.kind !== "rekor") {
|
|
1242
|
+
return { ok: false, reason: "anchor.kind must be 'rekor'" };
|
|
1243
|
+
}
|
|
1244
|
+
if (!anchor.body) return { ok: false, reason: "anchor missing body" };
|
|
1245
|
+
const payload = extractRekorPayloadHash(anchor.body);
|
|
1246
|
+
if (!payload) return { ok: false, reason: "rekor body is not a parseable hashedrekord" };
|
|
1247
|
+
if (payload.algorithm !== "sha256") {
|
|
1248
|
+
return { ok: false, reason: `unsupported payload hash algorithm "${payload.algorithm}"` };
|
|
1249
|
+
}
|
|
1250
|
+
if (payload.hex !== expectedBatchRootHex.toLowerCase()) {
|
|
1251
|
+
return { ok: false, reason: "rekor payload hash does not match batch_root" };
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
if (!verifyFull) {
|
|
1255
|
+
return {
|
|
1256
|
+
ok: true,
|
|
1257
|
+
trustLevel: TRUST_LEVELS.LOG_ANCHORED_STRUCTURAL,
|
|
1258
|
+
logIndex: anchor.logIndex,
|
|
1259
|
+
integratedTime: anchor.integratedTime,
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
if (!rekorPubKey) {
|
|
1264
|
+
return {
|
|
1265
|
+
ok: true,
|
|
1266
|
+
trustLevel: TRUST_LEVELS.LOG_ANCHORED_STRUCTURAL,
|
|
1267
|
+
logIndex: anchor.logIndex,
|
|
1268
|
+
integratedTime: anchor.integratedTime,
|
|
1269
|
+
fullFailReason: "rekorPubKey option required for full verification",
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// Inclusion proof
|
|
1274
|
+
const ip = anchor.inclusionProof;
|
|
1275
|
+
if (!ip?.rootHash || !Array.isArray(ip?.hashes) ||
|
|
1276
|
+
!Number.isInteger(ip?.logIndex) || !Number.isInteger(ip?.treeSize)) {
|
|
1277
|
+
return {
|
|
1278
|
+
ok: true,
|
|
1279
|
+
trustLevel: TRUST_LEVELS.LOG_ANCHORED_STRUCTURAL,
|
|
1280
|
+
logIndex: anchor.logIndex,
|
|
1281
|
+
integratedTime: anchor.integratedTime,
|
|
1282
|
+
fullFailReason: "anchor missing or malformed inclusionProof",
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
const leaf = rekorLeafHash(anchor.body);
|
|
1286
|
+
const inclOk = verifyInclusionProof({
|
|
1287
|
+
leafIndex: ip.logIndex,
|
|
1288
|
+
treeSize: ip.treeSize,
|
|
1289
|
+
hashesHex: ip.hashes,
|
|
1290
|
+
expectedRootHex: ip.rootHash,
|
|
1291
|
+
leafHash: leaf,
|
|
1292
|
+
});
|
|
1293
|
+
if (!inclOk) {
|
|
1294
|
+
return {
|
|
1295
|
+
ok: true,
|
|
1296
|
+
trustLevel: TRUST_LEVELS.LOG_ANCHORED_STRUCTURAL,
|
|
1297
|
+
logIndex: anchor.logIndex,
|
|
1298
|
+
integratedTime: anchor.integratedTime,
|
|
1299
|
+
fullFailReason: "inclusion proof failed to reproduce rootHash",
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// SET
|
|
1304
|
+
const setResult = verifyRekorSet({ anchor, rekorPubKey });
|
|
1305
|
+
if (!setResult.ok) {
|
|
1306
|
+
return {
|
|
1307
|
+
ok: true,
|
|
1308
|
+
trustLevel: TRUST_LEVELS.LOG_ANCHORED_STRUCTURAL,
|
|
1309
|
+
logIndex: anchor.logIndex,
|
|
1310
|
+
integratedTime: anchor.integratedTime,
|
|
1311
|
+
fullFailReason: setResult.reason,
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
return {
|
|
1316
|
+
ok: true,
|
|
1317
|
+
trustLevel: TRUST_LEVELS.LOG_ANCHORED,
|
|
1318
|
+
logIndex: anchor.logIndex,
|
|
1319
|
+
integratedTime: anchor.integratedTime,
|
|
1320
|
+
};
|
|
1321
|
+
}
|