From 0407f828025b451d7819e9b7204fd3dc65f0b6f0 Mon Sep 17 00:00:00 2001 From: andreatp Date: Thu, 27 Aug 2026 11:47:32 +0100 Subject: [PATCH 1/2] Verify sigstore signatures against the right digest, and pull by it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inlay:fetch could not verify any artifact signed with `cosign sign` — the command the README, the error message, and this repo's own publisher workflow all told users to run. Verification passed the wasm file to sigstore-java, which hashes it, while `cosign sign` binds its signature to the OCI manifest digest. The two can never agree. Select the digest by bundle shape: a DSSE envelope (`cosign sign`) is verified against the manifest digest, a message signature (`cosign sign-blob`) against the artifact bytes as before. sigstore-java's verify(byte[], ...) takes a digest — verify(Path, ...) is a thin wrapper that sha256s the file — and already dispatches on bundle shape internally, so only the choice of digest was ours to make. Also pull by the resolved digest rather than the tag. pullByDigest resolved a digest, then pulled with the original (mutable) ref and used the digest only as a cache key. The blob-hash check masked this: verification hashed whatever landed on disk. Binding verification to the manifest digest removes that check, so a tag moving between resolve and pull would go unnoticed. The lock file's pin is now actually enforced. `cosign sign` still cannot be verified end to end: sigstore-java (through 2.2.0) models the in-toto subject `name` as required, but it is optional per the spec and cosign omits it, so the payload fails to parse before the digest is compared. Report that explicitly instead of surfacing a Gson error. The digest handling above is already correct, so the upstream fix lands as a version bump. Until then, sign with `cosign sign-blob` + `oras attach`, which is what the publisher workflow and docs now do. Fix two further defects found along the way: fetchSigstoreBundle returned the first matching referrer, making verification nondeterministic when both bundle kinds are attached; and the fetched bundle was written next to the temp file and never cleaned up. The README documented an identity glob (`https://github.com/myorg/*`) that could never match — identities are compared with String.equals, so the `*` is a literal. --- .github/workflows/wasm-publish.yml | 21 ++- README.md | 21 ++- .../io/roastedroot/inlay/InlayClient.java | 65 +++++-- .../io/roastedroot/inlay/InlayClientIT.java | 102 +++++++++++ .../io/roastedroot/inlay/maven/FetchMojo.java | 158 ++++++++++++++++-- .../roastedroot/inlay/maven/FetchMojoIT.java | 26 +++ .../inlay/maven/FetchMojoSignedE2EIT.java | 51 ++++++ .../inlay/maven/FetchMojoTest.java | 75 +++++++++ .../resources/cosign-sign-dsse-bundle.json | 1 + .../sign-blob-message-signature-bundle.json | 69 ++++++++ 10 files changed, 552 insertions(+), 37 deletions(-) create mode 100644 maven-plugin/src/test/java/io/roastedroot/inlay/maven/FetchMojoSignedE2EIT.java create mode 100644 maven-plugin/src/test/resources/cosign-sign-dsse-bundle.json create mode 100644 maven-plugin/src/test/resources/sign-blob-message-signature-bundle.json diff --git a/.github/workflows/wasm-publish.yml b/.github/workflows/wasm-publish.yml index 88e8988..c0d9907 100644 --- a/.github/workflows/wasm-publish.yml +++ b/.github/workflows/wasm-publish.yml @@ -26,10 +26,10 @@ on: type: string default: '1.2.2' cosign-version: - description: 'Cosign version' + description: 'Cosign version (v3+ required: on v2.x, sign-blob needs --new-bundle-format)' required: false type: string - default: 'v2.4.1' + default: 'v3.1.2' permissions: contents: read @@ -67,8 +67,19 @@ jobs: "${{ inputs.image-ref }}:latest" \ "${{ inputs.wasm-file }}:application/wasm" - - name: Sign with cosign + # sign-blob, not sign: sigstore-java cannot yet parse cosign's DSSE payloads. + - name: Sign with cosign and attach the bundle if: inputs.sign run: | - cosign sign --yes "${{ inputs.image-ref }}:${{ inputs.version }}" - cosign sign --yes "${{ inputs.image-ref }}:latest" + BUNDLE="$(basename "${{ inputs.wasm-file }}").sigstore.json" + + cosign sign-blob --yes \ + --bundle "$BUNDLE" \ + "${{ inputs.wasm-file }}" + + for tag in "${{ inputs.version }}" latest; do + oras attach \ + --artifact-type application/vnd.dev.sigstore.bundle.v0.3+json \ + "${{ inputs.image-ref }}:${tag}" \ + "$BUNDLE:application/vnd.dev.sigstore.bundle.v0.3+json" + done diff --git a/README.md b/README.md index 609c90b..4cea589 100644 --- a/README.md +++ b/README.md @@ -130,15 +130,21 @@ Verification runs inline after fetch — no separate step. Configure on each mod ghcr.io/roastedroot/sqlite4j-wasm:3.51.0 ${project.build.directory}/wasm/libsqlite3.wasm https://token.actions.githubusercontent.com - https://github.com/roastedroot/* + https://github.com/OWNER/REPO/.github/workflows/wasm-publish.yml@refs/heads/main ``` -Uses [sigstore-java](https://github.com/sigstore/sigstore-java) keyless verification. Expects a `.sigstore.json` bundle alongside the artifact. +Both values are matched **exactly**. Globs such as `https://github.com/myorg/*` are not supported and will never match — the `*` is compared as a literal character. To read the correct values off an existing signature, inspect the signing certificate in the bundle: the identity is its subject alternative name, and the issuer is the OIDC issuer extension (`1.3.6.1.4.1.57264.1.1`). An artifact signed locally rather than by CI has the issuer `https://github.com/login/oauth` and the signer's email address as identity. + +Uses [sigstore-java](https://github.com/sigstore/sigstore-java) keyless verification. The bundle is discovered through OCI referrers on the resolved manifest digest — nothing needs to sit next to the artifact. If several sigstore bundles are attached, each is tried in a stable order and any one that verifies against the configured identity is accepted. + +Verification binds to the digest recorded in `wkg.lock`, and the artifact is fetched by that digest, so re-pointing a tag cannot change what you get. + +> **Sign with `cosign sign-blob`, not `cosign sign`.** `cosign sign` binds the signature to the OCI manifest digest inside a DSSE envelope, and sigstore-java (through 2.2.0) cannot parse those payloads: it requires an in-toto subject `name`, which is optional per the spec and which cosign omits. inlay reports this explicitly rather than failing obscurely. Once [sigstore-java](https://github.com/sigstore/sigstore-java) makes `InTotoPayload.Subject#getName` `@Nullable`, `cosign sign` will verify with no change to inlay. ## Publishing wasm to OCI -Requires [oras CLI](https://oras.land/docs/installation) and optionally [cosign](https://docs.sigstore.dev/cosign/system_config/installation/). Packages appear at `https://github.com/orgs//packages` — [set visibility to public](https://docs.github.com/en/packages/learn-github-packages/configuring-a-packages-access-control-and-visibility) after first push. +Requires [oras CLI](https://oras.land/docs/installation) and optionally [cosign](https://docs.sigstore.dev/cosign/system_config/installation/) v3+ (on cosign v2.x, add `--new-bundle-format` to `sign-blob`). Packages appear at `https://github.com/orgs//packages` — [set visibility to public](https://docs.github.com/en/packages/learn-github-packages/configuring-a-packages-access-control-and-visibility) after first push. ```sh echo $GHCR_TOKEN | oras login ghcr.io -u $GHCR_USER --password-stdin @@ -146,7 +152,14 @@ echo $GHCR_TOKEN | oras login ghcr.io -u $GHCR_USER --password-stdin oras push ghcr.io/roastedroot/sqlite4j-wasm:3.51.0 \ libsqlite3.wasm:application/wasm -cosign sign --yes ghcr.io/roastedroot/sqlite4j-wasm:3.51.0 +cosign sign-blob --yes \ + --bundle libsqlite3.wasm.sigstore.json \ + libsqlite3.wasm + +oras attach \ + --artifact-type application/vnd.dev.sigstore.bundle.v0.3+json \ + ghcr.io/roastedroot/sqlite4j-wasm:3.51.0 \ + libsqlite3.wasm.sigstore.json:application/vnd.dev.sigstore.bundle.v0.3+json ``` ### GitHub Actions — publisher diff --git a/core/src/main/java/io/roastedroot/inlay/InlayClient.java b/core/src/main/java/io/roastedroot/inlay/InlayClient.java index 0d64718..fe42b2b 100644 --- a/core/src/main/java/io/roastedroot/inlay/InlayClient.java +++ b/core/src/main/java/io/roastedroot/inlay/InlayClient.java @@ -4,7 +4,9 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.util.ArrayList; import java.util.Comparator; +import java.util.List; import java.util.stream.Stream; import land.oras.ArtifactType; import land.oras.ContainerRef; @@ -64,7 +66,8 @@ public Path pullByDigest(String imageRef, String digest, Path outputFile) { } } - ContainerRef containerRef = ContainerRef.parse(imageRef); + // Pull by digest, not by the mutable tag in imageRef. + ContainerRef containerRef = ContainerRef.parse(imageRef).withDigest(digest); try { Path tempDir = Files.createTempDirectory("inlay-pull-"); @@ -96,43 +99,81 @@ public Path pullByDigest(String imageRef, String digest, Path outputFile) { } public Path fetchSigstoreBundle(String imageRef, String digest, Path bundlePath) { + if (bundlePath == null) { + throw new InlayException("bundlePath must not be null"); + } + List bundles = fetchSigstoreBundleBlobs(imageRef, digest); + if (bundles.isEmpty()) { + return null; + } + return writeBundle(bundles.get(0), bundlePath); + } + + /** Every attached bundle, ordered by referrer digest so verification is deterministic. */ + public List fetchSigstoreBundles(String imageRef, String digest, Path targetDir) { + if (targetDir == null) { + throw new InlayException("targetDir must not be null"); + } + List bundles = fetchSigstoreBundleBlobs(imageRef, digest); + List paths = new ArrayList<>(); + for (int i = 0; i < bundles.size(); i++) { + paths.add(writeBundle(bundles.get(i), targetDir.resolve(i + ".sigstore.json"))); + } + return paths; + } + + private List fetchSigstoreBundleBlobs(String imageRef, String digest) { if (imageRef == null || imageRef.isEmpty()) { throw new InlayException("imageRef must not be null or empty"); } if (digest == null || digest.isEmpty()) { throw new InlayException("digest must not be null or empty"); } - if (bundlePath == null) { - throw new InlayException("bundlePath must not be null"); - } + List bundles = new ArrayList<>(); try { ContainerRef containerRef = ContainerRef.parse(imageRef); ContainerRef digestRef = containerRef.withDigest(digest); Referrers referrers = registry.getReferrers( digestRef, ArtifactType.from(Const.SIGSTORE_BUNDLE_MEDIA_TYPE)); + + List matching = new ArrayList<>(); for (ManifestDescriptor referrer : referrers.getManifests()) { - if (!Const.SIGSTORE_BUNDLE_MEDIA_TYPE.equals(referrer.getArtifactType())) { - continue; + if (Const.SIGSTORE_BUNDLE_MEDIA_TYPE.equals(referrer.getArtifactType())) { + matching.add(referrer); } + } + matching.sort(Comparator.comparing(ManifestDescriptor::getDigest)); + + for (ManifestDescriptor referrer : matching) { Descriptor descriptor = registry.getDescriptor(digestRef.withDigest(referrer.getDigest())); Manifest signatureManifest = Manifest.fromJson(descriptor.getJson()); for (Layer layer : signatureManifest.getLayers()) { if (Const.SIGSTORE_BUNDLE_MEDIA_TYPE.equals(layer.getMediaType())) { - byte[] bundle = registry.getBlob(digestRef.withDigest(layer.getDigest())); - Files.createDirectories(bundlePath.getParent()); - Files.write(bundlePath, bundle); - return bundlePath; + bundles.add(registry.getBlob(digestRef.withDigest(layer.getDigest()))); } } } } catch (InlayException e) { throw e; - } catch (RuntimeException | IOException e) { + } catch (RuntimeException e) { throw new InlayException("Failed to fetch sigstore bundle for: " + imageRef, e); } - return null; + return bundles; + } + + private static Path writeBundle(byte[] bundle, Path bundlePath) { + try { + Path parent = bundlePath.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.write(bundlePath, bundle); + return bundlePath; + } catch (IOException e) { + throw new InlayException("Failed to write sigstore bundle: " + bundlePath, e); + } } public String getDigest(String imageRef) { diff --git a/core/src/test/java/io/roastedroot/inlay/InlayClientIT.java b/core/src/test/java/io/roastedroot/inlay/InlayClientIT.java index afe90f7..3d3b12a 100644 --- a/core/src/test/java/io/roastedroot/inlay/InlayClientIT.java +++ b/core/src/test/java/io/roastedroot/inlay/InlayClientIT.java @@ -2,17 +2,28 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; +import land.oras.ArtifactType; import land.oras.ContainerRef; +import land.oras.Descriptor; +import land.oras.Layer; import land.oras.LocalPath; +import land.oras.Manifest; +import land.oras.ManifestDescriptor; import land.oras.Registry; +import land.oras.Subject; +import land.oras.utils.Const; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -162,6 +173,97 @@ void lockFileRoundTripWithRealDigest() throws Exception { assertEquals(digest, pkg.getDigest()); } + @Test + void pullByDigestPrefersTheDigestOverTheTag() throws Exception { + byte[] pinned = {0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00}; + byte[] other = {0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x42}; + + String pinnedRef = pushTaggedWasm("digest-pin", "pinned", pinned); + String otherRef = pushTaggedWasm("digest-pin", "other", other); + + InlayClient client = InlayClient.builder().insecure().noCache().build(); + String pinnedDigest = client.getDigest(pinnedRef); + assertNotEquals(pinnedDigest, client.getDigest(otherRef)); + + // Ask for the other tag while pinning the first digest: the digest must win. + Path outputFile = tempDir.resolve("pinned.wasm"); + client.pullByDigest(otherRef, pinnedDigest, outputFile); + + assertArrayEquals(pinned, Files.readAllBytes(outputFile)); + } + + private String pushTaggedWasm(String name, String tag, byte[] content) throws Exception { + Path wasmFile = tempDir.resolve(name + "-" + tag + ".wasm"); + Files.write(wasmFile, content); + String ref = registryUrl + "/test/" + name + ":" + tag; + insecureRegistry() + .pushArtifact(ContainerRef.parse(ref), LocalPath.of(wasmFile, "application/wasm")); + return ref; + } + + @Test + void fetchSigstoreBundlesReturnsEveryAttachedBundleInStableOrder() throws Exception { + byte[] content = {0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00}; + String ref = pushTestWasm("multi-bundle", content); + + InlayClient client = InlayClient.builder().insecure().noCache().build(); + String digest = client.getDigest(ref); + + byte[] first = "{\"bundle\":\"one\"}".getBytes(StandardCharsets.UTF_8); + byte[] second = "{\"bundle\":\"two\"}".getBytes(StandardCharsets.UTF_8); + attachSigstoreBundle(ref, digest, first); + attachSigstoreBundle(ref, digest, second); + + List bundles = client.fetchSigstoreBundles(ref, digest, tempDir.resolve("bundles")); + assertEquals(2, bundles.size()); + + List contents = new ArrayList<>(); + for (Path bundle : bundles) { + contents.add(Files.readString(bundle)); + } + assertTrue(contents.contains(new String(first, StandardCharsets.UTF_8))); + assertTrue(contents.contains(new String(second, StandardCharsets.UTF_8))); + + List again = client.fetchSigstoreBundles(ref, digest, tempDir.resolve("bundles2")); + List againContents = new ArrayList<>(); + for (Path bundle : again) { + againContents.add(Files.readString(bundle)); + } + assertEquals(contents, againContents); + } + + private void attachSigstoreBundle(String ref, String subjectDigest, byte[] bundle) { + Registry registry = insecureRegistry(); + ContainerRef containerRef = ContainerRef.parse(ref); + + Layer layer = + registry.pushBlob(containerRef, bundle) + .withMediaType(Const.SIGSTORE_BUNDLE_MEDIA_TYPE); + Descriptor subject = registry.getDescriptor(containerRef); + + Manifest manifest = + Manifest.empty() + .withArtifactType(ArtifactType.from(Const.SIGSTORE_BUNDLE_MEDIA_TYPE)) + .withLayers(List.of(layer)) + .withSubject( + Subject.of( + subject.getMediaType(), subjectDigest, subject.getSize())); + + registry.pushManifest( + containerRef.withDigest(ManifestDescriptor.of(manifest).getDigest()), manifest); + } + + @Test + void fetchSigstoreBundlesReturnsEmptyWhenNoBundleExists() throws Exception { + byte[] content = {0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00}; + String ref = pushTestWasm("no-sig-multi", content); + + InlayClient client = InlayClient.builder().insecure().noCache().build(); + String digest = client.getDigest(ref); + + assertTrue(client.fetchSigstoreBundles(ref, digest, tempDir.resolve("bundles")).isEmpty()); + } + @Test void fetchSigstoreBundleReturnsNullWhenNoBundleExists() throws Exception { byte[] content = {0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00}; diff --git a/maven-plugin/src/main/java/io/roastedroot/inlay/maven/FetchMojo.java b/maven-plugin/src/main/java/io/roastedroot/inlay/maven/FetchMojo.java index cc8cd19..df222bb 100644 --- a/maven-plugin/src/main/java/io/roastedroot/inlay/maven/FetchMojo.java +++ b/maven-plugin/src/main/java/io/roastedroot/inlay/maven/FetchMojo.java @@ -20,7 +20,9 @@ import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.security.GeneralSecurityException; +import java.util.Comparator; import java.util.List; +import java.util.stream.Stream; import org.apache.maven.plugin.AbstractMojo; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugins.annotations.LifecyclePhase; @@ -33,6 +35,17 @@ @Mojo(name = "fetch", defaultPhase = LifecyclePhase.GENERATE_SOURCES, threadSafe = true) public class FetchMojo extends AbstractMojo { + private static final String SIGSTORE_BUNDLE_MEDIA_TYPE = + "application/vnd.dev.sigstore.bundle.v0.3+json"; + + private static final String SIGNING_INSTRUCTIONS = + "Sign the artifact with cosign v3+ (on v2.x add --new-bundle-format):\n" + + " cosign sign-blob --yes --bundle .wasm.sigstore.json .wasm\n" + + " oras attach --artifact-type " + + SIGSTORE_BUNDLE_MEDIA_TYPE + + " .wasm.sigstore.json:" + + SIGSTORE_BUNDLE_MEDIA_TYPE; + @Parameter(required = true) private List modules; @@ -149,18 +162,23 @@ private boolean fetchModule(ModuleConfig module, LockFile lock, WkgParser parser module.getSigstoreIssuer() != null || module.getSigstoreIdentity() != null; if (needsVerification) { + Files.createDirectories(outputPath.getParent()); Path tempPath = null; + Path bundleDir = null; try { - Files.createDirectories(outputPath.getParent()); tempPath = Files.createTempFile(outputPath.getParent(), "inlay-", ".wasm.tmp"); + bundleDir = Files.createTempDirectory(outputPath.getParent(), "inlay-bundles-"); client.pullByDigest(imageRef, resolvedDigest, tempPath); - verifySigstore(client, imageRef, resolvedDigest, tempPath, module); + verifySigstore(client, imageRef, resolvedDigest, tempPath, bundleDir, module); Files.move(tempPath, outputPath, StandardCopyOption.REPLACE_EXISTING); tempPath = null; } finally { if (tempPath != null) { Files.deleteIfExists(tempPath); } + if (bundleDir != null) { + deleteRecursive(bundleDir); + } } } else { client.pullByDigest(imageRef, resolvedDigest, outputPath); @@ -184,24 +202,45 @@ private void verifySigstore( String imageRef, String digest, Path artifactPath, + Path bundleDir, ModuleConfig module) throws MojoExecutionException { - Path bundlePath = Path.of(artifactPath + ".sigstore.json"); + getLog().info("Fetching sigstore bundle for " + imageRef); + List bundlePaths = client.fetchSigstoreBundles(imageRef, digest, bundleDir); - if (!Files.exists(bundlePath)) { - getLog().info("Fetching sigstore bundle for " + imageRef); - Path fetched = client.fetchSigstoreBundle(imageRef, digest, bundlePath); - if (fetched == null) { - throw new MojoExecutionException( - "No sigstore bundle found for " - + imageRef - + ". Sign the artifact with: cosign sign --yes " - + imageRef); + if (bundlePaths.isEmpty()) { + throw new MojoExecutionException( + "No sigstore bundle found for " + imageRef + ". " + SIGNING_INSTRUCTIONS); + } + + getLog().info("Verifying signature for " + imageRef); + + // Any one bundle verifying against the configured identity is enough. + MojoExecutionException firstRealFailure = null; + MojoExecutionException firstUnsupported = null; + + for (Path bundlePath : bundlePaths) { + try { + verifyBundle(bundlePath, digest, artifactPath, module); + getLog().info("Signature verified for " + imageRef); + return; + } catch (UnsupportedDsseBundleException e) { + if (firstUnsupported == null) { + firstUnsupported = new MojoExecutionException(e.getMessage(), e); + } + } catch (MojoExecutionException e) { + if (firstRealFailure == null) { + firstRealFailure = e; + } } } - getLog().info("Verifying signature for " + artifactPath); + throw firstRealFailure != null ? firstRealFailure : firstUnsupported; + } + private void verifyBundle( + Path bundlePath, String digest, Path artifactPath, ModuleConfig module) + throws MojoExecutionException, UnsupportedDsseBundleException { try { Bundle bundle = Bundle.from(bundlePath, StandardCharsets.UTF_8); @@ -217,12 +256,21 @@ private void verifySigstore( optionsBuilder.addCertificateMatchers(matcherBuilder.build()); KeylessVerifier verifier = KeylessVerifier.builder().sigstorePublicDefaults().build(); - verifier.verify(artifactPath, bundle, optionsBuilder.build()); - getLog().info("Signature verified for " + artifactPath); + byte[] artifactDigest = selectArtifactDigest(bundle, digest); + if (artifactDigest != null) { + verifier.verify(artifactDigest, bundle, optionsBuilder.build()); + } else { + verifier.verify(artifactPath, bundle, optionsBuilder.build()); + } + } catch (KeylessVerificationException e) { + if (isMissingSubjectName(e)) { + throw new UnsupportedDsseBundleException(); + } + throw new MojoExecutionException( + "Signature verification failed for " + artifactPath + ": " + e.getMessage(), e); } catch (BundleParseException | IOException - | KeylessVerificationException | GeneralSecurityException | SigstoreConfigurationException e) { throw new MojoExecutionException( @@ -230,6 +278,84 @@ private void verifySigstore( } } + /** + * The digest a bundle's signature covers: the OCI manifest digest for `cosign sign`, or null + * for `cosign sign-blob`, where sigstore-java hashes the artifact itself. + */ + static byte[] selectArtifactDigest(Bundle bundle, String manifestDigest) + throws MojoExecutionException { + if (!bundle.getDsseEnvelope().isPresent()) { + return null; + } + + int sep = manifestDigest.indexOf(':'); + if (sep < 0 || !"sha256".equals(manifestDigest.substring(0, sep))) { + throw new MojoExecutionException( + "Unsupported manifest digest algorithm: " + + manifestDigest + + " (DSSE bundles are signed over a sha256 digest)"); + } + return hexToBytes(manifestDigest.substring(sep + 1)); + } + + static byte[] hexToBytes(String hex) throws MojoExecutionException { + if (hex.length() % 2 != 0) { + throw new MojoExecutionException("Invalid hex digest (odd length): " + hex); + } + byte[] out = new byte[hex.length() / 2]; + for (int i = 0; i < out.length; i++) { + int hi = Character.digit(hex.charAt(i * 2), 16); + int lo = Character.digit(hex.charAt(i * 2 + 1), 16); + if (hi < 0 || lo < 0) { + throw new MojoExecutionException("Invalid hex digest: " + hex); + } + out[i] = (byte) ((hi << 4) | lo); + } + return out; + } + + /** Delete once sigstore-java makes InTotoPayload.Subject#getName @Nullable. */ + static boolean isMissingSubjectName(Throwable e) { + for (Throwable t = e; t != null; t = t.getCause()) { + String message = t.getMessage(); + if (message != null && message.contains("Cannot build Subject")) { + return true; + } + if (t.getCause() == t) { + break; + } + } + return false; + } + + private static final class UnsupportedDsseBundleException extends Exception { + UnsupportedDsseBundleException() { + super( + "This artifact is signed with `cosign sign`, which produces a DSSE bundle whose" + + " in-toto subject has no \"name\" field. sigstore-java (through 2.2.0)" + + " requires that field, so the bundle cannot be parsed and the signature" + + " cannot be checked. Upstream fix needed: InTotoPayload.Subject#getName" + + " must be @Nullable (https://github.com/sigstore/sigstore-java). " + + SIGNING_INSTRUCTIONS); + } + } + + private void deleteRecursive(Path dir) { + try (Stream stream = Files.walk(dir)) { + stream.sorted(Comparator.reverseOrder()) + .forEach( + p -> { + try { + Files.deleteIfExists(p); + } catch (IOException e) { + getLog().debug("Failed to delete: " + p, e); + } + }); + } catch (IOException e) { + getLog().debug("Failed to clean up directory: " + dir, e); + } + } + private String resolvePackageRef(String packageRef, LockFile lock) throws MojoExecutionException { String namespace = packageRef; diff --git a/maven-plugin/src/test/java/io/roastedroot/inlay/maven/FetchMojoIT.java b/maven-plugin/src/test/java/io/roastedroot/inlay/maven/FetchMojoIT.java index 636e990..ed3b211 100644 --- a/maven-plugin/src/test/java/io/roastedroot/inlay/maven/FetchMojoIT.java +++ b/maven-plugin/src/test/java/io/roastedroot/inlay/maven/FetchMojoIT.java @@ -1,5 +1,7 @@ package io.roastedroot.inlay.maven; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -7,6 +9,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; import land.oras.ContainerRef; import land.oras.LocalPath; import land.oras.Registry; @@ -150,6 +154,28 @@ void fetchModuleDefaultOutputFile() throws Exception { assertTrue(Files.exists(expectedOutput)); } + @Test + void failedVerificationLeavesNoTempFilesBehind() throws Exception { + String ref = pushTestWasm("mojo-verify-cleanup"); + + File outputFile = tempDir.resolve("output/mojo-verify-cleanup.wasm").toFile(); + ModuleConfig module = new ModuleConfig(); + module.setImageRef(ref); + module.setOutputFile(outputFile); + module.setSigstoreIssuer("https://token.actions.githubusercontent.com"); + module.setSigstoreIdentity("https://github.com/example/nobody"); + + FetchMojo mojo = createMojo(module); + MojoExecutionException ex = + assertThrows(MojoExecutionException.class, () -> mojo.execute()); + assertTrue(ex.getMessage().contains("No sigstore bundle found")); + + assertFalse(outputFile.exists(), "unverified artifact must not be published"); + try (Stream leftovers = Files.list(outputFile.toPath().getParent())) { + assertEquals(List.of(), leftovers.collect(Collectors.toList())); + } + } + private FetchMojo createMojo(ModuleConfig module) throws Exception { return createMojo(module, false); } diff --git a/maven-plugin/src/test/java/io/roastedroot/inlay/maven/FetchMojoSignedE2EIT.java b/maven-plugin/src/test/java/io/roastedroot/inlay/maven/FetchMojoSignedE2EIT.java new file mode 100644 index 0000000..150e169 --- /dev/null +++ b/maven-plugin/src/test/java/io/roastedroot/inlay/maven/FetchMojoSignedE2EIT.java @@ -0,0 +1,51 @@ +package io.roastedroot.inlay.maven; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.nio.file.Path; +import java.util.List; +import org.apache.maven.model.Build; +import org.apache.maven.project.MavenProject; +import org.apache.maven.settings.Settings; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.io.TempDir; + +/** + * Opt-in check against a real signed artifact; keyless signing needs interactive OIDC, so this + * cannot run in CI. Set inlay.e2e.imageRef, inlay.e2e.issuer and inlay.e2e.identity to enable. + */ +@EnabledIfSystemProperty(named = "inlay.e2e.imageRef", matches = ".+") +class FetchMojoSignedE2EIT { + + @TempDir Path tempDir; + + @Test + void fetchesAndVerifiesASignedArtifact() throws Exception { + File outputFile = tempDir.resolve("out/module.wasm").toFile(); + + ModuleConfig module = new ModuleConfig(); + module.setImageRef(System.getProperty("inlay.e2e.imageRef")); + module.setOutputFile(outputFile); + module.setSigstoreIssuer(System.getProperty("inlay.e2e.issuer")); + module.setSigstoreIdentity(System.getProperty("inlay.e2e.identity")); + + FetchMojo mojo = new FetchMojo(); + FetchMojoTest.setField(mojo, "modules", List.of(module)); + FetchMojoTest.setField(mojo, "lockFile", tempDir.resolve("wkg.lock").toFile()); + FetchMojoTest.setField(mojo, "update", false); + FetchMojoTest.setField(mojo, "noCache", true); + FetchMojoTest.setField(mojo, "settings", new Settings()); + + MavenProject project = new MavenProject(); + Build build = new Build(); + build.setDirectory(tempDir.resolve("target").toString()); + project.setBuild(build); + FetchMojoTest.setField(mojo, "project", project); + + mojo.execute(); + + assertTrue(outputFile.exists(), "verified artifact was not written"); + } +} diff --git a/maven-plugin/src/test/java/io/roastedroot/inlay/maven/FetchMojoTest.java b/maven-plugin/src/test/java/io/roastedroot/inlay/maven/FetchMojoTest.java index ae30dc1..ff9e720 100644 --- a/maven-plugin/src/test/java/io/roastedroot/inlay/maven/FetchMojoTest.java +++ b/maven-plugin/src/test/java/io/roastedroot/inlay/maven/FetchMojoTest.java @@ -1,12 +1,20 @@ package io.roastedroot.inlay.maven; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import dev.sigstore.bundle.Bundle; import java.io.File; +import java.io.InputStream; +import java.io.InputStreamReader; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.List; import org.apache.maven.model.Build; @@ -18,6 +26,10 @@ class FetchMojoTest { + /** Manifest digest of the recorded DSSE fixture, which is also its in-toto subject. */ + private static final String MANIFEST_DIGEST = + "sha256:acd1363f4ee3194fc09ed7e0b196440c1476b8c088deeba942eb0bf000d3f62c"; + @TempDir Path tempDir; @Test @@ -64,6 +76,69 @@ void defaultOutputFileDeeplyNested() throws Exception { assertEquals("module.wasm", result.getName()); } + @Test + void dsseBundleSelectsTheManifestDigest() throws Exception { + byte[] selected = + FetchMojo.selectArtifactDigest( + loadBundle("cosign-sign-dsse-bundle.json"), MANIFEST_DIGEST); + + assertArrayEquals( + FetchMojo.hexToBytes(MANIFEST_DIGEST.substring("sha256:".length())), selected); + } + + @Test + void messageSignatureBundleDefersToTheArtifactBytes() throws Exception { + assertNull( + FetchMojo.selectArtifactDigest( + loadBundle("sign-blob-message-signature-bundle.json"), MANIFEST_DIGEST)); + } + + @Test + void dsseBundleRejectsNonSha256ManifestDigest() throws Exception { + Bundle bundle = loadBundle("cosign-sign-dsse-bundle.json"); + + MojoExecutionException ex = + assertThrows( + MojoExecutionException.class, + () -> FetchMojo.selectArtifactDigest(bundle, "sha512:abcdef")); + assertTrue(ex.getMessage().contains("Unsupported manifest digest algorithm")); + } + + @Test + void hexToBytesRoundTrips() throws Exception { + assertArrayEquals(new byte[] {0x00, (byte) 0xff, 0x1a}, FetchMojo.hexToBytes("00ff1a")); + } + + @Test + void hexToBytesRejectsMalformedInput() { + assertThrows(MojoExecutionException.class, () -> FetchMojo.hexToBytes("abc")); + assertThrows(MojoExecutionException.class, () -> FetchMojo.hexToBytes("zz")); + } + + @Test + void detectsTheUpstreamMissingSubjectNameFailure() { + Exception cause = + new IllegalStateException( + "Cannot build Subject, some of required attributes are not set [name]"); + Exception wrapped = new RuntimeException("Could not parse DSSE payload", cause); + + assertTrue(FetchMojo.isMissingSubjectName(wrapped)); + } + + @Test + void unrelatedFailuresAreNotMistakenForTheUpstreamBug() { + assertFalse( + FetchMojo.isMissingSubjectName( + new RuntimeException("DSSE signature was not valid"))); + } + + private static Bundle loadBundle(String fixture) throws Exception { + try (InputStream in = FetchMojoTest.class.getResourceAsStream("/" + fixture)) { + assertNotNull(in, "missing fixture: " + fixture); + return Bundle.from(new InputStreamReader(in, StandardCharsets.UTF_8)); + } + } + private File invokeDefaultOutputFile(String imageRef) throws Exception { FetchMojo mojo = createMojoWithProject(); Method method = FetchMojo.class.getDeclaredMethod("defaultOutputFile", String.class); diff --git a/maven-plugin/src/test/resources/cosign-sign-dsse-bundle.json b/maven-plugin/src/test/resources/cosign-sign-dsse-bundle.json new file mode 100644 index 0000000..a1ba47c --- /dev/null +++ b/maven-plugin/src/test/resources/cosign-sign-dsse-bundle.json @@ -0,0 +1 @@ +{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json","verificationMaterial":{"certificate":{"rawBytes":"MIIDMDCCAragAwIBAgIUYJOIr2PqqE18Wqb13wDQH3Of8nUwCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjYwNzIzMTcxMjU5WhcNMjYwNzIzMTcyMjU5WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEn0z9B1op8SbpdXGnUdZpd9gGBgckmx6rjyOyJnx4eH1OzP3Pkx0CmadV1icC1+JHUvyZDJgi4rnMKrSY9fYgh6OCAdUwggHRMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUirwM086jTb2QK/O2uaCqz25U1JgwHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4YZD8wKgYDVR0RAQH/BCAwHoEcYW5kcmVhLnBlcnVmZm8xOTgyQGdtYWlsLmNvbTAsBgorBgEEAYO/MAEBBB5odHRwczovL2dpdGh1Yi5jb20vbG9naW4vb2F1dGgwLgYKKwYBBAGDvzABCAQgDB5odHRwczovL2dpdGh1Yi5jb20vbG9naW4vb2F1dGgwUgYKKwYBBAGDvzABGAREDEJDZ2MxTnpreU1EazNFaVpvZEhSd2N6b2xNa1lsTWtabmFYUm9kV0l1WTI5dEpUSkdiRzluYVc0bE1rWnZZWFYwYUEwgYsGCisGAQQB1nkCBAIEfQR7AHkAdwDdPTBqxscRMmMZHhyZZzcCokpeuN48rf+HinKALynujgAAAZ+P91iOAAAEAwBIMEYCIQDsZg/HasvJBR87d4/xW7NGjc64wcJPkiXhj9HWxce2OgIhAPBraWj1Nfj5S/zHVbGPDEi6udd1myGffaLR97Lk4mh/MAoGCCqGSM49BAMDA2gAMGUCMQDwO/mmklvJKOmcWQ9Urd4Atx/LxW86ow0iwAUXxQAcNtp9y1291kGr0EevmYyZH2oCMHNvSHXimWqug33SmCe6ISUWYrZ6dDYgQ3pVV5yVPTKejD9CjE/bvOdSo0QxOidl/w=="},"tlogEntries":[{"logIndex":"2227086749","logId":{"keyId":"wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="},"kindVersion":{"kind":"dsse","version":"0.0.1"},"integratedTime":"1784826780","inclusionPromise":{"signedEntryTimestamp":"MEUCIQC3i8L8ZqTPSnHBv37fR9WdW9axWSPtbgkOfzaCSiKxawIgTg8uzLVWJuprfKuBktCcS66XBUw41TztdcUG+3IWXXI="},"inclusionProof":{"logIndex":"2105182487","rootHash":"AwN9ekc1/G7254+BpIdlnrNlK+hHBofK3HOWYBgBSsU=","treeSize":"2105182512","hashes":["3vb8mxw/7UV3lKjibgNnBc/OE4b2JYg713V3191HXxo=","oiha2YqFZpoicbmF5emPDV64+4pWOmdxmHiYqRWPax0=","wiHhnCkCV01aSmt0+BINIMktyaqx+2uSaTYMbqrKivw=","B/1D3uTy0xui35zyCuojJaosxyfVmKXh6dSiZbR8DSc=","nhXLjZYPjLShh68MITal9wRKbeA8lKVdk56Hwb9UNwU=","ue7CcHdEVIgviwTMxIhhtAQ1nqSIBdRjvGww/nno7V8=","C+FDqZHol8uH0QIVqLS+Uh4B/+/rFy8AMD30MaxcQwo=","885UIidieT+m0ZuDTXXEJs6RyqXQwWrFycnkqTQ9Bqk=","AsyEhypEOU7AL1g9DuAVfjTfwsTUzGokH3MvwHDZtm0=","KGuXDjDlx1/6NAKL4+XZJNOePsvmV5rU+jGnsV7bAmQ=","vdzWBLcY4FvgjLy2NIbkcNNS+p+ySAdkobg9AzqPER8=","IhQZcmxKNvhEeuR0e/9BfaAcH4I3KnnNA06zRpM7ioU=","h8oi5ypCFsYfNw5bLh23pcJRMYeI8+NMNOlRlyc6h+0=","fEeV8Bqor89rSrIrJm5Mjeh6Z4uVWtmeCDx9CT04x2A=","n3FrhrLV+aX3gf/a4yempn9Y+XjQCkkJAiMuDNnVN1k=","GxT6+CEXxY8Ak/besnRW/IK3DhneHQ5X5rIcw8L01DQ=","Rq/A2aTC9e54ldNjcpsJ26rX/h8JlN6ZHxgw7yEa6C0=","+/VZ56MsIPxMiyLAodzKXo5TEWdQp36z89qLhpzloAo=","daxmZaajRpZV+JxHiOYZhJBiSKN5ucqjh2WnGbHhirw=","DOCeoSMovIvLExkhIvisow9AuNXgeWs4ECkyR6EcqYU="],"checkpoint":{"envelope":"rekor.sigstore.dev - 1193050959916656506\n2105182512\nAwN9ekc1/G7254+BpIdlnrNlK+hHBofK3HOWYBgBSsU=\n\n— rekor.sigstore.dev wNI9ajBEAiBi61RvU9+1dR3CMmO7lN3c0olApmF92YVEiaAdsG7G7wIgK8nXdSNEaUl7MFT/csa0QweeafSgMZWDIVAUJjcC+Nc=\n"}},"canonicalizedBody":"eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiZHNzZSIsInNwZWMiOnsiZW52ZWxvcGVIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiZDIwMzVhZTdhODVjNGEwMTEyM2I4YTNkZjZkYmRmMGNmMzExYzIxNDZkNDg1NjRmMWYxMzA1ZDdlZjk2NzI4MiJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6ImIxNjdhYWEwZTViYzQ5Mjk3MDA3MWMxMTExNTg1NjY3ZjQ1NjkwZjJkZTgwMWI2NWRlNzRmODcyMTYzM2RjNGMifSwic2lnbmF0dXJlcyI6W3sic2lnbmF0dXJlIjoiTUVVQ0lEZ29oZElWMnBvVHJheEpFZ0RjS0QwQ0FwSFFxVU5mQlgzUUNEbm4vVmZiQWlFQTJSa0YyUUtMWUJQdnlISzJtZHdCQW5YSkNHMDI2R1Zyek9zTkJQMG1KRkU9IiwidmVyaWZpZXIiOiJMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VSTlJFTkRRWEpoWjBGM1NVSkJaMGxWV1VwUFNYSXlVSEZ4UlRFNFYzRmlNVE4zUkZGSU0wOW1PRzVWZDBObldVbExiMXBKZW1vd1JVRjNUWGNLVG5wRlZrMUNUVWRCTVZWRlEyaE5UV015Ykc1ak0xSjJZMjFWZFZwSFZqSk5ValIzU0VGWlJGWlJVVVJGZUZaNllWZGtlbVJIT1hsYVV6RndZbTVTYkFwamJURnNXa2RzYUdSSFZYZElhR05PVFdwWmQwNTZTWHBOVkdONFRXcFZOVmRvWTA1TmFsbDNUbnBKZWsxVVkzbE5hbFUxVjJwQlFVMUdhM2RGZDFsSUNrdHZXa2w2YWpCRFFWRlpTVXR2V2tsNmFqQkVRVkZqUkZGblFVVnVNSG81UWpGdmNEaFRZbkJrV0VkdVZXUmFjR1E1WjBkQ1oyTnJiWGcyY21wNVQza0tTbTU0TkdWSU1VOTZVRE5RYTNnd1EyMWhaRll4YVdORE1TdEtTRlYyZVZwRVNtZHBOSEp1VFV0eVUxazVabGxuYURaUFEwRmtWWGRuWjBoU1RVRTBSd3BCTVZWa1JIZEZRaTkzVVVWQmQwbElaMFJCVkVKblRsWklVMVZGUkVSQlMwSm5aM0pDWjBWR1FsRmpSRUY2UVdSQ1owNVdTRkUwUlVablVWVnBjbmROQ2pBNE5tcFVZakpSU3k5UE1uVmhRM0Y2TWpWVk1VcG5kMGgzV1VSV1VqQnFRa0puZDBadlFWVXpPVkJ3ZWpGWmEwVmFZalZ4VG1wd1MwWlhhWGhwTkZrS1drUTRkMHRuV1VSV1VqQlNRVkZJTDBKRFFYZEliMFZqV1ZjMWEyTnRWbWhNYmtKc1kyNVdiVnB0T0hoUFZHZDVVVWRrZEZsWGJITk1iVTUyWWxSQmN3cENaMjl5UW1kRlJVRlpUeTlOUVVWQ1FrSTFiMlJJVW5kamVtOTJUREprY0dSSGFERlphVFZxWWpJd2RtSkhPVzVoVnpSMllqSkdNV1JIWjNkTVoxbExDa3QzV1VKQ1FVZEVkbnBCUWtOQlVXZEVRalZ2WkVoU2QyTjZiM1pNTW1Sd1pFZG9NVmxwTldwaU1qQjJZa2M1Ym1GWE5IWmlNa1l4WkVkbmQxVm5XVXNLUzNkWlFrSkJSMFIyZWtGQ1IwRlNSVVJGU2tSYU1rMTRWRzV3Y21WVk1VVmhlazVHWVZad2RscEZhRk5rTWs0MllqSjRUbUV4YkhOVVYzUmhZbTFHV1FwVmJUbHJWakJzTVZkVVNUVmtSWEJWVTJ0a2FWSjZiSFZaVm1Nd1lrVXhjbGR1V2xwWFJsbDNXVlZGZDJkWmMwZERhWE5IUVZGUlFqRnVhME5DUVVsRkNtWlJVamRCU0d0QlpIZEVaRkJVUW5GNGMyTlNUVzFOV2tob2VWcGFlbU5EYjJ0d1pYVk9ORGh5Wml0SWFXNUxRVXg1Ym5WcVowRkJRVm9yVURreGFVOEtRVUZCUlVGM1FrbE5SVmxEU1ZGRWMxcG5MMGhoYzNaS1FsSTROMlEwTDNoWE4wNUhhbU0yTkhkalNsQnJhVmhvYWpsSVYzaGpaVEpQWjBsb1FWQkNjZ3BoVjJveFRtWnFOVk12ZWtoV1lrZFFSRVZwTm5Wa1pERnRlVWRtWm1GTVVqazNUR3MwYldndlRVRnZSME5EY1VkVFRUUTVRa0ZOUkVFeVowRk5SMVZEQ2sxUlJIZFBMMjF0YTJ4MlNrdFBiV05YVVRsVmNtUTBRWFI0TDB4NFZ6ZzJiM2N3YVhkQlZWaDRVVUZqVG5Sd09Ya3hNamt4YTBkeU1FVmxkbTFaZVZvS1NESnZRMDFJVG5aVFNGaHBiVmR4ZFdjek0xTnRRMlUyU1ZOVlYxbHlXalprUkZsblVUTndWbFkxZVZaUVZFdGxha1E1UTJwRkwySjJUMlJUYnpCUmVBcFBhV1JzTDNjOVBRb3RMUzB0TFVWT1JDQkRSVkpVU1VaSlEwRlVSUzB0TFMwdENnPT0ifV19fQ=="}],"timestampVerificationData":{"rfc3161Timestamps":[{"signedTimestamp":"MIICyTADAgEAMIICwAYJKoZIhvcNAQcCoIICsTCCAq0CAQMxDTALBglghkgBZQMEAgEwgbcGCyqGSIb3DQEJEAEEoIGnBIGkMIGhAgEBBgkrBgEEAYO/MAIwMTANBglghkgBZQMEAgEFAAQgAwzCDwVWZdB3WwNqJu+ZHwLaVzshODgRNZhPJWS9Fj0CFEst/z1mJJEAnSrhAURPFb42UPSGGA8yMDI2MDcyMzE3MTMwMFowAwIBAaAypDAwLjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MRUwEwYDVQQDEwxzaWdzdG9yZS10c2GgADGCAdswggHXAgEBMFEwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZAIUOhNULwyQYe68wUMvy4qOiyojiwwwCwYJYIZIAWUDBAIBoIH8MBoGCSqGSIb3DQEJAzENBgsqhkiG9w0BCRABBDAcBgkqhkiG9w0BCQUxDxcNMjYwNzIzMTcxMzAwWjAvBgkqhkiG9w0BCQQxIgQg2r4ZMh3vELDB+DnMjyfXRjrlSZJJHTcSeeWQWJuEWdkwgY4GCyqGSIb3DQEJEAIvMX8wfTB7MHkEIIX5J7wHq2LKw7RDVsEO/IGyxog/2nq55thw2dE6zQW3MFUwPaQ7MDkxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEgMB4GA1UEAxMXc2lnc3RvcmUtdHNhLXNlbGZzaWduZWQCFDoTVC8MkGHuvMFDL8uKjosqI4sMMAoGCCqGSM49BAMCBGcwZQIwZ9l6EqzWirAlq8VgTRBBiFq5FXepLdWOG1qT9F/XLS1V29uOevclJ/rUzB0c9qXAAjEAvlnrdSSro2nEHM4wDXI2E3bk/s4OLOfZOKb7g0rLzBvDAKu7tdmiSqjOaft90FY+"}]}},"dsseEnvelope":{"payload":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJkaWdlc3QiOnsic2hhMjU2IjoiYWNkMTM2M2Y0ZWUzMTk0ZmMwOWVkN2UwYjE5NjQ0MGMxNDc2YjhjMDg4ZGVlYmE5NDJlYjBiZjAwMGQzZjYyYyJ9LCJhbm5vdGF0aW9ucyI6e319XSwicHJlZGljYXRlVHlwZSI6Imh0dHBzOi8vc2lnc3RvcmUuZGV2L2Nvc2lnbi9zaWduL3YxIiwicHJlZGljYXRlIjp7fX0=","payloadType":"application/vnd.in-toto+json","signatures":[{"sig":"MEUCIDgohdIV2poTraxJEgDcKD0CApHQqUNfBX3QCDnn/VfbAiEA2RkF2QKLYBPvyHK2mdwBAnXJCG026GVrzOsNBP0mJFE="}]}} \ No newline at end of file diff --git a/maven-plugin/src/test/resources/sign-blob-message-signature-bundle.json b/maven-plugin/src/test/resources/sign-blob-message-signature-bundle.json new file mode 100644 index 0000000..3314367 --- /dev/null +++ b/maven-plugin/src/test/resources/sign-blob-message-signature-bundle.json @@ -0,0 +1,69 @@ +{ + "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "verificationMaterial": { + "certificate": { + "rawBytes": "MIIDMDCCAragAwIBAgIUYJOIr2PqqE18Wqb13wDQH3Of8nUwCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjYwNzIzMTcxMjU5WhcNMjYwNzIzMTcyMjU5WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEn0z9B1op8SbpdXGnUdZpd9gGBgckmx6rjyOyJnx4eH1OzP3Pkx0CmadV1icC1+JHUvyZDJgi4rnMKrSY9fYgh6OCAdUwggHRMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUirwM086jTb2QK/O2uaCqz25U1JgwHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4YZD8wKgYDVR0RAQH/BCAwHoEcYW5kcmVhLnBlcnVmZm8xOTgyQGdtYWlsLmNvbTAsBgorBgEEAYO/MAEBBB5odHRwczovL2dpdGh1Yi5jb20vbG9naW4vb2F1dGgwLgYKKwYBBAGDvzABCAQgDB5odHRwczovL2dpdGh1Yi5jb20vbG9naW4vb2F1dGgwUgYKKwYBBAGDvzABGAREDEJDZ2MxTnpreU1EazNFaVpvZEhSd2N6b2xNa1lsTWtabmFYUm9kV0l1WTI5dEpUSkdiRzluYVc0bE1rWnZZWFYwYUEwgYsGCisGAQQB1nkCBAIEfQR7AHkAdwDdPTBqxscRMmMZHhyZZzcCokpeuN48rf+HinKALynujgAAAZ+P91iOAAAEAwBIMEYCIQDsZg/HasvJBR87d4/xW7NGjc64wcJPkiXhj9HWxce2OgIhAPBraWj1Nfj5S/zHVbGPDEi6udd1myGffaLR97Lk4mh/MAoGCCqGSM49BAMDA2gAMGUCMQDwO/mmklvJKOmcWQ9Urd4Atx/LxW86ow0iwAUXxQAcNtp9y1291kGr0EevmYyZH2oCMHNvSHXimWqug33SmCe6ISUWYrZ6dDYgQ3pVV5yVPTKejD9CjE/bvOdSo0QxOidl/w==" + }, + "tlogEntries": [ + { + "logIndex": "2227086749", + "logId": { + "keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=" + }, + "kindVersion": { + "kind": "dsse", + "version": "0.0.1" + }, + "integratedTime": "1784826780", + "inclusionPromise": { + "signedEntryTimestamp": "MEUCIQC3i8L8ZqTPSnHBv37fR9WdW9axWSPtbgkOfzaCSiKxawIgTg8uzLVWJuprfKuBktCcS66XBUw41TztdcUG+3IWXXI=" + }, + "inclusionProof": { + "logIndex": "2105182487", + "rootHash": "AwN9ekc1/G7254+BpIdlnrNlK+hHBofK3HOWYBgBSsU=", + "treeSize": "2105182512", + "hashes": [ + "3vb8mxw/7UV3lKjibgNnBc/OE4b2JYg713V3191HXxo=", + "oiha2YqFZpoicbmF5emPDV64+4pWOmdxmHiYqRWPax0=", + "wiHhnCkCV01aSmt0+BINIMktyaqx+2uSaTYMbqrKivw=", + "B/1D3uTy0xui35zyCuojJaosxyfVmKXh6dSiZbR8DSc=", + "nhXLjZYPjLShh68MITal9wRKbeA8lKVdk56Hwb9UNwU=", + "ue7CcHdEVIgviwTMxIhhtAQ1nqSIBdRjvGww/nno7V8=", + "C+FDqZHol8uH0QIVqLS+Uh4B/+/rFy8AMD30MaxcQwo=", + "885UIidieT+m0ZuDTXXEJs6RyqXQwWrFycnkqTQ9Bqk=", + "AsyEhypEOU7AL1g9DuAVfjTfwsTUzGokH3MvwHDZtm0=", + "KGuXDjDlx1/6NAKL4+XZJNOePsvmV5rU+jGnsV7bAmQ=", + "vdzWBLcY4FvgjLy2NIbkcNNS+p+ySAdkobg9AzqPER8=", + "IhQZcmxKNvhEeuR0e/9BfaAcH4I3KnnNA06zRpM7ioU=", + "h8oi5ypCFsYfNw5bLh23pcJRMYeI8+NMNOlRlyc6h+0=", + "fEeV8Bqor89rSrIrJm5Mjeh6Z4uVWtmeCDx9CT04x2A=", + "n3FrhrLV+aX3gf/a4yempn9Y+XjQCkkJAiMuDNnVN1k=", + "GxT6+CEXxY8Ak/besnRW/IK3DhneHQ5X5rIcw8L01DQ=", + "Rq/A2aTC9e54ldNjcpsJ26rX/h8JlN6ZHxgw7yEa6C0=", + "+/VZ56MsIPxMiyLAodzKXo5TEWdQp36z89qLhpzloAo=", + "daxmZaajRpZV+JxHiOYZhJBiSKN5ucqjh2WnGbHhirw=", + "DOCeoSMovIvLExkhIvisow9AuNXgeWs4ECkyR6EcqYU=" + ], + "checkpoint": { + "envelope": "rekor.sigstore.dev - 1193050959916656506\n2105182512\nAwN9ekc1/G7254+BpIdlnrNlK+hHBofK3HOWYBgBSsU=\n\n\u2014 rekor.sigstore.dev wNI9ajBEAiBi61RvU9+1dR3CMmO7lN3c0olApmF92YVEiaAdsG7G7wIgK8nXdSNEaUl7MFT/csa0QweeafSgMZWDIVAUJjcC+Nc=\n" + } + }, + "canonicalizedBody": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiZHNzZSIsInNwZWMiOnsiZW52ZWxvcGVIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiZDIwMzVhZTdhODVjNGEwMTEyM2I4YTNkZjZkYmRmMGNmMzExYzIxNDZkNDg1NjRmMWYxMzA1ZDdlZjk2NzI4MiJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6ImIxNjdhYWEwZTViYzQ5Mjk3MDA3MWMxMTExNTg1NjY3ZjQ1NjkwZjJkZTgwMWI2NWRlNzRmODcyMTYzM2RjNGMifSwic2lnbmF0dXJlcyI6W3sic2lnbmF0dXJlIjoiTUVVQ0lEZ29oZElWMnBvVHJheEpFZ0RjS0QwQ0FwSFFxVU5mQlgzUUNEbm4vVmZiQWlFQTJSa0YyUUtMWUJQdnlISzJtZHdCQW5YSkNHMDI2R1Zyek9zTkJQMG1KRkU9IiwidmVyaWZpZXIiOiJMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VSTlJFTkRRWEpoWjBGM1NVSkJaMGxWV1VwUFNYSXlVSEZ4UlRFNFYzRmlNVE4zUkZGSU0wOW1PRzVWZDBObldVbExiMXBKZW1vd1JVRjNUWGNLVG5wRlZrMUNUVWRCTVZWRlEyaE5UV015Ykc1ak0xSjJZMjFWZFZwSFZqSk5ValIzU0VGWlJGWlJVVVJGZUZaNllWZGtlbVJIT1hsYVV6RndZbTVTYkFwamJURnNXa2RzYUdSSFZYZElhR05PVFdwWmQwNTZTWHBOVkdONFRXcFZOVmRvWTA1TmFsbDNUbnBKZWsxVVkzbE5hbFUxVjJwQlFVMUdhM2RGZDFsSUNrdHZXa2w2YWpCRFFWRlpTVXR2V2tsNmFqQkVRVkZqUkZGblFVVnVNSG81UWpGdmNEaFRZbkJrV0VkdVZXUmFjR1E1WjBkQ1oyTnJiWGcyY21wNVQza0tTbTU0TkdWSU1VOTZVRE5RYTNnd1EyMWhaRll4YVdORE1TdEtTRlYyZVZwRVNtZHBOSEp1VFV0eVUxazVabGxuYURaUFEwRmtWWGRuWjBoU1RVRTBSd3BCTVZWa1JIZEZRaTkzVVVWQmQwbElaMFJCVkVKblRsWklVMVZGUkVSQlMwSm5aM0pDWjBWR1FsRmpSRUY2UVdSQ1owNVdTRkUwUlVablVWVnBjbmROQ2pBNE5tcFVZakpSU3k5UE1uVmhRM0Y2TWpWVk1VcG5kMGgzV1VSV1VqQnFRa0puZDBadlFWVXpPVkJ3ZWpGWmEwVmFZalZ4VG1wd1MwWlhhWGhwTkZrS1drUTRkMHRuV1VSV1VqQlNRVkZJTDBKRFFYZEliMFZqV1ZjMWEyTnRWbWhNYmtKc1kyNVdiVnB0T0hoUFZHZDVVVWRrZEZsWGJITk1iVTUyWWxSQmN3cENaMjl5UW1kRlJVRlpUeTlOUVVWQ1FrSTFiMlJJVW5kamVtOTJUREprY0dSSGFERlphVFZxWWpJd2RtSkhPVzVoVnpSMllqSkdNV1JIWjNkTVoxbExDa3QzV1VKQ1FVZEVkbnBCUWtOQlVXZEVRalZ2WkVoU2QyTjZiM1pNTW1Sd1pFZG9NVmxwTldwaU1qQjJZa2M1Ym1GWE5IWmlNa1l4WkVkbmQxVm5XVXNLUzNkWlFrSkJSMFIyZWtGQ1IwRlNSVVJGU2tSYU1rMTRWRzV3Y21WVk1VVmhlazVHWVZad2RscEZhRk5rTWs0MllqSjRUbUV4YkhOVVYzUmhZbTFHV1FwVmJUbHJWakJzTVZkVVNUVmtSWEJWVTJ0a2FWSjZiSFZaVm1Nd1lrVXhjbGR1V2xwWFJsbDNXVlZGZDJkWmMwZERhWE5IUVZGUlFqRnVhME5DUVVsRkNtWlJVamRCU0d0QlpIZEVaRkJVUW5GNGMyTlNUVzFOV2tob2VWcGFlbU5EYjJ0d1pYVk9ORGh5Wml0SWFXNUxRVXg1Ym5WcVowRkJRVm9yVURreGFVOEtRVUZCUlVGM1FrbE5SVmxEU1ZGRWMxcG5MMGhoYzNaS1FsSTROMlEwTDNoWE4wNUhhbU0yTkhkalNsQnJhVmhvYWpsSVYzaGpaVEpQWjBsb1FWQkNjZ3BoVjJveFRtWnFOVk12ZWtoV1lrZFFSRVZwTm5Wa1pERnRlVWRtWm1GTVVqazNUR3MwYldndlRVRnZSME5EY1VkVFRUUTVRa0ZOUkVFeVowRk5SMVZEQ2sxUlJIZFBMMjF0YTJ4MlNrdFBiV05YVVRsVmNtUTBRWFI0TDB4NFZ6ZzJiM2N3YVhkQlZWaDRVVUZqVG5Sd09Ya3hNamt4YTBkeU1FVmxkbTFaZVZvS1NESnZRMDFJVG5aVFNGaHBiVmR4ZFdjek0xTnRRMlUyU1ZOVlYxbHlXalprUkZsblVUTndWbFkxZVZaUVZFdGxha1E1UTJwRkwySjJUMlJUYnpCUmVBcFBhV1JzTDNjOVBRb3RMUzB0TFVWT1JDQkRSVkpVU1VaSlEwRlVSUzB0TFMwdENnPT0ifV19fQ==" + } + ], + "timestampVerificationData": { + "rfc3161Timestamps": [ + { + "signedTimestamp": "MIICyTADAgEAMIICwAYJKoZIhvcNAQcCoIICsTCCAq0CAQMxDTALBglghkgBZQMEAgEwgbcGCyqGSIb3DQEJEAEEoIGnBIGkMIGhAgEBBgkrBgEEAYO/MAIwMTANBglghkgBZQMEAgEFAAQgAwzCDwVWZdB3WwNqJu+ZHwLaVzshODgRNZhPJWS9Fj0CFEst/z1mJJEAnSrhAURPFb42UPSGGA8yMDI2MDcyMzE3MTMwMFowAwIBAaAypDAwLjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MRUwEwYDVQQDEwxzaWdzdG9yZS10c2GgADGCAdswggHXAgEBMFEwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZAIUOhNULwyQYe68wUMvy4qOiyojiwwwCwYJYIZIAWUDBAIBoIH8MBoGCSqGSIb3DQEJAzENBgsqhkiG9w0BCRABBDAcBgkqhkiG9w0BCQUxDxcNMjYwNzIzMTcxMzAwWjAvBgkqhkiG9w0BCQQxIgQg2r4ZMh3vELDB+DnMjyfXRjrlSZJJHTcSeeWQWJuEWdkwgY4GCyqGSIb3DQEJEAIvMX8wfTB7MHkEIIX5J7wHq2LKw7RDVsEO/IGyxog/2nq55thw2dE6zQW3MFUwPaQ7MDkxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEgMB4GA1UEAxMXc2lnc3RvcmUtdHNhLXNlbGZzaWduZWQCFDoTVC8MkGHuvMFDL8uKjosqI4sMMAoGCCqGSM49BAMCBGcwZQIwZ9l6EqzWirAlq8VgTRBBiFq5FXepLdWOG1qT9F/XLS1V29uOevclJ/rUzB0c9qXAAjEAvlnrdSSro2nEHM4wDXI2E3bk/s4OLOfZOKb7g0rLzBvDAKu7tdmiSqjOaft90FY+" + } + ] + } + }, + "messageSignature": { + "messageDigest": { + "algorithm": "SHA2_256", + "digest": "k6RLu5bHUSGOTADUeeTBQ1gSKjiazKFiBbHk0NxflHY=" + }, + "signature": "MEUCIDgohdIV2poTraxJEgDcKD0CApHQqUNfBX3QCDnn/VfbAiEA2RkF2QKLYBPvyHK2mdwBAnXJCG026GVrzOsNBP0mJFE=" + } +} \ No newline at end of file From 270f3f0127a1d8dc0587f9a684d2decc46ea64fb Mon Sep 17 00:00:00 2001 From: andreatp Date: Thu, 27 Aug 2026 11:47:33 +0100 Subject: [PATCH 2/2] Support regex sigstore identities, and document the shared reusable-workflow identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sigstoreIssuer and sigstoreIdentity are compared with String.equals, so the glob the README suggested (https://github.com/roastedroot/*) could never match — the `*` was a literal. Add sigstoreIssuerRegex and sigstoreIdentityRegex, backed by sigstore-java's StringMatcher.regex, so the documented intent is actually expressible. Setting both forms of one field is rejected, and an invalid pattern is reported against the parameter that carried it rather than surfacing as a bare PatternSyntaxException. Document that a reusable workflow's certificate identity is the reusable workflow's own ref, identical for every repository that calls it. Pinning it proves an artifact was signed by something using that workflow, not that it came from the expected repository — the caller appears only in certificate extensions inlay does not check. That is a sharp edge worth stating next to the configuration that invites it. --- README.md | 13 ++++- .../io/roastedroot/inlay/maven/FetchMojo.java | 49 +++++++++++++++--- .../roastedroot/inlay/maven/ModuleConfig.java | 20 ++++++++ .../inlay/maven/FetchMojoTest.java | 51 +++++++++++++++++++ 4 files changed, 126 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4cea589..b0b9855 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,18 @@ Verification runs inline after fetch — no separate step. Configure on each mod ``` -Both values are matched **exactly**. Globs such as `https://github.com/myorg/*` are not supported and will never match — the `*` is compared as a literal character. To read the correct values off an existing signature, inspect the signing certificate in the bundle: the identity is its subject alternative name, and the issuer is the OIDC issuer extension (`1.3.6.1.4.1.57264.1.1`). An artifact signed locally rather than by CI has the issuer `https://github.com/login/oauth` and the signer's email address as identity. +`sigstoreIssuer` and `sigstoreIdentity` are matched **exactly** — a glob such as `https://github.com/myorg/*` never matches, because the `*` is compared as a literal character. For patterns, use `sigstoreIssuerRegex` / `sigstoreIdentityRegex` instead: + +```xml +https://token.actions.githubusercontent.com +https://github\.com/myorg/.* +``` + +Setting both the exact and the regex form of the same field is an error. Setting neither leaves that field unconstrained. + +To read the right values off an existing signature, inspect the signing certificate in the bundle: the identity is its subject alternative name, and the issuer is the OIDC issuer extension (`1.3.6.1.4.1.57264.1.1`). An artifact signed locally rather than by CI has the issuer `https://github.com/login/oauth` and the signer's email address as its identity. + +> **A reusable workflow's identity is shared.** For a job run through a reusable workflow, the certificate identity is the *reusable* workflow's ref — for the publisher below, `https://github.com/roastedroot/inlay/.github/workflows/wasm-publish.yml@refs/heads/main` — and it is the same for **every** repository that calls it. Pinning it proves the artifact was signed by something using that workflow, not that it came from the repository you expect. The calling repository appears only in other certificate extensions, which inlay does not currently check. If that distinction matters to you, sign from a workflow of your own instead. Uses [sigstore-java](https://github.com/sigstore/sigstore-java) keyless verification. The bundle is discovered through OCI referrers on the resolved manifest digest — nothing needs to sit next to the artifact. If several sigstore bundles are attached, each is tried in a stable order and any one that verifies against the configured identity is accepted. diff --git a/maven-plugin/src/main/java/io/roastedroot/inlay/maven/FetchMojo.java b/maven-plugin/src/main/java/io/roastedroot/inlay/maven/FetchMojo.java index df222bb..70649ba 100644 --- a/maven-plugin/src/main/java/io/roastedroot/inlay/maven/FetchMojo.java +++ b/maven-plugin/src/main/java/io/roastedroot/inlay/maven/FetchMojo.java @@ -6,6 +6,7 @@ import dev.sigstore.VerificationOptions.CertificateMatcher; import dev.sigstore.bundle.Bundle; import dev.sigstore.bundle.BundleParseException; +import dev.sigstore.strings.RegexSyntaxException; import dev.sigstore.strings.StringMatcher; import dev.sigstore.trustroot.SigstoreConfigurationException; import io.roastedroot.inlay.InlayClient; @@ -159,7 +160,10 @@ private boolean fetchModule(ModuleConfig module, LockFile lock, WkgParser parser } boolean needsVerification = - module.getSigstoreIssuer() != null || module.getSigstoreIdentity() != null; + module.getSigstoreIssuer() != null + || module.getSigstoreIdentity() != null + || module.getSigstoreIssuerRegex() != null + || module.getSigstoreIdentityRegex() != null; if (needsVerification) { Files.createDirectories(outputPath.getParent()); @@ -246,12 +250,19 @@ private void verifyBundle( var optionsBuilder = VerificationOptions.builder(); var matcherBuilder = CertificateMatcher.fulcio(); - if (module.getSigstoreIdentity() != null) { - matcherBuilder.subjectAlternativeName( - StringMatcher.string(module.getSigstoreIdentity())); + StringMatcher identity = + matcherFor( + "Identity", + module.getSigstoreIdentity(), + module.getSigstoreIdentityRegex()); + if (identity != null) { + matcherBuilder.subjectAlternativeName(identity); } - if (module.getSigstoreIssuer() != null) { - matcherBuilder.issuer(StringMatcher.string(module.getSigstoreIssuer())); + StringMatcher issuer = + matcherFor( + "Issuer", module.getSigstoreIssuer(), module.getSigstoreIssuerRegex()); + if (issuer != null) { + matcherBuilder.issuer(issuer); } optionsBuilder.addCertificateMatchers(matcherBuilder.build()); @@ -278,6 +289,32 @@ private void verifyBundle( } } + /** The exact form compares with String.equals, so globs need the regex form. */ + static StringMatcher matcherFor(String field, String exact, String regex) + throws MojoExecutionException { + if (exact != null && regex != null) { + throw new MojoExecutionException( + "Configure either sigstore" + + field + + " or sigstore" + + field + + "Regex, not" + + " both"); + } + if (exact != null) { + return StringMatcher.string(exact); + } + if (regex == null) { + return null; + } + try { + return StringMatcher.regex(regex); + } catch (RegexSyntaxException e) { + throw new MojoExecutionException( + "Invalid sigstore" + field + "Regex: " + regex + ": " + e.getMessage(), e); + } + } + /** * The digest a bundle's signature covers: the OCI manifest digest for `cosign sign`, or null * for `cosign sign-blob`, where sigstore-java hashes the artifact itself. diff --git a/maven-plugin/src/main/java/io/roastedroot/inlay/maven/ModuleConfig.java b/maven-plugin/src/main/java/io/roastedroot/inlay/maven/ModuleConfig.java index e6f7d20..f1822b1 100644 --- a/maven-plugin/src/main/java/io/roastedroot/inlay/maven/ModuleConfig.java +++ b/maven-plugin/src/main/java/io/roastedroot/inlay/maven/ModuleConfig.java @@ -9,6 +9,8 @@ public class ModuleConfig { private File outputFile; private String sigstoreIssuer; private String sigstoreIdentity; + private String sigstoreIssuerRegex; + private String sigstoreIdentityRegex; public String getImageRef() { return imageRef; @@ -49,4 +51,22 @@ public String getSigstoreIdentity() { public void setSigstoreIdentity(String sigstoreIdentity) { this.sigstoreIdentity = sigstoreIdentity; } + + /** Regular expression alternative to {@link #getSigstoreIssuer()}, which matches exactly. */ + public String getSigstoreIssuerRegex() { + return sigstoreIssuerRegex; + } + + public void setSigstoreIssuerRegex(String sigstoreIssuerRegex) { + this.sigstoreIssuerRegex = sigstoreIssuerRegex; + } + + /** Regular expression alternative to {@link #getSigstoreIdentity()}, which matches exactly. */ + public String getSigstoreIdentityRegex() { + return sigstoreIdentityRegex; + } + + public void setSigstoreIdentityRegex(String sigstoreIdentityRegex) { + this.sigstoreIdentityRegex = sigstoreIdentityRegex; + } } diff --git a/maven-plugin/src/test/java/io/roastedroot/inlay/maven/FetchMojoTest.java b/maven-plugin/src/test/java/io/roastedroot/inlay/maven/FetchMojoTest.java index ff9e720..26a2708 100644 --- a/maven-plugin/src/test/java/io/roastedroot/inlay/maven/FetchMojoTest.java +++ b/maven-plugin/src/test/java/io/roastedroot/inlay/maven/FetchMojoTest.java @@ -9,6 +9,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import dev.sigstore.bundle.Bundle; +import dev.sigstore.strings.StringMatcher; import java.io.File; import java.io.InputStream; import java.io.InputStreamReader; @@ -104,6 +105,56 @@ void dsseBundleRejectsNonSha256ManifestDigest() throws Exception { assertTrue(ex.getMessage().contains("Unsupported manifest digest algorithm")); } + @Test + void exactIdentityMatchesOnlyItself() throws Exception { + StringMatcher matcher = FetchMojo.matcherFor("Identity", "https://github.com/o/r", null); + + assertTrue(matcher.test("https://github.com/o/r")); + assertFalse(matcher.test("https://github.com/o/rr")); + } + + @Test + void exactIdentityDoesNotTreatStarAsAWildcard() throws Exception { + StringMatcher matcher = + FetchMojo.matcherFor("Identity", "https://github.com/myorg/*", null); + + assertFalse(matcher.test("https://github.com/myorg/repo")); + assertTrue(matcher.test("https://github.com/myorg/*")); + } + + @Test + void regexIdentityMatchesAPattern() throws Exception { + StringMatcher matcher = + FetchMojo.matcherFor("Identity", null, "https://github\\.com/myorg/.*"); + + assertTrue(matcher.test("https://github.com/myorg/repo")); + assertFalse(matcher.test("https://github.com/other/repo")); + } + + @Test + void noIdentityConfiguredLeavesTheFieldUnconstrained() throws Exception { + assertNull(FetchMojo.matcherFor("Identity", null, null)); + } + + @Test + void exactAndRegexTogetherIsRejected() { + MojoExecutionException ex = + assertThrows( + MojoExecutionException.class, + () -> FetchMojo.matcherFor("Issuer", "a", "b")); + assertTrue(ex.getMessage().contains("sigstoreIssuer")); + assertTrue(ex.getMessage().contains("sigstoreIssuerRegex")); + } + + @Test + void invalidRegexIsReportedAgainstItsParameter() { + MojoExecutionException ex = + assertThrows( + MojoExecutionException.class, + () -> FetchMojo.matcherFor("Identity", null, "[unclosed")); + assertTrue(ex.getMessage().contains("sigstoreIdentityRegex")); + } + @Test void hexToBytesRoundTrips() throws Exception { assertArrayEquals(new byte[] {0x00, (byte) 0xff, 0x1a}, FetchMojo.hexToBytes("00ff1a"));