@freedomofpress/sigstore-browser 0.1.13 → 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 -8
  17. package/dist/sigstore.d.ts.map +1 -1
  18. package/dist/sigstore.js +159 -362
  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 +46 -1
  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 -497
  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
@@ -14,8 +14,9 @@
14
14
  */
15
15
 
16
16
  import { base64ToUint8Array, uint8ArrayEqual } from "@freedomofpress/crypto-browser";
17
+ import { assertTimestampVerificationData } from "../bundle.js";
17
18
  import { CertificateChainVerifier, X509Certificate } from "../x509/index.js";
18
- import { RawTimestampAuthority } from "../interfaces.js";
19
+ import { parseValidityPeriod, RawTimestampAuthority } from "../interfaces.js";
19
20
  import { RFC3161Timestamp } from "../rfc3161/index.js";
20
21
 
21
22
  /**
@@ -32,6 +33,20 @@ export async function verifyRFC3161Timestamp(
32
33
  data: Uint8Array,
33
34
  timestampAuthorities: RawTimestampAuthority[]
34
35
  ): Promise<Date> {
36
+ return (await verifyTimestamp(timestamp, data, timestampAuthorities)).signingTime;
37
+ }
38
+
39
+ export interface VerifiedTimestamp {
40
+ signingTime: Date;
41
+ // Empty means unknown, which cannot add independence to named operators.
42
+ operator: string;
43
+ }
44
+
45
+ async function verifyTimestamp(
46
+ timestamp: RFC3161Timestamp,
47
+ data: Uint8Array,
48
+ timestampAuthorities: RawTimestampAuthority[]
49
+ ): Promise<VerifiedTimestamp> {
35
50
  const signingTime = timestamp.signingTime;
36
51
 
37
52
  // Filter for CAs which were valid at the time of signing
@@ -51,18 +66,19 @@ export async function verifyRFC3161Timestamp(
51
66
  validAuthorities.map(ca => verifyTimestampForCA(timestamp, data, ca))
52
67
  );
53
68
 
54
- const verified = verificationResults.some(
69
+ const verified = verificationResults.findIndex(
55
70
  result => result.status === "fulfilled"
56
71
  );
57
72
 
58
- if (!verified) {
73
+ if (verified < 0) {
59
74
  const errors = verificationResults
60
75
  .filter(r => r.status === "rejected")
61
76
  .map(r => (r as PromiseRejectedResult).reason?.message || 'Unknown error');
62
77
  throw new Error(`Timestamp could not be verified against any trusted authority. Errors: ${errors.join(', ')}`);
63
78
  }
64
79
 
65
- return signingTime;
80
+ const ca = validAuthorities[verified];
81
+ return { signingTime, operator: ca.operator || "" };
66
82
  }
67
83
 
68
84
  /**
@@ -75,18 +91,8 @@ function filterCertAuthorities(
75
91
  validAt: Date
76
92
  ): RawTimestampAuthority[] {
77
93
  return authorities.filter(ca => {
78
- if (ca.validFor) {
79
- const start = ca.validFor.start ? new Date(ca.validFor.start) : null;
80
- const end = ca.validFor.end ? new Date(ca.validFor.end) : null;
81
-
82
- if (start && validAt < start) {
83
- return false;
84
- }
85
- if (end && validAt > end) {
86
- return false;
87
- }
88
- }
89
- return true;
94
+ const { start, end } = parseValidityPeriod(ca.validFor);
95
+ return start <= validAt && validAt <= end;
90
96
  });
91
97
  }
92
98
 
@@ -160,14 +166,15 @@ async function verifyTimestampForCA(
160
166
  * @param timestampData - The timestamp verification data from the bundle
161
167
  * @param signature - The signature being verified
162
168
  * @param timestampAuthorities - List of trusted timestamp authorities
163
- * @returns Array of verified signing times (empty if no timestamps present)
169
+ * @returns The verified signing times and their operators (empty if no timestamps present)
164
170
  * @throws Error if any timestamp fails verification or duplicates are found
165
171
  */
166
172
  export async function verifyBundleTimestamp(
167
173
  timestampData: any,
168
174
  signature: Uint8Array,
169
175
  timestampAuthorities: RawTimestampAuthority[]
170
- ): Promise<Date[]> {
176
+ ): Promise<VerifiedTimestamp[]> {
177
+ assertTimestampVerificationData(timestampData);
171
178
  if (!timestampData?.rfc3161Timestamps?.length) {
172
179
  return [];
173
180
  }
@@ -175,21 +182,15 @@ export async function verifyBundleTimestamp(
175
182
  // Verify ALL timestamps - each must succeed (matches sigstore-js behavior)
176
183
  // Collect verification results for duplicate checking after verification
177
184
  // Reference: https://github.com/sigstore/sigstore-js/blob/main/packages/verify/src/verifier.ts#L86-L92
178
- const verifiedResults: Array<{ signingTime: Date; signerSerialNumber: string }> = [];
185
+ const verifiedResults: Array<VerifiedTimestamp & { signerSerialNumber: string }> = [];
179
186
 
180
187
  for (const tsData of timestampData.rfc3161Timestamps) {
181
188
  // Decode the base64-encoded timestamp
182
189
  const timestampBytes = base64ToUint8Array(tsData.signedTimestamp);
183
190
  const timestamp = RFC3161Timestamp.parse(timestampBytes);
184
191
 
185
- const signingTime = await verifyRFC3161Timestamp(
186
- timestamp,
187
- signature,
188
- timestampAuthorities
189
- );
190
-
191
192
  verifiedResults.push({
192
- signingTime,
193
+ ...(await verifyTimestamp(timestamp, signature, timestampAuthorities)),
193
194
  signerSerialNumber: Array.from(timestamp.signerSerialNumber).join(','),
194
195
  });
195
196
  }
@@ -207,5 +208,5 @@ export async function verifyBundleTimestamp(
207
208
  }
208
209
  }
209
210
 
210
- return verifiedResults.map(r => r.signingTime);
211
- }
211
+ return verifiedResults;
212
+ }
package/src/tlog/body.ts CHANGED
@@ -9,8 +9,9 @@
9
9
  * - Direct bundle comparison instead of SignatureContent abstraction
10
10
  */
11
11
 
12
- import { base64Decode } from "@freedomofpress/crypto-browser";
12
+ import { base64Decode, base64ToUint8Array, Uint8ArrayToString } from "@freedomofpress/crypto-browser";
13
13
  import type { SigstoreBundle, TLogEntry } from "../bundle.js";
14
+ import { X509Certificate } from "../x509/cert.js";
14
15
  import { verifyHashedRekordBody } from "./hashedrekord.js";
15
16
  import { verifyDSSEBody } from "./dsse.js";
16
17
  import { verifyIntotoBody } from "./intoto.js";
@@ -21,9 +22,11 @@ export interface RekorEntry {
21
22
  spec: unknown;
22
23
  }
23
24
 
25
+ // Checks that the entry body matches the bundle content and was logged under the signing certificate.
24
26
  export async function verifyTLogBody(
25
27
  entry: TLogEntry,
26
- bundle: SigstoreBundle
28
+ bundle: SigstoreBundle,
29
+ cert: X509Certificate,
27
30
  ): Promise<void> {
28
31
  const rekorEntry = parseCanonicalBody(entry);
29
32
 
@@ -37,16 +40,32 @@ export async function verifyTLogBody(
37
40
 
38
41
  switch (rekorEntry.kind) {
39
42
  case "hashedrekord":
40
- return verifyHashedRekordBody(rekorEntry, bundle);
43
+ return verifyHashedRekordBody(rekorEntry, bundle, cert);
41
44
  case "dsse":
42
- return verifyDSSEBody(rekorEntry, bundle);
45
+ return verifyDSSEBody(rekorEntry, bundle, cert);
43
46
  case "intoto":
44
- return verifyIntotoBody(rekorEntry, bundle);
47
+ return verifyIntotoBody(rekorEntry, bundle, cert);
45
48
  default:
46
49
  throw new Error(`Unsupported TLog entry kind: ${rekorEntry.kind}`);
47
50
  }
48
51
  }
49
52
 
53
+ // Compares the certificate recorded in the entry (base64 DER, or base64 PEM for Rekor v1) with the signing certificate.
54
+ export function assertLoggedCertificate(
55
+ cert: X509Certificate,
56
+ encoded: unknown,
57
+ pem: boolean,
58
+ ): void {
59
+ if (typeof encoded !== "string" || encoded === "") {
60
+ throw new Error("TLog entry does not record the signing certificate");
61
+ }
62
+ const bytes = base64ToUint8Array(encoded);
63
+ const logged = X509Certificate.parse(pem ? Uint8ArrayToString(bytes) : bytes);
64
+ if (!cert.equals(logged)) {
65
+ throw new Error("Certificate in TLog entry does not match the signing certificate");
66
+ }
67
+ }
68
+
50
69
  function parseCanonicalBody(entry: TLogEntry): RekorEntry {
51
70
  try {
52
71
  const decodedBody = base64Decode(entry.canonicalizedBody);
@@ -7,13 +7,6 @@ https://github.com/sigstore/sigstore-js/blob/main/packages/verify/src/timestamp/
7
7
  Key differences:
8
8
  - Uses Uint8Array instead of Buffer
9
9
  - Uses Web Crypto API instead of Node.js crypto module
10
- - Manual handling of ED25519 (raw) vs ECDSA (DER) signature formats
11
- - Inline key import from raw bytes
12
-
13
- Additional functionality not in reference:
14
- - filterTLogsByDate() - inlined from sigstore-js/packages/verify/src/trust/filter.ts
15
- - importTLogKey() - new browser-compatible key import with multiple format support
16
- - Special handling for Rekor v2 bundles without integratedTime
17
10
 
18
11
  Follows the signed note format specification:
19
12
  https://github.com/transparency-dev/formats/blob/main/log/README.md
@@ -21,14 +14,12 @@ https://github.com/transparency-dev/formats/blob/main/log/README.md
21
14
 
22
15
  import {
23
16
  base64ToUint8Array,
24
- importKey,
25
- KeyTypes,
26
17
  stringToUint8Array,
27
18
  uint8ArrayEqual,
28
19
  verifySignature,
29
20
  } from "@freedomofpress/crypto-browser";
30
21
  import type { TLogEntry } from "../bundle.js";
31
- import type { RawLogs } from "../interfaces.js";
22
+ import type { RekorKeyInfo } from "../interfaces.js";
32
23
 
33
24
  // Signed note format per https://github.com/transparency-dev/formats
34
25
  // Body is separated from signatures by a blank line
@@ -126,129 +117,38 @@ export class LogCheckpoint {
126
117
  }
127
118
  }
128
119
 
129
- // Verifies checkpoint signature and ensures root hash matches inclusion proof
120
+ // Verifies that the checkpoint is signed by the entry's log and describes the same tree as the inclusion proof.
130
121
  export async function verifyCheckpoint(
131
122
  entry: TLogEntry,
132
- tlogs: RawLogs
123
+ log: RekorKeyInfo
133
124
  ): Promise<void> {
134
- if (!entry.inclusionProof?.checkpoint) {
125
+ const proof = entry.inclusionProof;
126
+ if (!proof?.checkpoint?.envelope) {
135
127
  throw new Error("Missing checkpoint in inclusion proof");
136
128
  }
137
129
 
138
- // Filter by log ID first (matches entry's log ID)
139
- const entryLogId = base64ToUint8Array(entry.logId.keyId);
140
- const matchingTLogs = tlogs.filter((tlog) => {
141
- const tlogId = base64ToUint8Array(tlog.logId.keyId);
142
- return uint8ArrayEqual(tlogId, entryLogId);
143
- });
144
-
145
- // Then filter by date if integratedTime is available
146
- // For Rekor v2 bundles, integratedTime may be null, so we skip date filtering
147
- const validTLogs = entry.integratedTime
148
- ? filterTLogsByDate(matchingTLogs, new Date(Number(entry.integratedTime) * 1000))
149
- : matchingTLogs;
150
-
151
- const inclusionProof = entry.inclusionProof;
152
- const signedNote = SignedNote.fromString(inclusionProof.checkpoint.envelope);
130
+ const signedNote = SignedNote.fromString(proof.checkpoint.envelope);
153
131
  const checkpoint = LogCheckpoint.fromString(signedNote.note);
154
-
155
- if (!(await verifySignedNote(signedNote, validTLogs))) {
156
- throw new Error("Invalid checkpoint signature");
157
- }
158
-
159
- const rootHash = base64ToUint8Array(inclusionProof.rootHash);
160
- if (!uint8ArrayEqual(checkpoint.logHash, rootHash)) {
161
- throw new Error("Root hash mismatch between checkpoint and inclusion proof");
162
- }
163
- }
164
-
165
- // Verifies checkpoint signatures using trusted TLog keys
166
- async function verifySignedNote(
167
- signedNote: SignedNote,
168
- tlogs: RawLogs
169
- ): Promise<boolean> {
170
132
  const data = stringToUint8Array(signedNote.note);
171
133
 
172
- // We need at least one valid signature from a known TLog
173
- let hasValidSignature = false;
174
-
175
- for (const signature of signedNote.signatures) {
176
- // Match signature to TLog using key hint (first 4 bytes of key ID)
177
- const tlog = tlogs.find((tlog) => {
178
- const logId = base64ToUint8Array(tlog.logId.keyId);
179
- return uint8ArrayEqual(logId.subarray(0, 4), signature.keyHint);
180
- });
181
-
182
- if (!tlog) {
183
- // Skip unknown signatures (e.g., from witnesses we don't know about)
184
- continue;
185
- }
186
-
187
- const publicKey = await importTLogKey(tlog);
188
-
189
- // Both ED25519 and ECDSA verification are now handled by verifySignature
190
- // ED25519 signatures are raw (64 bytes), ECDSA signatures are DER-encoded
191
- const verified = await verifySignature(
192
- publicKey,
193
- data,
194
- signature.signature,
195
- tlog.hashAlgorithm
196
- );
197
-
198
- if (verified) {
199
- hasValidSignature = true;
134
+ // Signatures from other parties (e.g. witnesses) are ignored; the key hint is the first 4 bytes of the log ID.
135
+ let valid = false;
136
+ for (const sig of signedNote.signatures) {
137
+ if (
138
+ uint8ArrayEqual(sig.keyHint, log.logId.subarray(0, 4)) &&
139
+ (await verifySignature(log.publicKey, data, sig.signature, log.hashAlgorithm))
140
+ ) {
141
+ valid = true;
200
142
  }
201
143
  }
202
-
203
- return hasValidSignature;
204
- }
205
-
206
-
207
- // Inlined from sigstore-js/packages/verify/src/trust/filter.ts
208
- function filterTLogsByDate(tlogs: RawLogs, targetDate: Date): RawLogs {
209
- return tlogs.filter((tlog) => {
210
- const start = new Date(tlog.publicKey.validFor.start);
211
- const end = tlog.publicKey.validFor.end
212
- ? new Date(tlog.publicKey.validFor.end)
213
- : null;
214
-
215
- return targetDate >= start && (!end || targetDate <= end);
216
- });
217
- }
218
-
219
- // Browser-compatible key import supporting multiple formats (not in sigstore-js reference)
220
- async function importTLogKey(tlog: RawLogs[0]): Promise<CryptoKey> {
221
- // Parse keyDetails to extract key type and scheme
222
- // Formats can be:
223
- // - "PKIX_ECDSA_P256_SHA_256" (production format)
224
- // - "PKIX_ED25519"
225
- // - "ecdsa-sha2-nistp256" (SSH/test format)
226
- const keyDetails = tlog.publicKey.keyDetails;
227
- let keyType: string;
228
- let scheme: string;
229
-
230
- if (keyDetails === "ecdsa-sha2-nistp256") {
231
- // SSH-style ECDSA P-256 format used in tests
232
- keyType = KeyTypes.Ecdsa;
233
- scheme = "P256-SHA256";
234
- } else if (keyDetails.includes("ECDSA")) {
235
- keyType = KeyTypes.Ecdsa;
236
- // Extract the curve and hash, e.g., "P256_SHA_256" from "PKIX_ECDSA_P256_SHA_256"
237
- scheme = keyDetails.replace("PKIX_ECDSA_", "").replace(/_/g, "-")
238
- } else if (keyDetails.includes("ED25519")) {
239
- keyType = KeyTypes.Ed25519;
240
- scheme = KeyTypes.Ed25519;
241
- } else if (keyDetails.includes("RSA")) {
242
- keyType = KeyTypes.RSA;
243
- // Extract RSA details, e.g., "PSS_SHA_256" from "PKIX_RSA_PSS_SHA_256"
244
- scheme = keyDetails.replace("PKIX_RSA_", "").replace(/_/g, "-")
245
- } else {
246
- throw new Error(`Unsupported key type in keyDetails: ${keyDetails}`);
144
+ if (!valid) {
145
+ throw new Error("Invalid checkpoint signature");
247
146
  }
248
147
 
249
- return importKey(
250
- keyType,
251
- scheme,
252
- tlog.publicKey.rawBytes
253
- );
148
+ if (
149
+ checkpoint.logSize !== BigInt(proof.treeSize) ||
150
+ !uint8ArrayEqual(checkpoint.logHash, base64ToUint8Array(proof.rootHash))
151
+ ) {
152
+ throw new Error("Checkpoint does not match inclusion proof");
153
+ }
254
154
  }
package/src/tlog/dsse.ts CHANGED
@@ -14,7 +14,8 @@
14
14
  import { base64ToUint8Array, hexToUint8Array, uint8ArrayEqual } from "@freedomofpress/crypto-browser";
15
15
  import { getHashAlgorithm } from "../interfaces.js";
16
16
  import type { SigstoreBundle } from "../bundle.js";
17
- import type { RekorEntry } from "./body.js";
17
+ import type { X509Certificate } from "../x509/cert.js";
18
+ import { assertLoggedCertificate, type RekorEntry } from "./body.js";
18
19
 
19
20
  interface DSSESpec {
20
21
  signatures?: Array<{
@@ -40,15 +41,16 @@ interface DSSEEntry extends RekorEntry {
40
41
 
41
42
  export async function verifyDSSEBody(
42
43
  entry: RekorEntry,
43
- bundle: SigstoreBundle
44
+ bundle: SigstoreBundle,
45
+ cert: X509Certificate,
44
46
  ): Promise<void> {
45
47
  const dsseEntry = entry as DSSEEntry;
46
48
 
47
49
  switch (dsseEntry.apiVersion) {
48
50
  case "0.0.1":
49
- return verifyDSSE001Body(dsseEntry, bundle);
51
+ return verifyDSSE001Body(dsseEntry, bundle, cert);
50
52
  case "0.0.2":
51
- return verifyDSSE002Body(dsseEntry, bundle);
53
+ return verifyDSSE002Body(dsseEntry, bundle, cert);
52
54
  default:
53
55
  throw new Error(
54
56
  `Unsupported dsse version: ${dsseEntry.apiVersion}`
@@ -58,7 +60,8 @@ export async function verifyDSSEBody(
58
60
 
59
61
  async function verifyDSSE001Body(
60
62
  entry: DSSEEntry,
61
- bundle: SigstoreBundle
63
+ bundle: SigstoreBundle,
64
+ cert: X509Certificate,
62
65
  ): Promise<void> {
63
66
  if (!bundle.dsseEnvelope) {
64
67
  throw new Error("Bundle missing dsseEnvelope for DSSE entry");
@@ -67,6 +70,7 @@ async function verifyDSSE001Body(
67
70
  if (!entry.spec.signatures || entry.spec.signatures.length !== 1) {
68
71
  throw new Error("DSSE entry must have exactly one signature");
69
72
  }
73
+ assertLoggedCertificate(cert, entry.spec.signatures[0].verifier, true);
70
74
 
71
75
  const tlogSig = entry.spec.signatures[0].signature;
72
76
  const tlogSigBytes = base64ToUint8Array(tlogSig);
@@ -101,7 +105,8 @@ async function verifyDSSE001Body(
101
105
  // New functionality for DSSE v0.0.2 (not in sigstore-js reference, which only supports v0.0.1)
102
106
  async function verifyDSSE002Body(
103
107
  entry: DSSEEntry,
104
- bundle: SigstoreBundle
108
+ bundle: SigstoreBundle,
109
+ cert: X509Certificate,
105
110
  ): Promise<void> {
106
111
  if (!bundle.dsseEnvelope) {
107
112
  throw new Error("Bundle missing dsseEnvelope for DSSE v0.0.2 entry");
@@ -115,6 +120,7 @@ async function verifyDSSE002Body(
115
120
  if (!spec.signatures || spec.signatures.length !== 1) {
116
121
  throw new Error("DSSE v0.0.2 entry must have exactly one signature");
117
122
  }
123
+ assertLoggedCertificate(cert, spec.signatures[0].verifier?.x509Certificate?.rawBytes, false);
118
124
 
119
125
  const tlogSig = spec.signatures[0].content;
120
126
  const tlogSigBytes = base64ToUint8Array(tlogSig);
@@ -12,7 +12,8 @@
12
12
 
13
13
  import { base64ToUint8Array, hexToUint8Array, uint8ArrayEqual } from "@freedomofpress/crypto-browser";
14
14
  import type { SigstoreBundle } from "../bundle.js";
15
- import type { RekorEntry } from "./body.js";
15
+ import type { X509Certificate } from "../x509/cert.js";
16
+ import { assertLoggedCertificate, type RekorEntry } from "./body.js";
16
17
 
17
18
  interface HashedRekordSpec {
18
19
  signature: {
@@ -39,8 +40,8 @@ interface HashedRekordV002Spec {
39
40
  hashedRekordV002: {
40
41
  signature: {
41
42
  content: string;
42
- verifier: {
43
- x509Certificate: {
43
+ verifier?: {
44
+ x509Certificate?: {
44
45
  rawBytes: string;
45
46
  };
46
47
  };
@@ -54,15 +55,16 @@ interface HashedRekordV002Spec {
54
55
 
55
56
  export async function verifyHashedRekordBody(
56
57
  entry: RekorEntry,
57
- bundle: SigstoreBundle
58
+ bundle: SigstoreBundle,
59
+ cert: X509Certificate,
58
60
  ): Promise<void> {
59
61
  const hashedRekordEntry = entry as HashedRekordEntry;
60
62
 
61
63
  switch (hashedRekordEntry.apiVersion) {
62
64
  case "0.0.1":
63
- return verifyHashedRekordV001Body(hashedRekordEntry, bundle);
65
+ return verifyHashedRekordV001Body(hashedRekordEntry, bundle, cert);
64
66
  case "0.0.2":
65
- return verifyHashedRekordV002Body(hashedRekordEntry, bundle);
67
+ return verifyHashedRekordV002Body(hashedRekordEntry, bundle, cert);
66
68
  default:
67
69
  throw new Error(
68
70
  `Unsupported hashedrekord version: ${hashedRekordEntry.apiVersion}`
@@ -72,12 +74,14 @@ export async function verifyHashedRekordBody(
72
74
 
73
75
  function verifyHashedRekordV001Body(
74
76
  entry: HashedRekordEntry,
75
- bundle: SigstoreBundle
77
+ bundle: SigstoreBundle,
78
+ cert: X509Certificate,
76
79
  ): void {
77
80
  const spec = entry.spec as HashedRekordSpec;
78
81
  if (!bundle.messageSignature) {
79
82
  throw new Error("Bundle missing messageSignature for hashedrekord entry");
80
83
  }
84
+ assertLoggedCertificate(cert, spec.signature?.publicKey?.content, true);
81
85
 
82
86
  const tlogSig = spec.signature.content || "";
83
87
  const tlogSigBytes = base64ToUint8Array(tlogSig);
@@ -101,12 +105,14 @@ function verifyHashedRekordV001Body(
101
105
  // New functionality for HashedRekord v0.0.2 (not in sigstore-js reference, which only supports v0.0.1)
102
106
  function verifyHashedRekordV002Body(
103
107
  entry: HashedRekordEntry,
104
- bundle: SigstoreBundle
108
+ bundle: SigstoreBundle,
109
+ cert: X509Certificate,
105
110
  ): void {
106
111
  const spec = (entry.spec as HashedRekordV002Spec).hashedRekordV002;
107
112
  if (!bundle.messageSignature) {
108
113
  throw new Error("Bundle missing messageSignature for hashedrekord v0.0.2 entry");
109
114
  }
115
+ assertLoggedCertificate(cert, spec?.signature?.verifier?.x509Certificate?.rawBytes, false);
110
116
 
111
117
  // NOTE: HashedRekord v0.0.2 uses single base64 encoding (unlike intoto which is double-encoded)
112
118
  // Verified against Sigstore conformance test suite bundles
@@ -13,7 +13,8 @@
13
13
  import { base64Decode, base64ToUint8Array, hexToUint8Array, uint8ArrayEqual } from "@freedomofpress/crypto-browser";
14
14
  import { getHashAlgorithm } from "../interfaces.js";
15
15
  import type { SigstoreBundle } from "../bundle.js";
16
- import type { RekorEntry } from "./body.js";
16
+ import type { X509Certificate } from "../x509/cert.js";
17
+ import { assertLoggedCertificate, type RekorEntry } from "./body.js";
17
18
 
18
19
  interface IntotoEnvelope {
19
20
  payload: string;
@@ -48,7 +49,8 @@ interface IntotoEntry extends RekorEntry {
48
49
 
49
50
  export async function verifyIntotoBody(
50
51
  entry: RekorEntry,
51
- bundle: SigstoreBundle
52
+ bundle: SigstoreBundle,
53
+ cert: X509Certificate,
52
54
  ): Promise<void> {
53
55
  const intotoEntry = entry as IntotoEntry;
54
56
 
@@ -67,6 +69,7 @@ export async function verifyIntotoBody(
67
69
  if (!tlogEnvelope.signatures || tlogEnvelope.signatures.length !== 1) {
68
70
  throw new Error("Intoto entry must have exactly one signature");
69
71
  }
72
+ assertLoggedCertificate(cert, tlogEnvelope.signatures[0].publicKey, true);
70
73
 
71
74
  // The signature in the intoto tlog entry is double-base64-encoded:
72
75
  // 1. Raw signature bytes are base64-encoded (normal)