@freedomofpress/sigstore-browser 0.1.14 → 0.1.15

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.
Files changed (60) hide show
  1. package/.github/workflows/conformance.yml +1 -1
  2. package/.github/workflows/publish.yml +1 -1
  3. package/README.md +20 -16
  4. package/dist/bundle.d.ts +2 -0
  5. package/dist/bundle.d.ts.map +1 -1
  6. package/dist/bundle.js +66 -1
  7. package/dist/bundle.js.map +1 -1
  8. package/dist/interfaces.d.ts +17 -2
  9. package/dist/interfaces.d.ts.map +1 -1
  10. package/dist/interfaces.js +14 -0
  11. package/dist/interfaces.js.map +1 -1
  12. package/dist/policy.d.ts +3 -3
  13. package/dist/policy.d.ts.map +1 -1
  14. package/dist/policy.js +4 -4
  15. package/dist/policy.js.map +1 -1
  16. package/dist/sigstore.d.ts +8 -17
  17. package/dist/sigstore.d.ts.map +1 -1
  18. package/dist/sigstore.js +159 -392
  19. package/dist/sigstore.js.map +1 -1
  20. package/dist/timestamp/tsa.d.ts +6 -2
  21. package/dist/timestamp/tsa.d.ts.map +1 -1
  22. package/dist/timestamp/tsa.js +15 -18
  23. package/dist/timestamp/tsa.js.map +1 -1
  24. package/dist/tlog/body.d.ts +3 -1
  25. package/dist/tlog/body.d.ts.map +1 -1
  26. package/dist/tlog/body.js +18 -5
  27. package/dist/tlog/body.js.map +1 -1
  28. package/dist/tlog/checkpoint.d.ts +2 -2
  29. package/dist/tlog/checkpoint.d.ts.map +1 -1
  30. package/dist/tlog/checkpoint.js +17 -95
  31. package/dist/tlog/checkpoint.js.map +1 -1
  32. package/dist/tlog/dsse.d.ts +3 -2
  33. package/dist/tlog/dsse.d.ts.map +1 -1
  34. package/dist/tlog/dsse.js +8 -5
  35. package/dist/tlog/dsse.js.map +1 -1
  36. package/dist/tlog/hashedrekord.d.ts +3 -2
  37. package/dist/tlog/hashedrekord.d.ts.map +1 -1
  38. package/dist/tlog/hashedrekord.js +8 -5
  39. package/dist/tlog/hashedrekord.js.map +1 -1
  40. package/dist/tlog/intoto.d.ts +3 -2
  41. package/dist/tlog/intoto.d.ts.map +1 -1
  42. package/dist/tlog/intoto.js +3 -1
  43. package/dist/tlog/intoto.js.map +1 -1
  44. package/package.json +2 -2
  45. package/src/bundle.ts +87 -0
  46. package/src/ed25519.browser.test.ts +76 -0
  47. package/src/index.browser.test.ts +38 -27
  48. package/src/interfaces.ts +29 -2
  49. package/src/policy.ts +5 -5
  50. package/src/security.browser.test.ts +249 -0
  51. package/src/sigstore.ts +191 -534
  52. package/src/timestamp/tsa.ts +29 -28
  53. package/src/tlog/body.ts +24 -5
  54. package/src/tlog/checkpoint.ts +22 -122
  55. package/src/tlog/dsse.ts +12 -6
  56. package/src/tlog/hashedrekord.ts +14 -8
  57. package/src/tlog/intoto.ts +5 -2
  58. package/src/verify.browser.test.ts +281 -0
  59. package/test/fixtures/rekor2.ts +14 -0
  60. package/test/fixtures/sigstore.ts +13 -0
package/dist/sigstore.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { base64ToUint8Array, ByteStream, canonicalize, importKey, stringToUint8Array, uint8ArrayEqual, Uint8ArrayToHex, Uint8ArrayToString, verifySignature, verifySignatureOverDigest, } from "@freedomofpress/crypto-browser";
2
2
  import { HashAlgorithms } from "./interfaces.js";
3
3
  import { CertificateChainVerifier, EXTENSION_OID_SCT, X509Certificate, X509SCTExtension, } from "./x509/index.js";
4
+ import { assertBundle } from "./bundle.js";
4
5
  import { preAuthEncoding } from "./dsse.js";
5
- import { SigstoreRoots, } from "./interfaces.js";
6
+ import { parseValidityPeriod, SigstoreRoots, } from "./interfaces.js";
6
7
  import { verifyMerkleInclusion } from "./tlog/merkle.js";
7
8
  import { verifyCheckpoint } from "./tlog/checkpoint.js";
8
9
  import { verifyTLogBody } from "./tlog/body.js";
@@ -10,6 +11,15 @@ import { verifyBundleTimestamp } from "./timestamp/tsa.js";
10
11
  import { TrustedRootProvider } from "./trust/tuf.js";
11
12
  import { AnyOf, AllOf, OIDCIssuer, OIDCIssuerV2, Identity } from "./policy.js";
12
13
  const MEDIA_TYPE_BASE = "application/vnd.dev.sigstore.bundle";
14
+ // Upper bounds on in-toto subjects and digests, matching sigstore-go.
15
+ const MAX_SUBJECTS = 1024;
16
+ const MAX_SUBJECT_DIGESTS = 32;
17
+ // Unknown operators can satisfy threshold one, but add no independence to named operators.
18
+ function countOperators(operators) {
19
+ if (operators.size === 0)
20
+ return 0;
21
+ return Math.max(1, operators.size - (operators.has("") ? 1 : 0));
22
+ }
13
23
  /**
14
24
  * Extract bundle version from mediaType string
15
25
  * Reference: https://github.com/sigstore/sigstore-go/blob/main/pkg/bundle/bundle.go#L159-L177
@@ -28,24 +38,12 @@ function getBundleVersion(mediaType) {
28
38
  const version = mediaType
29
39
  .replace(`${MEDIA_TYPE_BASE}.v`, "")
30
40
  .replace("+json", "");
31
- // Basic semver validation (major.minor or major.minor.patch)
32
- if (/^\d+\.\d+(\.\d+)?$/.test(version)) {
41
+ // Only the versions this verifier implements are accepted.
42
+ if (/^0\.[123]$/.test(version)) {
33
43
  return version;
34
44
  }
35
45
  }
36
- // Default to 0.1 for unknown formats
37
- return "0.1";
38
- }
39
- /**
40
- * Rekor v2 entries omit `integratedTime` and MUST carry a signed RFC3161
41
- * timestamp instead. Check for actual content, not just presence: an empty
42
- * `timestampVerificationData: {}` is truthy, so `if (!timestampVerificationData)`
43
- * is bypassable and leaves the cert validity window unanchored in time.
44
- */
45
- export function assertRekorV2Timestamp(timestampData) {
46
- if (!timestampData?.rfc3161Timestamps?.length) {
47
- throw new Error("Rekor v2 bundles require a timestamp for verification.");
48
- }
46
+ throw new Error(`Unsupported bundle media type: ${mediaType}`);
49
47
  }
50
48
  export class SigstoreVerifier {
51
49
  constructor(options = {}) {
@@ -56,48 +54,34 @@ export class SigstoreVerifier {
56
54
  ctlogThreshold: options.ctlogThreshold ?? 1,
57
55
  tsaThreshold: options.tsaThreshold ?? 0,
58
56
  };
59
- }
60
- async loadLog(frozenTimestamp, logs) {
61
- // Load the first Rekor transparency log key that's valid at the frozen timestamp
62
- // and store its log ID for verification. This matches sigstore-go's approach of
63
- // looking up the verifier by log ID (verify/tlog.go:80-83).
64
- for (const log of logs) {
65
- // if start date is not in the future, and if an end doesn't exist or is in the future
66
- if (frozenTimestamp > new Date(log.publicKey.validFor.start) &&
67
- (!log.publicKey.validFor.end ||
68
- new Date(log.publicKey.validFor.end) > frozenTimestamp)) {
69
- return {
70
- publicKey: await importKey(log.publicKey.keyDetails, log.publicKey.keyDetails, log.publicKey.rawBytes),
71
- logId: base64ToUint8Array(log.logId.keyId),
72
- };
57
+ for (const [name, threshold] of Object.entries(this.options)) {
58
+ if (!Number.isSafeInteger(threshold) || threshold < 0) {
59
+ throw new Error(`${name} must be a non-negative safe integer`);
73
60
  }
74
61
  }
75
- // Return undefined instead of throwing - some bundles don't need Rekor keys
76
- // (e.g., v0.3 bundles with inclusion proofs)
77
- return undefined;
78
62
  }
79
- async loadCTLogs(frozenTimestamp, ctlogs) {
80
- const result = [];
81
- for (const log of ctlogs) {
82
- const start = new Date(log.publicKey.validFor.start);
83
- const end = log.publicKey.validFor.end
84
- ? new Date(log.publicKey.validFor.end)
85
- : new Date('9999-12-31'); // No expiry means valid forever
86
- // Include logs that are valid (started before frozen timestamp)
87
- // We keep all logs, even expired ones, for historical verification
88
- if (start <= frozenTimestamp) {
89
- const publicKey = await importKey(log.publicKey.keyDetails, log.publicKey.keyDetails, log.publicKey.rawBytes);
90
- result.push({
91
- logID: base64ToUint8Array(log.logId.keyId),
92
- publicKey,
93
- validFor: { start, end },
94
- });
95
- }
96
- }
97
- if (result.length === 0) {
98
- throw new Error("Could not find any valid CT logs in sigstore root.");
99
- }
100
- return result;
63
+ // Loads every Rekor key; entries select theirs by log ID and SETs are checked against its validity window.
64
+ async loadLog(logs) {
65
+ return Promise.all(logs.map(async (log) => ({
66
+ publicKey: await importKey(log.publicKey.keyDetails, log.publicKey.keyDetails, log.publicKey.rawBytes),
67
+ logId: base64ToUint8Array(log.logId.keyId),
68
+ hashAlgorithm: log.hashAlgorithm,
69
+ // Empty means unknown; countOperators never adds it to the named operator count.
70
+ operator: log.operator || "",
71
+ validFor: parseValidityPeriod(log.publicKey.validFor),
72
+ })));
73
+ }
74
+ // Loads every CT log; SCTs select their log by ID and are checked against its validity window.
75
+ async loadCTLogs(ctlogs) {
76
+ if (ctlogs.length === 0) {
77
+ throw new Error("Could not find any CT logs in sigstore root.");
78
+ }
79
+ return Promise.all(ctlogs.map(async (log) => ({
80
+ logID: base64ToUint8Array(log.logId.keyId),
81
+ publicKey: await importKey(log.publicKey.keyDetails, log.publicKey.keyDetails, log.publicKey.rawBytes),
82
+ operator: log.operator || "",
83
+ validFor: parseValidityPeriod(log.publicKey.validFor),
84
+ })));
101
85
  }
102
86
  // Adapted from https://github.com/sigstore/sigstore-js/blob/main/packages/verify/src/key/certificate.ts#L22-L53
103
87
  // Verifies that the leaf certificate chains to a trusted CA and is valid at the given timestamp.
@@ -126,60 +110,24 @@ export class SigstoreVerifier {
126
110
  }
127
111
  throw new Error(`Failed to verify certificate chain: ${lastError?.message || 'No valid CAs found'}`);
128
112
  }
129
- // Load timestamp authorities that are valid at the frozen timestamp.
130
- // Unlike sigstore-js which doesn't pre-load TSAs (it passes raw TSA data to timestamp verification),
131
- // we parse and filter them at initialization time for consistency with how we handle CAs and other roots.
132
- loadTSA(frozenTimestamp, tsas) {
133
- if (!tsas || tsas.length === 0) {
134
- return [];
135
- }
136
- const result = [];
137
- for (const tsa of tsas) {
138
- const start = new Date(tsa.validFor.start);
139
- const end = tsa.validFor.end ? new Date(tsa.validFor.end) : new Date(8640000000000000);
140
- if (frozenTimestamp > start && frozenTimestamp < end) {
141
- const certChain = tsa.certChain.certificates.map(cert => X509Certificate.parse(base64ToUint8Array(cert.rawBytes)));
142
- if (certChain.length > 0) {
143
- result.push({
144
- certChain,
145
- validFor: { start, end },
146
- });
147
- }
148
- }
149
- }
150
- return result;
151
- }
152
- // Load certificate authorities (Fulcio CAs) that are valid at the frozen timestamp.
153
- // Similar to sigstore-js's filterCertAuthorities() in trust/filter.ts, but we also
154
- // parse the certificates at load time whereas sigstore-js keeps them in the trust material
155
- // and parses them during verification. This pre-loading approach is consistent with our
156
- // architecture of loading all trusted roots at initialization.
157
- loadCA(frozenTimestamp, cas) {
158
- const result = [];
159
- for (const ca of cas) {
160
- const start = new Date(ca.validFor.start);
161
- const end = ca.validFor.end ? new Date(ca.validFor.end) : new Date(8640000000000000);
162
- if (frozenTimestamp > start && frozenTimestamp < end) {
163
- const certChain = ca.certChain.certificates.map(cert => X509Certificate.parse(base64ToUint8Array(cert.rawBytes)));
164
- if (certChain.length > 0) {
165
- result.push({
166
- certChain,
167
- validFor: { start, end },
168
- });
169
- }
170
- }
171
- }
172
- return result;
113
+ // Loads every Fulcio CA; verifyCertificateChain() selects CAs by the observer timestamp.
114
+ loadCA(cas) {
115
+ return cas
116
+ .filter((ca) => ca.certChain.certificates.length > 0)
117
+ .map((ca) => ({
118
+ certChain: ca.certChain.certificates.map((cert) => X509Certificate.parse(base64ToUint8Array(cert.rawBytes))),
119
+ validFor: parseValidityPeriod(ca.validFor),
120
+ }));
173
121
  }
174
122
  async loadSigstoreRoot(rawRoot) {
175
- const frozenTimestamp = new Date();
176
- this.rawRoot = rawRoot;
177
- this.root = {
178
- rekor: await this.loadLog(frozenTimestamp, rawRoot[SigstoreRoots.tlogs]),
179
- ctlogs: await this.loadCTLogs(frozenTimestamp, rawRoot[SigstoreRoots.ctlogs]),
180
- certificateAuthorities: this.loadCA(frozenTimestamp, rawRoot[SigstoreRoots.certificateAuthorities]),
181
- timestampAuthorities: this.loadTSA(frozenTimestamp, rawRoot.timestampAuthorities),
123
+ rawRoot.timestampAuthorities?.forEach((authority) => parseValidityPeriod(authority.validFor));
124
+ const root = {
125
+ rekor: await this.loadLog(rawRoot[SigstoreRoots.tlogs]),
126
+ ctlogs: await this.loadCTLogs(rawRoot[SigstoreRoots.ctlogs]),
127
+ certificateAuthorities: this.loadCA(rawRoot[SigstoreRoots.certificateAuthorities]),
182
128
  };
129
+ this.rawRoot = rawRoot;
130
+ this.root = root;
183
131
  }
184
132
  /**
185
133
  * Load Sigstore trusted root via TUF
@@ -196,7 +144,7 @@ export class SigstoreVerifier {
196
144
  // Key differences:
197
145
  // - Adds duplicate SCT detection (not in reference)
198
146
  // - Inline CT log filtering by logID and validity period (reference uses filterTLogAuthorities)
199
- // - Returns array of verified SCT logIDs for threshold checking (matches reference behavior)
147
+ // - Returns the distinct operators of the logs whose SCTs verified, for threshold checking
200
148
  async verifySCT(cert, issuer, ctlogs) {
201
149
  let extSCT;
202
150
  // Verifying the SCT requires that we remove the SCT extension and
@@ -247,7 +195,7 @@ export class SigstoreVerifier {
247
195
  // Calculate and return the verification results for each SCT
248
196
  // Unlike sigstore-go which counts verified SCTs and checks threshold at the end,
249
197
  // sigstore-js throws immediately if any SCT fails verification
250
- const verifiedSCTs = [];
198
+ const operators = new Set();
251
199
  for (const sct of extSCT.signedCertificateTimestamps) {
252
200
  // Find the CT log that matches this SCT's log ID and is valid for the SCT datetime
253
201
  const validCTLogs = ctlogs.filter((log) => {
@@ -262,190 +210,81 @@ export class SigstoreVerifier {
262
210
  for (const log of validCTLogs) {
263
211
  try {
264
212
  if (await sct.verify(preCert.buffer, log.publicKey)) {
265
- return true;
213
+ return log;
266
214
  }
267
215
  }
268
216
  catch {
269
217
  // Continue trying other logs
270
218
  }
271
219
  }
272
- return false;
220
+ return undefined;
273
221
  })();
274
222
  if (!verified) {
275
223
  throw new Error("SCT verification failed");
276
224
  }
277
- verifiedSCTs.push(sct.logID);
225
+ operators.add(verified.operator);
278
226
  }
279
- return verifiedSCTs;
227
+ return operators;
228
+ }
229
+ // Verifies the signed entry timestamp and returns the integrated time it binds.
230
+ async verifySET(entry, promise, log) {
231
+ const integratedTime = Number(entry.integratedTime);
232
+ const integratedDate = new Date(integratedTime * 1000);
233
+ if (integratedDate < log.validFor.start || integratedDate > log.validFor.end) {
234
+ throw new Error("Rekor key was not valid at the integrated time.");
235
+ }
236
+ const signed = stringToUint8Array(canonicalize({
237
+ body: entry.canonicalizedBody,
238
+ integratedTime,
239
+ logIndex: Number(entry.logIndex),
240
+ logID: Uint8ArrayToHex(log.logId),
241
+ }));
242
+ if (!(await verifySignature(log.publicKey, signed, base64ToUint8Array(promise), log.hashAlgorithm))) {
243
+ throw new Error("Failed to verify the inclusion promise in the provided bundle.");
244
+ }
245
+ return integratedDate;
280
246
  }
281
- async verifyInclusionPromise(cert, bundle, rekor) {
247
+ // Fully verifies every entry from a trusted log and returns the SET-bound integrated times.
248
+ // Entries from unknown logs are ignored; the threshold counts distinct log operators that verified.
249
+ async verifyTlogEntries(cert, bundle, rekor) {
282
250
  const entries = bundle.verificationMaterial.tlogEntries;
283
- // Check that bundle has enough tlog entries to meet threshold
284
- if (entries.length < this.options.tlogThreshold) {
285
- throw new Error(`Not enough tlog entries: ${entries.length} < ${this.options.tlogThreshold}`);
286
- }
287
- // Prevent DoS via excessive entries and threshold bypass via duplicates
288
- // Matches sigstore-go limits (verify/tlog.go:46-57)
289
- const MAX_TLOG_ENTRIES = 32;
290
- if (entries.length > MAX_TLOG_ENTRIES) {
291
- throw new Error(`Too many tlog entries: ${entries.length} > ${MAX_TLOG_ENTRIES}`);
292
- }
293
- for (let i = 0; i < entries.length; i++) {
294
- for (let j = i + 1; j < entries.length; j++) {
295
- const iLogId = Uint8ArrayToHex(base64ToUint8Array(entries[i].logId.keyId));
296
- const jLogId = Uint8ArrayToHex(base64ToUint8Array(entries[j].logId.keyId));
297
- if (iLogId === jLogId && entries[i].logIndex === entries[j].logIndex) {
298
- throw new Error(`Duplicate tlog entry found: logID=${iLogId}, logIndex=${entries[i].logIndex}`);
299
- }
300
- }
301
- }
302
- const entry = entries[0];
303
- // Extract bundle version from mediaType
304
- // Reference: https://github.com/sigstore/sigstore-go/blob/main/pkg/bundle/bundle.go#L159-L177
305
- const bundleVersion = getBundleVersion(bundle.mediaType);
306
- const isV02OrLater = parseFloat(bundleVersion) >= 0.2;
307
- // Bundle v0.2+ requires an inclusion proof
308
- if (isV02OrLater && !entry.inclusionProof) {
309
- throw new Error("Bundle v0.2+ requires an inclusion proof.");
310
- }
311
- // For rekor2/v0.3 bundles with inclusion proofs, the inclusion promise is optional
312
- if (!entry.inclusionPromise?.signedEntryTimestamp) {
313
- // If there's no inclusion promise, there must be an inclusion proof
314
- if (!entry.inclusionProof) {
315
- throw new Error("Bundle must have either an inclusion promise or an inclusion proof.");
251
+ // Only v0.1 bundles may rely on an inclusion promise alone.
252
+ const requireProof = getBundleVersion(bundle.mediaType) !== "0.1";
253
+ const operators = new Set();
254
+ const integratedTimes = [];
255
+ for (const entry of entries) {
256
+ const logId = base64ToUint8Array(entry.logId.keyId);
257
+ const log = rekor.find((l) => uint8ArrayEqual(l.logId, logId));
258
+ if (!log)
259
+ continue;
260
+ const promise = entry.inclusionPromise?.signedEntryTimestamp;
261
+ if (!entry.inclusionProof && (requireProof || !promise)) {
262
+ throw new Error("Transparency log entry requires an inclusion proof.");
316
263
  }
317
- }
318
- else {
319
- // Verify the inclusion promise signature if present
320
- // For v0.3 bundles that have both inclusion promise and proof,
321
- // we can skip the promise verification if we don't have a Rekor key
322
- // and there's a valid inclusion proof
323
- if (!rekor && entry.inclusionProof) {
324
- // Skip promise verification if we have an inclusion proof
325
- // The inclusion proof will be verified later
264
+ if (promise) {
265
+ integratedTimes.push(await this.verifySET(entry, promise, log));
326
266
  }
327
- else {
328
- if (!rekor) {
329
- throw new Error("Rekor public key not found in trusted root");
330
- }
331
- // Verify the log ID matches (matches sigstore-go verify/tlog.go:80-83)
332
- const entryLogId = base64ToUint8Array(entry.logId.keyId);
333
- if (!uint8ArrayEqual(rekor.logId, entryLogId)) {
334
- throw new Error(`Rekor log ID mismatch: bundle uses ${Uint8ArrayToHex(entryLogId)} but loaded key is for ${Uint8ArrayToHex(rekor.logId)}`);
335
- }
336
- const signature = base64ToUint8Array(entry.inclusionPromise.signedEntryTimestamp);
337
- const keyId = Uint8ArrayToHex(entryLogId);
338
- const integratedTime = Number(entry.integratedTime);
339
- const signed = stringToUint8Array(canonicalize({
340
- body: entry.canonicalizedBody,
341
- integratedTime: integratedTime,
342
- logIndex: Number(entry.logIndex),
343
- logID: keyId,
344
- }));
345
- if (!(await verifySignature(rekor.publicKey, signed, signature))) {
346
- throw new Error("Failed to verify the inclusion promise in the provided bundle.");
347
- }
267
+ if (entry.inclusionProof) {
268
+ await verifyMerkleInclusion(entry);
269
+ await verifyCheckpoint(entry, log);
348
270
  }
349
- }
350
- // Validate integrated time and logged certificate
351
- // Note: Rekor v2 bundles don't have integrated time in the tlog entry
352
- if (entry.integratedTime) {
353
- const integratedTime = Number(entry.integratedTime);
354
- const integratedDate = new Date(integratedTime * 1000);
355
- if (!cert.validForDate(integratedDate)) {
271
+ if (entry.integratedTime && !cert.validForDate(new Date(Number(entry.integratedTime) * 1000))) {
356
272
  throw new Error("Artifact signing was logged outside of the certificate validity.");
357
273
  }
274
+ await verifyTLogBody(entry, bundle, cert);
275
+ operators.add(log.operator);
358
276
  }
359
- else {
360
- // Rekor v2 bundles (no integratedTime) require a signed RFC3161 timestamp.
361
- // An empty timestampVerificationData object ({}) must NOT satisfy this.
362
- assertRekorV2Timestamp(bundle.verificationMaterial.timestampVerificationData);
277
+ const verifiedOperators = countOperators(operators);
278
+ if (verifiedOperators < this.options.tlogThreshold) {
279
+ throw new Error(`Not enough verified transparency logs: ${verifiedOperators} < ${this.options.tlogThreshold}`);
363
280
  }
364
- // Verify that the certificate in the log matches the signing certificate
365
- // The format depends on the entry type (hashedrekord vs dsse) and version
366
- const bodyJson = JSON.parse(Uint8ArrayToString(base64ToUint8Array(entry.canonicalizedBody)));
367
- if (bodyJson.kind === "hashedrekord") {
368
- let loggedCertContent;
369
- // Check for hashedRekordV002 structure (Rekor v2)
370
- if (bodyJson.spec.hashedRekordV002) {
371
- const verifier = bodyJson.spec.hashedRekordV002.signature.verifier;
372
- if (verifier?.x509Certificate) {
373
- loggedCertContent = verifier.x509Certificate.rawBytes;
374
- }
375
- }
376
- // Check for older hashedrekord structure
377
- else if (bodyJson.spec.signature?.publicKey) {
378
- loggedCertContent = bodyJson.spec.signature.publicKey.content;
379
- }
380
- if (loggedCertContent) {
381
- // For hashedrekord v0.0.1, publicKey.content is base64-encoded PEM
382
- // For hashedRekordV002, x509Certificate.rawBytes is base64-encoded DER
383
- let loggedCert;
384
- if (bodyJson.spec.hashedRekordV002) {
385
- loggedCert = X509Certificate.parse(base64ToUint8Array(loggedCertContent));
386
- }
387
- else {
388
- const pemString = Uint8ArrayToString(base64ToUint8Array(loggedCertContent));
389
- loggedCert = X509Certificate.parse(pemString);
390
- }
391
- if (!cert.equals(loggedCert)) {
392
- throw new Error("Certificate in Rekor log does not match the signing certificate.");
393
- }
394
- }
395
- }
396
- else if (bodyJson.kind === "dsse") {
397
- // DSSE v0.0.1: certificate is in spec.signatures[0].verifier (base64-encoded PEM)
398
- // https://github.com/sigstore/sigstore-go/blob/main/pkg/tlog/entry.go#L356-L357
399
- const verifierContent = bodyJson.spec.signatures?.[0]?.verifier;
400
- if (verifierContent) {
401
- const pemString = Uint8ArrayToString(base64ToUint8Array(verifierContent));
402
- const loggedCert = X509Certificate.parse(pemString);
403
- if (!cert.equals(loggedCert)) {
404
- throw new Error("Certificate in DSSE tlog entry does not match the signing certificate.");
405
- }
406
- }
407
- }
408
- else if (bodyJson.kind === "intoto") {
409
- // intoto v0.0.2: certificate is in spec.content.envelope.signatures[0].publicKey (base64-encoded PEM)
410
- // https://github.com/sigstore/sigstore-go/blob/main/pkg/tlog/entry.go#L360-L361
411
- const publicKeyContent = bodyJson.spec.content?.envelope?.signatures?.[0]?.publicKey;
412
- if (publicKeyContent) {
413
- const pemString = Uint8ArrayToString(base64ToUint8Array(publicKeyContent));
414
- const loggedCert = X509Certificate.parse(pemString);
415
- if (!cert.equals(loggedCert)) {
416
- throw new Error("Certificate in intoto tlog entry does not match the signing certificate.");
417
- }
418
- }
419
- }
420
- else {
421
- // Unknown entry type - this should not happen with standard Sigstore bundles
422
- throw new Error(`Unsupported tlog entry kind: ${bodyJson.kind}`);
423
- }
424
- return true;
281
+ return integratedTimes;
425
282
  }
426
- async verifyInclusionProof(bundle) {
427
- if (!this.rawRoot) {
428
- throw new Error("Sigstore root is undefined");
429
- }
430
- if (bundle.verificationMaterial.tlogEntries.length < 1) {
431
- throw new Error("No transparency log entries found in bundle");
432
- }
433
- // Verify inclusion proof for ALL entries, not just the first one
434
- // Reference: https://github.com/sigstore/sigstore-go/blob/main/pkg/verify/tlog.go#L74-L127
435
- for (const entry of bundle.verificationMaterial.tlogEntries) {
436
- // Only verify if there's an inclusion proof (v0.3/rekor2 bundles)
437
- // v0.1 bundles use inclusion promises instead, verified in verifyInclusionPromise
438
- if (entry.inclusionProof) {
439
- await verifyMerkleInclusion(entry);
440
- if (entry.inclusionProof.checkpoint) {
441
- await verifyCheckpoint(entry, this.rawRoot.tlogs);
442
- }
443
- }
444
- }
445
- }
446
- async verifyArtifactPolicy(policy, bundle, data, isDigestOnly = false) {
447
- // Quick checks first: does the signing certificate have the correct identity?
448
- if (!this.root) {
283
+ // Shared checks: policy, transparency log, timestamps, chain at every observer time, SCTs.
284
+ // Returns the signing certificate and the bundle's signature.
285
+ async verifyBundle(bundle, policy) {
286
+ assertBundle(bundle);
287
+ if (!this.root || !this.rawRoot) {
449
288
  throw new Error("Sigstore root is undefined");
450
289
  }
451
290
  const cert = bundle.verificationMaterial.certificate ||
@@ -454,67 +293,49 @@ export class SigstoreVerifier {
454
293
  throw new Error("No certificate found in bundle");
455
294
  }
456
295
  const signingCert = X509Certificate.parse(base64ToUint8Array(cert.rawBytes));
457
- // Handle both regular bundles (messageSignature) and DSSE bundles (dsseEnvelope)
458
- let signature;
459
- if (bundle.messageSignature) {
460
- signature = base64ToUint8Array(bundle.messageSignature.signature);
461
- }
462
- else if (bundle.dsseEnvelope) {
463
- if (!bundle.dsseEnvelope.signatures || bundle.dsseEnvelope.signatures.length === 0) {
464
- throw new Error("DSSE envelope has no signatures");
465
- }
466
- signature = base64ToUint8Array(bundle.dsseEnvelope.signatures[0].sig);
467
- }
468
- else {
469
- throw new Error("Bundle does not contain a message signature or DSSE envelope");
296
+ const signature = base64ToUint8Array(bundle.messageSignature ? bundle.messageSignature.signature : bundle.dsseEnvelope.signatures[0].sig);
297
+ await policy.verify(signingCert);
298
+ // Observer timestamps come from SET-bound integrated times and verified RFC 3161 timestamps.
299
+ // Rekor v2 entries carry no integrated time, so they need a TSA timestamp to be anchored at all.
300
+ const integratedTimes = await this.verifyTlogEntries(signingCert, bundle, this.root.rekor);
301
+ const timestamps = await verifyBundleTimestamp(bundle.verificationMaterial.timestampVerificationData, signature, this.rawRoot.timestampAuthorities || []);
302
+ const tsaOperators = countOperators(new Set(timestamps.map((t) => t.operator)));
303
+ if (tsaOperators < this.options.tsaThreshold) {
304
+ throw new Error(`Not enough verified TSA operators: ${tsaOperators} < ${this.options.tsaThreshold}`);
305
+ }
306
+ const observerTimes = [...integratedTimes, ...timestamps.map((t) => t.signingTime)];
307
+ if (observerTimes.length === 0) {
308
+ throw new Error("No verified observer timestamp anchors the signature in time.");
309
+ }
310
+ // The CA window and the whole chain are checked at every observer time, never at the leaf's own notBefore.
311
+ let certPath = [];
312
+ for (const ts of observerTimes) {
313
+ certPath = await this.verifyCertificateChain(ts, signingCert, this.root.certificateAuthorities);
470
314
  }
471
- // # 1 Basic stuff: ceritificate policy validation
472
- policy.verify(signingCert);
473
- // # 2 Certificate validity - verify chain to trusted CA
474
- // Similar to sigstore-js key/index.ts:59-64 which calls verifyCertificateChain()
475
- // Returns the verified certificate path [leaf, intermediate(s), root]
476
- const certPath = await this.verifyCertificateChain(signingCert.notBefore, signingCert, this.root.certificateAuthorities);
477
- // # 3 To verify the SCT we need to build a preCert (because the cert was logged without the SCT)
478
- // https://github.com/sigstore/sigstore-js/packages/verify/src/key/sct.ts#L45
479
- // Similar to sigstore-js key/index.ts:67 which uses path[0] (leaf) and path[1] (issuer)
480
- // for SCT verification. Handle edge case where path has only one cert (self-signed root).
481
315
  const issuerCert = certPath.length > 1 ? certPath[1] : certPath[0];
482
- const verifiedSCTs = await this.verifySCT(signingCert, issuerCert, this.root.ctlogs);
483
- if (verifiedSCTs.length < this.options.ctlogThreshold) {
484
- throw new Error(`Not enough valid SCTs: found ${verifiedSCTs.length}, required ${this.options.ctlogThreshold}`);
485
- }
486
- // # 4 Rekor inclusion promise
487
- if (!(await this.verifyInclusionPromise(signingCert, bundle, this.root.rekor))) {
488
- throw new Error("Inclusion promise validation failed.");
489
- }
490
- // # 5 Rekor inclusion proof (Merkle tree verification)
491
- await this.verifyInclusionProof(bundle);
492
- // # 5.1 Rekor body verification - verify ALL entries (matches sigstore-js verifier.ts:147)
493
- for (const entry of bundle.verificationMaterial.tlogEntries) {
494
- await verifyTLogBody(entry, bundle);
495
- }
496
- // # 6 TSA Timestamp Verification
497
- // Verify all timestamps and enforce threshold
498
- // Reference: https://github.com/sigstore/sigstore-js/blob/main/packages/verify/src/verifier.ts#L101-L106
499
- const verifiedTimestamps = await verifyBundleTimestamp(bundle.verificationMaterial.timestampVerificationData, signature, this.rawRoot?.timestampAuthorities || []);
500
- if (verifiedTimestamps.length < this.options.tsaThreshold) {
501
- throw new Error(`Not enough verified TSA timestamps: ${verifiedTimestamps.length} < ${this.options.tsaThreshold}`);
316
+ const ctOperators = countOperators(await this.verifySCT(signingCert, issuerCert, this.root.ctlogs));
317
+ if (ctOperators < this.options.ctlogThreshold) {
318
+ throw new Error(`Not enough verified CT log operators: ${ctOperators} < ${this.options.ctlogThreshold}`);
502
319
  }
503
- // If we have verified timestamps, check certificate validity at each timestamp time
504
- for (const verifiedTimestamp of verifiedTimestamps) {
505
- if (!signingCert.validForDate(verifiedTimestamp)) {
506
- throw new Error("Certificate was not valid at the time of timestamping");
507
- }
320
+ return { signingCert, signature };
321
+ }
322
+ async verifyArtifactPolicy(policy, bundle, data, isDigestOnly = false) {
323
+ if (isDigestOnly && data.byteLength !== 32) {
324
+ throw new Error("SHA-256 digest must be exactly 32 bytes");
508
325
  }
326
+ const { signingCert, signature } = await this.verifyBundle(bundle, policy);
509
327
  // # 7 Revocation *skipping* not really a thing (unsurprisingly)
510
328
  // # 8 verify the signed data
511
329
  if (bundle.dsseEnvelope) {
512
- // For DSSE bundles, verify the signature over the PAE
330
+ // Only in-toto statements are understood; other payload types must not be read as one.
331
+ if (bundle.dsseEnvelope.payloadType !== "application/vnd.in-toto+json") {
332
+ throw new Error(`Unsupported DSSE payload type: ${bundle.dsseEnvelope.payloadType}`);
333
+ }
513
334
  const payloadBytes = base64ToUint8Array(bundle.dsseEnvelope.payload);
514
335
  const payload = JSON.parse(Uint8ArrayToString(payloadBytes));
515
336
  // Verify the artifact digest matches a subject in the in-toto statement
516
- if (!payload.subject || payload.subject.length === 0) {
517
- throw new Error("DSSE payload has no subject");
337
+ if (!Array.isArray(payload?.subject) || payload.subject.length === 0 || payload.subject.length > MAX_SUBJECTS) {
338
+ throw new Error(`DSSE payload must have between 1 and ${MAX_SUBJECTS} subjects`);
518
339
  }
519
340
  // Compute or extract the artifact digest
520
341
  let artifactDigest;
@@ -526,14 +347,14 @@ export class SigstoreVerifier {
526
347
  // data is the file content, compute the digest
527
348
  artifactDigest = Uint8ArrayToHex(new Uint8Array(await crypto.subtle.digest(HashAlgorithms.SHA256, data)));
528
349
  }
529
- // Find matching subject by scanning all subjects (not just [0])
530
- let matchedSubject = null;
350
+ // Every subject must carry a bounded digest map; any one of them may match the artifact.
351
+ let matchedSubject = false;
531
352
  for (const subject of payload.subject) {
532
- const subjectDigest = subject.digest?.["sha256"];
533
- if (subjectDigest && artifactDigest === subjectDigest.toLowerCase()) {
534
- matchedSubject = subject;
535
- break;
353
+ const digests = subject?.digest;
354
+ if (!digests || typeof digests !== "object" || Array.isArray(digests) || Object.keys(digests).length > MAX_SUBJECT_DIGESTS) {
355
+ throw new Error(`Invalid DSSE subject digest map (maximum ${MAX_SUBJECT_DIGESTS} digests)`);
536
356
  }
357
+ matchedSubject || (matchedSubject = typeof digests.sha256 === "string" && artifactDigest === digests.sha256.toLowerCase());
537
358
  }
538
359
  if (!matchedSubject) {
539
360
  throw new Error(`Artifact digest ${artifactDigest} does not match any subject in DSSE payload`);
@@ -547,6 +368,14 @@ export class SigstoreVerifier {
547
368
  }
548
369
  }
549
370
  else {
371
+ // The bundle's message digest must be the digest of the artifact being verified.
372
+ const { messageDigest } = bundle.messageSignature;
373
+ const digest = isDigestOnly
374
+ ? data
375
+ : new Uint8Array(await crypto.subtle.digest(HashAlgorithms.SHA256, data));
376
+ if (messageDigest.algorithm !== "SHA2_256" || !uint8ArrayEqual(digest, base64ToUint8Array(messageDigest.digest))) {
377
+ throw new Error("Artifact digest does not match the bundle message digest");
378
+ }
550
379
  const publicKey = await signingCert.publicKeyObj;
551
380
  if (isDigestOnly) {
552
381
  // For hashedrekord bundles, verify signature over the digest directly.
@@ -593,70 +422,10 @@ export class SigstoreVerifier {
593
422
  * @returns The payload type and payload bytes from the verified envelope
594
423
  */
595
424
  async verifyDsse(bundle, policy) {
596
- if (!this.root) {
597
- throw new Error("Sigstore root is undefined");
598
- }
425
+ const { signingCert, signature } = await this.verifyBundle(bundle, policy);
599
426
  if (!bundle.dsseEnvelope) {
600
427
  throw new Error("Bundle does not contain a DSSE envelope");
601
428
  }
602
- // Extract certificate from bundle
603
- const cert = bundle.verificationMaterial.certificate ||
604
- bundle.verificationMaterial.x509CertificateChain?.certificates[0];
605
- if (!cert) {
606
- throw new Error("No certificate found in bundle");
607
- }
608
- const signingCert = X509Certificate.parse(base64ToUint8Array(cert.rawBytes));
609
- // (1) Verify certificate chain to trusted CA
610
- const certPath = await this.verifyCertificateChain(signingCert.notBefore, signingCert, this.root.certificateAuthorities);
611
- // (2) Verify SCTs
612
- const issuerCert = certPath.length > 1 ? certPath[1] : certPath[0];
613
- const verifiedSCTs = await this.verifySCT(signingCert, issuerCert, this.root.ctlogs);
614
- if (verifiedSCTs.length < this.options.ctlogThreshold) {
615
- throw new Error(`Not enough valid SCTs: found ${verifiedSCTs.length}, required ${this.options.ctlogThreshold}`);
616
- }
617
- // (3) Verify the signing certificate against the policy
618
- policy.verify(signingCert);
619
- // (4) Verify Rekor inclusion promise
620
- if (!(await this.verifyInclusionPromise(signingCert, bundle, this.root.rekor))) {
621
- throw new Error("Inclusion promise validation failed");
622
- }
623
- // (5) Verify Rekor inclusion proof (Merkle tree verification)
624
- await this.verifyInclusionProof(bundle);
625
- // Validate envelope has exactly one signature (matches sigstore-python dsse._verify)
626
- if (!bundle.dsseEnvelope.signatures || bundle.dsseEnvelope.signatures.length !== 1) {
627
- throw new Error(`DSSE envelope must have exactly 1 signature, got ${bundle.dsseEnvelope.signatures?.length ?? 0}`);
628
- }
629
- // (6) TSA Timestamp verification
630
- const signature = base64ToUint8Array(bundle.dsseEnvelope.signatures[0].sig);
631
- const verifiedTimestamps = await verifyBundleTimestamp(bundle.verificationMaterial.timestampVerificationData, signature, this.rawRoot?.timestampAuthorities || []);
632
- if (verifiedTimestamps.length < this.options.tsaThreshold) {
633
- throw new Error(`Not enough verified TSA timestamps: ${verifiedTimestamps.length} < ${this.options.tsaThreshold}`);
634
- }
635
- for (const verifiedTimestamp of verifiedTimestamps) {
636
- if (!signingCert.validForDate(verifiedTimestamp)) {
637
- throw new Error("Certificate was not valid at the time of timestamping");
638
- }
639
- }
640
- // Every tlog entry must be anchored in time by at least one observer
641
- // timestamp, matching the observer-timestamp enforcement in the upstream
642
- // sigstore-js and sigstore-go clients. A v1 entry carries integratedTime
643
- // (the signing certificate must be valid at that instant); a v2 entry omits
644
- // it and must instead carry a signed RFC3161 timestamp. Without either, the
645
- // certificate's validity window is never anchored — a crafted bundle that
646
- // strips integratedTime + the inclusion promise and supplies an empty
647
- // timestampVerificationData ({}) would otherwise pass. This mirrors the
648
- // guard in verify(), which callers verifying via verifyDsse() would miss.
649
- for (const entry of bundle.verificationMaterial.tlogEntries) {
650
- if (entry.integratedTime) {
651
- const integratedDate = new Date(Number(entry.integratedTime) * 1000);
652
- if (!signingCert.validForDate(integratedDate)) {
653
- throw new Error("Artifact signing was logged outside of the certificate validity.");
654
- }
655
- }
656
- else {
657
- assertRekorV2Timestamp(bundle.verificationMaterial.timestampVerificationData);
658
- }
659
- }
660
429
  // (7) Verify the DSSE envelope signature
661
430
  const payloadBytes = base64ToUint8Array(bundle.dsseEnvelope.payload);
662
431
  const pae = preAuthEncoding(bundle.dsseEnvelope.payloadType, payloadBytes);
@@ -665,13 +434,11 @@ export class SigstoreVerifier {
665
434
  if (!verified) {
666
435
  throw new Error("DSSE signature verification failed");
667
436
  }
668
- // (8) Verify the consistency of the log entry's body against the bundle materials
669
- // The entry MUST be of type "dsse" for DSSE verification
437
+ // (8) Every entry MUST be of type "dsse" for DSSE verification
670
438
  for (const entry of bundle.verificationMaterial.tlogEntries) {
671
439
  if (entry.kindVersion.kind !== "dsse") {
672
440
  throw new Error(`Expected entry type dsse, got ${entry.kindVersion.kind}`);
673
441
  }
674
- await verifyTLogBody(entry, bundle);
675
442
  }
676
443
  // Return the verified payload
677
444
  return {