From b6b6db1f8e6030e7d2138c223f48ba6463ab1a9c Mon Sep 17 00:00:00 2001 From: giulio <66009328+lsd-cat@users.noreply.github.com> Date: Wed, 11 Mar 2026 00:00:45 +0100 Subject: [PATCH] feat: add Sigstore claims prefix matching and tests https://github.com/freedomofpress/webcat/issues/127 --- extension/src/webcat/validators.ts | 15 +- .../tests/webcat/sigstore-claims.test.ts | 146 ++++++++++++++++++ 2 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 extension/tests/webcat/sigstore-claims.test.ts diff --git a/extension/src/webcat/validators.ts b/extension/src/webcat/validators.ts index 3ca2922..4d449b9 100644 --- a/extension/src/webcat/validators.ts +++ b/extension/src/webcat/validators.ts @@ -713,6 +713,15 @@ class ClaimPolicy implements VerificationPolicy { private expected: string, ) {} + private claimMatches(got: string): boolean { + if (this.expected.startsWith("^")) { + const expectedPrefix = this.expected.slice(1); + return got.startsWith(expectedPrefix); + } + + return got === this.expected; + } + verify(cert: X509Certificate): void { // Special case: SubjectAltName (2.5.29.17) --- if (this.oid === "2.5.29.17") { @@ -730,7 +739,9 @@ class ClaimPolicy implements VerificationPolicy { const other = sanExt.otherName(EXTENSION_OID_OTHERNAME); if (other) allSans.add(other); - if (!allSans.has(this.expected)) { + const sanMatches = [...allSans].some((san) => this.claimMatches(san)); + + if (!sanMatches) { throw new PolicyError( `SAN mismatch for 2.5.29.17: expected '${this.expected}'`, ); @@ -775,7 +786,7 @@ class ClaimPolicy implements VerificationPolicy { throw new PolicyError(`Unable to decode extension ${this.oid}`); } - if (got !== this.expected) { + if (!this.claimMatches(got)) { throw new PolicyError( `Extension ${this.oid} mismatch: got '${got}', expected '${this.expected}'`, ); diff --git a/extension/tests/webcat/sigstore-claims.test.ts b/extension/tests/webcat/sigstore-claims.test.ts new file mode 100644 index 0000000..17de333 --- /dev/null +++ b/extension/tests/webcat/sigstore-claims.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it, vi } from "vitest"; + +const mockVerifyArtifactPolicy = vi.fn(); + +vi.mock("@freedomofpress/sigstore-browser", () => { + class PolicyError extends Error {} + + class AllOf { + constructor(private policies: Array<{ verify: (cert: unknown) => void }>) {} + + verify(cert: unknown) { + for (const policy of this.policies) { + policy.verify(cert); + } + } + } + + class SigstoreVerifier { + async loadSigstoreRoot() { + return; + } + + async verifyArtifactPolicy( + policy: { verify: (cert: unknown) => void }, + bundle: { cert: unknown }, + ) { + mockVerifyArtifactPolicy(policy, bundle); + policy.verify(bundle.cert); + return true; + } + } + + return { + AllOf, + EXTENSION_OID_OTHERNAME: "othername-oid", + PolicyError, + SigstoreVerifier, + }; +}); + +import { + Manifest, + SigstoreEnrollment, + SigstoreSignatures, +} from "../../src/webcat/interfaces/bundle"; +import { verifySigstoreManifest } from "../../src/webcat/validators"; + +function createSanCert(san: string) { + return { + extSubjectAltName: { + uri: san, + otherName: () => undefined, + }, + extension: () => undefined, + notBefore: new Date(), + }; +} + +function createExtensionCert(oid: string, value: string) { + return { + extSubjectAltName: undefined, + extension: (extOid: string) => { + if (extOid !== oid) { + return undefined; + } + + return { + value: new TextEncoder().encode(value), + valueObj: {}, + }; + }, + notBefore: new Date(), + }; +} + +function baseEnrollment(claims: Record): SigstoreEnrollment { + return { + trusted_root: "dHJ1c3Qtcm9vdA", + max_age: 3600, + claims, + }; +} + +const manifest: Manifest = { + version: 1, + timestamp: "0", + hashes: { a: "b" }, +}; + +describe("verifySigstoreManifest claim matching", () => { + it("matches SAN prefixes when claim is prefixed with ^", async () => { + const enrollment = baseEnrollment({ + "2.5.29.17": "^https://github.com/example/", + }); + + const signatures = [ + { cert: createSanCert("https://github.com/example/repo") }, + ] as unknown as SigstoreSignatures; + + const result = await verifySigstoreManifest( + enrollment, + manifest, + signatures, + ); + + expect(result).toBeNull(); + expect(mockVerifyArtifactPolicy).toHaveBeenCalled(); + }); + + it("matches extension prefixes when claim is prefixed with ^", async () => { + const oid = "1.2.3.4"; + const enrollment = baseEnrollment({ + [oid]: "^https://issuer.example/", + }); + + const signatures = [ + { cert: createExtensionCert(oid, "https://issuer.example/value") }, + ] as unknown as SigstoreSignatures; + + const result = await verifySigstoreManifest( + enrollment, + manifest, + signatures, + ); + + expect(result).toBeNull(); + }); + + it("keeps exact matching when claim is not wrapped", async () => { + const enrollment = baseEnrollment({ + "2.5.29.17": "https://github.com/example", + }); + + const signatures = [ + { cert: createSanCert("https://github.com/example/repo") }, + ] as unknown as SigstoreSignatures; + + const result = await verifySigstoreManifest( + enrollment, + manifest, + signatures, + ); + + expect(result).not.toBeNull(); + }); +});