@freedomofpress/sigstore-browser 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sigstore.ts CHANGED
@@ -36,6 +36,37 @@ import { verifyTLogBody } from "./tlog/body.js";
36
36
  import { verifyBundleTimestamp } from "./timestamp/tsa.js";
37
37
  import { TrustedRootProvider } from "./trust/tuf.js";
38
38
 
39
+ const MEDIA_TYPE_BASE = "application/vnd.dev.sigstore.bundle";
40
+
41
+ /**
42
+ * Extract bundle version from mediaType string
43
+ * Reference: https://github.com/sigstore/sigstore-go/blob/main/pkg/bundle/bundle.go#L159-L177
44
+ */
45
+ function getBundleVersion(mediaType: string): string {
46
+ switch (mediaType) {
47
+ case `${MEDIA_TYPE_BASE}+json;version=0.1`:
48
+ return "0.1";
49
+ case `${MEDIA_TYPE_BASE}+json;version=0.2`:
50
+ return "0.2";
51
+ case `${MEDIA_TYPE_BASE}+json;version=0.3`:
52
+ return "0.3";
53
+ }
54
+
55
+ // New format: "application/vnd.dev.sigstore.bundle.v0.3+json"
56
+ if (mediaType.startsWith(`${MEDIA_TYPE_BASE}.v`) && mediaType.endsWith("+json")) {
57
+ const version = mediaType
58
+ .replace(`${MEDIA_TYPE_BASE}.v`, "")
59
+ .replace("+json", "");
60
+ // Basic semver validation (major.minor or major.minor.patch)
61
+ if (/^\d+\.\d+(\.\d+)?$/.test(version)) {
62
+ return version;
63
+ }
64
+ }
65
+
66
+ // Default to 0.1 for unknown formats
67
+ return "0.1";
68
+ }
69
+
39
70
  export interface SigstoreVerifierOptions {
40
71
  tlogThreshold?: number;
41
72
  ctlogThreshold?: number;
@@ -244,8 +275,7 @@ export class SigstoreVerifier {
244
275
 
245
276
  // Adapted from https://github.com/sigstore/sigstore-js/blob/main/packages/verify/src/key/sct.ts
246
277
  // Key differences:
247
- // - Adds duplicate SCT detection
248
- // - Adds SCT timestamp validity checking
278
+ // - Adds duplicate SCT detection (not in reference)
249
279
  // - Inline CT log filtering by logID and validity period (reference uses filterTLogAuthorities)
250
280
  // - Returns array of verified SCT logIDs for threshold checking (matches reference behavior)
251
281
  async verifySCT(
@@ -312,42 +342,39 @@ export class SigstoreVerifier {
312
342
  preCert.appendUint24(tbs.length);
313
343
  preCert.appendView(tbs);
314
344
 
315
- // Collect all verified SCTs to check against threshold
345
+ // Calculate and return the verification results for each SCT
346
+ // Unlike sigstore-go which counts verified SCTs and checks threshold at the end,
347
+ // sigstore-js throws immediately if any SCT fails verification
316
348
  const verifiedSCTs: Uint8Array[] = [];
317
349
 
318
350
  for (const sct of extSCT.signedCertificateTimestamps) {
319
- // SCT should be within certificate validity period
320
- if (sct.datetime < cert.notBefore || sct.datetime > cert.notAfter) {
321
- continue; // Skip invalid SCT timestamp, don't fail yet
322
- }
323
-
324
351
  // Find the CT log that matches this SCT's log ID and is valid for the SCT datetime
325
352
  const validCTLogs = ctlogs.filter((log) => {
326
353
  // Check if log IDs match
327
- if (log.logID.length !== sct.logID.length) return false;
328
- for (let i = 0; i < log.logID.length; i++) {
329
- if (log.logID[i] !== sct.logID[i]) return false;
330
- }
354
+ if (!uint8ArrayEqual(log.logID, sct.logID)) return false;
331
355
  // Check that the SCT datetime is within the log's validity period
332
356
  return log.validFor.start <= sct.datetime && sct.datetime <= log.validFor.end;
333
357
  });
334
358
 
335
- // Try to verify with any valid CT log
336
- let verified = false;
337
- for (const log of validCTLogs) {
338
- try {
339
- if (await sct.verify(preCert.buffer, log.publicKey)) {
340
- verified = true;
341
- break; // Found a valid log for this SCT
359
+ // See if the SCT is valid for any of the CT logs
360
+ const verified = await (async () => {
361
+ for (const log of validCTLogs) {
362
+ try {
363
+ if (await sct.verify(preCert.buffer, log.publicKey)) {
364
+ return true;
365
+ }
366
+ } catch {
367
+ // Continue trying other logs
342
368
  }
343
- } catch (e) {
344
- // Continue trying other logs
345
369
  }
346
- }
370
+ return false;
371
+ })();
347
372
 
348
- if (verified) {
349
- verifiedSCTs.push(sct.logID);
373
+ if (!verified) {
374
+ throw new Error("SCT verification failed");
350
375
  }
376
+
377
+ verifiedSCTs.push(sct.logID);
351
378
  }
352
379
 
353
380
  return verifiedSCTs;
@@ -360,9 +387,10 @@ export class SigstoreVerifier {
360
387
  ): Promise<boolean> {
361
388
  const entries = bundle.verificationMaterial.tlogEntries;
362
389
 
363
- if (entries.length < 1) {
390
+ // Check that bundle has enough tlog entries to meet threshold
391
+ if (entries.length < this.options.tlogThreshold) {
364
392
  throw new Error(
365
- "Failed to find a transparency log entry in the provided bundle.",
393
+ `Not enough tlog entries: ${entries.length} < ${this.options.tlogThreshold}`,
366
394
  );
367
395
  }
368
396
 
@@ -390,9 +418,8 @@ export class SigstoreVerifier {
390
418
  const entry = entries[0];
391
419
 
392
420
  // Extract bundle version from mediaType
393
- // e.g., "application/vnd.dev.sigstore.bundle+json;version=0.2"
394
- const versionMatch = bundle.mediaType.match(/version=(\d+\.\d+)/);
395
- const bundleVersion = versionMatch ? versionMatch[1] : "0.1";
421
+ // Reference: https://github.com/sigstore/sigstore-go/blob/main/pkg/bundle/bundle.go#L159-L177
422
+ const bundleVersion = getBundleVersion(bundle.mediaType);
396
423
  const isV02OrLater = parseFloat(bundleVersion) >= 0.2;
397
424
 
398
425
  // Bundle v0.2+ requires an inclusion proof
@@ -512,14 +539,31 @@ export class SigstoreVerifier {
512
539
  }
513
540
  }
514
541
  } else if (bodyJson.kind === "dsse") {
515
- // DSSE entries store signatures differently
516
- // The certificate is verified through the bundle's verification material
517
- // No additional check needed here
542
+ // DSSE v0.0.1: certificate is in spec.signatures[0].verifier (base64-encoded PEM)
543
+ // https://github.com/sigstore/sigstore-go/blob/main/pkg/tlog/entry.go#L356-L357
544
+ const verifierContent = bodyJson.spec.signatures?.[0]?.verifier;
545
+ if (verifierContent) {
546
+ const pemString = Uint8ArrayToString(base64ToUint8Array(verifierContent));
547
+ const loggedCert = X509Certificate.parse(pemString);
548
+ if (!cert.equals(loggedCert)) {
549
+ throw new Error(
550
+ "Certificate in DSSE tlog entry does not match the signing certificate.",
551
+ );
552
+ }
553
+ }
518
554
  } else if (bodyJson.kind === "intoto") {
519
- // Intoto entries are DSSE-based and don't store certificates in the tlog entry
520
- // The certificate is part of the bundle's verification material which has already
521
- // been verified against the CA root. The intoto verification in tlog/intoto.ts
522
- // verifies the signature and payload hash match between the tlog and bundle.
555
+ // intoto v0.0.2: certificate is in spec.content.envelope.signatures[0].publicKey (base64-encoded PEM)
556
+ // https://github.com/sigstore/sigstore-go/blob/main/pkg/tlog/entry.go#L360-L361
557
+ const publicKeyContent = bodyJson.spec.content?.envelope?.signatures?.[0]?.publicKey;
558
+ if (publicKeyContent) {
559
+ const pemString = Uint8ArrayToString(base64ToUint8Array(publicKeyContent));
560
+ const loggedCert = X509Certificate.parse(pemString);
561
+ if (!cert.equals(loggedCert)) {
562
+ throw new Error(
563
+ "Certificate in intoto tlog entry does not match the signing certificate.",
564
+ );
565
+ }
566
+ }
523
567
  } else {
524
568
  // Unknown entry type - this should not happen with standard Sigstore bundles
525
569
  throw new Error(`Unsupported tlog entry kind: ${bodyJson.kind}`);
@@ -537,15 +581,17 @@ export class SigstoreVerifier {
537
581
  throw new Error("No transparency log entries found in bundle");
538
582
  }
539
583
 
540
- const entry = bundle.verificationMaterial.tlogEntries[0];
584
+ // Verify inclusion proof for ALL entries, not just the first one
585
+ // Reference: https://github.com/sigstore/sigstore-go/blob/main/pkg/verify/tlog.go#L74-L127
586
+ for (const entry of bundle.verificationMaterial.tlogEntries) {
587
+ // Only verify if there's an inclusion proof (v0.3/rekor2 bundles)
588
+ // v0.1 bundles use inclusion promises instead, verified in verifyInclusionPromise
589
+ if (entry.inclusionProof) {
590
+ await verifyMerkleInclusion(entry);
541
591
 
542
- // Only verify if there's an inclusion proof (v0.3/rekor2 bundles)
543
- // v0.1 bundles use inclusion promises instead, verified in verifyInclusionPromise
544
- if (entry.inclusionProof) {
545
- await verifyMerkleInclusion(entry);
546
-
547
- if (entry.inclusionProof.checkpoint) {
548
- await verifyCheckpoint(entry, this.rawRoot.tlogs);
592
+ if (entry.inclusionProof.checkpoint) {
593
+ await verifyCheckpoint(entry, this.rawRoot.tlogs);
594
+ }
549
595
  }
550
596
  }
551
597
  }
@@ -629,26 +675,29 @@ export class SigstoreVerifier {
629
675
  // # 5 Rekor inclusion proof (Merkle tree verification)
630
676
  await this.verifyInclusionProof(bundle);
631
677
 
632
- // # 5.1 Rekor body verification
633
- if (bundle.verificationMaterial.tlogEntries.length > 0) {
634
- await verifyTLogBody(
635
- bundle.verificationMaterial.tlogEntries[0],
636
- bundle
637
- );
678
+ // # 5.1 Rekor body verification - verify ALL entries (matches sigstore-js verifier.ts:147)
679
+ for (const entry of bundle.verificationMaterial.tlogEntries) {
680
+ await verifyTLogBody(entry, bundle);
638
681
  }
639
682
 
640
- // # 6 TSA Timestamp Verification (if present)
641
- let verifiedTimestamp: Date | undefined;
642
- if (bundle.verificationMaterial.timestampVerificationData) {
643
- // Verify TSA timestamps if present
644
- verifiedTimestamp = await verifyBundleTimestamp(
645
- bundle.verificationMaterial.timestampVerificationData,
646
- signature,
647
- this.rawRoot?.timestampAuthorities || []
683
+ // # 6 TSA Timestamp Verification
684
+ // Verify all timestamps and enforce threshold
685
+ // Reference: https://github.com/sigstore/sigstore-js/blob/main/packages/verify/src/verifier.ts#L101-L106
686
+ const verifiedTimestamps = await verifyBundleTimestamp(
687
+ bundle.verificationMaterial.timestampVerificationData,
688
+ signature,
689
+ this.rawRoot?.timestampAuthorities || []
690
+ );
691
+
692
+ if (verifiedTimestamps.length < this.options.tsaThreshold) {
693
+ throw new Error(
694
+ `Not enough verified TSA timestamps: ${verifiedTimestamps.length} < ${this.options.tsaThreshold}`
648
695
  );
696
+ }
649
697
 
650
- // If we have a verified timestamp, check certificate validity at that time
651
- if (verifiedTimestamp && !signingCert.validForDate(verifiedTimestamp)) {
698
+ // If we have verified timestamps, check certificate validity at each timestamp time
699
+ for (const verifiedTimestamp of verifiedTimestamps) {
700
+ if (!signingCert.validForDate(verifiedTimestamp)) {
652
701
  throw new Error(
653
702
  "Certificate was not valid at the time of timestamping"
654
703
  );
@@ -152,53 +152,60 @@ async function verifyTimestampForCA(
152
152
  }
153
153
 
154
154
  /**
155
- * Extracts the verified timestamp from a bundle's timestamp verification data
155
+ * Verifies all timestamps in a bundle's timestamp verification data
156
156
  *
157
- * New functionality for Sigstore bundle integration (not in sigstore-js reference)
157
+ * Matches sigstore-js behavior: all timestamps must successfully verify.
158
+ * Reference: https://github.com/sigstore/sigstore-js/blob/main/packages/verify/src/verifier.ts#L64-L108
158
159
  *
159
160
  * @param timestampData - The timestamp verification data from the bundle
160
161
  * @param signature - The signature being verified
161
162
  * @param timestampAuthorities - List of trusted timestamp authorities
162
- * @returns The verified signing time, or undefined if no timestamp
163
+ * @returns Array of verified signing times (empty if no timestamps present)
164
+ * @throws Error if any timestamp fails verification or duplicates are found
163
165
  */
164
166
  export async function verifyBundleTimestamp(
165
167
  timestampData: any,
166
168
  signature: Uint8Array,
167
169
  timestampAuthorities: RawTimestampAuthority[]
168
- ): Promise<Date | undefined> {
170
+ ): Promise<Date[]> {
169
171
  if (!timestampData?.rfc3161Timestamps?.length) {
170
- return undefined;
172
+ return [];
171
173
  }
172
174
 
173
- // Check for duplicate timestamps
174
- const seenTimestamps = new Set<string>();
175
+ // Verify ALL timestamps - each must succeed (matches sigstore-js behavior)
176
+ // Collect verification results for duplicate checking after verification
177
+ // Reference: https://github.com/sigstore/sigstore-js/blob/main/packages/verify/src/verifier.ts#L86-L92
178
+ const verifiedResults: Array<{ signingTime: Date; signerSerialNumber: string }> = [];
179
+
175
180
  for (const tsData of timestampData.rfc3161Timestamps) {
176
- if (seenTimestamps.has(tsData.signedTimestamp)) {
177
- throw new Error("Duplicate TSA timestamp detected");
178
- }
179
- seenTimestamps.add(tsData.signedTimestamp);
181
+ // Decode the base64-encoded timestamp
182
+ const timestampBytes = base64ToUint8Array(tsData.signedTimestamp);
183
+ const timestamp = RFC3161Timestamp.parse(timestampBytes);
184
+
185
+ const signingTime = await verifyRFC3161Timestamp(
186
+ timestamp,
187
+ signature,
188
+ timestampAuthorities
189
+ );
190
+
191
+ verifiedResults.push({
192
+ signingTime,
193
+ signerSerialNumber: Array.from(timestamp.signerSerialNumber).join(','),
194
+ });
180
195
  }
181
196
 
182
- // Process each RFC3161 timestamp
183
- const errors: string[] = [];
184
- for (const tsData of timestampData.rfc3161Timestamps) {
185
- try {
186
- // Decode the base64-encoded timestamp
187
- const timestampBytes = base64ToUint8Array(tsData.signedTimestamp);
188
- const timestamp = RFC3161Timestamp.parse(timestampBytes);
189
-
190
- const signingTime = await verifyRFC3161Timestamp(
191
- timestamp,
192
- signature,
193
- timestampAuthorities
194
- );
195
- return signingTime;
196
- } catch (e) {
197
- // Continue to next timestamp if this one fails
198
- errors.push(e instanceof Error ? e.message : String(e));
199
- continue;
197
+ // Check for duplicate timestamps using deep equality on parsed values
198
+ // sigstore-js uses isDeepStrictEqual on {type, logID, timestamp} objects
199
+ for (let i = 0; i < verifiedResults.length; i++) {
200
+ for (let j = i + 1; j < verifiedResults.length; j++) {
201
+ if (
202
+ verifiedResults[i].signingTime.getTime() === verifiedResults[j].signingTime.getTime() &&
203
+ verifiedResults[i].signerSerialNumber === verifiedResults[j].signerSerialNumber
204
+ ) {
205
+ throw new Error("Duplicate TSA timestamp detected");
206
+ }
200
207
  }
201
208
  }
202
209
 
203
- throw new Error(`No valid RFC3161 timestamps found. Errors: ${errors.join(', ')}`);
210
+ return verifiedResults.map(r => r.signingTime);
204
211
  }
package/src/tlog/dsse.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { base64ToUint8Array, hexToUint8Array, uint8ArrayEqual } from "@freedomofpress/crypto-browser";
15
- import { HashAlgorithms } from "../interfaces.js";
15
+ import { getHashAlgorithm } from "../interfaces.js";
16
16
  import type { SigstoreBundle } from "../bundle.js";
17
17
  import type { RekorEntry } from "./body.js";
18
18
 
@@ -81,16 +81,16 @@ async function verifyDSSE001Body(
81
81
  throw new Error("DSSE signature mismatch between TLog entry and bundle");
82
82
  }
83
83
 
84
- const tlogHash = entry.spec.payloadHash?.value || "";
85
- if (!tlogHash) {
86
- throw new Error("DSSE entry missing payloadHash");
84
+ if (!entry.spec.payloadHash?.value || !entry.spec.payloadHash?.algorithm) {
85
+ throw new Error("DSSE entry missing payloadHash or algorithm");
87
86
  }
88
87
 
89
- const tlogHashBytes = hexToUint8Array(tlogHash);
88
+ const hashAlg = getHashAlgorithm(entry.spec.payloadHash.algorithm);
89
+ const tlogHashBytes = hexToUint8Array(entry.spec.payloadHash.value);
90
90
 
91
91
  const payloadBytes = base64ToUint8Array(bundle.dsseEnvelope.payload);
92
92
  const bundleHashBytes = new Uint8Array(
93
- await crypto.subtle.digest(HashAlgorithms.SHA256, payloadBytes as Uint8Array<ArrayBuffer>)
93
+ await crypto.subtle.digest(hashAlg, payloadBytes as Uint8Array<ArrayBuffer>)
94
94
  );
95
95
 
96
96
  if (!uint8ArrayEqual(tlogHashBytes, bundleHashBytes)) {
@@ -129,16 +129,16 @@ async function verifyDSSE002Body(
129
129
  throw new Error("DSSE signature mismatch between TLog entry and bundle (v0.0.2)");
130
130
  }
131
131
 
132
- const tlogHash = spec.payloadHash?.digest || "";
133
- if (!tlogHash) {
134
- throw new Error("DSSE v0.0.2 entry missing payloadHash");
132
+ if (!spec.payloadHash?.digest || !spec.payloadHash?.algorithm) {
133
+ throw new Error("DSSE v0.0.2 entry missing payloadHash or algorithm");
135
134
  }
136
135
 
137
- const tlogHashBytes = base64ToUint8Array(tlogHash);
136
+ const hashAlg = getHashAlgorithm(spec.payloadHash.algorithm);
137
+ const tlogHashBytes = base64ToUint8Array(spec.payloadHash.digest);
138
138
 
139
139
  const payloadBytes = base64ToUint8Array(bundle.dsseEnvelope.payload);
140
140
  const bundleHashBytes = new Uint8Array(
141
- await crypto.subtle.digest(HashAlgorithms.SHA256, payloadBytes as Uint8Array<ArrayBuffer>)
141
+ await crypto.subtle.digest(hashAlg, payloadBytes as Uint8Array<ArrayBuffer>)
142
142
  );
143
143
 
144
144
  if (!uint8ArrayEqual(tlogHashBytes, bundleHashBytes)) {
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { base64Decode, base64ToUint8Array, hexToUint8Array, uint8ArrayEqual } from "@freedomofpress/crypto-browser";
14
- import { HashAlgorithms } from "../interfaces.js";
14
+ import { getHashAlgorithm } from "../interfaces.js";
15
15
  import type { SigstoreBundle } from "../bundle.js";
16
16
  import type { RekorEntry } from "./body.js";
17
17
 
@@ -87,12 +87,16 @@ export async function verifyIntotoBody(
87
87
  }
88
88
 
89
89
  if (intotoEntry.spec.content.payloadHash) {
90
- const tlogHash = intotoEntry.spec.content.payloadHash.value;
91
- const tlogHashBytes = hexToUint8Array(tlogHash);
90
+ if (!intotoEntry.spec.content.payloadHash.algorithm) {
91
+ throw new Error("Intoto entry missing payloadHash algorithm");
92
+ }
93
+
94
+ const hashAlg = getHashAlgorithm(intotoEntry.spec.content.payloadHash.algorithm);
95
+ const tlogHashBytes = hexToUint8Array(intotoEntry.spec.content.payloadHash.value);
92
96
 
93
97
  const payloadBytes = base64ToUint8Array(bundle.dsseEnvelope.payload);
94
98
  const bundleHashBytes = new Uint8Array(
95
- await crypto.subtle.digest(HashAlgorithms.SHA256, payloadBytes as Uint8Array<ArrayBuffer>)
99
+ await crypto.subtle.digest(hashAlg, payloadBytes as Uint8Array<ArrayBuffer>)
96
100
  );
97
101
 
98
102
  if (!uint8ArrayEqual(tlogHashBytes, bundleHashBytes)) {
package/src/x509/cert.ts CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  verifySignature,
23
23
  } from "@freedomofpress/crypto-browser";
24
24
  import { KeyTypes } from "../interfaces.js";
25
- import { ECDSA_CURVE_NAMES, ECDSA_SIGNATURE_ALGOS, RSA_SIGNATURE_ALGOS } from "../oid.js";
25
+ import { DEFAULT_HASH_ALGORITHM, ECDSA_CURVE_NAMES, ECDSA_SIGNATURE_ALGOS, OID_RSASSA_PSS, RSA_SIGNATURE_ALGOS, SHA2_HASH_ALGOS } from "../oid.js";
26
26
  import {
27
27
  X509AuthorityKeyIDExtension,
28
28
  X509BasicConstraintsExtension,
@@ -96,27 +96,33 @@ export class X509Certificate {
96
96
  }
97
97
 
98
98
  /**
99
- * Import the public key with a specific hash algorithm for RSA keys
100
- * For ECDSA keys, the hash parameter is ignored
99
+ * Import the public key with a specific hash algorithm and signature scheme for RSA keys.
100
+ * For ECDSA keys, both parameters are ignored.
101
+ * @param hashAlg - Hash algorithm (e.g., "sha384") for RSA keys
102
+ * @param usePss - If true, import as RSA-PSS key; if false, import as PKCS#1 v1.5
101
103
  */
102
- public async getPublicKeyObj(hashAlg?: string): Promise<CryptoKey> {
104
+ public async getPublicKeyObj(hashAlg?: string, usePss?: boolean): Promise<CryptoKey> {
103
105
  const publicKey = this.subjectPublicKeyInfoObj.toDER();
104
106
  const spki = ASN1Obj.parseBuffer(publicKey);
105
107
  const algorithmOID = spki.subs[0].subs[0].toOID();
106
108
 
107
- // Check if it's an RSA key (OID 1.2.840.113549.1.1.1)
108
- if (algorithmOID === "1.2.840.113549.1.1.1") {
109
- // RSA key - use provided hash or default to SHA-256
110
- const hash = hashAlg || "sha256";
111
- let scheme: string;
112
- if (hash.includes("384")) {
113
- scheme = "SHA384";
114
- } else if (hash.includes("512")) {
115
- scheme = "SHA512";
116
- } else {
117
- scheme = "SHA256";
118
- }
119
- return importKey(KeyTypes.RSA, `RSA_PKCS1V5_${scheme}`, Uint8ArrayToBase64(publicKey));
109
+ // RSA key (OID 1.2.840.113549.1.1.1) or RSA-PSS key (OID 1.2.840.113549.1.1.10)
110
+ const isRsaKey = algorithmOID === "1.2.840.113549.1.1.1";
111
+ const isRsaPssKey = algorithmOID === OID_RSASSA_PSS;
112
+
113
+ if (isRsaPssKey) {
114
+ // WebCrypto doesn't support importing keys with id-RSASSA-PSS OID, only rsaEncryption
115
+ throw new Error(
116
+ "RSA-PSS public keys (id-RSASSA-PSS OID) are not supported by WebCrypto. " +
117
+ "Only certificates with standard RSA keys (rsaEncryption OID) signed using RSA-PSS are supported."
118
+ );
119
+ }
120
+
121
+ if (isRsaKey) {
122
+ const hash = hashAlg || DEFAULT_HASH_ALGORITHM;
123
+ // crypto-browser extracts hash from scheme and uses PSS unless "PKCS1" is present
124
+ const scheme = usePss ? hash : `PKCS1_${hash}`;
125
+ return importKey(KeyTypes.RSA, scheme, Uint8ArrayToBase64(publicKey));
120
126
  } else {
121
127
  // ECDSA key - the curve OID is in the second element
122
128
  const curveOID = spki.subs[0].subs[1]?.toOID();
@@ -134,7 +140,41 @@ export class X509Certificate {
134
140
 
135
141
  get signatureAlgorithm(): string {
136
142
  const oid: string = this.signatureAlgorithmObj.subs[0].toOID();
137
- return ECDSA_SIGNATURE_ALGOS[oid];
143
+ return ECDSA_SIGNATURE_ALGOS[oid] || RSA_SIGNATURE_ALGOS[oid] || this.parseRsaPssHashAlgorithm();
144
+ }
145
+
146
+ get signatureAlgorithmOid(): string {
147
+ return this.signatureAlgorithmObj.subs[0].toOID();
148
+ }
149
+
150
+ /**
151
+ * Parse hash algorithm from RSA-PSS signature algorithm parameters.
152
+ * RSA-PSS parameters are: SEQUENCE { hashAlgorithm, maskGenAlgorithm, saltLength, trailerField }
153
+ */
154
+ private parseRsaPssHashAlgorithm(): string {
155
+ const sigAlgOid = this.signatureAlgorithmObj.subs[0].toOID();
156
+ if (sigAlgOid !== OID_RSASSA_PSS) {
157
+ return "";
158
+ }
159
+
160
+ // RSA-PSS has parameters in the second element of the signature algorithm sequence
161
+ const params = this.signatureAlgorithmObj.subs[1];
162
+ if (!params || params.subs.length === 0) {
163
+ return DEFAULT_HASH_ALGORITHM;
164
+ }
165
+
166
+ // First element is hashAlgorithm [0] EXPLICIT
167
+ const hashAlgWrapper = params.subs[0];
168
+ if (hashAlgWrapper && hashAlgWrapper.subs.length > 0) {
169
+ // The wrapper contains a SEQUENCE with the hash algorithm OID
170
+ const hashAlgSeq = hashAlgWrapper.subs[0];
171
+ if (hashAlgSeq && hashAlgSeq.subs.length > 0) {
172
+ const hashOid = hashAlgSeq.subs[0].toOID();
173
+ return SHA2_HASH_ALGOS[hashOid] || DEFAULT_HASH_ALGORITHM;
174
+ }
175
+ }
176
+
177
+ return DEFAULT_HASH_ALGORITHM;
138
178
  }
139
179
 
140
180
  get signatureValue(): Uint8Array {
@@ -214,15 +254,19 @@ export class X509Certificate {
214
254
 
215
255
  public async verify(issuerCertificate?: X509Certificate): Promise<boolean> {
216
256
  // Extract the hash algorithm from this certificate's signature algorithm
217
- // (not the issuer's own signature algorithm)
218
- const sigAlgOID = this.signatureAlgorithmObj.subs[0].toOID();
219
- const hashAlg = RSA_SIGNATURE_ALGOS[sigAlgOID] || ECDSA_SIGNATURE_ALGOS[sigAlgOID];
257
+ const sigAlgOID = this.signatureAlgorithmOid;
258
+ const isRsaPss = sigAlgOID === OID_RSASSA_PSS;
259
+
260
+ // Get hash algorithm - for RSA-PSS, parse from parameters; otherwise lookup in tables
261
+ const hashAlg = isRsaPss
262
+ ? this.parseRsaPssHashAlgorithm()
263
+ : (RSA_SIGNATURE_ALGOS[sigAlgOID] || ECDSA_SIGNATURE_ALGOS[sigAlgOID]);
220
264
 
221
265
  // Use the issuer's public key if provided, otherwise use the subject's (for self-signed certs)
222
- // Import with the correct hash algorithm for this certificate's signature
266
+ // Import with the correct hash algorithm and key type for this certificate's signature
223
267
  const publicKeyObj = issuerCertificate
224
- ? await issuerCertificate.getPublicKeyObj(hashAlg)
225
- : await this.getPublicKeyObj(hashAlg);
268
+ ? await issuerCertificate.getPublicKeyObj(hashAlg, isRsaPss)
269
+ : await this.getPublicKeyObj(hashAlg, isRsaPss);
226
270
 
227
271
  return await verifySignature(
228
272
  publicKeyObj,
package/src/x509/sct.ts CHANGED
@@ -112,7 +112,11 @@ export class SignedCertificateTimestamp {
112
112
  const stream = new ByteStream(buf);
113
113
 
114
114
  // Version - enum { v1(0), (255) }
115
+ // RFC 6962 specifies only v1(0) is valid (for now)
115
116
  const version = stream.getUint8();
117
+ if (version !== 0) {
118
+ throw new Error(`Unsupported SCT version: ${version} (expected 0 for v1)`);
119
+ }
116
120
 
117
121
  // Log ID - struct { opaque key_id[32]; }
118
122
  const logID = stream.getBlock(32);