@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/src/sigstore.ts CHANGED
@@ -17,14 +17,14 @@ import {
17
17
  X509Certificate,
18
18
  X509SCTExtension,
19
19
  } from "./x509/index.js";
20
- import { SigstoreBundle } from "./bundle.js";
20
+ import { assertBundle, SigstoreBundle, TLogEntry } from "./bundle.js";
21
21
  import { preAuthEncoding } from "./dsse.js";
22
22
  import {
23
23
  CertAuthority,
24
24
  CTLog,
25
+ parseValidityPeriod,
25
26
  RawCAs,
26
27
  RawLogs,
27
- RawTimestampAuthorities,
28
28
  RekorKeyInfo,
29
29
  Sigstore,
30
30
  SigstoreRoots,
@@ -39,6 +39,15 @@ import type { VerificationPolicy } from "./policy.js";
39
39
  import { AnyOf, AllOf, OIDCIssuer, OIDCIssuerV2, Identity } from "./policy.js";
40
40
 
41
41
  const MEDIA_TYPE_BASE = "application/vnd.dev.sigstore.bundle";
42
+ // Upper bounds on in-toto subjects and digests, matching sigstore-go.
43
+ const MAX_SUBJECTS = 1024;
44
+ const MAX_SUBJECT_DIGESTS = 32;
45
+
46
+ // Unknown operators can satisfy threshold one, but add no independence to named operators.
47
+ function countOperators(operators: Set<string>): number {
48
+ if (operators.size === 0) return 0;
49
+ return Math.max(1, operators.size - (operators.has("") ? 1 : 0));
50
+ }
42
51
 
43
52
  /**
44
53
  * Extract bundle version from mediaType string
@@ -59,28 +68,13 @@ function getBundleVersion(mediaType: string): string {
59
68
  const version = mediaType
60
69
  .replace(`${MEDIA_TYPE_BASE}.v`, "")
61
70
  .replace("+json", "");
62
- // Basic semver validation (major.minor or major.minor.patch)
63
- if (/^\d+\.\d+(\.\d+)?$/.test(version)) {
71
+ // Only the versions this verifier implements are accepted.
72
+ if (/^0\.[123]$/.test(version)) {
64
73
  return version;
65
74
  }
66
75
  }
67
76
 
68
- // Default to 0.1 for unknown formats
69
- return "0.1";
70
- }
71
-
72
- /**
73
- * Rekor v2 entries omit `integratedTime` and MUST carry a signed RFC3161
74
- * timestamp instead. Check for actual content, not just presence: an empty
75
- * `timestampVerificationData: {}` is truthy, so `if (!timestampVerificationData)`
76
- * is bypassable and leaves the cert validity window unanchored in time.
77
- */
78
- export function assertRekorV2Timestamp(
79
- timestampData?: { rfc3161Timestamps?: readonly unknown[] },
80
- ): void {
81
- if (!timestampData?.rfc3161Timestamps?.length) {
82
- throw new Error("Rekor v2 bundles require a timestamp for verification.");
83
- }
77
+ throw new Error(`Unsupported bundle media type: ${mediaType}`);
84
78
  }
85
79
 
86
80
  export interface SigstoreVerifierOptions {
@@ -102,67 +96,48 @@ export class SigstoreVerifier {
102
96
  ctlogThreshold: options.ctlogThreshold ?? 1,
103
97
  tsaThreshold: options.tsaThreshold ?? 0,
104
98
  };
105
- }
106
-
107
- async loadLog(frozenTimestamp: Date, logs: RawLogs): Promise<RekorKeyInfo | undefined> {
108
- // Load the first Rekor transparency log key that's valid at the frozen timestamp
109
- // and store its log ID for verification. This matches sigstore-go's approach of
110
- // looking up the verifier by log ID (verify/tlog.go:80-83).
111
-
112
- for (const log of logs) {
113
- // if start date is not in the future, and if an end doesn't exist or is in the future
114
- if (
115
- frozenTimestamp > new Date(log.publicKey.validFor.start) &&
116
- (!log.publicKey.validFor.end ||
117
- new Date(log.publicKey.validFor.end) > frozenTimestamp)
118
- ) {
119
- return {
120
- publicKey: await importKey(
121
- log.publicKey.keyDetails,
122
- log.publicKey.keyDetails,
123
- log.publicKey.rawBytes,
124
- ),
125
- logId: base64ToUint8Array(log.logId.keyId),
126
- };
99
+ for (const [name, threshold] of Object.entries(this.options)) {
100
+ if (!Number.isSafeInteger(threshold) || threshold < 0) {
101
+ throw new Error(`${name} must be a non-negative safe integer`);
127
102
  }
128
103
  }
129
-
130
- // Return undefined instead of throwing - some bundles don't need Rekor keys
131
- // (e.g., v0.3 bundles with inclusion proofs)
132
- return undefined;
133
104
  }
134
105
 
135
- async loadCTLogs(frozenTimestamp: Date, ctlogs: RawLogs): Promise<CTLog[]> {
136
- const result: CTLog[] = [];
137
-
138
- for (const log of ctlogs) {
139
- const start = new Date(log.publicKey.validFor.start);
140
- const end = log.publicKey.validFor.end
141
- ? new Date(log.publicKey.validFor.end)
142
- : new Date('9999-12-31'); // No expiry means valid forever
143
-
144
- // Include logs that are valid (started before frozen timestamp)
145
- // We keep all logs, even expired ones, for historical verification
146
- if (start <= frozenTimestamp) {
147
- const publicKey = await importKey(
106
+ // Loads every Rekor key; entries select theirs by log ID and SETs are checked against its validity window.
107
+ async loadLog(logs: RawLogs): Promise<RekorKeyInfo[]> {
108
+ return Promise.all(
109
+ logs.map(async (log) => ({
110
+ publicKey: await importKey(
148
111
  log.publicKey.keyDetails,
149
112
  log.publicKey.keyDetails,
150
113
  log.publicKey.rawBytes,
151
- );
152
-
153
- result.push({
154
- logID: base64ToUint8Array(log.logId.keyId),
155
- publicKey,
156
- validFor: { start, end },
157
- });
158
- }
159
- }
114
+ ),
115
+ logId: base64ToUint8Array(log.logId.keyId),
116
+ hashAlgorithm: log.hashAlgorithm,
117
+ // Empty means unknown; countOperators never adds it to the named operator count.
118
+ operator: log.operator || "",
119
+ validFor: parseValidityPeriod(log.publicKey.validFor),
120
+ })),
121
+ );
122
+ }
160
123
 
161
- if (result.length === 0) {
162
- throw new Error("Could not find any valid CT logs in sigstore root.");
124
+ // Loads every CT log; SCTs select their log by ID and are checked against its validity window.
125
+ async loadCTLogs(ctlogs: RawLogs): Promise<CTLog[]> {
126
+ if (ctlogs.length === 0) {
127
+ throw new Error("Could not find any CT logs in sigstore root.");
163
128
  }
164
-
165
- return result;
129
+ return Promise.all(
130
+ ctlogs.map(async (log) => ({
131
+ logID: base64ToUint8Array(log.logId.keyId),
132
+ publicKey: await importKey(
133
+ log.publicKey.keyDetails,
134
+ log.publicKey.keyDetails,
135
+ log.publicKey.rawBytes,
136
+ ),
137
+ operator: log.operator || "",
138
+ validFor: parseValidityPeriod(log.publicKey.validFor),
139
+ })),
140
+ );
166
141
  }
167
142
 
168
143
  // Adapted from https://github.com/sigstore/sigstore-js/blob/main/packages/verify/src/key/certificate.ts#L22-L53
@@ -199,82 +174,29 @@ export class SigstoreVerifier {
199
174
  throw new Error(`Failed to verify certificate chain: ${lastError?.message || 'No valid CAs found'}`);
200
175
  }
201
176
 
202
- // Load timestamp authorities that are valid at the frozen timestamp.
203
- // Unlike sigstore-js which doesn't pre-load TSAs (it passes raw TSA data to timestamp verification),
204
- // we parse and filter them at initialization time for consistency with how we handle CAs and other roots.
205
- loadTSA(
206
- frozenTimestamp: Date,
207
- tsas?: RawTimestampAuthorities,
208
- ): CertAuthority[] {
209
- if (!tsas || tsas.length === 0) {
210
- return [];
211
- }
212
-
213
- const result: CertAuthority[] = [];
214
-
215
- for (const tsa of tsas) {
216
- const start = new Date(tsa.validFor.start);
217
- const end = tsa.validFor.end ? new Date(tsa.validFor.end) : new Date(8640000000000000);
218
-
219
- if (frozenTimestamp > start && frozenTimestamp < end) {
220
- const certChain = tsa.certChain.certificates.map(cert =>
221
- X509Certificate.parse(base64ToUint8Array(cert.rawBytes))
222
- );
223
-
224
- if (certChain.length > 0) {
225
- result.push({
226
- certChain,
227
- validFor: { start, end },
228
- });
229
- }
230
- }
231
- }
232
-
233
- return result;
234
- }
235
-
236
- // Load certificate authorities (Fulcio CAs) that are valid at the frozen timestamp.
237
- // Similar to sigstore-js's filterCertAuthorities() in trust/filter.ts, but we also
238
- // parse the certificates at load time whereas sigstore-js keeps them in the trust material
239
- // and parses them during verification. This pre-loading approach is consistent with our
240
- // architecture of loading all trusted roots at initialization.
241
- loadCA(frozenTimestamp: Date, cas: RawCAs): CertAuthority[] {
242
- const result: CertAuthority[] = [];
243
-
244
- for (const ca of cas) {
245
- const start = new Date(ca.validFor.start);
246
- const end = ca.validFor.end ? new Date(ca.validFor.end) : new Date(8640000000000000);
247
-
248
- if (frozenTimestamp > start && frozenTimestamp < end) {
249
- const certChain = ca.certChain.certificates.map(cert =>
250
- X509Certificate.parse(base64ToUint8Array(cert.rawBytes))
251
- );
252
-
253
- if (certChain.length > 0) {
254
- result.push({
255
- certChain,
256
- validFor: { start, end },
257
- });
258
- }
259
- }
260
- }
261
-
262
- return result;
177
+ // Loads every Fulcio CA; verifyCertificateChain() selects CAs by the observer timestamp.
178
+ loadCA(cas: RawCAs): CertAuthority[] {
179
+ return cas
180
+ .filter((ca) => ca.certChain.certificates.length > 0)
181
+ .map((ca) => ({
182
+ certChain: ca.certChain.certificates.map((cert) =>
183
+ X509Certificate.parse(base64ToUint8Array(cert.rawBytes)),
184
+ ),
185
+ validFor: parseValidityPeriod(ca.validFor),
186
+ }));
263
187
  }
264
188
 
265
189
  async loadSigstoreRoot(rawRoot: TrustedRoot) {
266
- const frozenTimestamp = new Date();
267
-
268
- this.rawRoot = rawRoot;
269
- this.root = {
270
- rekor: await this.loadLog(frozenTimestamp, rawRoot[SigstoreRoots.tlogs]),
271
- ctlogs: await this.loadCTLogs(frozenTimestamp, rawRoot[SigstoreRoots.ctlogs]),
272
- certificateAuthorities: this.loadCA(
273
- frozenTimestamp,
274
- rawRoot[SigstoreRoots.certificateAuthorities],
275
- ),
276
- timestampAuthorities: this.loadTSA(frozenTimestamp, rawRoot.timestampAuthorities),
190
+ rawRoot.timestampAuthorities?.forEach((authority) =>
191
+ parseValidityPeriod(authority.validFor),
192
+ );
193
+ const root = {
194
+ rekor: await this.loadLog(rawRoot[SigstoreRoots.tlogs]),
195
+ ctlogs: await this.loadCTLogs(rawRoot[SigstoreRoots.ctlogs]),
196
+ certificateAuthorities: this.loadCA(rawRoot[SigstoreRoots.certificateAuthorities]),
277
197
  };
198
+ this.rawRoot = rawRoot;
199
+ this.root = root;
278
200
  }
279
201
 
280
202
  /**
@@ -293,12 +215,12 @@ export class SigstoreVerifier {
293
215
  // Key differences:
294
216
  // - Adds duplicate SCT detection (not in reference)
295
217
  // - Inline CT log filtering by logID and validity period (reference uses filterTLogAuthorities)
296
- // - Returns array of verified SCT logIDs for threshold checking (matches reference behavior)
218
+ // - Returns the distinct operators of the logs whose SCTs verified, for threshold checking
297
219
  async verifySCT(
298
220
  cert: X509Certificate,
299
221
  issuer: X509Certificate,
300
222
  ctlogs: CTLog[],
301
- ): Promise<Uint8Array[]> {
223
+ ): Promise<Set<string>> {
302
224
  let extSCT: X509SCTExtension | undefined;
303
225
 
304
226
  // Verifying the SCT requires that we remove the SCT extension and
@@ -361,7 +283,7 @@ export class SigstoreVerifier {
361
283
  // Calculate and return the verification results for each SCT
362
284
  // Unlike sigstore-go which counts verified SCTs and checks threshold at the end,
363
285
  // sigstore-js throws immediately if any SCT fails verification
364
- const verifiedSCTs: Uint8Array[] = [];
286
+ const operators = new Set<string>();
365
287
 
366
288
  for (const sct of extSCT.signedCertificateTimestamps) {
367
289
  // Find the CT log that matches this SCT's log ID and is valid for the SCT datetime
@@ -377,349 +299,169 @@ export class SigstoreVerifier {
377
299
  for (const log of validCTLogs) {
378
300
  try {
379
301
  if (await sct.verify(preCert.buffer, log.publicKey)) {
380
- return true;
302
+ return log;
381
303
  }
382
304
  } catch {
383
305
  // Continue trying other logs
384
306
  }
385
307
  }
386
- return false;
308
+ return undefined;
387
309
  })();
388
310
 
389
311
  if (!verified) {
390
312
  throw new Error("SCT verification failed");
391
313
  }
392
314
 
393
- verifiedSCTs.push(sct.logID);
315
+ operators.add(verified.operator);
394
316
  }
395
317
 
396
- return verifiedSCTs;
318
+ return operators;
319
+ }
320
+
321
+ // Verifies the signed entry timestamp and returns the integrated time it binds.
322
+ private async verifySET(entry: TLogEntry, promise: string, log: RekorKeyInfo): Promise<Date> {
323
+ const integratedTime = Number(entry.integratedTime);
324
+ const integratedDate = new Date(integratedTime * 1000);
325
+ if (integratedDate < log.validFor.start || integratedDate > log.validFor.end) {
326
+ throw new Error("Rekor key was not valid at the integrated time.");
327
+ }
328
+ const signed = stringToUint8Array(
329
+ canonicalize({
330
+ body: entry.canonicalizedBody,
331
+ integratedTime,
332
+ logIndex: Number(entry.logIndex),
333
+ logID: Uint8ArrayToHex(log.logId),
334
+ }),
335
+ );
336
+ if (!(await verifySignature(log.publicKey, signed, base64ToUint8Array(promise), log.hashAlgorithm))) {
337
+ throw new Error("Failed to verify the inclusion promise in the provided bundle.");
338
+ }
339
+ return integratedDate;
397
340
  }
398
341
 
399
- async verifyInclusionPromise(
342
+ // Fully verifies every entry from a trusted log and returns the SET-bound integrated times.
343
+ // Entries from unknown logs are ignored; the threshold counts distinct log operators that verified.
344
+ private async verifyTlogEntries(
400
345
  cert: X509Certificate,
401
346
  bundle: SigstoreBundle,
402
- rekor: RekorKeyInfo | undefined,
403
- ): Promise<boolean> {
347
+ rekor: RekorKeyInfo[],
348
+ ): Promise<Date[]> {
404
349
  const entries = bundle.verificationMaterial.tlogEntries;
405
-
406
- // Check that bundle has enough tlog entries to meet threshold
407
- if (entries.length < this.options.tlogThreshold) {
408
- throw new Error(
409
- `Not enough tlog entries: ${entries.length} < ${this.options.tlogThreshold}`,
410
- );
411
- }
412
-
413
- // Prevent DoS via excessive entries and threshold bypass via duplicates
414
- // Matches sigstore-go limits (verify/tlog.go:46-57)
415
- const MAX_TLOG_ENTRIES = 32;
416
- if (entries.length > MAX_TLOG_ENTRIES) {
417
- throw new Error(
418
- `Too many tlog entries: ${entries.length} > ${MAX_TLOG_ENTRIES}`,
419
- );
420
- }
421
-
422
- for (let i = 0; i < entries.length; i++) {
423
- for (let j = i + 1; j < entries.length; j++) {
424
- const iLogId = Uint8ArrayToHex(base64ToUint8Array(entries[i].logId.keyId));
425
- const jLogId = Uint8ArrayToHex(base64ToUint8Array(entries[j].logId.keyId));
426
- if (iLogId === jLogId && entries[i].logIndex === entries[j].logIndex) {
427
- throw new Error(
428
- `Duplicate tlog entry found: logID=${iLogId}, logIndex=${entries[i].logIndex}`,
429
- );
430
- }
431
- }
432
- }
433
-
434
- const entry = entries[0];
435
-
436
- // Extract bundle version from mediaType
437
- // Reference: https://github.com/sigstore/sigstore-go/blob/main/pkg/bundle/bundle.go#L159-L177
438
- const bundleVersion = getBundleVersion(bundle.mediaType);
439
- const isV02OrLater = parseFloat(bundleVersion) >= 0.2;
440
-
441
- // Bundle v0.2+ requires an inclusion proof
442
- if (isV02OrLater && !entry.inclusionProof) {
443
- throw new Error(
444
- "Bundle v0.2+ requires an inclusion proof.",
445
- );
446
- }
447
-
448
- // For rekor2/v0.3 bundles with inclusion proofs, the inclusion promise is optional
449
- if (!entry.inclusionPromise?.signedEntryTimestamp) {
450
- // If there's no inclusion promise, there must be an inclusion proof
451
- if (!entry.inclusionProof) {
452
- throw new Error(
453
- "Bundle must have either an inclusion promise or an inclusion proof.",
454
- );
455
- }
456
- } else {
457
- // Verify the inclusion promise signature if present
458
- // For v0.3 bundles that have both inclusion promise and proof,
459
- // we can skip the promise verification if we don't have a Rekor key
460
- // and there's a valid inclusion proof
461
- if (!rekor && entry.inclusionProof) {
462
- // Skip promise verification if we have an inclusion proof
463
- // The inclusion proof will be verified later
464
- } else {
465
- if (!rekor) {
466
- throw new Error("Rekor public key not found in trusted root");
467
- }
468
-
469
- // Verify the log ID matches (matches sigstore-go verify/tlog.go:80-83)
470
- const entryLogId = base64ToUint8Array(entry.logId.keyId);
471
- if (!uint8ArrayEqual(rekor.logId, entryLogId)) {
472
- throw new Error(
473
- `Rekor log ID mismatch: bundle uses ${Uint8ArrayToHex(entryLogId)} but loaded key is for ${Uint8ArrayToHex(rekor.logId)}`
474
- );
475
- }
476
-
477
- const signature = base64ToUint8Array(
478
- entry.inclusionPromise.signedEntryTimestamp,
479
- );
480
-
481
- const keyId = Uint8ArrayToHex(entryLogId);
482
- const integratedTime = Number(entry.integratedTime);
483
-
484
- const signed = stringToUint8Array(
485
- canonicalize({
486
- body: entry.canonicalizedBody,
487
- integratedTime: integratedTime,
488
- logIndex: Number(entry.logIndex),
489
- logID: keyId,
490
- }),
491
- );
492
-
493
- if (!(await verifySignature(rekor.publicKey, signed, signature))) {
494
- throw new Error(
495
- "Failed to verify the inclusion promise in the provided bundle.",
496
- );
497
- }
498
- }
499
- }
500
-
501
- // Validate integrated time and logged certificate
502
- // Note: Rekor v2 bundles don't have integrated time in the tlog entry
503
- if (entry.integratedTime) {
504
- const integratedTime = Number(entry.integratedTime);
505
- const integratedDate = new Date(integratedTime * 1000);
506
-
507
- if (!cert.validForDate(integratedDate)) {
508
- throw new Error(
509
- "Artifact signing was logged outside of the certificate validity.",
510
- );
511
- }
512
- } else {
513
- // Rekor v2 bundles (no integratedTime) require a signed RFC3161 timestamp.
514
- // An empty timestampVerificationData object ({}) must NOT satisfy this.
515
- assertRekorV2Timestamp(
516
- bundle.verificationMaterial.timestampVerificationData,
517
- );
518
- }
519
-
520
- // Verify that the certificate in the log matches the signing certificate
521
- // The format depends on the entry type (hashedrekord vs dsse) and version
522
- const bodyJson = JSON.parse(Uint8ArrayToString(base64ToUint8Array(entry.canonicalizedBody)));
523
-
524
- if (bodyJson.kind === "hashedrekord") {
525
- let loggedCertContent: string | undefined;
526
-
527
- // Check for hashedRekordV002 structure (Rekor v2)
528
- if (bodyJson.spec.hashedRekordV002) {
529
- const verifier = bodyJson.spec.hashedRekordV002.signature.verifier;
530
- if (verifier?.x509Certificate) {
531
- loggedCertContent = verifier.x509Certificate.rawBytes;
532
- }
350
+ // Only v0.1 bundles may rely on an inclusion promise alone.
351
+ const requireProof = getBundleVersion(bundle.mediaType) !== "0.1";
352
+ const operators = new Set<string>();
353
+ const integratedTimes: Date[] = [];
354
+
355
+ for (const entry of entries) {
356
+ const logId = base64ToUint8Array(entry.logId.keyId);
357
+ const log = rekor.find((l) => uint8ArrayEqual(l.logId, logId));
358
+ if (!log) continue;
359
+
360
+ const promise = entry.inclusionPromise?.signedEntryTimestamp;
361
+ if (!entry.inclusionProof && (requireProof || !promise)) {
362
+ throw new Error("Transparency log entry requires an inclusion proof.");
533
363
  }
534
- // Check for older hashedrekord structure
535
- else if (bodyJson.spec.signature?.publicKey) {
536
- loggedCertContent = bodyJson.spec.signature.publicKey.content;
364
+ if (promise) {
365
+ integratedTimes.push(await this.verifySET(entry, promise, log));
537
366
  }
538
-
539
- if (loggedCertContent) {
540
- // For hashedrekord v0.0.1, publicKey.content is base64-encoded PEM
541
- // For hashedRekordV002, x509Certificate.rawBytes is base64-encoded DER
542
- let loggedCert: X509Certificate;
543
- if (bodyJson.spec.hashedRekordV002) {
544
- loggedCert = X509Certificate.parse(base64ToUint8Array(loggedCertContent));
545
- } else {
546
- const pemString = Uint8ArrayToString(base64ToUint8Array(loggedCertContent));
547
- loggedCert = X509Certificate.parse(pemString);
548
- }
549
-
550
- if (!cert.equals(loggedCert)) {
551
- throw new Error(
552
- "Certificate in Rekor log does not match the signing certificate.",
553
- );
554
- }
555
- }
556
- } else if (bodyJson.kind === "dsse") {
557
- // DSSE v0.0.1: certificate is in spec.signatures[0].verifier (base64-encoded PEM)
558
- // https://github.com/sigstore/sigstore-go/blob/main/pkg/tlog/entry.go#L356-L357
559
- const verifierContent = bodyJson.spec.signatures?.[0]?.verifier;
560
- if (verifierContent) {
561
- const pemString = Uint8ArrayToString(base64ToUint8Array(verifierContent));
562
- const loggedCert = X509Certificate.parse(pemString);
563
- if (!cert.equals(loggedCert)) {
564
- throw new Error(
565
- "Certificate in DSSE tlog entry does not match the signing certificate.",
566
- );
567
- }
367
+ if (entry.inclusionProof) {
368
+ await verifyMerkleInclusion(entry);
369
+ await verifyCheckpoint(entry, log);
568
370
  }
569
- } else if (bodyJson.kind === "intoto") {
570
- // intoto v0.0.2: certificate is in spec.content.envelope.signatures[0].publicKey (base64-encoded PEM)
571
- // https://github.com/sigstore/sigstore-go/blob/main/pkg/tlog/entry.go#L360-L361
572
- const publicKeyContent = bodyJson.spec.content?.envelope?.signatures?.[0]?.publicKey;
573
- if (publicKeyContent) {
574
- const pemString = Uint8ArrayToString(base64ToUint8Array(publicKeyContent));
575
- const loggedCert = X509Certificate.parse(pemString);
576
- if (!cert.equals(loggedCert)) {
577
- throw new Error(
578
- "Certificate in intoto tlog entry does not match the signing certificate.",
579
- );
580
- }
371
+ if (entry.integratedTime && !cert.validForDate(new Date(Number(entry.integratedTime) * 1000))) {
372
+ throw new Error("Artifact signing was logged outside of the certificate validity.");
581
373
  }
582
- } else {
583
- // Unknown entry type - this should not happen with standard Sigstore bundles
584
- throw new Error(`Unsupported tlog entry kind: ${bodyJson.kind}`);
585
- }
586
-
587
- return true;
588
- }
589
-
590
- async verifyInclusionProof(bundle: SigstoreBundle): Promise<void> {
591
- if (!this.rawRoot) {
592
- throw new Error("Sigstore root is undefined");
593
- }
594
-
595
- if (bundle.verificationMaterial.tlogEntries.length < 1) {
596
- throw new Error("No transparency log entries found in bundle");
374
+ await verifyTLogBody(entry, bundle, cert);
375
+ operators.add(log.operator);
597
376
  }
598
377
 
599
- // Verify inclusion proof for ALL entries, not just the first one
600
- // Reference: https://github.com/sigstore/sigstore-go/blob/main/pkg/verify/tlog.go#L74-L127
601
- for (const entry of bundle.verificationMaterial.tlogEntries) {
602
- // Only verify if there's an inclusion proof (v0.3/rekor2 bundles)
603
- // v0.1 bundles use inclusion promises instead, verified in verifyInclusionPromise
604
- if (entry.inclusionProof) {
605
- await verifyMerkleInclusion(entry);
606
-
607
- if (entry.inclusionProof.checkpoint) {
608
- await verifyCheckpoint(entry, this.rawRoot.tlogs);
609
- }
610
- }
378
+ const verifiedOperators = countOperators(operators);
379
+ if (verifiedOperators < this.options.tlogThreshold) {
380
+ throw new Error(`Not enough verified transparency logs: ${verifiedOperators} < ${this.options.tlogThreshold}`);
611
381
  }
382
+ return integratedTimes;
612
383
  }
613
384
 
614
- public async verifyArtifactPolicy(
615
- policy: VerificationPolicy,
385
+ // Shared checks: policy, transparency log, timestamps, chain at every observer time, SCTs.
386
+ // Returns the signing certificate and the bundle's signature.
387
+ private async verifyBundle(
616
388
  bundle: SigstoreBundle,
617
- data: Uint8Array,
618
- isDigestOnly: boolean = false,
619
- ): Promise<boolean> {
620
- // Quick checks first: does the signing certificate have the correct identity?
621
-
622
- if (!this.root) {
389
+ policy: VerificationPolicy,
390
+ ): Promise<{ signingCert: X509Certificate; signature: Uint8Array }> {
391
+ assertBundle(bundle);
392
+ if (!this.root || !this.rawRoot) {
623
393
  throw new Error("Sigstore root is undefined");
624
394
  }
625
395
 
626
396
  const cert = bundle.verificationMaterial.certificate ||
627
397
  bundle.verificationMaterial.x509CertificateChain?.certificates[0];
628
-
629
398
  if (!cert) {
630
399
  throw new Error("No certificate found in bundle");
631
400
  }
632
-
633
401
  const signingCert = X509Certificate.parse(base64ToUint8Array(cert.rawBytes));
634
-
635
- // Handle both regular bundles (messageSignature) and DSSE bundles (dsseEnvelope)
636
- let signature: Uint8Array;
637
- if (bundle.messageSignature) {
638
- signature = base64ToUint8Array(bundle.messageSignature.signature);
639
- } else if (bundle.dsseEnvelope) {
640
- if (!bundle.dsseEnvelope.signatures || bundle.dsseEnvelope.signatures.length === 0) {
641
- throw new Error("DSSE envelope has no signatures");
642
- }
643
- signature = base64ToUint8Array(bundle.dsseEnvelope.signatures[0].sig);
644
- } else {
645
- throw new Error("Bundle does not contain a message signature or DSSE envelope");
646
- }
647
- // # 1 Basic stuff: ceritificate policy validation
648
- policy.verify(signingCert);
649
-
650
- // # 2 Certificate validity - verify chain to trusted CA
651
- // Similar to sigstore-js key/index.ts:59-64 which calls verifyCertificateChain()
652
- // Returns the verified certificate path [leaf, intermediate(s), root]
653
- const certPath = await this.verifyCertificateChain(
654
- signingCert.notBefore,
655
- signingCert,
656
- this.root.certificateAuthorities
402
+ const signature = base64ToUint8Array(
403
+ bundle.messageSignature ? bundle.messageSignature.signature : bundle.dsseEnvelope.signatures[0].sig,
657
404
  );
658
405
 
659
- // # 3 To verify the SCT we need to build a preCert (because the cert was logged without the SCT)
660
- // https://github.com/sigstore/sigstore-js/packages/verify/src/key/sct.ts#L45
661
- // Similar to sigstore-js key/index.ts:67 which uses path[0] (leaf) and path[1] (issuer)
662
- // for SCT verification. Handle edge case where path has only one cert (self-signed root).
663
- const issuerCert = certPath.length > 1 ? certPath[1] : certPath[0];
664
- const verifiedSCTs = await this.verifySCT(signingCert, issuerCert, this.root.ctlogs);
665
- if (verifiedSCTs.length < this.options.ctlogThreshold) {
666
- throw new Error(
667
- `Not enough valid SCTs: found ${verifiedSCTs.length}, required ${this.options.ctlogThreshold}`
668
- );
669
- }
670
-
671
- // # 4 Rekor inclusion promise
672
- if (
673
- !(await this.verifyInclusionPromise(signingCert, bundle, this.root.rekor))
674
- ) {
675
- throw new Error("Inclusion promise validation failed.");
676
- }
677
-
678
- // # 5 Rekor inclusion proof (Merkle tree verification)
679
- await this.verifyInclusionProof(bundle);
406
+ await policy.verify(signingCert);
680
407
 
681
- // # 5.1 Rekor body verification - verify ALL entries (matches sigstore-js verifier.ts:147)
682
- for (const entry of bundle.verificationMaterial.tlogEntries) {
683
- await verifyTLogBody(entry, bundle);
684
- }
685
-
686
- // # 6 TSA Timestamp Verification
687
- // Verify all timestamps and enforce threshold
688
- // Reference: https://github.com/sigstore/sigstore-js/blob/main/packages/verify/src/verifier.ts#L101-L106
689
- const verifiedTimestamps = await verifyBundleTimestamp(
408
+ // Observer timestamps come from SET-bound integrated times and verified RFC 3161 timestamps.
409
+ // Rekor v2 entries carry no integrated time, so they need a TSA timestamp to be anchored at all.
410
+ const integratedTimes = await this.verifyTlogEntries(signingCert, bundle, this.root.rekor);
411
+ const timestamps = await verifyBundleTimestamp(
690
412
  bundle.verificationMaterial.timestampVerificationData,
691
413
  signature,
692
- this.rawRoot?.timestampAuthorities || []
414
+ this.rawRoot.timestampAuthorities || [],
693
415
  );
416
+ const tsaOperators = countOperators(new Set(timestamps.map((t) => t.operator)));
417
+ if (tsaOperators < this.options.tsaThreshold) {
418
+ throw new Error(`Not enough verified TSA operators: ${tsaOperators} < ${this.options.tsaThreshold}`);
419
+ }
420
+ const observerTimes = [...integratedTimes, ...timestamps.map((t) => t.signingTime)];
421
+ if (observerTimes.length === 0) {
422
+ throw new Error("No verified observer timestamp anchors the signature in time.");
423
+ }
694
424
 
695
- if (verifiedTimestamps.length < this.options.tsaThreshold) {
696
- throw new Error(
697
- `Not enough verified TSA timestamps: ${verifiedTimestamps.length} < ${this.options.tsaThreshold}`
698
- );
425
+ // The CA window and the whole chain are checked at every observer time, never at the leaf's own notBefore.
426
+ let certPath: X509Certificate[] = [];
427
+ for (const ts of observerTimes) {
428
+ certPath = await this.verifyCertificateChain(ts, signingCert, this.root.certificateAuthorities);
429
+ }
430
+ const issuerCert = certPath.length > 1 ? certPath[1] : certPath[0];
431
+ const ctOperators = countOperators(await this.verifySCT(signingCert, issuerCert, this.root.ctlogs));
432
+ if (ctOperators < this.options.ctlogThreshold) {
433
+ throw new Error(`Not enough verified CT log operators: ${ctOperators} < ${this.options.ctlogThreshold}`);
699
434
  }
700
435
 
701
- // If we have verified timestamps, check certificate validity at each timestamp time
702
- for (const verifiedTimestamp of verifiedTimestamps) {
703
- if (!signingCert.validForDate(verifiedTimestamp)) {
704
- throw new Error(
705
- "Certificate was not valid at the time of timestamping"
706
- );
707
- }
436
+ return { signingCert, signature };
437
+ }
438
+
439
+ public async verifyArtifactPolicy(
440
+ policy: VerificationPolicy,
441
+ bundle: SigstoreBundle,
442
+ data: Uint8Array,
443
+ isDigestOnly: boolean = false,
444
+ ): Promise<boolean> {
445
+ if (isDigestOnly && data.byteLength !== 32) {
446
+ throw new Error("SHA-256 digest must be exactly 32 bytes");
708
447
  }
448
+ const { signingCert, signature } = await this.verifyBundle(bundle, policy);
709
449
 
710
450
  // # 7 Revocation *skipping* not really a thing (unsurprisingly)
711
451
 
712
452
  // # 8 verify the signed data
713
453
  if (bundle.dsseEnvelope) {
714
- // For DSSE bundles, verify the signature over the PAE
454
+ // Only in-toto statements are understood; other payload types must not be read as one.
455
+ if (bundle.dsseEnvelope.payloadType !== "application/vnd.in-toto+json") {
456
+ throw new Error(`Unsupported DSSE payload type: ${bundle.dsseEnvelope.payloadType}`);
457
+ }
715
458
  const payloadBytes = base64ToUint8Array(bundle.dsseEnvelope.payload);
716
459
  const payload = JSON.parse(Uint8ArrayToString(payloadBytes));
717
460
 
718
461
  // Verify the artifact digest matches a subject in the in-toto statement
719
- if (!payload.subject || payload.subject.length === 0) {
720
- throw new Error("DSSE payload has no subject");
462
+ if (!Array.isArray(payload?.subject) || payload.subject.length === 0 || payload.subject.length > MAX_SUBJECTS) {
463
+ throw new Error(`DSSE payload must have between 1 and ${MAX_SUBJECTS} subjects`);
721
464
  }
722
-
723
465
  // Compute or extract the artifact digest
724
466
  let artifactDigest: string;
725
467
  if (isDigestOnly) {
@@ -732,14 +474,14 @@ export class SigstoreVerifier {
732
474
  );
733
475
  }
734
476
 
735
- // Find matching subject by scanning all subjects (not just [0])
736
- let matchedSubject = null;
477
+ // Every subject must carry a bounded digest map; any one of them may match the artifact.
478
+ let matchedSubject = false;
737
479
  for (const subject of payload.subject) {
738
- const subjectDigest = subject.digest?.["sha256"];
739
- if (subjectDigest && artifactDigest === subjectDigest.toLowerCase()) {
740
- matchedSubject = subject;
741
- break;
480
+ const digests = subject?.digest;
481
+ if (!digests || typeof digests !== "object" || Array.isArray(digests) || Object.keys(digests).length > MAX_SUBJECT_DIGESTS) {
482
+ throw new Error(`Invalid DSSE subject digest map (maximum ${MAX_SUBJECT_DIGESTS} digests)`);
742
483
  }
484
+ matchedSubject ||= typeof digests.sha256 === "string" && artifactDigest === digests.sha256.toLowerCase();
743
485
  }
744
486
 
745
487
  if (!matchedSubject) {
@@ -757,6 +499,14 @@ export class SigstoreVerifier {
757
499
  throw new Error("DSSE signature verification failed");
758
500
  }
759
501
  } else {
502
+ // The bundle's message digest must be the digest of the artifact being verified.
503
+ const { messageDigest } = bundle.messageSignature;
504
+ const digest = isDigestOnly
505
+ ? data
506
+ : new Uint8Array(await crypto.subtle.digest(HashAlgorithms.SHA256, data as BufferSource));
507
+ if (messageDigest.algorithm !== "SHA2_256" || !uint8ArrayEqual(digest, base64ToUint8Array(messageDigest.digest))) {
508
+ throw new Error("Artifact digest does not match the bundle message digest");
509
+ }
760
510
  const publicKey = await signingCert.publicKeyObj;
761
511
 
762
512
  if (isDigestOnly) {
@@ -824,102 +574,11 @@ public async verifyArtifact(
824
574
  bundle: SigstoreBundle,
825
575
  policy: VerificationPolicy,
826
576
  ): Promise<{ payloadType: string; payload: Uint8Array }> {
827
- if (!this.root) {
828
- throw new Error("Sigstore root is undefined");
829
- }
830
-
577
+ const { signingCert, signature } = await this.verifyBundle(bundle, policy);
831
578
  if (!bundle.dsseEnvelope) {
832
579
  throw new Error("Bundle does not contain a DSSE envelope");
833
580
  }
834
581
 
835
- // Extract certificate from bundle
836
- const cert = bundle.verificationMaterial.certificate ||
837
- bundle.verificationMaterial.x509CertificateChain?.certificates[0];
838
-
839
- if (!cert) {
840
- throw new Error("No certificate found in bundle");
841
- }
842
-
843
- const signingCert = X509Certificate.parse(base64ToUint8Array(cert.rawBytes));
844
-
845
- // (1) Verify certificate chain to trusted CA
846
- const certPath = await this.verifyCertificateChain(
847
- signingCert.notBefore,
848
- signingCert,
849
- this.root.certificateAuthorities
850
- );
851
-
852
- // (2) Verify SCTs
853
- const issuerCert = certPath.length > 1 ? certPath[1] : certPath[0];
854
- const verifiedSCTs = await this.verifySCT(signingCert, issuerCert, this.root.ctlogs);
855
- if (verifiedSCTs.length < this.options.ctlogThreshold) {
856
- throw new Error(
857
- `Not enough valid SCTs: found ${verifiedSCTs.length}, required ${this.options.ctlogThreshold}`
858
- );
859
- }
860
-
861
- // (3) Verify the signing certificate against the policy
862
- policy.verify(signingCert);
863
-
864
- // (4) Verify Rekor inclusion promise
865
- if (!(await this.verifyInclusionPromise(signingCert, bundle, this.root.rekor))) {
866
- throw new Error("Inclusion promise validation failed");
867
- }
868
-
869
- // (5) Verify Rekor inclusion proof (Merkle tree verification)
870
- await this.verifyInclusionProof(bundle);
871
-
872
- // Validate envelope has exactly one signature (matches sigstore-python dsse._verify)
873
- if (!bundle.dsseEnvelope.signatures || bundle.dsseEnvelope.signatures.length !== 1) {
874
- throw new Error(
875
- `DSSE envelope must have exactly 1 signature, got ${bundle.dsseEnvelope.signatures?.length ?? 0}`
876
- );
877
- }
878
-
879
- // (6) TSA Timestamp verification
880
- const signature = base64ToUint8Array(bundle.dsseEnvelope.signatures[0].sig);
881
- const verifiedTimestamps = await verifyBundleTimestamp(
882
- bundle.verificationMaterial.timestampVerificationData,
883
- signature,
884
- this.rawRoot?.timestampAuthorities || []
885
- );
886
-
887
- if (verifiedTimestamps.length < this.options.tsaThreshold) {
888
- throw new Error(
889
- `Not enough verified TSA timestamps: ${verifiedTimestamps.length} < ${this.options.tsaThreshold}`
890
- );
891
- }
892
-
893
- for (const verifiedTimestamp of verifiedTimestamps) {
894
- if (!signingCert.validForDate(verifiedTimestamp)) {
895
- throw new Error("Certificate was not valid at the time of timestamping");
896
- }
897
- }
898
-
899
- // Every tlog entry must be anchored in time by at least one observer
900
- // timestamp, matching the observer-timestamp enforcement in the upstream
901
- // sigstore-js and sigstore-go clients. A v1 entry carries integratedTime
902
- // (the signing certificate must be valid at that instant); a v2 entry omits
903
- // it and must instead carry a signed RFC3161 timestamp. Without either, the
904
- // certificate's validity window is never anchored — a crafted bundle that
905
- // strips integratedTime + the inclusion promise and supplies an empty
906
- // timestampVerificationData ({}) would otherwise pass. This mirrors the
907
- // guard in verify(), which callers verifying via verifyDsse() would miss.
908
- for (const entry of bundle.verificationMaterial.tlogEntries) {
909
- if (entry.integratedTime) {
910
- const integratedDate = new Date(Number(entry.integratedTime) * 1000);
911
- if (!signingCert.validForDate(integratedDate)) {
912
- throw new Error(
913
- "Artifact signing was logged outside of the certificate validity.",
914
- );
915
- }
916
- } else {
917
- assertRekorV2Timestamp(
918
- bundle.verificationMaterial.timestampVerificationData,
919
- );
920
- }
921
- }
922
-
923
582
  // (7) Verify the DSSE envelope signature
924
583
  const payloadBytes = base64ToUint8Array(bundle.dsseEnvelope.payload);
925
584
  const pae = preAuthEncoding(bundle.dsseEnvelope.payloadType, payloadBytes);
@@ -930,13 +589,11 @@ public async verifyArtifact(
930
589
  throw new Error("DSSE signature verification failed");
931
590
  }
932
591
 
933
- // (8) Verify the consistency of the log entry's body against the bundle materials
934
- // The entry MUST be of type "dsse" for DSSE verification
592
+ // (8) Every entry MUST be of type "dsse" for DSSE verification
935
593
  for (const entry of bundle.verificationMaterial.tlogEntries) {
936
594
  if (entry.kindVersion.kind !== "dsse") {
937
595
  throw new Error(`Expected entry type dsse, got ${entry.kindVersion.kind}`);
938
596
  }
939
- await verifyTLogBody(entry, bundle);
940
597
  }
941
598
 
942
599
  // Return the verified payload