diff --git a/src/main/java/land/oras/policy/PolicyRequirement.java b/src/main/java/land/oras/policy/PolicyRequirement.java index 0934d889..456a3cdf 100644 --- a/src/main/java/land/oras/policy/PolicyRequirement.java +++ b/src/main/java/land/oras/policy/PolicyRequirement.java @@ -24,6 +24,8 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import java.security.PublicKey; +import java.util.List; import land.oras.OrasModel; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -203,14 +205,21 @@ boolean verify(PolicyContext context) { /** * Require a valid keyed Sigstore/Cosign signature attached to the image as an OCI referrer. * - * Only public keys on keyPath or keyData are supported; keyless verification is not supported + *

Exactly one of {@code keyPath}, {@code keyPaths}, {@code keyData}, or {@code keyDatas} must + * be present; keyless verification is not supported. * - *

JSON example ({@code signedIdentity}, if present, is ignored): + *

+ * + *

JSON examples ({@code signedIdentity}, if present, is ignored): *

{@code
-     * {
-     *   "type": "sigstoreSigned",
-     *   "keyPath": "/etc/pki/containers/cosign.pub"
-     * }
+     * {"type": "sigstoreSigned", "keyPath": "/etc/pki/containers/cosign.pub"}
+     * {"type": "sigstoreSigned", "keyPaths": ["/etc/pki/a.pub", "/etc/pki/b.pub"]}
+     * {"type": "sigstoreSigned", "keyDatas": ["", ""]}
      * }
*/ @OrasModel @@ -228,18 +237,37 @@ public static final class SigstoreSigned extends PolicyRequirement { private final @Nullable String keyPath; private final @Nullable String keyData; + private final @Nullable List keyPaths; + private final @Nullable List keyDatas; /** * Creates a new {@link SigstoreSigned} requirement. * - * @param keyPath path to a Sigstore/Cosign public key file (mutually exclusive with {@code keyData}). - * @param keyData base64-encoded Sigstore/Cosign public key (mutually exclusive with {@code keyPath}). + * @param keyPath path to a single Sigstore/Cosign public key file. + * @param keyData base64-encoded single Sigstore/Cosign public key. + * @param keyPaths list of paths to Sigstore/Cosign public key files. + * @param keyDatas list of base64-encoded Sigstore/Cosign public keys. */ @JsonCreator public SigstoreSigned( - @JsonProperty("keyPath") @Nullable String keyPath, @JsonProperty("keyData") @Nullable String keyData) { + @JsonProperty("keyPath") @Nullable String keyPath, + @JsonProperty("keyData") @Nullable String keyData, + @JsonProperty("keyPaths") @Nullable List keyPaths, + @JsonProperty("keyDatas") @Nullable List keyDatas) { this.keyPath = keyPath; this.keyData = keyData; + this.keyPaths = keyPaths; + this.keyDatas = keyDatas; + } + + /** + * Convenience constructor for a single key (backward compatibility). + * + * @param keyPath path to a Sigstore/Cosign public key file (mutually exclusive with {@code keyData}). + * @param keyData base64-encoded Sigstore/Cosign public key (mutually exclusive with {@code keyPath}). + */ + public SigstoreSigned(@Nullable String keyPath, @Nullable String keyData) { + this(keyPath, keyData, null, null); } @Override @@ -258,26 +286,24 @@ boolean verify(PolicyContext context) { context.getScope()); return true; } - if (keyPath == null && keyData == null) { + if (keyPath == null && keyData == null && keyPaths == null && keyDatas == null) { LOG.warn( - "Policy requirement '{}' for {} has no keyPath or keyData " + "Policy requirement '{}' for {} has no keyPath, keyPaths, keyData, or keyDatas " + "(keyless verification is not supported); rejecting", getType(), context.getReference()); return false; } - java.security.PublicKey key = SigstoreVerifier.loadKey(this); - if (key == null) { + List keys = SigstoreVerifier.loadKeys(this); + if (keys.isEmpty()) { LOG.warn( - "Policy requirement '{}' for {} could not load the configured public key " - + "(keyPath={}, keyData={}); rejecting", + "Policy requirement '{}' for {} could not load any configured public key; rejecting", getType(), - context.getReference(), - keyPath, - keyData != null ? "" : null); + context.getReference()); return false; } - boolean verified = SigstoreVerifier.verify(context.fetchSignatureBundle(), imageDigest, key); + List bundles = context.fetchSignatureBundle(); + boolean verified = SigstoreVerifier.verifyWithAnyKey(bundles, imageDigest, keys); if (!verified) { LOG.warn( "Policy requirement '{}' failed: no valid signature for {}", getType(), context.getReference()); @@ -302,5 +328,23 @@ boolean verify(PolicyContext context) { public @Nullable String getKeyData() { return keyData; } + + /** + * Return the list of paths to Sigstore/Cosign public key files, or {@code null} if not set. + * + * @return the key paths list, may be {@code null}. + */ + public @Nullable List getKeyPaths() { + return keyPaths; + } + + /** + * Return the list of base64-encoded Sigstore/Cosign public keys, or {@code null} if not set. + * + * @return the key datas list, may be {@code null}. + */ + public @Nullable List getKeyDatas() { + return keyDatas; + } } } diff --git a/src/main/java/land/oras/policy/SigstoreVerifier.java b/src/main/java/land/oras/policy/SigstoreVerifier.java index 57b1cb83..a38b6017 100644 --- a/src/main/java/land/oras/policy/SigstoreVerifier.java +++ b/src/main/java/land/oras/policy/SigstoreVerifier.java @@ -101,13 +101,28 @@ private SigstoreVerifier() {} * @return {@code true} if at least one bundle verifies and binds to {@code imageDigest}. */ static boolean verify(List bundles, String imageDigest, PublicKey trustedKey) { + return verifyWithAnyKey(bundles, imageDigest, List.of(trustedKey)); + } + + /** + * Verify that at least one of the given Sigstore bundles is a valid signature, made by + * any of the trusted keys, over the given image digest. + * + * @param bundles the raw bundle blob bytes fetched from the registry (one per referrer). + * @param imageDigest the full image digest being pulled, e.g. {@code "sha256:abc..."}. + * @param trustedKeys the list of public keys configured in the policy requirement. + * @return {@code true} if at least one bundle verifies and binds to {@code imageDigest}. + */ + static boolean verifyWithAnyKey(List bundles, String imageDigest, List trustedKeys) { if (bundles.isEmpty()) { LOG.debug("No Sigstore bundles attached to image {}", imageDigest); return false; } for (byte[] bundle : bundles) { - if (verifyBundle(bundle, imageDigest, trustedKey)) { - return true; + for (PublicKey key : trustedKeys) { + if (verifyBundle(bundle, imageDigest, key)) { + return true; + } } } LOG.debug("No attached Sigstore bundle verified for artifact with digest {}", imageDigest); @@ -263,6 +278,52 @@ static byte[] preAuthEncoding(String payloadType, byte[] payload) { return null; } + /** + * Load all public keys configured on a {@link PolicyRequirement.SigstoreSigned} requirement. + * Considers {@code keyPath}, {@code keyData}, {@code keyPaths}, and {@code keyDatas}. + * Keys that fail to load are skipped with a warning. + * + * @param requirement the policy requirement. + * @return a non-null (possibly empty) list of successfully loaded public keys. + */ + static List loadKeys(PolicyRequirement.SigstoreSigned requirement) { + java.util.List keys = new java.util.ArrayList<>(); + + // Single-key fields + PublicKey single = loadKey(requirement); + if (single != null) { + keys.add(single); + } + + // keyPaths list + List keyPaths = requirement.getKeyPaths(); + if (keyPaths != null) { + for (String path : keyPaths) { + PublicKey k = loadKeyFromPath(path); + if (k != null) { + keys.add(k); + } else { + LOG.warn("Failed to load public key from keyPaths entry: {}", path); + } + } + } + + // keyDatas list + List keyDatas = requirement.getKeyDatas(); + if (keyDatas != null) { + for (String data : keyDatas) { + PublicKey k = loadKeyFromData(data); + if (k != null) { + keys.add(k); + } else { + LOG.warn("Failed to load public key from keyDatas entry"); + } + } + } + + return keys; + } + private static @Nullable PublicKey loadKeyFromPath(String path) { String pem; try { diff --git a/src/test/java/land/oras/policy/ContainersPolicyTest.java b/src/test/java/land/oras/policy/ContainersPolicyTest.java index 3a32376f..eca0ab4e 100644 --- a/src/test/java/land/oras/policy/ContainersPolicyTest.java +++ b/src/test/java/land/oras/policy/ContainersPolicyTest.java @@ -347,6 +347,138 @@ void sigstoreSignedWithKeyDataInline(@TempDir Path dir) throws IOException { SigstoreTestSupport.signedBundle(kp.getPrivate(), SigstoreTestSupport.IMAGE_DIGEST)))); } + @Test + void sigstoreSignedWithKeyPathsList(@TempDir Path dir) throws IOException { + KeyPair kp1 = SigstoreTestSupport.generateKeyPair(); + KeyPair kp2 = SigstoreTestSupport.generateKeyPair(); + Path keyFile1 = dir.resolve("cosign1.pub"); + Path keyFile2 = dir.resolve("cosign2.pub"); + Files.writeString(keyFile1, SigstoreTestSupport.publicKeyPem(kp1.getPublic())); + Files.writeString(keyFile2, SigstoreTestSupport.publicKeyPem(kp2.getPublic())); + + Path path = dir.resolve("policy.json"); + // language=json + Files.writeString( + path, + """ + { + "default": [{"type": "reject"}], + "transports": { + "docker": { + "registry.example.com/app": [{ + "type": "sigstoreSigned", + "keyPaths": ["%s", "%s"] + }] + } + } + } + """ + .formatted( + keyFile1.toString().replace("\\", "\\\\"), + keyFile2.toString().replace("\\", "\\\\"))); + ContainersPolicy policy = ContainersPolicy.newPolicy(path); + + // Accepted when signed by kp1 + assertDoesNotThrow(() -> policy.verify(context( + "registry.example.com/app", + "registry.example.com/app:latest", + SigstoreTestSupport.signedBundle(kp1.getPrivate(), SigstoreTestSupport.IMAGE_DIGEST)))); + + // Accepted when signed by kp2 + assertDoesNotThrow(() -> policy.verify(context( + "registry.example.com/app", + "registry.example.com/app:latest", + SigstoreTestSupport.signedBundle(kp2.getPrivate(), SigstoreTestSupport.IMAGE_DIGEST)))); + + // Rejected when signed by an untrusted key + KeyPair attacker = SigstoreTestSupport.generateKeyPair(); + assertThrows( + OrasException.class, + () -> policy.verify(context( + "registry.example.com/app", + "registry.example.com/app:latest", + SigstoreTestSupport.signedBundle(attacker.getPrivate(), SigstoreTestSupport.IMAGE_DIGEST)))); + } + + @Test + void sigstoreSignedWithKeyDatasList(@TempDir Path dir) throws IOException { + KeyPair kp1 = SigstoreTestSupport.generateKeyPair(); + KeyPair kp2 = SigstoreTestSupport.generateKeyPair(); + + Path path = dir.resolve("policy.json"); + // language=json + Files.writeString( + path, + """ + { + "default": [{"type": "reject"}], + "transports": { + "docker": { + "registry.example.com/app": [{ + "type": "sigstoreSigned", + "keyDatas": ["%s", "%s"] + }] + } + } + } + """ + .formatted( + SigstoreTestSupport.keyData(kp1.getPublic()), + SigstoreTestSupport.keyData(kp2.getPublic()))); + ContainersPolicy policy = ContainersPolicy.newPolicy(path); + + // Accepted when signed by kp1 + assertDoesNotThrow(() -> policy.verify(context( + "registry.example.com/app", + "registry.example.com/app:latest", + SigstoreTestSupport.signedBundle(kp1.getPrivate(), SigstoreTestSupport.IMAGE_DIGEST)))); + + // Accepted when signed by kp2 + assertDoesNotThrow(() -> policy.verify(context( + "registry.example.com/app", + "registry.example.com/app:latest", + SigstoreTestSupport.signedBundle(kp2.getPrivate(), SigstoreTestSupport.IMAGE_DIGEST)))); + + // Rejected when signed by an untrusted key + KeyPair attacker = SigstoreTestSupport.generateKeyPair(); + assertThrows( + OrasException.class, + () -> policy.verify(context( + "registry.example.com/app", + "registry.example.com/app:latest", + SigstoreTestSupport.signedBundle(attacker.getPrivate(), SigstoreTestSupport.IMAGE_DIGEST)))); + } + + @Test + void sigstoreSignedKeyPathsDeserializesCorrectly(@TempDir Path dir) throws IOException { + Path path = dir.resolve("policy.json"); + // language=json + Files.writeString( + path, + """ + { + "default": [{"type": "reject"}], + "transports": { + "docker": { + "registry.example.com/app": [{ + "type": "sigstoreSigned", + "keyPaths": ["/etc/pki/a.pub", "/etc/pki/b.pub"] + }] + } + } + } + """); + ContainersPolicy policy = ContainersPolicy.newPolicy(path); + List reqs = policy.resolveRequirements(Transport.DOCKER, "registry.example.com/app"); + assertEquals(1, reqs.size()); + PolicyRequirement.SigstoreSigned sigstore = (PolicyRequirement.SigstoreSigned) reqs.get(0); + assertNull(sigstore.getKeyPath()); + assertNull(sigstore.getKeyData()); + assertNotNull(sigstore.getKeyPaths()); + assertEquals(List.of("/etc/pki/a.pub", "/etc/pki/b.pub"), sigstore.getKeyPaths()); + assertNull(sigstore.getKeyDatas()); + } + @Test void signedByGpgIsDeniedBecauseNotImplemented(@TempDir Path dir) throws IOException { Path path = dir.resolve("policy.json"); diff --git a/src/test/java/land/oras/policy/SigstoreVerifierTest.java b/src/test/java/land/oras/policy/SigstoreVerifierTest.java index bb4c5362..21cb200b 100644 --- a/src/test/java/land/oras/policy/SigstoreVerifierTest.java +++ b/src/test/java/land/oras/policy/SigstoreVerifierTest.java @@ -124,6 +124,52 @@ void loadsKeyFromKeyPathAndKeyData(@TempDir Path dir) throws Exception { assertNull(SigstoreVerifier.loadKey(none)); } + @Test + void loadsKeysFromKeyPathsAndKeyDatas(@TempDir Path dir) throws Exception { + KeyPair kp1 = generateEcKeyPair(); + KeyPair kp2 = generateEcKeyPair(); + String pem1 = toPem(kp1.getPublic()); + String pem2 = toPem(kp2.getPublic()); + + Path keyFile1 = dir.resolve("cosign1.pub"); + Path keyFile2 = dir.resolve("cosign2.pub"); + Files.writeString(keyFile1, pem1); + Files.writeString(keyFile2, pem2); + + String keyData1 = Base64.getEncoder().encodeToString(pem1.getBytes(StandardCharsets.UTF_8)); + String keyData2 = Base64.getEncoder().encodeToString(pem2.getBytes(StandardCharsets.UTF_8)); + + // keyPaths list + PolicyRequirement.SigstoreSigned fromPaths = new PolicyRequirement.SigstoreSigned( + null, null, List.of(keyFile1.toString(), keyFile2.toString()), null); + List loadedFromPaths = SigstoreVerifier.loadKeys(fromPaths); + assertEquals(2, loadedFromPaths.size()); + + // keyDatas list + PolicyRequirement.SigstoreSigned fromDatas = + new PolicyRequirement.SigstoreSigned(null, null, null, List.of(keyData1, keyData2)); + List loadedFromDatas = SigstoreVerifier.loadKeys(fromDatas); + assertEquals(2, loadedFromDatas.size()); + } + + @Test + void verifyWithAnyKeyAcceptsSignatureFromAnyKeyInList() throws Exception { + KeyPair kp1 = generateEcKeyPair(); + KeyPair kp2 = generateEcKeyPair(); + + // Bundle signed by kp1 + byte[] bundle = buildBundle(kp1.getPrivate(), IMAGE_HEX, Const.KEY_SHA256_ECDSA_SIGNATURE_ALGORITHM); + + // Verification passes when kp1 is in the list (even with kp2 present) + assertTrue(SigstoreVerifier.verifyWithAnyKey( + List.of(bundle), IMAGE_DIGEST, List.of(kp1.getPublic(), kp2.getPublic()))); + + // Verification fails when neither key matches + KeyPair kp3 = generateEcKeyPair(); + assertFalse(SigstoreVerifier.verifyWithAnyKey( + List.of(bundle), IMAGE_DIGEST, List.of(kp2.getPublic(), kp3.getPublic()))); + } + @Test void policyVerificationPassesForSignedImageAndFailsWhenTampered(@TempDir Path dir) throws Exception { KeyPair kp = generateEcKeyPair();