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):
+ *
+ * - {@code keyPath} / {@code keyData} – a single Sigstore public key; only signatures made
+ * by this key are accepted.
+ * - {@code keyPaths} / {@code keyDatas} – a list of Sigstore public keys; signatures made
+ * by any key in the list are accepted.
+ *
+ *
+ * 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();