Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 63 additions & 19 deletions src/main/java/land/oras/policy/PolicyRequirement.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
* <p>Exactly one of {@code keyPath}, {@code keyPaths}, {@code keyData}, or {@code keyDatas} must
* be present; keyless verification is not supported.
*
* <p>JSON example ({@code signedIdentity}, if present, is ignored):
* <ul>
* <li>{@code keyPath} / {@code keyData} – a single Sigstore public key; only signatures made
* by this key are accepted.</li>
* <li>{@code keyPaths} / {@code keyDatas} – a list of Sigstore public keys; signatures made
* by <em>any</em> key in the list are accepted.</li>
* </ul>
*
* <p>JSON examples ({@code signedIdentity}, if present, is ignored):
* <pre>{@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": ["<base64-pem>", "<base64-pem>"]}
* }</pre>
*/
@OrasModel
Expand All @@ -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<String> keyPaths;
private final @Nullable List<String> 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<String> keyPaths,
@JsonProperty("keyDatas") @Nullable List<String> 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
Expand All @@ -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<PublicKey> 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 ? "<set>" : null);
context.getReference());
return false;
}
boolean verified = SigstoreVerifier.verify(context.fetchSignatureBundle(), imageDigest, key);
List<byte[]> bundles = context.fetchSignatureBundle();
boolean verified = SigstoreVerifier.verifyWithAnyKey(bundles, imageDigest, keys);
if (!verified) {
LOG.warn(
"Policy requirement '{}' failed: no valid signature for {}", getType(), context.getReference());
Expand All @@ -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<String> 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<String> getKeyDatas() {
return keyDatas;
}
}
}
65 changes: 63 additions & 2 deletions src/main/java/land/oras/policy/SigstoreVerifier.java
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,28 @@ private SigstoreVerifier() {}
* @return {@code true} if at least one bundle verifies and binds to {@code imageDigest}.
*/
static boolean verify(List<byte[]> 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
* <em>any</em> 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<byte[]> bundles, String imageDigest, List<PublicKey> 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);
Expand Down Expand Up @@ -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<PublicKey> loadKeys(PolicyRequirement.SigstoreSigned requirement) {
java.util.List<PublicKey> keys = new java.util.ArrayList<>();

// Single-key fields
PublicKey single = loadKey(requirement);
if (single != null) {
keys.add(single);
}

// keyPaths list
List<String> 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<String> 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 {
Expand Down
132 changes: 132 additions & 0 deletions src/test/java/land/oras/policy/ContainersPolicyTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<PolicyRequirement> 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");
Expand Down
Loading
Loading