diff --git a/README.md b/README.md index edcf30a8..27786d78 100644 --- a/README.md +++ b/README.md @@ -275,9 +275,26 @@ nd_builtin_cache: true |-------|------|---------|-------------| | `url` | string | - | Base URL of the service | | `credentials.bearer.token` | string | - | Bearer token for authentication | +| `credentials.client_tls.cert` | path | - | PEM file path with the client certificate (mTLS) | +| `credentials.client_tls.private_key` | path | - | PKCS#8 PEM file path with the client private key | +| `credentials.client_tls.private_key_passphrase` | string | - | (Reserved — encrypted PEM keys not supported. Use a JKS / PKCS#12 keystore instead.) | +| `credentials.client_tls.cert_reread_interval_seconds` | int | - | Interval to reload the client cert/key from disk for rotation | +| `credentials.client_tls.keystore.path` | path | - | JKS / PKCS#12 keystore path holding the client cert + key | +| `credentials.client_tls.keystore.password` | string | - | Keystore password | +| `credentials.client_tls.keystore.key_password` | string | (defaults to `password`) | Password for the private key entry | +| `credentials.client_tls.keystore.type` | string | inferred from extension, else `PKCS12` | Keystore type (`JKS`, `PKCS12`) | +| `tls.ca_cert` | path | - | PEM file path with trust roots used to verify the server cert | +| `tls.system_ca_required` | boolean | false | Also trust the JVM default trust store in addition to `ca_cert` / `truststore` | +| `tls.truststore.path` | path | - | JKS / PKCS#12 truststore path used to verify the server cert | +| `tls.truststore.password` | string | - | Truststore password | +| `tls.truststore.type` | string | inferred from extension, else `PKCS12` | Truststore type (`JKS`, `PKCS12`) | | `response_header_timeout_seconds` | int | 10 | HTTP response header timeout | | `allow_insecure_tls` | boolean | false | Allow insecure TLS (dev only) | +Any string value may reference an environment variable with `${VAR}`; the SDK substitutes it during config load (matches Go-OPA). Use `\${VAR}` to keep a literal `${VAR}` in the file. + +See [opa-services/README.md](opa-services/README.md#tls-and-mtls) for a full mTLS walkthrough, including JKS / PKCS#12 keystores and the programmatic `setSslContext` escape hatch for HSM-backed or rotated keys. + #### Bundles | Field | Type | Default | Description | diff --git a/opa-services/README.md b/opa-services/README.md index 647ed651..6d35ecf5 100644 --- a/opa-services/README.md +++ b/opa-services/README.md @@ -91,6 +91,129 @@ status: service: acmecorp ``` +### TLS and mTLS + +Services support two related TLS blocks, mirroring Go-OPA: + +- `services..tls` — trust roots used to verify the server certificate. +- `services..credentials.client_tls` — client certificate and key presented during the TLS handshake (mTLS). + +Both apply to all HTTP traffic for the service: bundle downloads, decision-log uploads, status reports, and discovery. + +```yaml +services: + acmecorp: + url: https://policy.example.com + tls: + ca_cert: /etc/ssl/corp-ca.pem + system_ca_required: true + credentials: + client_tls: + cert: /etc/ssl/client.pem + private_key: /etc/ssl/client-key.pem + cert_reread_interval_seconds: 3600 +``` + +| Field | Description | +|-------|-------------| +| `tls.ca_cert` | Path to a PEM file containing one or more trust roots for verifying the server. | +| `tls.truststore.{path,password,type}` | Java-native JKS / PKCS#12 truststore (alternative to `ca_cert`). Mutually exclusive with `ca_cert`. | +| `tls.system_ca_required` | When `true`, the JVM's default trust store is also trusted in addition to `ca_cert` / `truststore`. | +| `credentials.client_tls.cert` | Path to a PEM file with the client certificate (and any intermediates). | +| `credentials.client_tls.private_key` | Path to an unencrypted PKCS#8 PEM file with the client private key. | +| `credentials.client_tls.cert_reread_interval_seconds` | If set, the cert and key are reloaded from disk on this interval to support runtime rotation. | +| `credentials.client_tls.keystore.{path,password,key_password,type}` | JKS / PKCS#12 keystore alternative (path is mutually exclusive with `cert` / `private_key`; supports password-protected keys). | + +Only **unencrypted PKCS#8** PEM private keys are accepted by the file-based loader (the JDK has no first-class support for legacy PKCS#1 / SEC1 / encrypted PEMs without third-party crypto). Convert PKCS#1 keys with: + +```sh +openssl pkcs8 -topk8 -nocrypt -in key.pem -out key-pkcs8.pem +``` + +For encrypted or password-protected keys, use a **JKS / PKCS#12 keystore** instead: + +```yaml +services: + acmecorp: + url: https://policy.example.com + tls: + truststore: + path: /etc/ssl/truststore.jks + password: ${TRUSTSTORE_PASSWORD} + type: JKS + credentials: + client_tls: + keystore: + path: /etc/ssl/client.p12 + password: ${KEYSTORE_PASSWORD} + key_password: ${KEY_PASSWORD} +``` + +Programmatic equivalent (file-based mTLS): + +```java +Config config = new Config() + .addService(new Config.ServiceConfig() + .setName("acmecorp") + .setUrl("https://policy.example.com") + .setTls(new Config.TlsConfig() + .setCaCert("/etc/ssl/corp-ca.pem") + .setSystemCaRequired(true)) + .setCredentials(new Config.CredentialsConfig() + .setClientTls(new Config.ClientTlsConfig() + .setCert("/etc/ssl/client.pem") + .setPrivateKey("/etc/ssl/client-key.pem") + .setCertRereadIntervalSeconds(3600)))); +``` + +Programmatic equivalent (in-memory keystore from a secret manager — no files on disk): + +```java +KeyStore clientStore = loadFromVault(); +KeyStore trustStore = loadCaTrust(); + +Config.ServiceConfig service = new Config.ServiceConfig() + .setName("acmecorp") + .setUrl("https://policy.example.com") + .setTls(new Config.TlsConfig() + .setTruststore(new Config.TruststoreConfig().setKeyStore(trustStore))) + .setCredentials(new Config.CredentialsConfig() + .setClientTls(new Config.ClientTlsConfig() + .setKeystore(new Config.KeystoreConfig() + .setKeyStore(clientStore) + .setKeyPassword("vault-issued-key-pw")))); +``` + +For keystores that cannot be expressed any other way (HSM-backed keys, custom `KeyManager` chains), supply a fully constructed `SSLContext` directly. When set, file-based and keystore TLS fields are rejected during validation: + +```java +SSLContext sslContext = buildSslContextFromHsm(); + +Config.ServiceConfig service = new Config.ServiceConfig() + .setName("acmecorp") + .setUrl("https://policy.example.com") + .setSslContext(sslContext); +``` + +### Environment-variable interpolation + +Any string in YAML / JSON config may reference an environment variable with `${VAR}`. The SDK substitutes references at load time, matching Go-OPA's behaviour, so secrets stay out of committed config files: + +```yaml +services: + acmecorp: + url: https://policy.example.com + credentials: + bearer: + token: ${OPA_BEARER_TOKEN} + tls: + truststore: + path: /etc/ssl/truststore.jks + password: ${TRUSTSTORE_PASSWORD} +``` + +Missing variables produce a `ConfigurationException` at startup — silent empty substitution would mask credential and TLS misconfiguration. Escape with a leading backslash to keep a literal `${VAR}` in the config (`\${VAR}`). + ### Lifecycle Management ```java diff --git a/opa-services/src/main/java/io/github/open_policy_agent/opa/Opa.java b/opa-services/src/main/java/io/github/open_policy_agent/opa/Opa.java index 27b492d1..fc1293de 100644 --- a/opa-services/src/main/java/io/github/open_policy_agent/opa/Opa.java +++ b/opa-services/src/main/java/io/github/open_policy_agent/opa/Opa.java @@ -12,6 +12,7 @@ import io.github.open_policy_agent.opa.bundle.Bundle; import io.github.open_policy_agent.opa.config.Config; import io.github.open_policy_agent.opa.config.ConfigurationException; +import io.github.open_policy_agent.opa.config.EnvInterpolator; import io.github.open_policy_agent.opa.logging.Logger; import io.github.open_policy_agent.opa.mapper.RegoMapper; import io.github.open_policy_agent.opa.metrics.Metrics; @@ -708,7 +709,16 @@ public Opa build() { } if (config == null) { try { - config = YAML_MAPPER.readValue(configIn, Config.class); + // Read fully so we can apply ${VAR} env-var interpolation before parsing. Avoids + // plaintext secrets in committed YAML; matches Go-OPA's config-loading behaviour. + char[] buf = new char[8192]; + StringBuilder raw = new StringBuilder(); + int n; + while ((n = configIn.read(buf)) > 0) { + raw.append(buf, 0, n); + } + String interpolated = EnvInterpolator.interpolate(raw.toString()); + config = YAML_MAPPER.readValue(interpolated, Config.class); } catch (IOException e) { throw new RuntimeException(e); } diff --git a/opa-services/src/main/java/io/github/open_policy_agent/opa/config/Config.java b/opa-services/src/main/java/io/github/open_policy_agent/opa/config/Config.java index 06180294..d6e6031e 100644 --- a/opa-services/src/main/java/io/github/open_policy_agent/opa/config/Config.java +++ b/opa-services/src/main/java/io/github/open_policy_agent/opa/config/Config.java @@ -1,9 +1,12 @@ package io.github.open_policy_agent.opa.config; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import java.security.KeyStore; import java.util.HashMap; import java.util.Map; +import javax.net.ssl.SSLContext; public class Config { @@ -445,6 +448,12 @@ public static class ServiceConfig { @JsonProperty("allow_insecure_tls") private boolean allowInsecureTLS = false; + private TlsConfig tls; + + @JsonIgnore private SSLContext sslContext; + + private Map headers; + public int getResponseHeaderTimeoutSeconds() { return responseHeaderTimeoutSeconds; } @@ -463,12 +472,44 @@ public ServiceConfig setAllowInsecureTLS(boolean allowInsecureTLS) { return this; } + public TlsConfig getTls() { + return tls; + } + + /** + * Server-TLS configuration (trust roots) for verifying the service certificate. See {@link + * TlsConfig} for the YAML-equivalent fields. + */ + public ServiceConfig setTls(TlsConfig tls) { + this.tls = tls; + return this; + } + + public SSLContext getSslContext() { + return sslContext; + } + + /** + * Programmatic override for the per-service {@link SSLContext}. + * + *

When set, it is used as-is and file-based TLS fields ({@link TlsConfig}, {@link + * ClientTlsConfig}) are rejected during validation. Use this for keystores that can't be + * expressed in YAML (HSM-backed keys, custom KeyManager chains, etc.). For keystores from a + * secret manager, prefer the more ergonomic {@link TruststoreConfig#setKeyStore(KeyStore)} / + * {@link KeystoreConfig#setKeyStore(KeyStore)} pass-throughs. + */ + public ServiceConfig setSslContext(SSLContext sslContext) { + this.sslContext = sslContext; + return this; + } + public CredentialsConfig getCredentials() { return credentials; } - public void setCredentials(CredentialsConfig credentials) { + public ServiceConfig setCredentials(CredentialsConfig credentials) { this.credentials = credentials; + return this; } public String getName() { @@ -489,6 +530,22 @@ public ServiceConfig setUrl(String url) { return this; } + /** + * Extra HTTP headers applied to every request the SDK sends to this service (bundle + * downloads, decision-log uploads, status reports). Applied after credentials, so they may + * override built-in headers (including {@code Authorization}). Use sparingly — bearer/mTLS + * cover most auth needs; this hook exists for services that require non-standard headers + * (e.g. caller-identity tokens). + */ + public Map getHeaders() { + return headers; + } + + public ServiceConfig setHeaders(Map headers) { + this.headers = headers; + return this; + } + @Override public String toString() { return "ServiceConfig{" @@ -500,6 +557,146 @@ public String toString() { + ", url='" + url + '\'' + + ", tls=" + + tls + + '}'; + } + } + + /** + * Server-TLS configuration for a service (trust roots). + * + *

Mirrors Go-OPA's {@code services..tls} block. {@code ca_cert} is the file-based PEM + * path; {@code truststore} is a Java-native JKS / PKCS#12 alternative. The two are mutually + * exclusive — pick whichever your deployment already manages. + */ + public static class TlsConfig { + @JsonProperty("ca_cert") + private String caCert; + + @JsonProperty("system_ca_required") + private boolean systemCaRequired = false; + + private TruststoreConfig truststore; + + public String getCaCert() { + return caCert; + } + + /** + * Path to a PEM file containing one or more trust-root certificates used to verify the server + * certificate. Mutually exclusive with {@link #setTruststore(TruststoreConfig)}. + */ + public TlsConfig setCaCert(String caCert) { + this.caCert = caCert; + return this; + } + + public boolean isSystemCaRequired() { + return systemCaRequired; + } + + /** + * When {@code true}, the JVM's default trust store is also accepted in addition to the + * configured {@link #setCaCert(String) ca_cert} or {@link #setTruststore(TruststoreConfig) + * truststore}. + */ + public TlsConfig setSystemCaRequired(boolean systemCaRequired) { + this.systemCaRequired = systemCaRequired; + return this; + } + + public TruststoreConfig getTruststore() { + return truststore; + } + + /** + * Java-native JKS / PKCS#12 truststore. Use this when your deployment already manages trust + * roots in a keystore (typical for Java shops). Mutually exclusive with {@link + * #setCaCert(String) ca_cert}. + */ + public TlsConfig setTruststore(TruststoreConfig truststore) { + this.truststore = truststore; + return this; + } + + @Override + public String toString() { + return "TlsConfig{caCert='" + + caCert + + "', systemCaRequired=" + + systemCaRequired + + ", truststore=" + + truststore + + '}'; + } + } + + /** + * JKS / PKCS#12 truststore. Either {@link #setPath(String) path} (loaded from disk) or {@link + * #setKeyStore(KeyStore) keyStore} (programmatic) must be set, but not both. + */ + public static class TruststoreConfig { + private String path; + private String password; + private String type; + + @JsonIgnore private KeyStore keyStore; + + public String getPath() { + return path; + } + + public TruststoreConfig setPath(String path) { + this.path = path; + return this; + } + + public String getPassword() { + return password; + } + + public TruststoreConfig setPassword(String password) { + this.password = password; + return this; + } + + public String getType() { + return type; + } + + /** + * Keystore type. Defaults to {@code PKCS12} (or inferred from the file extension when {@link + * #setPath(String) path} ends in {@code .jks}). + */ + public TruststoreConfig setType(String type) { + this.type = type; + return this; + } + + public KeyStore getKeyStore() { + return keyStore; + } + + /** + * Programmatic SDK pass-through for an in-memory {@link KeyStore} (e.g. loaded from a secret + * manager). When set, {@link #setPath(String) path} must be {@code null}. + */ + public TruststoreConfig setKeyStore(KeyStore keyStore) { + this.keyStore = keyStore; + return this; + } + + @Override + public String toString() { + return "TruststoreConfig{path='" + + path + + "', type='" + + type + + "', password=" + + (password == null ? "null" : "") + + ", keyStore=" + + (keyStore == null ? "null" : "") + '}'; } } @@ -507,6 +704,9 @@ public String toString() { public static class CredentialsConfig { private BearerConfig bearer; + @JsonProperty("client_tls") + private ClientTlsConfig clientTls; + public BearerConfig getBearer() { return bearer; } @@ -516,9 +716,216 @@ public CredentialsConfig setBearer(BearerConfig bearer) { return this; } + public ClientTlsConfig getClientTls() { + return clientTls; + } + + public CredentialsConfig setClientTls(ClientTlsConfig clientTls) { + this.clientTls = clientTls; + return this; + } + + @Override + public String toString() { + return "CredentialsConfig{bearer=" + bearer + ", clientTls=" + clientTls + '}'; + } + } + + /** + * Client-TLS credentials for mTLS bundle downloads (and all service HTTP traffic). + * + *

Mirrors Go-OPA's {@code services..credentials.client_tls} block. Two ways to provide + * the cert+key pair: + * + *

    + *
  • {@code cert} + {@code private_key}: PEM files on disk, PKCS#8 unencrypted private key. + *
  • {@code keystore}: a JKS / PKCS#12 keystore on disk or supplied programmatically. + *
+ * + *

Pick one — they are mutually exclusive. + */ + public static class ClientTlsConfig { + private String cert; + + @JsonProperty("private_key") + private String privateKey; + + @JsonProperty("private_key_passphrase") + private String privateKeyPassphrase; + + @JsonProperty("cert_reread_interval_seconds") + private Integer certRereadIntervalSeconds; + + private KeystoreConfig keystore; + + public String getCert() { + return cert; + } + + /** + * Path to a PEM file with the client certificate chain. Mutually exclusive with {@link + * #setKeystore(KeystoreConfig)}. + */ + public ClientTlsConfig setCert(String cert) { + this.cert = cert; + return this; + } + + public String getPrivateKey() { + return privateKey; + } + + /** + * Path to an unencrypted PKCS#8 PEM file containing the client private key. PKCS#1 / SEC1 keys + * are not supported — convert with {@code openssl pkcs8 -topk8 -nocrypt}, or supply a + * keystore via {@link #setKeystore(KeystoreConfig)}. + */ + public ClientTlsConfig setPrivateKey(String privateKey) { + this.privateKey = privateKey; + return this; + } + + public String getPrivateKeyPassphrase() { + return privateKeyPassphrase; + } + + /** + * Reserved. Encrypted PEM keys are not supported by the file-based loader; use a JKS / + * PKCS#12 keystore for password-protected keys. + */ + public ClientTlsConfig setPrivateKeyPassphrase(String privateKeyPassphrase) { + this.privateKeyPassphrase = privateKeyPassphrase; + return this; + } + + public Integer getCertRereadIntervalSeconds() { + return certRereadIntervalSeconds; + } + + /** + * If set and {@code > 0}, the cert and private-key files are re-read on this interval (in + * seconds) so external rotation tools can swap them in place. Only applies to the + * file-based {@code cert}/{@code private_key} fields, not to keystores. + */ + public ClientTlsConfig setCertRereadIntervalSeconds(Integer certRereadIntervalSeconds) { + this.certRereadIntervalSeconds = certRereadIntervalSeconds; + return this; + } + + public KeystoreConfig getKeystore() { + return keystore; + } + + /** + * JKS / PKCS#12 keystore providing the client cert and key. Mutually exclusive with {@link + * #setCert(String) cert} / {@link #setPrivateKey(String) private_key}. + */ + public ClientTlsConfig setKeystore(KeystoreConfig keystore) { + this.keystore = keystore; + return this; + } + + @Override + public String toString() { + return "ClientTlsConfig{cert='" + + cert + + "', privateKey='" + + privateKey + + "', privateKeyPassphrase=" + + (privateKeyPassphrase == null ? "null" : "") + + ", certRereadIntervalSeconds=" + + certRereadIntervalSeconds + + ", keystore=" + + keystore + + '}'; + } + } + + /** + * JKS / PKCS#12 keystore holding a client certificate and private key. Either {@link + * #setPath(String) path} or {@link #setKeyStore(KeyStore) keyStore} must be set, but not both. + */ + public static class KeystoreConfig { + private String path; + private String password; + + @JsonProperty("key_password") + private String keyPassword; + + private String type; + + @JsonIgnore private KeyStore keyStore; + + public String getPath() { + return path; + } + + public KeystoreConfig setPath(String path) { + this.path = path; + return this; + } + + public String getPassword() { + return password; + } + + public KeystoreConfig setPassword(String password) { + this.password = password; + return this; + } + + public String getKeyPassword() { + return keyPassword; + } + + /** + * Password for the private key entry within the keystore. Defaults to {@link + * #setPassword(String) password} when unset, matching standard {@code keytool} usage. + */ + public KeystoreConfig setKeyPassword(String keyPassword) { + this.keyPassword = keyPassword; + return this; + } + + public String getType() { + return type; + } + + /** + * Keystore type. Defaults to {@code PKCS12} (or inferred from the file extension when {@link + * #setPath(String) path} ends in {@code .jks}). + */ + public KeystoreConfig setType(String type) { + this.type = type; + return this; + } + + public KeyStore getKeyStore() { + return keyStore; + } + + /** + * Programmatic SDK pass-through for an in-memory {@link KeyStore}. When set, {@link + * #setPath(String) path} must be {@code null}. + */ + public KeystoreConfig setKeyStore(KeyStore keyStore) { + this.keyStore = keyStore; + return this; + } + @Override public String toString() { - return "CredentialsConfig{" + "bearer=" + bearer + '}'; + return "KeystoreConfig{path='" + + path + + "', type='" + + type + + "', password=" + + (password == null ? "null" : "") + + ", keyPassword=" + + (keyPassword == null ? "null" : "") + + ", keyStore=" + + (keyStore == null ? "null" : "") + + '}'; } } @@ -536,7 +943,7 @@ public BearerConfig setToken(String token) { @Override public String toString() { - return "BearerConfig{" + "token='" + token + '\'' + '}'; + return "BearerConfig{token=" + (token == null ? "null" : "") + '}'; } } diff --git a/opa-services/src/main/java/io/github/open_policy_agent/opa/config/EnvInterpolator.java b/opa-services/src/main/java/io/github/open_policy_agent/opa/config/EnvInterpolator.java new file mode 100644 index 00000000..8805654a --- /dev/null +++ b/opa-services/src/main/java/io/github/open_policy_agent/opa/config/EnvInterpolator.java @@ -0,0 +1,54 @@ +package io.github.open_policy_agent.opa.config; + +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Replaces {@code ${VAR}} references in raw config text with environment-variable values, matching + * Go-OPA's behaviour. Missing variables throw — silently substituting an empty string would mask + * misconfiguration of credentials and TLS paths. + * + *

Escape with a leading backslash ({@code \${VAR}}) to keep a literal {@code ${VAR}} in the + * config (the backslash is consumed). + */ +public final class EnvInterpolator { + + private static final Pattern PLACEHOLDER = + Pattern.compile("(\\\\?)\\$\\{([A-Za-z_][A-Za-z0-9_]*)\\}"); + + private EnvInterpolator() {} + + /** Interpolate against the process environment ({@link System#getenv()}). */ + public static String interpolate(String input) { + return interpolate(input, System.getenv()); + } + + /** Interpolate against an arbitrary lookup, useful for tests. */ + public static String interpolate(String input, Map env) { + if (input == null || input.indexOf('$') < 0) { + return input; + } + Matcher m = PLACEHOLDER.matcher(input); + StringBuilder out = new StringBuilder(input.length()); + while (m.find()) { + String escape = m.group(1); + String varName = m.group(2); + String replacement; + if (!escape.isEmpty()) { + // Escaped: drop the backslash, keep the literal placeholder. + replacement = "${" + varName + "}"; + } else { + String value = env.get(varName); + if (value == null) { + throw new ConfigurationException( + "Environment variable '" + varName + "' referenced in config is not set"); + } + replacement = value; + } + m.appendReplacement(out, Matcher.quoteReplacement(replacement)); + } + m.appendTail(out); + return out.toString(); + } +} diff --git a/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/BundleDownloader.java b/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/BundleDownloader.java index d894c003..fdb337fd 100644 --- a/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/BundleDownloader.java +++ b/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/BundleDownloader.java @@ -10,8 +10,13 @@ import java.nio.file.Paths; import java.nio.file.attribute.FileTime; import java.time.Duration; +import java.util.HashSet; +import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import io.github.open_policy_agent.opa.config.Config; @@ -34,6 +39,7 @@ public abstract class BundleDownloader { protected final String name; protected final PluginManager manager; + protected final ServicePlugin.Service authService; protected final HttpClient httpClient; protected final CompletableFuture initialActivation; @@ -43,17 +49,85 @@ public abstract class BundleDownloader { protected String etag; protected long lastModifiedTime = 0; - protected BundleDownloader(String name, PluginManager manager) { + /** + * Construct a BundleDownloader. + * + * @param name bundle name (used in log messages) + * @param manager the owning plugin manager + * @param authService the {@link ServicePlugin.Service} that owns the {@link HttpClient} (with + * its SSLContext, credentials, and headers) used for HTTP downloads. May be {@code null} if + * this downloader only ever handles {@code file://} URIs or filesystem paths; in that case + * a default HTTP client is used for any unauthenticated HTTP fallback. + */ + protected BundleDownloader( + String name, PluginManager manager, ServicePlugin.Service authService) { this.name = name; this.manager = manager; - this.httpClient = - HttpClient.newBuilder() - .followRedirects(HttpClient.Redirect.NORMAL) - .connectTimeout(Duration.ofSeconds(10)) - .build(); + this.authService = authService; + this.httpClient = authService != null ? authService.getClient() : defaultHttpClient(); this.initialActivation = new CompletableFuture<>(); } + private static HttpClient defaultHttpClient() { + return HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(Duration.ofSeconds(10)) + .build(); + } + + /** + * Build a daemon scheduler with the "drop pending delayed tasks on shutdown" policy. Suitable + * for chained-delay polling and for periodic cert/config reloads — in both cases {@code + * shutdown()} returns promptly without waiting through the next scheduled tick. + */ + public static ScheduledExecutorService newPollScheduler(int poolSize, String threadName) { + ScheduledThreadPoolExecutor exec = + new ScheduledThreadPoolExecutor( + poolSize, + r -> { + Thread t = new Thread(r, threadName); + t.setDaemon(true); + return t; + }); + exec.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); + return exec; + } + + public static ScheduledExecutorService newPollScheduler(String threadName) { + return newPollScheduler(1, threadName); + } + + /** + * Validate a {@link Config.PollingConfig}. Each error is prefixed with {@code subjectPrefix} + * (e.g. {@code "Bundle 'authz'"} or {@code "Discovery"}) so the caller can mix it into its own + * error set. + */ + public static Set validatePolling( + Config.PollingConfig polling, String subjectPrefix) { + Set errors = new HashSet<>(); + if (polling == null) { + return errors; + } + Integer min = polling.getMinDelaySeconds(); + Integer max = polling.getMaxDelaySeconds(); + if (min != null && min < 0) { + errors.add(subjectPrefix + " polling.min_delay_seconds must be >= 0"); + } + if (max != null && max < 0) { + errors.add(subjectPrefix + " polling.max_delay_seconds must be >= 0"); + } + if (min != null && max != null && min >= 0 && max >= 0 && min > max) { + errors.add( + subjectPrefix + + " polling.min_delay_seconds (" + + min + + ") must be <= max_delay_seconds (" + + max + + ")"); + } + return errors; + } + public BundleDownloader setService(String service) { this.service = service; return this; @@ -69,6 +143,14 @@ public BundleDownloader setPolling(Config.PollingConfig polling) { return this; } + /** + * @return a future that completes when the first bundle download succeeds, or completes + * exceptionally with the underlying download/activation error + */ + public CompletableFuture getInitialActivation() { + return initialActivation; + } + /** * Start polling for bundle updates. * @@ -85,15 +167,41 @@ public CompletableFuture startPolling(ScheduledExecutorService scheduler) ? polling.getMaxDelaySeconds() : 120; - // Schedule initial download scheduler.schedule(this::downloadBundle, 0, TimeUnit.SECONDS); - - // Schedule periodic downloads - scheduler.scheduleAtFixedRate(this::downloadBundle, minDelay, maxDelay, TimeUnit.SECONDS); + scheduleNextPoll(scheduler, minDelay, maxDelay); return initialActivation; } + // Re-schedules the next download with a uniformly random delay in [minDelay, maxDelay], + // matching Go-OPA's jittered polling. ScheduledExecutorService has no built-in jitter, so the + // task chains itself. RejectedExecutionException after a shutdown breaks the chain cleanly. + private void scheduleNextPoll(ScheduledExecutorService scheduler, int minDelay, int maxDelay) { + long delay = + minDelay >= maxDelay + ? minDelay + : ThreadLocalRandom.current().nextLong(minDelay, (long) maxDelay + 1); + try { + scheduler.schedule( + () -> { + try { + downloadBundle(); + } catch (Exception e) { + // downloadBundle() handles its own logging; swallow so the chain keeps polling. + // Only Exception is caught here — Errors (OOM, etc.) propagate and let the + // executor's uncaught-exception handler tear down the pool, which is the right + // outcome for unrecoverable conditions. + } finally { + scheduleNextPoll(scheduler, minDelay, maxDelay); + } + }, + delay, + TimeUnit.SECONDS); + } catch (RejectedExecutionException stopped) { + // Scheduler was shut down; let the chain end. + } + } + /** * Download the bundle from the configured service and resource. * @@ -189,6 +297,11 @@ private void handleHttpDownload(URI uri) throws IOException, InterruptedExceptio requestBuilder.header("If-None-Match", etag); } + if (authService != null) { + requestBuilder = authService.applyCredentials(requestBuilder); + requestBuilder = authService.applyHeaders(requestBuilder); + } + HttpRequest request = requestBuilder.build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); diff --git a/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/BundlePlugin.java b/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/BundlePlugin.java index 7cfadf8f..58863c6b 100644 --- a/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/BundlePlugin.java +++ b/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/BundlePlugin.java @@ -5,7 +5,6 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import io.github.open_policy_agent.opa.bundle.TarballBundleLoader; @@ -36,10 +35,16 @@ public Set validate(PluginManager manager) { } else { if (bundle.getService() == null || bundle.getService().isEmpty()) { errors.add("Bundle '" + name + "' has missing or empty service reference"); + } else if (manager.getConfig().getService(bundle.getService()) == null) { + // Fail fast on typos in the service name. Without this, a misconfigured bundle would + // pass validate() and only surface on the first download attempt (as a log line). + errors.add( + "Bundle '" + name + "' references unknown service '" + bundle.getService() + "'"); } if (bundle.getResource() == null || bundle.getResource().isEmpty()) { errors.add("Bundle '" + name + "' has missing or empty resource path"); } + errors.addAll(BundleDownloader.validatePolling(bundle.getPolling(), "Bundle '" + name + "'")); } } return errors; @@ -48,11 +53,16 @@ public Set validate(PluginManager manager) { public Plugin initialize(PluginManager manager) { BundlePlugin plugin = new BundlePlugin(); plugin.manager = manager; - plugin.scheduler = Executors.newScheduledThreadPool(1, r -> { - Thread t = new Thread(r, "opa-bundle-scheduler"); - t.setDaemon(true); - return t; - }); + plugin.scheduler = BundleDownloader.newPollScheduler("opa-bundle-scheduler"); + + // ServicePlugin is initialized before BundlePlugin by Opa.Builder, so it's safe to look up + // here. When absent (e.g. a test constructing BundlePlugin directly), fall back to a default + // HttpClient; that path still supports file:// URIs and unauthenticated HTTP. + ServicePlugin servicePlugin = null; + Plugin raw = manager.getPlugin("services"); + if (raw instanceof ServicePlugin) { + servicePlugin = (ServicePlugin) raw; + } if (manager.getConfig().getBundles() != null) { for (Map.Entry entry : @@ -60,9 +70,12 @@ public Plugin initialize(PluginManager manager) { String name = entry.getKey(); Config.BundleConfig bundleConfig = entry.getValue(); + ServicePlugin.Service svc = + servicePlugin == null ? null : servicePlugin.getService(bundleConfig.getService()); + plugin.bundles.put( name, - new Bundle(name, manager) + new Bundle(name, manager, svc) .setService(bundleConfig.getService()) .setResource(bundleConfig.getResource()) .setPolling(bundleConfig.getPolling())); @@ -102,6 +115,11 @@ public void start() { }); } + /** Get a bundle by name (primarily for tests and status reporting). */ + public Bundle getBundle(String name) { + return bundles.get(name); + } + @Override public void stop() { if (scheduler != null) { @@ -123,8 +141,8 @@ public void stop() { /** Bundle downloader that activates policy and data bundles. */ public static class Bundle extends BundleDownloader { - private Bundle(String name, PluginManager manager) { - super(name, manager); + private Bundle(String name, PluginManager manager, ServicePlugin.Service authService) { + super(name, manager, authService); } public String getName() { diff --git a/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/DiscoveryPlugin.java b/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/DiscoveryPlugin.java index fabc2446..6212e30e 100644 --- a/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/DiscoveryPlugin.java +++ b/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/DiscoveryPlugin.java @@ -7,7 +7,6 @@ import java.util.HashSet; import java.util.Set; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.zip.GZIPInputStream; @@ -62,6 +61,8 @@ public Set validate(PluginManager manager) { errors.add("Discovery has missing or empty resource path"); } + errors.addAll(BundleDownloader.validatePolling(discovery.getPolling(), "Discovery")); + return errors; } @@ -69,17 +70,32 @@ public Set validate(PluginManager manager) { public Plugin initialize(PluginManager manager) { DiscoveryPlugin plugin = new DiscoveryPlugin(); plugin.manager = manager; - plugin.scheduler = Executors.newScheduledThreadPool(1, r -> { - Thread t = new Thread(r, "opa-discovery-scheduler"); - t.setDaemon(true); - return t; - }); + plugin.scheduler = BundleDownloader.newPollScheduler("opa-discovery-scheduler"); Config.DiscoveryConfig discoveryConfig = manager.getConfig().getDiscovery(); if (discoveryConfig != null) { String name = discoveryConfig.getName() != null ? discoveryConfig.getName() : "discovery"; + + ServicePlugin.Service svc = null; + Plugin raw = manager.getPlugin("services"); + if (raw instanceof ServicePlugin) { + svc = ((ServicePlugin) raw).getService(discoveryConfig.getService()); + // Validate caught the missing-service case earlier; assert defensively when the + // ServicePlugin IS registered but the lookup fails — never silently fall back to the + // default HTTP client and skip the configured TLS context. + if (svc == null) { + throw new PluginInitializationException( + "Discovery plugin: service '" + + discoveryConfig.getService() + + "' not found in ServicePlugin") + .withContext("serviceName", discoveryConfig.getService()); + } + } + // raw==null path: tests that wire DiscoveryPlugin directly without a ServicePlugin. + // svc stays null and BundleDownloader falls back to a default HTTP client. + plugin.discoveryBundle = - new DiscoveryBundle(name, manager) + new DiscoveryBundle(name, manager, svc) .setService(discoveryConfig.getService()) .setResource(discoveryConfig.getResource()) .setPolling(discoveryConfig.getPolling()); @@ -141,8 +157,8 @@ private static class DiscoveryBundle extends BundleDownloader { private Config discoveredConfig; // Store the last successfully loaded config - private DiscoveryBundle(String name, PluginManager manager) { - super(name, manager); + private DiscoveryBundle(String name, PluginManager manager, ServicePlugin.Service service) { + super(name, manager, service); } @Override diff --git a/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/ServicePlugin.java b/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/ServicePlugin.java index c8b8396b..a8d90141 100644 --- a/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/ServicePlugin.java +++ b/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/ServicePlugin.java @@ -5,27 +5,27 @@ import java.net.http.HttpRequest; import java.net.http.HttpRequest.Builder; import java.net.http.HttpResponse; -import java.security.SecureRandom; -import java.security.cert.X509Certificate; import java.time.Duration; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; -import javax.net.ssl.SSLContext; -import javax.net.ssl.TrustManager; -import javax.net.ssl.X509TrustManager; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; import io.github.open_policy_agent.opa.config.Config; import io.github.open_policy_agent.opa.logging.Logger; +import io.github.open_policy_agent.opa.tls.SslContextBuilder; public final class ServicePlugin implements Plugin { private final Map services = new HashMap<>(); private PluginManager manager; + private ScheduledExecutorService certReloadScheduler; public ServicePlugin() {} - public Set validate(PluginManager manager) { + public Set validate(PluginManager manager) { Set errors = new HashSet<>(); if (manager.getConfig().getServices() == null || manager.getConfig().getServices().isEmpty()) { @@ -49,6 +49,114 @@ public Set validate(PluginManager manager) { if (credential != null && credential.getType().equals(Credential.Type.NOT_SUPPORTED)) { errors.add("Service '" + serviceName + "' has unsupported credential type"); } + + errors.addAll(validateTls(serviceName, service)); + } + return errors; + } + + private Set validateTls(String serviceName, Config.ServiceConfig service) { + Set errors = new HashSet<>(); + Function err = msg -> "Service '" + serviceName + "' " + msg; + + Config.ClientTlsConfig clientTls = + service.getCredentials() == null ? null : service.getCredentials().getClientTls(); + boolean hasServerTls = SslContextBuilder.hasServerTls(service); + boolean hasClientTls = SslContextBuilder.hasClientTls(service); + boolean hasProgrammatic = service.getSslContext() != null; + + if (service.isAllowInsecureTLS() && (hasServerTls || hasClientTls || hasProgrammatic)) { + errors.add(err.apply("sets allow_insecure_tls=true alongside other TLS config; choose one")); + } + + if (hasProgrammatic && (hasServerTls || hasClientTls)) { + errors.add(err.apply("sets programmatic SSLContext alongside file-based TLS config; choose one")); + } + + Config.TlsConfig tlsBlock = service.getTls(); + if (tlsBlock != null) { + boolean caCertSet = tlsBlock.getCaCert() != null && !tlsBlock.getCaCert().isEmpty(); + Config.TruststoreConfig truststore = tlsBlock.getTruststore(); + boolean truststoreSet = truststore != null + && (truststore.getKeyStore() != null + || (truststore.getPath() != null && !truststore.getPath().isEmpty())); + + if (caCertSet && truststoreSet) { + errors.add(err.apply("tls block sets both ca_cert and truststore; choose one")); + } + if (!caCertSet && !truststoreSet && !tlsBlock.isSystemCaRequired()) { + errors.add( + err.apply( + "tls block has no effect: set ca_cert, truststore, or system_ca_required=true," + + " or remove the block")); + } + if (truststoreSet) { + errors.addAll(validateTruststore(serviceName, truststore)); + } + } + + if (clientTls != null) { + boolean certSet = clientTls.getCert() != null && !clientTls.getCert().isEmpty(); + boolean keySet = clientTls.getPrivateKey() != null && !clientTls.getPrivateKey().isEmpty(); + Config.KeystoreConfig keystore = clientTls.getKeystore(); + boolean keystoreSet = keystore != null + && (keystore.getKeyStore() != null + || (keystore.getPath() != null && !keystore.getPath().isEmpty())); + + if (keystoreSet && (certSet || keySet)) { + errors.add( + err.apply("credentials.client_tls sets both keystore and cert/private_key; choose one")); + } + if (!keystoreSet && certSet != keySet) { + errors.add(err.apply("credentials.client_tls must set both cert and private_key")); + } + if (!keySet + && clientTls.getPrivateKeyPassphrase() != null + && !clientTls.getPrivateKeyPassphrase().isEmpty()) { + errors.add(err.apply("credentials.client_tls.private_key_passphrase requires private_key")); + } + if (clientTls.getCertRereadIntervalSeconds() != null + && clientTls.getCertRereadIntervalSeconds() < 0) { + errors.add(err.apply("credentials.client_tls.cert_reread_interval_seconds must be >= 0")); + } + if (clientTls.getCertRereadIntervalSeconds() != null + && clientTls.getCertRereadIntervalSeconds() > 0 + && (!certSet || !keySet)) { + errors.add( + err.apply( + "credentials.client_tls.cert_reread_interval_seconds requires both cert" + + " and private_key")); + } + if (keystoreSet) { + errors.addAll(validateKeystore(serviceName, keystore)); + } + } + + // Path existence is intentionally NOT checked here. Certificate files may be rotated into + // place after startup (short-lived CM2 certs, discovery-driven config). The builder + // fails fast with a clear error on the first download if a path is missing or unreadable. + + return errors; + } + + private static Set validateKeystore(String serviceName, Config.KeystoreConfig ks) { + Set errors = new HashSet<>(); + Function err = msg -> "Service '" + serviceName + "' " + msg; + boolean pathSet = ks.getPath() != null && !ks.getPath().isEmpty(); + boolean programmaticSet = ks.getKeyStore() != null; + if (pathSet && programmaticSet) { + errors.add(err.apply("credentials.client_tls.keystore sets both path and programmatic KeyStore; choose one")); + } + return errors; + } + + private static Set validateTruststore(String serviceName, Config.TruststoreConfig ts) { + Set errors = new HashSet<>(); + Function err = msg -> "Service '" + serviceName + "' " + msg; + boolean pathSet = ts.getPath() != null && !ts.getPath().isEmpty(); + boolean programmaticSet = ts.getKeyStore() != null; + if (pathSet && programmaticSet) { + errors.add(err.apply("tls.truststore sets both path and programmatic KeyStore; choose one")); } return errors; } @@ -61,63 +169,66 @@ public Plugin initialize(PluginManager manager) { return plugin; } - for (Map.Entry entry : - manager.getConfig().getServices().entrySet()) { - String serviceName = entry.getKey(); - Config.ServiceConfig service = entry.getValue(); + // Two threads so a slow reload (e.g. an NFS stall) on one service doesn't stall the others. + // This is a deliberate small pool; bump only if many services rotate. + plugin.certReloadScheduler = + BundleDownloader.newPollScheduler(2, "opa-service-cert-reload"); - // Set the name from the map key if not already set - if (service.getName() == null || service.getName().isEmpty()) { - service.setName(serviceName); - } + try { + for (Map.Entry entry : + manager.getConfig().getServices().entrySet()) { + String serviceName = entry.getKey(); + Config.ServiceConfig service = entry.getValue(); + + if (service.getName() == null || service.getName().isEmpty()) { + service.setName(serviceName); + } - // Build HttpClient with optional insecure TLS support - HttpClient.Builder clientBuilder = - HttpClient.newBuilder() - .followRedirects(HttpClient.Redirect.NORMAL) - .version(HttpClient.Version.HTTP_2) - .connectTimeout(Duration.ofSeconds(service.getResponseHeaderTimeoutSeconds())); + HttpClient.Builder clientBuilder = + HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .version(HttpClient.Version.HTTP_2) + .connectTimeout(Duration.ofSeconds(service.getResponseHeaderTimeoutSeconds())); - // Configure insecure TLS if enabled (for development/testing only) - if (service.isAllowInsecureTLS()) { try { - SSLContext sslContext = SSLContext.getInstance("TLS"); - sslContext.init( - null, - new TrustManager[] { - new X509TrustManager() { - public void checkClientTrusted(X509Certificate[] chain, String authType) {} - - public void checkServerTrusted(X509Certificate[] chain, String authType) {} - - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; - } - } - }, - new SecureRandom()); - - clientBuilder.sslContext(sslContext); - manager - .getLogger() - .warn( - "Service '%s' has insecure TLS enabled - this should only be used in development", - serviceName); + SslContextBuilder.Tls tls = + SslContextBuilder.build(service, plugin.certReloadScheduler, manager.getLogger()); + if (tls.getSslContext() != null) { + clientBuilder.sslContext(tls.getSslContext()); + if (service.isAllowInsecureTLS()) { + manager + .getLogger() + .warn( + "Service '%s' has insecure TLS enabled - this should only be used in development", + serviceName); + } + } + clientBuilder.sslParameters(tls.getSslParameters()); } catch (Exception e) { throw new PluginInitializationException( - "Failed to configure insecure TLS for service '" + serviceName + "'", e) + "Failed to configure TLS for service '" + serviceName + "': " + e.getMessage(), e) .withContext("serviceName", serviceName); } - } - - HttpClient client = clientBuilder.build(); - plugin.services.put( - service.getName(), - new Service(client, manager.getLogger()) - .setName(service.getName()) - .setUrl(service.getUrl()) - .setCredentials(getCredential(service))); + HttpClient client = clientBuilder.build(); + + plugin.services.put( + service.getName(), + Service.builder(client, manager.getLogger()) + .name(service.getName()) + .url(service.getUrl()) + .responseHeaderTimeoutSeconds(service.getResponseHeaderTimeoutSeconds()) + .allowInsecureTls(service.isAllowInsecureTLS()) + .credentials(getCredential(service)) + .headers(service.getHeaders()) + .build()); + } + } catch (RuntimeException e) { + // Partial init failed: shut down the scheduler so any reload tasks already scheduled + // for earlier services don't outlive this initialize() call. + plugin.certReloadScheduler.shutdownNow(); + plugin.certReloadScheduler = null; + throw e; } return plugin; @@ -130,8 +241,18 @@ public void start() { @Override public void stop() { - // Services plugin has no resources to clean up (no scheduler) manager.getLogger().info("Stopping services plugin..."); + if (certReloadScheduler != null) { + certReloadScheduler.shutdown(); + try { + if (!certReloadScheduler.awaitTermination(5, TimeUnit.SECONDS)) { + certReloadScheduler.shutdownNow(); + } + } catch (InterruptedException e) { + certReloadScheduler.shutdownNow(); + Thread.currentThread().interrupt(); + } + } } /** @@ -152,6 +273,11 @@ private Credential getCredential(Config.ServiceConfig service) { if (service.getCredentials().getBearer() != null) { return new BearerCredential().setToken(service.getCredentials().getBearer().getToken()); } + // client_tls is handled at the SSLContext layer (not a per-request modifier), so it maps + // to no-op credentials at the HTTP level. + if (service.getCredentials().getClientTls() != null) { + return null; + } return new Credential() { @Override public Type getType() { @@ -177,36 +303,51 @@ enum Type { } } - public static class Service { + /** + * A configured service, holding everything required to talk to it: the per-service {@link + * HttpClient} (with its SSLContext already wired), the URL, credentials, and any extra headers. + * + *

Construct via {@link #builder(HttpClient, Logger)}; instances are immutable after build. + */ + public static final class Service { private final Logger logger; - private String name; - private String url; - private int responseHeaderTimeoutSeconds = 10; - private boolean allowInsecureTLS = false; - private Credential credentials; + private final String name; + private final String url; + private final int responseHeaderTimeoutSeconds; + private final boolean allowInsecureTLS; + private final Credential credentials; private final HttpClient client; + private final Map headers; + + private Service(Builder b) { + this.client = b.client; + this.logger = b.logger; + this.name = b.name; + this.url = b.url; + this.responseHeaderTimeoutSeconds = b.responseHeaderTimeoutSeconds; + this.allowInsecureTLS = b.allowInsecureTls; + this.credentials = b.credentials; + this.headers = b.headers == null ? null : new HashMap<>(b.headers); + } - private Service(HttpClient client, Logger logger) { - this.client = client; - this.logger = logger; + public static Builder builder(HttpClient client, Logger logger) { + return new Builder(client, logger); } void post(String path, String body) { - Builder builder = + HttpRequest.Builder request = HttpRequest.newBuilder() .uri(buildUri(path)) .header("Content-Type", "application/json") .header("Accept", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)); - if (credentials != null) { - builder = credentials.modifyRequest(builder); - } - HttpRequest request = builder.build(); + request = applyCredentials(request); + request = applyHeaders(request); client - .sendAsync(request, HttpResponse.BodyHandlers.ofString()) + .sendAsync(request.build(), HttpResponse.BodyHandlers.ofString()) .thenAccept( resp -> { logger.debug("POST request sent successfully: " + resp.statusCode()); @@ -218,6 +359,32 @@ void post(String path, String body) { }); } + /** + * Apply this service's credentials to an in-progress request. No-op when no credentials are + * configured. Exposed so other plugins (e.g. bundle downloads) can use the same auth path as + * {@link #post}. + */ + public HttpRequest.Builder applyCredentials(HttpRequest.Builder request) { + if (credentials != null) { + return credentials.modifyRequest(request); + } + return request; + } + + /** + * Apply this service's user-supplied {@code headers} to a request. Uses {@code setHeader} + * (replace, not append) so user-supplied entries override built-in headers like + * {@code Authorization} rather than producing duplicates. + */ + public HttpRequest.Builder applyHeaders(HttpRequest.Builder request) { + if (headers != null) { + for (Map.Entry h : headers.entrySet()) { + request.setHeader(h.getKey(), h.getValue()); + } + } + return request; + } + /** * Build a properly formatted URI by combining the base URL with the path. * @@ -258,45 +425,70 @@ public String getName() { return name; } - public Service setName(String name) { - this.name = name; - return this; - } - public String getUrl() { return url; } - public Service setUrl(String url) { - this.url = url; - return this; - } - public int getResponseHeaderTimeoutSeconds() { return responseHeaderTimeoutSeconds; } - public Service setResponseHeaderTimeoutSeconds(int responseHeaderTimeoutSeconds) { - this.responseHeaderTimeoutSeconds = responseHeaderTimeoutSeconds; - return this; - } - public boolean isAllowInsecureTls() { return allowInsecureTLS; } - public Service setAllowInsecureTls(boolean allowInsecureTls) { - this.allowInsecureTLS = allowInsecureTls; - return this; - } - public Credential getCredentials() { return credentials; } - public Service setCredentials(Credential credentials) { - this.credentials = credentials; - return this; + public static final class Builder { + private final HttpClient client; + private final Logger logger; + private String name; + private String url; + private int responseHeaderTimeoutSeconds = 10; + private boolean allowInsecureTls = false; + private Credential credentials; + private Map headers; + + private Builder(HttpClient client, Logger logger) { + this.client = client; + this.logger = logger; + } + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder url(String url) { + this.url = url; + return this; + } + + public Builder responseHeaderTimeoutSeconds(int seconds) { + this.responseHeaderTimeoutSeconds = seconds; + return this; + } + + public Builder allowInsecureTls(boolean allow) { + this.allowInsecureTls = allow; + return this; + } + + public Builder credentials(Credential credentials) { + this.credentials = credentials; + return this; + } + + public Builder headers(Map headers) { + this.headers = headers; + return this; + } + + public Service build() { + return new Service(this); + } } } diff --git a/opa-services/src/main/java/io/github/open_policy_agent/opa/tls/KeyStores.java b/opa-services/src/main/java/io/github/open_policy_agent/opa/tls/KeyStores.java new file mode 100644 index 00000000..c1aa3e20 --- /dev/null +++ b/opa-services/src/main/java/io/github/open_policy_agent/opa/tls/KeyStores.java @@ -0,0 +1,33 @@ +package io.github.open_policy_agent.opa.tls; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.List; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; + +/** Internal helpers for building in-memory PKCS12 keystores from parsed PEM material. */ +final class KeyStores { + + static final String ALIAS = "key"; + private static final char[] EMPTY_PASSWORD = new char[0]; + + private KeyStores() {} + + /** + * Build a {@link KeyManager}{@code []} from a single cert chain + private key, wrapped in an + * in-memory PKCS12 store. The password is empty since the store never leaves process memory. + */ + static KeyManager[] keyManagers(List chain, PrivateKey key) + throws GeneralSecurityException, IOException { + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(null, EMPTY_PASSWORD); + ks.setKeyEntry(ALIAS, key, EMPTY_PASSWORD, chain.toArray(new X509Certificate[0])); + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, EMPTY_PASSWORD); + return kmf.getKeyManagers(); + } +} diff --git a/opa-services/src/main/java/io/github/open_policy_agent/opa/tls/PemLoader.java b/opa-services/src/main/java/io/github/open_policy_agent/opa/tls/PemLoader.java new file mode 100644 index 00000000..cbca22d4 --- /dev/null +++ b/opa-services/src/main/java/io/github/open_policy_agent/opa/tls/PemLoader.java @@ -0,0 +1,157 @@ +package io.github.open_policy_agent.opa.tls; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collection; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * PEM parsing utilities for X.509 certificates and private keys, using only the JDK standard + * library. + * + *

Supports PKCS#8 PEM ({@code -----BEGIN PRIVATE KEY-----}) keys with any algorithm the JDK + * KeyFactory recognises (RSA, EC, DSA). Encrypted PKCS#8 ({@code BEGIN ENCRYPTED PRIVATE KEY}), + * PKCS#1 RSA ({@code BEGIN RSA PRIVATE KEY}), and SEC1 EC ({@code BEGIN EC PRIVATE KEY}) are + * not supported — convert to unencrypted PKCS#8 first, or supply credentials via a JKS / + * PKCS#12 keystore (see {@code credentials.client_tls.keystore}). + */ +public final class PemLoader { + + private static final Pattern PEM_BLOCK = + Pattern.compile( + "-----BEGIN ([A-Z0-9 ]+?)-----\\s*([A-Za-z0-9+/=\\r\\n\\s]+?)\\s*-----END \\1-----"); + + private PemLoader() {} + + /** + * Load all X.509 certificates from a PEM file (cert chain). + * + * @param path path to a PEM file containing one or more {@code CERTIFICATE} blocks + * @return the certificates, in file order + */ + public static List loadCertificates(Path path) throws IOException { + return parseCertificates(Files.readAllBytes(path), path.toString()); + } + + static List parseCertificates(byte[] data, String source) throws IOException { + List certs = new ArrayList<>(); + CertificateFactory factory; + try { + factory = CertificateFactory.getInstance("X.509"); + } catch (CertificateException e) { + throw new IOException("X.509 CertificateFactory unavailable", e); + } + + Matcher m = PEM_BLOCK.matcher(new String(data, StandardCharsets.UTF_8)); + while (m.find()) { + String label = m.group(1).trim(); + if (!label.equals("CERTIFICATE")) { + continue; + } + byte[] der = decodeBase64(m.group(2), source); + try { + Collection parsed = + factory.generateCertificates(new java.io.ByteArrayInputStream(der)); + for (java.security.cert.Certificate c : parsed) { + certs.add((X509Certificate) c); + } + } catch (CertificateException e) { + throw new IOException( + "Failed to parse certificate in " + source + ": " + e.getMessage(), e); + } + } + if (certs.isEmpty()) { + throw new IOException("No CERTIFICATE blocks found in " + source); + } + return certs; + } + + /** + * Load an unencrypted PKCS#8 private key from a PEM file. + * + * @param path path to a PEM file + * @param passphrase reserved for future use; encrypted PEM keys are not supported by this + * loader. If non-null, an {@link IOException} is thrown so misconfiguration surfaces early + * rather than silently ignoring the passphrase. + * @return the parsed private key + */ + public static PrivateKey loadPrivateKey(Path path, char[] passphrase) throws IOException { + return parsePrivateKey(Files.readAllBytes(path), passphrase, path.toString()); + } + + static PrivateKey parsePrivateKey(byte[] data, char[] passphrase, String source) + throws IOException { + Matcher m = PEM_BLOCK.matcher(new String(data, StandardCharsets.UTF_8)); + while (m.find()) { + String label = m.group(1).trim(); + switch (label) { + case "PRIVATE KEY": + if (passphrase != null) { + throw new IOException( + "Key in " + + source + + " is unencrypted PKCS#8 but a passphrase was supplied. Either remove the" + + " passphrase or supply credentials via a JKS / PKCS#12 keystore."); + } + return decodePkcs8(decodeBase64(m.group(2), source), source); + case "ENCRYPTED PRIVATE KEY": + throw new IOException( + "Encrypted PEM private keys are not supported in " + + source + + ". Convert to unencrypted PKCS#8 (openssl pkcs8 -topk8 -nocrypt) or use a" + + " JKS / PKCS#12 keystore via credentials.client_tls.keystore."); + case "RSA PRIVATE KEY": + case "EC PRIVATE KEY": + throw new IOException( + "PKCS#1 / SEC1 PEM private keys are not supported in " + + source + + " (found '" + + label + + "'). Convert to PKCS#8 with" + + " 'openssl pkcs8 -topk8 -nocrypt -in key.pem -out key-pkcs8.pem'."); + default: + // Skip unrelated blocks (e.g. CERTIFICATE) when key+chain share a file. + } + } + throw new IOException("No private-key PEM block found in " + source); + } + + private static PrivateKey decodePkcs8(byte[] der, String source) throws IOException { + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(der); + // Try the algorithms KeyFactory commonly supports. The DER itself encodes the algorithm OID, + // so KeyFactory.generatePrivate validates it; we just need to pick the right factory. + for (String alg : new String[] {"RSA", "EC", "DSA"}) { + try { + return KeyFactory.getInstance(alg).generatePrivate(spec); + } catch (InvalidKeySpecException ignored) { + // try next + } catch (GeneralSecurityException e) { + throw new IOException("Failed to load private key from " + source + ": " + e.getMessage(), e); + } + } + throw new IOException( + "Unsupported private-key algorithm in " + source + " (expected RSA, EC, or DSA PKCS#8)"); + } + + private static byte[] decodeBase64(String body, String source) throws IOException { + try { + return Base64.getMimeDecoder().decode(body); + } catch (IllegalArgumentException e) { + throw new IOException("Malformed base64 in PEM block in " + source + ": " + e.getMessage(), e); + } + } +} diff --git a/opa-services/src/main/java/io/github/open_policy_agent/opa/tls/ReloadingX509KeyManager.java b/opa-services/src/main/java/io/github/open_policy_agent/opa/tls/ReloadingX509KeyManager.java new file mode 100644 index 00000000..e5360766 --- /dev/null +++ b/opa-services/src/main/java/io/github/open_policy_agent/opa/tls/ReloadingX509KeyManager.java @@ -0,0 +1,228 @@ +package io.github.open_policy_agent.opa.tls; + +import java.io.IOException; +import java.net.Socket; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.Principal; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.X509ExtendedKeyManager; +import io.github.open_policy_agent.opa.logging.Logger; + +/** + * An {@link X509ExtendedKeyManager} that periodically re-reads its cert and private-key files and + * rebuilds the underlying delegate when the on-disk bytes change. + * + *

Designed for short-lived certificates (e.g. cert-manager issues 24-hour certs) where the + * deployment refreshes them in place. Polled reload, SHA-256 dedupe, parse only on change. + * + *

The key manager delegate is swapped atomically, so in-flight handshakes keep using the old + * delegate and new handshakes pick up the new one. + * + *

Call {@link #close()} to cancel the reload schedule when the owning service is torn down or + * its config is reloaded; otherwise the task continues until the scheduler itself shuts down. + */ +public final class ReloadingX509KeyManager extends X509ExtendedKeyManager implements AutoCloseable { + + private final Path certPath; + private final Path keyPath; + private final char[] keyPassphrase; + private final Logger logger; + private final String serviceName; + + private final AtomicReference state = new AtomicReference<>(); + private final AtomicReference> reloadFuture = new AtomicReference<>(); + + /** + * Build and start a reloading key manager. + * + * @param certPath PEM cert-chain path + * @param keyPath PEM PKCS#8 private-key path + * @param keyPassphrase reserved; encrypted PEM keys are not supported (pass {@code null}) + * @param scheduler executor used to schedule periodic reloads + * @param rereadInterval how often to check the on-disk bytes (must be positive) + * @param logger logger for reload events + * @param serviceName name of the owning service (for log messages) + */ + public static ReloadingX509KeyManager create( + Path certPath, + Path keyPath, + char[] keyPassphrase, + ScheduledExecutorService scheduler, + Duration rereadInterval, + Logger logger, + String serviceName) + throws IOException, GeneralSecurityException { + if (rereadInterval == null || rereadInterval.isNegative() || rereadInterval.isZero()) { + throw new IllegalArgumentException("rereadInterval must be positive"); + } + ReloadingX509KeyManager mgr = + new ReloadingX509KeyManager(certPath, keyPath, keyPassphrase, logger, serviceName); + mgr.loadOrThrow(); + long seconds = rereadInterval.getSeconds(); + if (seconds < 1) { + seconds = 1; + } + ScheduledFuture future = + scheduler.scheduleAtFixedRate(mgr::reloadIfChanged, seconds, seconds, TimeUnit.SECONDS); + mgr.reloadFuture.set(future); + return mgr; + } + + private ReloadingX509KeyManager( + Path certPath, Path keyPath, char[] keyPassphrase, Logger logger, String serviceName) { + this.certPath = certPath; + this.keyPath = keyPath; + this.keyPassphrase = keyPassphrase == null ? null : keyPassphrase.clone(); + this.logger = logger; + this.serviceName = serviceName; + } + + private void loadOrThrow() throws IOException, GeneralSecurityException { + byte[] certBytes = Files.readAllBytes(certPath); + byte[] keyBytes = Files.readAllBytes(keyPath); + state.set(build(certBytes, keyBytes, sha256(certBytes), sha256(keyBytes))); + } + + /** + * Visible for testing. In production this is only called from the scheduler. + * + *

{@code synchronized} so that concurrent callers (e.g. two reload scheduler threads, or + * a test invoking this directly while the scheduler also fires) can't see a partial-rotation + * state where the cert file has been rewritten but the key file hasn't. Contention is + * negligible since reloads are infrequent. + */ + synchronized void reloadIfChanged() { + try { + byte[] certBytes = Files.readAllBytes(certPath); + byte[] keyBytes = Files.readAllBytes(keyPath); + byte[] certHash = sha256(certBytes); + byte[] keyHash = sha256(keyBytes); + State current = state.get(); + // Require BOTH hashes to match before skipping. If only one file has been rewritten (a + // partial rotation where cert and key are updated non-atomically), we need to rebuild — + // reusing the old delegate would mix a new cert with the old key and break handshakes. + if (Arrays.equals(current.certHash, certHash) + && Arrays.equals(current.keyHash, keyHash)) { + return; + } + state.set(build(certBytes, keyBytes, certHash, keyHash)); + logger.info("Service '%s': reloaded client TLS certificate", serviceName); + } catch (IOException | GeneralSecurityException e) { + // Swallow and log: a transient failure (cert being rewritten, passphrase mismatch during + // rotation) shouldn't kill the scheduler. The next tick retries with the current delegate + // still serving in-flight handshakes. + logger.error( + "Service '%s': failed to reload client TLS certificate: %s", + serviceName, e.getMessage()); + } + } + + /** + * Cancel the periodic reload task. Idempotent. The currently-loaded delegate keeps serving + * in-flight handshakes; only future scheduler ticks are stopped. + */ + @Override + public void close() { + ScheduledFuture future = reloadFuture.getAndSet(null); + if (future != null) { + future.cancel(false); + } + } + + private State build(byte[] certBytes, byte[] keyBytes, byte[] certHash, byte[] keyHash) + throws IOException, GeneralSecurityException { + List chain = + PemLoader.parseCertificates(certBytes, certPath.toString()); + PrivateKey key = + PemLoader.parsePrivateKey(keyBytes, keyPassphrase, keyPath.toString()); + + X509ExtendedKeyManager delegate = null; + for (javax.net.ssl.KeyManager km : KeyStores.keyManagers(chain, key)) { + if (km instanceof X509ExtendedKeyManager) { + delegate = (X509ExtendedKeyManager) km; + break; + } + } + if (delegate == null) { + throw new IllegalStateException("No X509ExtendedKeyManager available from default provider"); + } + return new State(delegate, certHash, keyHash); + } + + private static byte[] sha256(byte[] data) { + try { + return MessageDigest.getInstance("SHA-256").digest(data); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } + + private X509ExtendedKeyManager delegate() { + return state.get().delegate; + } + + @Override + public String[] getClientAliases(String keyType, Principal[] issuers) { + return delegate().getClientAliases(keyType, issuers); + } + + @Override + public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { + return delegate().chooseClientAlias(keyType, issuers, socket); + } + + @Override + public String[] getServerAliases(String keyType, Principal[] issuers) { + return delegate().getServerAliases(keyType, issuers); + } + + @Override + public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) { + return delegate().chooseServerAlias(keyType, issuers, socket); + } + + @Override + public X509Certificate[] getCertificateChain(String alias) { + return delegate().getCertificateChain(alias); + } + + @Override + public PrivateKey getPrivateKey(String alias) { + return delegate().getPrivateKey(alias); + } + + @Override + public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) { + return delegate().chooseEngineClientAlias(keyType, issuers, engine); + } + + @Override + public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) { + return delegate().chooseEngineServerAlias(keyType, issuers, engine); + } + + private static final class State { + final X509ExtendedKeyManager delegate; + final byte[] certHash; + final byte[] keyHash; + + State(X509ExtendedKeyManager delegate, byte[] certHash, byte[] keyHash) { + this.delegate = delegate; + this.certHash = certHash; + this.keyHash = keyHash; + } + } +} diff --git a/opa-services/src/main/java/io/github/open_policy_agent/opa/tls/SslContextBuilder.java b/opa-services/src/main/java/io/github/open_policy_agent/opa/tls/SslContextBuilder.java new file mode 100644 index 00000000..95dc77eb --- /dev/null +++ b/opa-services/src/main/java/io/github/open_policy_agent/opa/tls/SslContextBuilder.java @@ -0,0 +1,263 @@ +package io.github.open_policy_agent.opa.tls; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.ScheduledExecutorService; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; +import io.github.open_policy_agent.opa.config.Config; +import io.github.open_policy_agent.opa.logging.Logger; + +/** + * Builds per-service {@link SSLContext} and {@link SSLParameters} from a {@link + * Config.ServiceConfig}. + * + *

Precedence, highest first: + * + *

    + *
  1. Programmatic override ({@link Config.ServiceConfig#getSslContext()}). + *
  2. {@code allow_insecure_tls: true} — trust-all context (development only). + *
  3. File / keystore-based config ({@code tls.ca_cert}, {@code tls.truststore}, {@code + * credentials.client_tls.cert} / {@code .keystore}). + *
  4. Nothing configured → {@code null} (caller keeps the HttpClient default). + *
+ * + *

SSL parameters always pin the minimum TLS version to 1.2, matching Go-OPA's {@code + * DefaultMinTLSVersion} and swift-opa-sdk. + */ +public final class SslContextBuilder { + + private static final String[] MIN_TLS_PROTOCOLS = {"TLSv1.2", "TLSv1.3"}; + private static final String[] APPLICATION_PROTOCOLS = {"h2", "http/1.1"}; + + private SslContextBuilder() {} + + public static Tls build( + Config.ServiceConfig service, + ScheduledExecutorService reloadScheduler, + Logger logger) + throws IOException, GeneralSecurityException { + + SSLParameters params = new SSLParameters(); + params.setProtocols(MIN_TLS_PROTOCOLS); + // ALPN must be set explicitly because HttpClient.Builder#sslParameters replaces Java's + // built-in defaults wholesale. Without "h2" in this list, an HTTP/2 client that negotiates + // ALPN with the server still ends up with no application protocol agreed, causing client-cert + // re-presentation to silently fail under TLS 1.3 — server returns 401 with no body. Listing + // both lets the same SSLContext serve HTTP/1.1 and HTTP/2 clients identically. + params.setApplicationProtocols(APPLICATION_PROTOCOLS); + + SSLContext programmatic = service.getSslContext(); + if (programmatic != null) { + return new Tls(programmatic, params); + } + + if (service.isAllowInsecureTLS()) { + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(null, new TrustManager[] {TrustAllManager.INSTANCE}, new SecureRandom()); + return new Tls(ctx, params); + } + + boolean hasServerTls = hasServerTls(service); + boolean hasClientTls = hasClientTls(service); + + if (!hasServerTls && !hasClientTls) { + return new Tls(null, params); + } + + KeyManager[] keyManagers = hasClientTls + ? buildKeyManagers(service, reloadScheduler, logger) + : null; + TrustManager[] trustManagers = hasServerTls + ? buildTrustManagers(service.getTls()) + : null; + + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(keyManagers, trustManagers, new SecureRandom()); + return new Tls(ctx, params); + } + + /** True when the service configures any form of trust roots (PEM CA or keystore). */ + public static boolean hasServerTls(Config.ServiceConfig service) { + Config.TlsConfig tls = service.getTls(); + if (tls == null) { + return false; + } + if (tls.getCaCert() != null && !tls.getCaCert().isEmpty()) { + return true; + } + Config.TruststoreConfig ts = tls.getTruststore(); + return ts != null && (ts.getKeyStore() != null || (ts.getPath() != null && !ts.getPath().isEmpty())); + } + + /** True when the service configures a client certificate (PEM or keystore) for mTLS. */ + public static boolean hasClientTls(Config.ServiceConfig service) { + if (service.getCredentials() == null) { + return false; + } + Config.ClientTlsConfig clientTls = service.getCredentials().getClientTls(); + if (clientTls == null) { + return false; + } + if (clientTls.getCert() != null && !clientTls.getCert().isEmpty()) { + return true; + } + Config.KeystoreConfig ks = clientTls.getKeystore(); + return ks != null && (ks.getKeyStore() != null || (ks.getPath() != null && !ks.getPath().isEmpty())); + } + + private static KeyManager[] buildKeyManagers( + Config.ServiceConfig service, ScheduledExecutorService reloadScheduler, Logger logger) + throws IOException, GeneralSecurityException { + Config.ClientTlsConfig clientTls = service.getCredentials().getClientTls(); + + if (clientTls.getKeystore() != null) { + return keyManagersFromKeystore(clientTls.getKeystore()); + } + + Path certPath = Paths.get(clientTls.getCert()); + Path keyPath = Paths.get(clientTls.getPrivateKey()); + char[] passphrase = + clientTls.getPrivateKeyPassphrase() == null + ? null + : clientTls.getPrivateKeyPassphrase().toCharArray(); + Integer reread = clientTls.getCertRereadIntervalSeconds(); + + if (reread != null && reread > 0) { + ReloadingX509KeyManager km = + ReloadingX509KeyManager.create( + certPath, + keyPath, + passphrase, + reloadScheduler, + java.time.Duration.ofSeconds(reread), + logger, + service.getName()); + return new KeyManager[] {km}; + } + + return KeyStores.keyManagers( + PemLoader.loadCertificates(certPath), PemLoader.loadPrivateKey(keyPath, passphrase)); + } + + private static KeyManager[] keyManagersFromKeystore(Config.KeystoreConfig cfg) + throws IOException, GeneralSecurityException { + KeyStore ks = loadKeyStore(cfg.getKeyStore(), cfg.getPath(), cfg.getType(), cfg.getPassword()); + char[] keyPass = + (cfg.getKeyPassword() != null ? cfg.getKeyPassword() : cfg.getPassword() == null ? "" : cfg.getPassword()) + .toCharArray(); + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, keyPass); + return kmf.getKeyManagers(); + } + + // Package-private for tests. + static TrustManager[] buildTrustManagers(Config.TlsConfig tls) + throws IOException, GeneralSecurityException { + KeyStore ts; + if (tls.getTruststore() != null) { + ts = loadKeyStore( + tls.getTruststore().getKeyStore(), + tls.getTruststore().getPath(), + tls.getTruststore().getType(), + tls.getTruststore().getPassword()); + } else { + Path caPath = Paths.get(tls.getCaCert()); + List userCAs = PemLoader.loadCertificates(caPath); + ts = KeyStore.getInstance("PKCS12"); + ts.load(null, new char[0]); + int idx = 0; + for (X509Certificate ca : userCAs) { + ts.setCertificateEntry("user-ca-" + idx++, ca); + } + } + + // system_ca_required=true: merge system trust anchors into the same keystore so a single + // PKIX validator chains through either set. Composing two trust managers with try/catch is + // tempting but unsafe: a revoked or expired cert that fails the user check would silently + // fall through to the system check and could be accepted if the system happened to chain it. + if (tls.isSystemCaRequired()) { + TrustManagerFactory sysTmf = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + sysTmf.init((KeyStore) null); + int idx = 0; + for (TrustManager tm : sysTmf.getTrustManagers()) { + if (tm instanceof X509TrustManager) { + for (X509Certificate sysCa : ((X509TrustManager) tm).getAcceptedIssuers()) { + ts.setCertificateEntry("sys-ca-" + idx++, sysCa); + } + } + } + } + + TrustManagerFactory tmf = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(ts); + return tmf.getTrustManagers(); + } + + private static KeyStore loadKeyStore(KeyStore programmatic, String path, String type, String password) + throws IOException, GeneralSecurityException { + if (programmatic != null) { + return programmatic; + } + String resolvedType = + type != null && !type.isEmpty() + ? type + : (path != null && path.toLowerCase(Locale.ROOT).endsWith(".jks") ? "JKS" : "PKCS12"); + KeyStore ks = KeyStore.getInstance(resolvedType); + char[] pw = password == null ? new char[0] : password.toCharArray(); + try (InputStream in = Files.newInputStream(Paths.get(path))) { + ks.load(in, pw); + } + return ks; + } + + /** Container for a configured {@link SSLContext} and its {@link SSLParameters}. */ + public static final class Tls { + private final SSLContext sslContext; + private final SSLParameters sslParameters; + + Tls(SSLContext sslContext, SSLParameters sslParameters) { + this.sslContext = sslContext; + this.sslParameters = sslParameters; + } + + public SSLContext getSslContext() { + return sslContext; + } + + public SSLParameters getSslParameters() { + return sslParameters; + } + } + + private static final class TrustAllManager implements X509TrustManager { + static final TrustAllManager INSTANCE = new TrustAllManager(); + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) {} + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) {} + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + } +} diff --git a/opa-services/src/test/java/io/github/open_policy_agent/opa/config/ConfigTest.java b/opa-services/src/test/java/io/github/open_policy_agent/opa/config/ConfigTest.java index 41318b56..2b5384d0 100644 --- a/opa-services/src/test/java/io/github/open_policy_agent/opa/config/ConfigTest.java +++ b/opa-services/src/test/java/io/github/open_policy_agent/opa/config/ConfigTest.java @@ -324,4 +324,64 @@ void config_toString_includesDiscovery() { assertNotNull(configString); assertTrue(configString.contains("discovery")); } + + @Test + void config_loadsMtlsFromYaml() throws Exception { + String yaml = + "services:\n" + + " test-service:\n" + + " url: https://opa.example.com\n" + + " tls:\n" + + " ca_cert: /etc/ssl/corp-ca.pem\n" + + " system_ca_required: true\n" + + " credentials:\n" + + " client_tls:\n" + + " cert: /etc/ssl/client.pem\n" + + " private_key: /etc/ssl/client-key.pem\n" + + " private_key_passphrase: \"secret\"\n" + + " cert_reread_interval_seconds: 3600\n"; + + ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); + Config config = mapper.readValue(new StringReader(yaml), Config.class); + + Config.ServiceConfig svc = config.getService("test-service"); + assertNotNull(svc); + + Config.TlsConfig tls = svc.getTls(); + assertNotNull(tls); + assertEquals("/etc/ssl/corp-ca.pem", tls.getCaCert()); + assertTrue(tls.isSystemCaRequired()); + + Config.ClientTlsConfig clientTls = svc.getCredentials().getClientTls(); + assertNotNull(clientTls); + assertEquals("/etc/ssl/client.pem", clientTls.getCert()); + assertEquals("/etc/ssl/client-key.pem", clientTls.getPrivateKey()); + assertEquals("secret", clientTls.getPrivateKeyPassphrase()); + assertEquals(3600, clientTls.getCertRereadIntervalSeconds()); + } + + @Test + void config_mtlsDefaults() { + Config.TlsConfig tls = new Config.TlsConfig(); + assertFalse(tls.isSystemCaRequired()); + assertNull(tls.getCaCert()); + + Config.ClientTlsConfig clientTls = new Config.ClientTlsConfig(); + assertNull(clientTls.getCert()); + assertNull(clientTls.getPrivateKey()); + assertNull(clientTls.getPrivateKeyPassphrase()); + assertNull(clientTls.getCertRereadIntervalSeconds()); + } + + @Test + void config_clientTlsToString_redactsPassphrase() { + Config.ClientTlsConfig clientTls = + new Config.ClientTlsConfig() + .setCert("/c.pem") + .setPrivateKey("/k.pem") + .setPrivateKeyPassphrase("super-secret"); + String s = clientTls.toString(); + assertTrue(s.contains(""), s); + assertFalse(s.contains("super-secret"), "passphrase must never appear in toString(): " + s); + } } diff --git a/opa-services/src/test/java/io/github/open_policy_agent/opa/config/EnvInterpolatorTest.java b/opa-services/src/test/java/io/github/open_policy_agent/opa/config/EnvInterpolatorTest.java new file mode 100644 index 00000000..c1028908 --- /dev/null +++ b/opa-services/src/test/java/io/github/open_policy_agent/opa/config/EnvInterpolatorTest.java @@ -0,0 +1,62 @@ +package io.github.open_policy_agent.opa.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class EnvInterpolatorTest { + + @Test + void interpolate_substitutesKnownVar() { + Map env = new HashMap<>(); + env.put("TOKEN", "abc123"); + String out = EnvInterpolator.interpolate("token: ${TOKEN}", env); + assertEquals("token: abc123", out); + } + + @Test + void interpolate_multiplePlaceholders() { + Map env = new HashMap<>(); + env.put("USER", "alice"); + env.put("PASS", "secret"); + String out = EnvInterpolator.interpolate("user=${USER}, pass=${PASS}", env); + assertEquals("user=alice, pass=secret", out); + } + + @Test + void interpolate_missingVar_throws() { + Map env = new HashMap<>(); + ConfigurationException e = + assertThrows( + ConfigurationException.class, + () -> EnvInterpolator.interpolate("token: ${UNSET}", env)); + assertTrue(e.getMessage().contains("UNSET")); + } + + @Test + void interpolate_escaped_keepsLiteral() { + Map env = new HashMap<>(); + env.put("TOKEN", "abc"); + String out = EnvInterpolator.interpolate("token: \\${TOKEN}", env); + assertEquals("token: ${TOKEN}", out); + } + + @Test + void interpolate_noPlaceholder_returnsUnchanged() { + String input = "plain config string"; + assertEquals(input, EnvInterpolator.interpolate(input, new HashMap<>())); + } + + @Test + void interpolate_replacementWithDollarSigns_escapesProperly() { + // Matcher.appendReplacement treats $ specially; quoteReplacement guards against this. + Map env = new HashMap<>(); + env.put("VAR", "$weird $1 value"); + String out = EnvInterpolator.interpolate("v=${VAR}", env); + assertEquals("v=$weird $1 value", out); + } +} diff --git a/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/BundlePluginMtlsIT.java b/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/BundlePluginMtlsIT.java new file mode 100644 index 00000000..b1e1241e --- /dev/null +++ b/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/BundlePluginMtlsIT.java @@ -0,0 +1,284 @@ +package io.github.open_policy_agent.opa.plugins; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsParameters; +import com.sun.net.httpserver.HttpsServer; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; +import java.util.zip.GZIPOutputStream; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.TrustManagerFactory; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import io.github.open_policy_agent.opa.config.Config; +import io.github.open_policy_agent.opa.logging.Logger; +import io.github.open_policy_agent.opa.storage.InMem; +import io.github.open_policy_agent.opa.storage.Store; +import io.github.open_policy_agent.opa.tls.PemLoader; +import io.github.open_policy_agent.opa.tls.TlsFixtures; + +/** + * Integration test exercising an mTLS bundle download end-to-end: the JDK's {@link HttpsServer} + * requires a valid client certificate, and the {@link BundlePlugin} must present one via the new + * {@code credentials.client_tls} config. + */ +class BundlePluginMtlsIT { + + @TempDir static Path fixtureDir; + static TlsFixtures fx; + static byte[] bundleData; + + private HttpsServer server; + private final AtomicReference observedAuthHeader = new AtomicReference<>(); + + @BeforeAll + static void setupFixtures() throws Exception { + fx = TlsFixtures.generate(fixtureDir); + bundleData = createValidBundle(); + } + + @AfterEach + void tearDown() { + if (server != null) { + server.stop(0); + } + } + + @Test + void mtls_validClient_downloadsBundle() throws Exception { + int port = startServer(true); + + Config.ServiceConfig service = + new Config.ServiceConfig() + .setName("test-service") + .setUrl("https://localhost:" + port) + .setTls(new Config.TlsConfig().setCaCert(fx.ca.toString())) + .setCredentials( + new Config.CredentialsConfig() + .setClientTls( + new Config.ClientTlsConfig() + .setCert(fx.client.toString()) + .setPrivateKey(fx.clientKey.toString()))); + + runBundleDownload( + service, + future -> assertDoesNotThrow(() -> future.get(10, TimeUnit.SECONDS))); + } + + @Test + void mtls_missingClientCert_failsHandshake() throws Exception { + int port = startServer(true); + + Config.ServiceConfig service = + new Config.ServiceConfig() + .setName("test-service") + .setUrl("https://localhost:" + port) + .setTls(new Config.TlsConfig().setCaCert(fx.ca.toString())); + // No credentials.client_tls → handshake should fail because server requires client auth. + + runBundleDownload( + service, + future -> { + ExecutionException e = + assertThrows( + ExecutionException.class, () -> future.get(10, TimeUnit.SECONDS)); + // Check the top-level cause, not the whole chain. TLS/network failures propagate + // directly as IOException/SSLException (from HttpClient.send), while bundle-parse + // failures wrap the underlying IOException in a RuntimeException thrown from + // activateBundle. The direct-cause check distinguishes the two cleanly. + Throwable cause = e.getCause(); + assertTrue( + cause instanceof javax.net.ssl.SSLException + || cause instanceof java.io.IOException, + "expected SSLException or IOException as direct cause but got: " + + (cause == null + ? "null" + : cause.getClass().getSimpleName() + ": " + cause.getMessage())); + }); + } + + @Test + void bearerToken_reachesBundleDownload() throws Exception { + int port = startServer(false); + + Config.ServiceConfig service = + new Config.ServiceConfig() + .setName("test-service") + .setUrl("https://localhost:" + port) + .setTls(new Config.TlsConfig().setCaCert(fx.ca.toString())) + .setCredentials( + new Config.CredentialsConfig() + .setBearer(new Config.BearerConfig().setToken("let-me-in"))); + + runBundleDownload( + service, + future -> { + assertDoesNotThrow(() -> future.get(10, TimeUnit.SECONDS)); + assertTrue( + "Bearer let-me-in".equals(observedAuthHeader.get()), + "bundle download should carry the service's bearer token; saw: " + + observedAuthHeader.get()); + }); + } + + private void runBundleDownload( + Config.ServiceConfig service, ThrowingConsumer> assertion) + throws Exception { + Config config = new Config(); + config.setServices(Collections.singletonMap(service.getName(), service)); + Config.BundleConfig bundleCfg = + new Config.BundleConfig().setService(service.getName()).setResource("/bundles/authz.tar.gz"); + config.setBundles(Collections.singletonMap("authz", bundleCfg)); + + Logger logger = new Logger.StandardLogger(); + Store store = new InMem(); + PluginManager manager = + new PluginManager.Builder() + .withId("it") + .withStore(store) + .withConfig(config) + .withLogger(logger) + .build(); + + // Initialize services first so the bundle plugin can find the configured HttpClient. + ServicePlugin servicePlugin = (ServicePlugin) new ServicePlugin().initialize(manager); + manager.registerPlugin("services", servicePlugin); + servicePlugin.start(); + + BundlePlugin bundlePlugin = (BundlePlugin) new BundlePlugin().initialize(manager); + manager.registerPlugin("bundles", bundlePlugin); + bundlePlugin.start(); + + // Wait on the bundle's own initialActivation future so the original exception (SSLException + // etc.) is preserved. Going through the plugin-status listener would lose the cause. + CompletableFuture activation = bundlePlugin.getBundle("authz").getInitialActivation(); + + try { + assertion.accept(activation); + } finally { + bundlePlugin.stop(); + servicePlugin.stop(); + } + } + + private int startServer(boolean requireClientAuth) throws Exception { + server = HttpsServer.create(new InetSocketAddress("localhost", 0), 0); + server.setHttpsConfigurator(new Configurator(buildServerContext(), requireClientAuth)); + server.createContext( + "/bundles/authz.tar.gz", + exchange -> { + observedAuthHeader.set(exchange.getRequestHeaders().getFirst("Authorization")); + exchange.getResponseHeaders().add("Content-Type", "application/vnd.openpolicyagent.bundles"); + exchange.sendResponseHeaders(200, bundleData.length); + exchange.getResponseBody().write(bundleData); + exchange.close(); + }); + server.start(); + return server.getAddress().getPort(); + } + + private SSLContext buildServerContext() throws Exception { + List chain = PemLoader.loadCertificates(fx.server); + PrivateKey key = PemLoader.loadPrivateKey(fx.serverKey, null); + + char[] empty = new char[0]; + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(null, empty); + ks.setKeyEntry("server", key, empty, chain.toArray(new X509Certificate[0])); + + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, empty); + + List cas = PemLoader.loadCertificates(fx.ca); + KeyStore ts = KeyStore.getInstance("PKCS12"); + ts.load(null, empty); + for (int i = 0; i < cas.size(); i++) { + ts.setCertificateEntry("ca-" + i, cas.get(i)); + } + TrustManagerFactory tmf = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(ts); + + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), new SecureRandom()); + return ctx; + } + + private static byte[] createValidBundle() throws IOException { + ByteArrayOutputStream byteOut = new ByteArrayOutputStream(); + try (GZIPOutputStream gzipOut = new GZIPOutputStream(byteOut); + TarArchiveOutputStream tarOut = new TarArchiveOutputStream(gzipOut)) { + String planJson = + "{\"static\": {\"entrypoints\": [{\"path\": \"data/test/allow\", \"plan_compiled\": []}]}}"; + byte[] planBytes = planJson.getBytes(); + TarArchiveEntry plan = new TarArchiveEntry("plan.json"); + plan.setSize(planBytes.length); + tarOut.putArchiveEntry(plan); + tarOut.write(planBytes); + tarOut.closeArchiveEntry(); + + byte[] manifestBytes = "{\"revision\": \"test-123\"}".getBytes(); + TarArchiveEntry manifest = new TarArchiveEntry(".manifest"); + manifest.setSize(manifestBytes.length); + tarOut.putArchiveEntry(manifest); + tarOut.write(manifestBytes); + tarOut.closeArchiveEntry(); + + tarOut.finish(); + } + return byteOut.toByteArray(); + } + + private static final class Configurator extends HttpsConfigurator { + private final boolean requireClientAuth; + + Configurator(SSLContext ctx, boolean requireClientAuth) { + super(ctx); + this.requireClientAuth = requireClientAuth; + } + + @Override + public void configure(HttpsParameters params) { + SSLContext ctx = getSSLContext(); + SSLEngine engine = ctx.createSSLEngine(); + SSLParameters sslParams = ctx.getDefaultSSLParameters(); + sslParams.setNeedClientAuth(requireClientAuth); + sslParams.setCipherSuites(engine.getEnabledCipherSuites()); + sslParams.setProtocols(engine.getEnabledProtocols()); + params.setSSLParameters(sslParams); + } + } + + @FunctionalInterface + private interface ThrowingConsumer { + void accept(T t) throws Exception; + } + + // Silence an IDE warning; TimeoutException is thrown indirectly via future.get(..). + @SuppressWarnings("unused") + private static void touch(TimeoutException e) {} +} diff --git a/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/BundlePluginTest.java b/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/BundlePluginTest.java index 8bea75e8..e7259044 100644 --- a/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/BundlePluginTest.java +++ b/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/BundlePluginTest.java @@ -101,6 +101,31 @@ void validate_missingResource_returnsError() { assertTrue(errors.stream().anyMatch(e -> e.contains("missing or empty resource path"))); } + @Test + void validate_unknownServiceReference_returnsError() { + Config.BundleConfig bundle = + new Config.BundleConfig() + .setService("does-not-exist") + .setResource("/bundles/test.tar.gz"); + config.setBundles(Collections.singletonMap("test-bundle", bundle)); + + manager = + new PluginManager.Builder() + .withId("test-opa") + .withStore(store) + .withConfig(config) + .withLogger(mockLogger) + .build(); + + BundlePlugin plugin = new BundlePlugin(); + Set errors = plugin.validate(manager); + + assertFalse(errors.isEmpty()); + assertTrue( + errors.stream().anyMatch(e -> e.contains("references unknown service 'does-not-exist'")), + "expected unknown-service error, got: " + errors); + } + @Test void validate_validConfig_returnsNoErrors() { Config.BundleConfig bundle = @@ -121,6 +146,53 @@ void validate_validConfig_returnsNoErrors() { assertTrue(errors.isEmpty()); } + @Test + void validate_pollingMinGreaterThanMax_returnsError() { + Config.BundleConfig bundle = + new Config.BundleConfig() + .setService("test-service") + .setResource("/bundles/test.tar.gz") + .setPolling( + new Config.PollingConfig().setMinDelaySeconds(120).setMaxDelaySeconds(60)); + config.setBundles(Collections.singletonMap("test-bundle", bundle)); + + manager = + new PluginManager.Builder() + .withId("test-opa") + .withStore(store) + .withConfig(config) + .withLogger(mockLogger) + .build(); + + Set errors = new BundlePlugin().validate(manager); + assertTrue( + errors.stream().anyMatch(e -> e.contains("must be <= max_delay_seconds")), + "expected min>max error, got: " + errors); + } + + @Test + void validate_pollingNegativeDelay_returnsError() { + Config.BundleConfig bundle = + new Config.BundleConfig() + .setService("test-service") + .setResource("/bundles/test.tar.gz") + .setPolling(new Config.PollingConfig().setMinDelaySeconds(-1)); + config.setBundles(Collections.singletonMap("test-bundle", bundle)); + + manager = + new PluginManager.Builder() + .withId("test-opa") + .withStore(store) + .withConfig(config) + .withLogger(mockLogger) + .build(); + + Set errors = new BundlePlugin().validate(manager); + assertTrue( + errors.stream().anyMatch(e -> e.contains("min_delay_seconds must be >= 0")), + "expected negative-delay error, got: " + errors); + } + @Test void initialize_noBundlesConfigured_returnsPlugin() { manager = diff --git a/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/ServicePluginTest.java b/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/ServicePluginTest.java index 0c89579e..6c906820 100644 --- a/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/ServicePluginTest.java +++ b/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/ServicePluginTest.java @@ -7,8 +7,12 @@ import java.util.HashMap; import java.util.Map; import java.util.Set; +import java.util.function.Consumer; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import io.github.open_policy_agent.opa.config.Config; import io.github.open_policy_agent.opa.logging.Logger; import io.github.open_policy_agent.opa.storage.InMem; @@ -116,7 +120,6 @@ void validate_validServiceWithBearerToken_returnsNoErrors() { @Test void validate_serviceNameFromMapKey_setsName() { - // Service without name set - should be set from map key Config.ServiceConfig service = new Config.ServiceConfig().setUrl("https://example.com"); config.setServices(Collections.singletonMap("my-service", service)); @@ -137,7 +140,6 @@ void validate_serviceNameFromMapKey_setsName() { @Test void initialize_noServices_returnsPlugin() { - // Empty services map config.setServices(new HashMap<>()); manager = @@ -242,10 +244,8 @@ void validate_multipleServices_allValid_returnsNoErrors() { services.put( "service1", new Config.ServiceConfig().setName("service1").setUrl("https://example1.com")); - services.put( "service2", new Config.ServiceConfig().setName("service2").setUrl("https://example2.com")); - services.put( "service3", new Config.ServiceConfig().setName("service3").setUrl("https://example3.com")); @@ -267,7 +267,6 @@ void validate_multipleServices_allValid_returnsNoErrors() { @Test void initialize_nullServices_returnsPlugin() { - // Minimal config: new Config() with no services set (getServices() returns null) manager = new PluginManager.Builder() .withId("test-opa") @@ -289,8 +288,7 @@ void validate_multipleServices_oneInvalid_returnsError() { services.put( "service1", new Config.ServiceConfig().setName("service1").setUrl("https://example1.com")); - - services.put("service2", new Config.ServiceConfig().setName("service2")); // Missing URL + services.put("service2", new Config.ServiceConfig().setName("service2")); config.setServices(services); @@ -309,4 +307,152 @@ void validate_multipleServices_oneInvalid_returnsError() { assertTrue(errors.stream().anyMatch(e -> e.contains("service2"))); assertTrue(errors.stream().anyMatch(e -> e.contains("missing or empty URL"))); } + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidTlsCases") + void validate_tls_invalidCases_returnError( + String caseName, Consumer mutator, String expectedFragment) { + Config.ServiceConfig service = + new Config.ServiceConfig().setName("s").setUrl("https://example.com"); + mutator.accept(service); + config.setServices(Collections.singletonMap("s", service)); + + manager = + new PluginManager.Builder() + .withId("t") + .withStore(store) + .withConfig(config) + .withLogger(mockLogger) + .build(); + + Set errors = new ServicePlugin().validate(manager); + assertTrue( + errors.stream().anyMatch(e -> e.contains(expectedFragment)), + "expected error containing '" + expectedFragment + "' but got " + errors); + } + + static java.util.stream.Stream invalidTlsCases() { + return java.util.stream.Stream.of( + Arguments.of( + "allowInsecureTls + programmatic SSLContext", + (Consumer) + s -> s.setAllowInsecureTLS(true).setSslContext(mock(javax.net.ssl.SSLContext.class)), + "allow_insecure_tls=true alongside"), + Arguments.of( + "programmatic SSLContext + ca_cert", + (Consumer) + s -> + s.setTls(new Config.TlsConfig().setCaCert("/nonexistent/ca.pem")) + .setSslContext(mock(javax.net.ssl.SSLContext.class)), + "programmatic SSLContext alongside"), + Arguments.of( + "tls block has neither ca_cert nor system_ca_required", + (Consumer) s -> s.setTls(new Config.TlsConfig()), + "tls block has no effect"), + Arguments.of( + "tls.ca_cert + tls.truststore", + (Consumer) + s -> + s.setTls( + new Config.TlsConfig() + .setCaCert("/etc/ca.pem") + .setTruststore(new Config.TruststoreConfig().setPath("/etc/ts.jks"))), + "both ca_cert and truststore"), + Arguments.of( + "client_tls cert without private_key", + (Consumer) + s -> + s.setCredentials( + new Config.CredentialsConfig() + .setClientTls(new Config.ClientTlsConfig().setCert("/some/cert.pem"))), + "must set both cert and private_key"), + Arguments.of( + "client_tls keystore + cert", + (Consumer) + s -> + s.setCredentials( + new Config.CredentialsConfig() + .setClientTls( + new Config.ClientTlsConfig() + .setCert("/c.pem") + .setPrivateKey("/k.pem") + .setKeystore( + new Config.KeystoreConfig().setPath("/etc/ks.p12")))), + "both keystore and cert/private_key"), + Arguments.of( + "negative cert_reread_interval_seconds", + (Consumer) + s -> + s.setCredentials( + new Config.CredentialsConfig() + .setClientTls( + new Config.ClientTlsConfig() + .setCert("/c.pem") + .setPrivateKey("/k.pem") + .setCertRereadIntervalSeconds(-1))), + "cert_reread_interval_seconds must be >= 0"), + Arguments.of( + "cert_reread_interval_seconds without cert/key", + (Consumer) + s -> + s.setCredentials( + new Config.CredentialsConfig() + .setClientTls( + new Config.ClientTlsConfig().setCertRereadIntervalSeconds(60))), + "cert_reread_interval_seconds requires both cert")); + } + + @Test + void validate_tls_bearerAndClientTls_isAllowed() { + // Bearer tokens and mTLS are not mutually exclusive — many APIs combine a session bearer + // token with mTLS for client identity. Validate accepts the combination. + Config.ClientTlsConfig ctls = + new Config.ClientTlsConfig().setCert("/c.pem").setPrivateKey("/k.pem"); + Config.CredentialsConfig creds = + new Config.CredentialsConfig() + .setBearer(new Config.BearerConfig().setToken("abc")) + .setClientTls(ctls); + Config.ServiceConfig service = + new Config.ServiceConfig().setName("s").setUrl("https://example.com").setCredentials(creds); + config.setServices(Collections.singletonMap("s", service)); + + manager = + new PluginManager.Builder() + .withId("t") + .withStore(store) + .withConfig(config) + .withLogger(mockLogger) + .build(); + + Set errors = new ServicePlugin().validate(manager); + assertTrue(errors.isEmpty(), "expected no errors, got: " + errors); + } + + @Test + void stop_shutsDownCertReloadScheduler() throws Exception { + Config.ServiceConfig service = + new Config.ServiceConfig().setName("s").setUrl("https://example.com"); + config.setServices(Collections.singletonMap("s", service)); + + manager = + new PluginManager.Builder() + .withId("t") + .withStore(store) + .withConfig(config) + .withLogger(mockLogger) + .build(); + + ServicePlugin plugin = (ServicePlugin) new ServicePlugin().initialize(manager); + + java.lang.reflect.Field f = ServicePlugin.class.getDeclaredField("certReloadScheduler"); + f.setAccessible(true); + java.util.concurrent.ScheduledExecutorService scheduler = + (java.util.concurrent.ScheduledExecutorService) f.get(plugin); + assertNotNull(scheduler, "scheduler should be created when services are configured"); + assertFalse(scheduler.isShutdown(), "scheduler must be running before stop()"); + + plugin.stop(); + + assertTrue(scheduler.isShutdown(), "scheduler must be shut down after stop()"); + } } diff --git a/opa-services/src/test/java/io/github/open_policy_agent/opa/tls/PemLoaderTest.java b/opa-services/src/test/java/io/github/open_policy_agent/opa/tls/PemLoaderTest.java new file mode 100644 index 00000000..9d88211c --- /dev/null +++ b/opa-services/src/test/java/io/github/open_policy_agent/opa/tls/PemLoaderTest.java @@ -0,0 +1,114 @@ +package io.github.open_policy_agent.opa.tls; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.List; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class PemLoaderTest { + + @TempDir static Path dir; + static TlsFixtures fx; + + @BeforeAll + static void setup() throws Exception { + fx = TlsFixtures.generate(dir); + } + + @Test + void loadCertificates_chain() throws IOException { + List certs = PemLoader.loadCertificates(fx.client); + assertEquals(1, certs.size()); + assertTrue(certs.get(0).getSubjectX500Principal().getName().contains("opa-test-client")); + } + + @Test + void loadCertificates_emptyFile_throws() throws IOException { + Path empty = Files.createTempFile(dir, "empty", ".pem"); + IOException e = assertThrows(IOException.class, () -> PemLoader.loadCertificates(empty)); + assertTrue(e.getMessage().contains("No CERTIFICATE blocks")); + } + + @Test + void loadPrivateKey_pkcs8Unencrypted() throws IOException { + PrivateKey key = PemLoader.loadPrivateKey(fx.clientKey, null); + assertNotNull(key); + assertEquals("RSA", key.getAlgorithm()); + } + + @Test + void loadPrivateKey_encryptedPkcs8_throwsWithGuidance() { + // Encrypted PEM keys are intentionally unsupported — users are expected to convert to + // unencrypted PKCS#8 or supply a JKS / PKCS#12 keystore via credentials.client_tls.keystore. + IOException e = + assertThrows( + IOException.class, + () -> PemLoader.loadPrivateKey(fx.clientKeyEncrypted, fx.clientKeyPassphrase.toCharArray())); + assertTrue( + e.getMessage().contains("Encrypted PEM private keys are not supported"), + "expected encrypted-key guidance, got: " + e.getMessage()); + } + + @Test + void loadPrivateKey_passphraseOnUnencrypted_throws() { + // A passphrase supplied for an unencrypted key is almost certainly a misconfiguration — + // refuse it loudly so a stale passphrase doesn't silently get ignored. + IOException e = + assertThrows( + IOException.class, + () -> PemLoader.loadPrivateKey(fx.clientKey, "ignored".toCharArray())); + assertTrue(e.getMessage().contains("unencrypted PKCS#8 but a passphrase was supplied")); + } + + @Test + void loadPrivateKey_pkcs1Rsa_throwsWithConversionHint() throws IOException, InterruptedException { + Path pkcs1 = dir.resolve("pkcs1.pem"); + int rc1 = + new ProcessBuilder( + "openssl", "rsa", "-in", fx.clientKey.toString(), "-out", pkcs1.toString()) + .redirectErrorStream(true) + .start() + .waitFor(); + if (!Files.isReadable(pkcs1) + || rc1 != 0 + || !Files.readString(pkcs1).contains("BEGIN RSA PRIVATE KEY")) { + new ProcessBuilder( + "openssl", + "rsa", + "-traditional", + "-in", + fx.clientKey.toString(), + "-out", + pkcs1.toString()) + .redirectErrorStream(true) + .start() + .waitFor(); + } + String header = Files.readString(pkcs1); + assertTrue( + header.contains("BEGIN RSA PRIVATE KEY"), + "expected PKCS#1 key but got:\n" + header.substring(0, Math.min(header.length(), 120))); + + IOException e = assertThrows(IOException.class, () -> PemLoader.loadPrivateKey(pkcs1, null)); + assertTrue( + e.getMessage().contains("Convert to PKCS#8"), + "expected conversion hint, got: " + e.getMessage()); + } + + @Test + void loadPrivateKey_malformed_throws() throws IOException { + Path bad = Files.createTempFile(dir, "bad", ".pem"); + Files.writeString(bad, "-----BEGIN PRIVATE KEY-----\nnot-real-base64!!!\n-----END PRIVATE KEY-----\n"); + assertThrows(Exception.class, () -> PemLoader.loadPrivateKey(bad, null)); + } +} diff --git a/opa-services/src/test/java/io/github/open_policy_agent/opa/tls/ReloadingX509KeyManagerTest.java b/opa-services/src/test/java/io/github/open_policy_agent/opa/tls/ReloadingX509KeyManagerTest.java new file mode 100644 index 00000000..d416c6bf --- /dev/null +++ b/opa-services/src/test/java/io/github/open_policy_agent/opa/tls/ReloadingX509KeyManagerTest.java @@ -0,0 +1,258 @@ +package io.github.open_policy_agent.opa.tls; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.Arrays; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import io.github.open_policy_agent.opa.logging.Logger; + +class ReloadingX509KeyManagerTest { + + @TempDir static Path dir; + static TlsFixtures fx; + + private ScheduledExecutorService scheduler; + + @BeforeAll + static void setupFixtures() throws Exception { + fx = TlsFixtures.generate(dir); + } + + @BeforeEach + void setUp() { + scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "reload-test"); + t.setDaemon(true); + return t; + }); + } + + @AfterEach + void tearDown() throws Exception { + scheduler.shutdownNow(); + scheduler.awaitTermination(2, TimeUnit.SECONDS); + } + + @Test + void reload_bytesUnchanged_delegateNotSwapped() throws Exception { + Logger logger = mock(Logger.class); + ReloadingX509KeyManager km = + ReloadingX509KeyManager.create( + fx.client, fx.clientKey, null, scheduler, /*interval*/ Duration.ofDays(365), logger, "svc"); + + X509Certificate[] first = km.getCertificateChain("key"); + assertNotNull(first); + + km.reloadIfChanged(); // no bytes change → should be a no-op + + X509Certificate[] second = km.getCertificateChain("key"); + // The JDK key manager returns a fresh array per call, so assertSame on arrays would be + // wrong. Assert the cert contents are unchanged. + assertArrayEquals( + first, + second, + "delegate's cert chain should be unchanged (delegate not rebuilt when bytes are unchanged)"); + + // No info log, no error log — reload was a silent no-op. + verifyNoMoreInteractions(logger); + } + + @Test + void reload_bytesChanged_delegateSwapped() throws Exception { + // Make a writable copy of the fixture cert/key so we can rewrite them mid-test. + Path certCopy = dir.resolve("client-mutable.pem"); + Path keyCopy = dir.resolve("client-mutable-key.pem"); + Files.copy(fx.client, certCopy); + Files.copy(fx.clientKey, keyCopy); + + Logger logger = mock(Logger.class); + ReloadingX509KeyManager km = + ReloadingX509KeyManager.create( + certCopy, keyCopy, null, scheduler, Duration.ofDays(365), logger, "svc"); + + X509Certificate[] before = km.getCertificateChain("key"); + assertNotNull(before); + + // Generate a fresh client cert/key pair signed by the same CA, then overwrite the files. + Path ca = fx.ca; + Path caKey = fx.caKey; + Path csr = dir.resolve("rotated.csr"); + runOpenssl( + "openssl", + "genpkey", + "-algorithm", + "RSA", + "-out", + keyCopy.toString(), + "-pkeyopt", + "rsa_keygen_bits:2048"); + runOpenssl( + "openssl", + "req", + "-new", + "-key", + keyCopy.toString(), + "-out", + csr.toString(), + "-subj", + "/CN=opa-test-client-rotated"); + runOpenssl( + "openssl", + "x509", + "-req", + "-in", + csr.toString(), + "-CA", + ca.toString(), + "-CAkey", + caKey.toString(), + "-CAcreateserial", + "-CAserial", + dir.resolve("ca.srl").toString(), + "-out", + certCopy.toString(), + "-days", + "1"); + + km.reloadIfChanged(); + + X509Certificate[] after = km.getCertificateChain("key"); + assertNotNull(after); + assertNotEquals( + before[0].getSubjectX500Principal(), + after[0].getSubjectX500Principal(), + "subject should differ after rotation"); + + verify(logger).info(eq("Service '%s': reloaded client TLS certificate"), eq("svc")); + } + + @Test + void reload_partialRotation_certOnly_delegateSwapped() throws Exception { + // Verifies the comment at ReloadingX509KeyManager.reloadIfChanged: "Require BOTH hashes to + // match before skipping. If only one file has been rewritten ... we need to rebuild." The + // rebuild produces a mismatched cert+key pair (JCA doesn't validate this at build time, only + // at handshake), but detecting the partial state is the contract being tested here. + Path certCopy = dir.resolve("client-partial.pem"); + Path keyCopy = dir.resolve("client-partial-key.pem"); + Files.copy(fx.client, certCopy); + Files.copy(fx.clientKey, keyCopy); + + Logger logger = mock(Logger.class); + ReloadingX509KeyManager km = + ReloadingX509KeyManager.create( + certCopy, keyCopy, null, scheduler, Duration.ofDays(365), logger, "svc"); + + X509Certificate[] before = km.getCertificateChain("key"); + assertNotNull(before); + + // Generate a fresh keypair signed by the same CA, write only the new cert (NOT the key). + Path freshKey = dir.resolve("partial-fresh.key"); + Path csr = dir.resolve("partial.csr"); + runOpenssl( + "openssl", + "genpkey", + "-algorithm", + "RSA", + "-out", + freshKey.toString(), + "-pkeyopt", + "rsa_keygen_bits:2048"); + runOpenssl( + "openssl", + "req", + "-new", + "-key", + freshKey.toString(), + "-out", + csr.toString(), + "-subj", + "/CN=opa-test-client-partial"); + runOpenssl( + "openssl", + "x509", + "-req", + "-in", + csr.toString(), + "-CA", + fx.ca.toString(), + "-CAkey", + fx.caKey.toString(), + "-CAcreateserial", + "-CAserial", + dir.resolve("ca.srl").toString(), + "-out", + certCopy.toString(), + "-days", + "1"); + + km.reloadIfChanged(); + + X509Certificate[] after = km.getCertificateChain("key"); + assertNotEquals( + before[0].getSubjectX500Principal(), + after[0].getSubjectX500Principal(), + "rebuild must trigger when only the cert file changes; the next reload tick recovers" + + " once the key file catches up"); + } + + @Test + void reload_corruptedFile_delegateRetained() throws Exception { + Path certCopy = dir.resolve("client-bad-test.pem"); + Path keyCopy = dir.resolve("client-bad-test-key.pem"); + Files.copy(fx.client, certCopy); + Files.copy(fx.clientKey, keyCopy); + + Logger logger = mock(Logger.class); + ReloadingX509KeyManager km = + ReloadingX509KeyManager.create( + certCopy, keyCopy, null, scheduler, Duration.ofDays(365), logger, "svc"); + + X509Certificate[] before = km.getCertificateChain("key"); + assertNotNull(before); + + // Corrupt the cert file. The reload should fail internally, log an error, and keep the + // previous valid delegate. + Files.writeString(certCopy, "-----BEGIN CERTIFICATE-----\nnope\n-----END CERTIFICATE-----\n"); + + km.reloadIfChanged(); + + X509Certificate[] after = km.getCertificateChain("key"); + assertArrayEquals( + before, + after, + "delegate must not be swapped when reload fails — old cert still serves handshakes"); + verify(logger, atLeastOnce()) + .error( + eq("Service '%s': failed to reload client TLS certificate: %s"), eq("svc"), anyString()); + } + + private static void runOpenssl(String... command) throws Exception { + ProcessBuilder pb = new ProcessBuilder(command).directory(dir.toFile()).redirectErrorStream(true); + Process p = pb.start(); + byte[] out = p.getInputStream().readAllBytes(); + int rc = p.waitFor(); + if (rc != 0) { + throw new RuntimeException( + "openssl failed (rc=" + rc + "): " + Arrays.toString(command) + "\n" + new String(out)); + } + } +} diff --git a/opa-services/src/test/java/io/github/open_policy_agent/opa/tls/SslContextBuilderTest.java b/opa-services/src/test/java/io/github/open_policy_agent/opa/tls/SslContextBuilderTest.java new file mode 100644 index 00000000..1766a7f8 --- /dev/null +++ b/opa-services/src/test/java/io/github/open_policy_agent/opa/tls/SslContextBuilderTest.java @@ -0,0 +1,230 @@ +package io.github.open_policy_agent.opa.tls; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import java.security.SecureRandom; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import io.github.open_policy_agent.opa.config.Config; +import io.github.open_policy_agent.opa.logging.Logger; + +class SslContextBuilderTest { + + @TempDir static Path dir; + static TlsFixtures fx; + static final Logger LOG = new Logger.StandardLogger(); + + @BeforeAll + static void setup() throws Exception { + fx = TlsFixtures.generate(dir); + } + + @Test + void programmaticOverride_returnedUnchanged() throws Exception { + SSLContext pre = SSLContext.getInstance("TLS"); + pre.init(null, new TrustManager[] {acceptAll()}, new SecureRandom()); + + Config.ServiceConfig cfg = new Config.ServiceConfig().setName("s").setSslContext(pre); + SslContextBuilder.Tls tls = SslContextBuilder.build(cfg, null, LOG); + + assertSame(pre, tls.getSslContext()); + assertArrayEquals(new String[] {"TLSv1.2", "TLSv1.3"}, tls.getSslParameters().getProtocols()); + } + + @Test + void insecureTls_buildsTrustAllContext() throws Exception { + Config.ServiceConfig cfg = new Config.ServiceConfig().setName("s").setAllowInsecureTLS(true); + SslContextBuilder.Tls tls = SslContextBuilder.build(cfg, null, LOG); + + assertNotNull(tls.getSslContext()); + // Sanity: it should present no preconfigured trust anchors. + assertTrue(tls.getSslContext().getProtocol().startsWith("TLS")); + } + + @Test + void noConfig_returnsNullContext() throws Exception { + Config.ServiceConfig cfg = new Config.ServiceConfig().setName("s"); + SslContextBuilder.Tls tls = SslContextBuilder.build(cfg, null, LOG); + + assertNull(tls.getSslContext()); + assertArrayEquals(new String[] {"TLSv1.2", "TLSv1.3"}, tls.getSslParameters().getProtocols()); + } + + @Test + void caCertOnly_buildsServerAuthOnlyContext() throws Exception { + Config.ServiceConfig cfg = + new Config.ServiceConfig() + .setName("s") + .setTls(new Config.TlsConfig().setCaCert(fx.ca.toString())); + SslContextBuilder.Tls tls = SslContextBuilder.build(cfg, null, LOG); + + assertNotNull(tls.getSslContext()); + } + + @Test + void clientTlsAndCa_buildsMutualContext() throws Exception { + Config.ServiceConfig cfg = + new Config.ServiceConfig() + .setName("s") + .setTls(new Config.TlsConfig().setCaCert(fx.ca.toString())) + .setCredentials( + new Config.CredentialsConfig() + .setClientTls( + new Config.ClientTlsConfig() + .setCert(fx.client.toString()) + .setPrivateKey(fx.clientKey.toString()))); + + SslContextBuilder.Tls tls = SslContextBuilder.build(cfg, null, LOG); + + assertNotNull(tls.getSslContext()); + } + + @Test + void systemCaNotRequired_acceptsOnlyUserCa() throws Exception { + Config.TlsConfig tls = new Config.TlsConfig().setCaCert(fx.ca.toString()); + TrustManager[] tms = SslContextBuilder.buildTrustManagers(tls); + X509TrustManager x = (X509TrustManager) tms[0]; + + java.security.cert.X509Certificate userCa = PemLoader.loadCertificates(fx.ca).get(0); + java.security.cert.X509Certificate[] accepted = x.getAcceptedIssuers(); + assertEquals(1, accepted.length, "only user CA should be trusted"); + assertEquals(userCa, accepted[0]); + } + + @Test + void systemCaRequired_mergesUserAndSystemAnchors() throws Exception { + int systemCount; + { + javax.net.ssl.TrustManagerFactory sysTmf = + javax.net.ssl.TrustManagerFactory.getInstance( + javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm()); + sysTmf.init((java.security.KeyStore) null); + systemCount = + ((X509TrustManager) sysTmf.getTrustManagers()[0]).getAcceptedIssuers().length; + } + assertTrue(systemCount > 0, "system trust store should be non-empty"); + + Config.TlsConfig tls = + new Config.TlsConfig().setCaCert(fx.ca.toString()).setSystemCaRequired(true); + TrustManager[] tms = SslContextBuilder.buildTrustManagers(tls); + X509TrustManager x = (X509TrustManager) tms[0]; + + java.security.cert.X509Certificate userCa = PemLoader.loadCertificates(fx.ca).get(0); + java.security.cert.X509Certificate[] accepted = x.getAcceptedIssuers(); + assertEquals( + systemCount + 1, + accepted.length, + "merged trust must contain user CA on top of all system roots"); + assertTrue( + java.util.Arrays.stream(accepted).anyMatch(c -> c.equals(userCa)), + "user CA must be present in merged trust"); + } + + @Test + void systemCaRequired_userTrustedChain_passesValidation() throws Exception { + Config.TlsConfig tls = + new Config.TlsConfig().setCaCert(fx.ca.toString()).setSystemCaRequired(true); + TrustManager[] tms = SslContextBuilder.buildTrustManagers(tls); + X509TrustManager x = (X509TrustManager) tms[0]; + + // The fixture client cert is signed by fx.ca; the merged trust manager must accept it. + java.security.cert.X509Certificate clientLeaf = + PemLoader.loadCertificates(fx.client).get(0); + x.checkClientTrusted( + new java.security.cert.X509Certificate[] {clientLeaf}, "RSA"); + } + + @Test + void truststore_pkcs12Path_buildsTrustManagers() throws Exception { + // Build a PKCS#12 truststore on disk holding the fixture CA, then point Config at it. + java.nio.file.Path tsPath = dir.resolve("trust.p12"); + java.security.cert.X509Certificate userCa = PemLoader.loadCertificates(fx.ca).get(0); + char[] pw = "ts-pass".toCharArray(); + java.security.KeyStore ts = java.security.KeyStore.getInstance("PKCS12"); + ts.load(null, pw); + ts.setCertificateEntry("ca", userCa); + try (java.io.OutputStream out = java.nio.file.Files.newOutputStream(tsPath)) { + ts.store(out, pw); + } + + Config.TlsConfig tlsCfg = + new Config.TlsConfig() + .setTruststore( + new Config.TruststoreConfig().setPath(tsPath.toString()).setPassword("ts-pass")); + TrustManager[] tms = SslContextBuilder.buildTrustManagers(tlsCfg); + X509TrustManager x = (X509TrustManager) tms[0]; + java.security.cert.X509Certificate[] accepted = x.getAcceptedIssuers(); + assertEquals(1, accepted.length); + assertEquals(userCa, accepted[0]); + } + + @Test + void truststore_programmaticKeyStore_buildsTrustManagers() throws Exception { + // SDK pass-through path — no file on disk. Reviewer asked specifically for this. + java.security.cert.X509Certificate userCa = PemLoader.loadCertificates(fx.ca).get(0); + java.security.KeyStore ts = java.security.KeyStore.getInstance("PKCS12"); + ts.load(null, new char[0]); + ts.setCertificateEntry("ca", userCa); + + Config.TlsConfig tlsCfg = + new Config.TlsConfig() + .setTruststore(new Config.TruststoreConfig().setKeyStore(ts)); + TrustManager[] tms = SslContextBuilder.buildTrustManagers(tlsCfg); + X509TrustManager x = (X509TrustManager) tms[0]; + java.security.cert.X509Certificate[] accepted = x.getAcceptedIssuers(); + assertEquals(1, accepted.length); + assertEquals(userCa, accepted[0]); + } + + @Test + void clientTls_keystoreProgrammatic_buildsContext() throws Exception { + // Build a programmatic keystore from the fixture client cert + key. + java.security.cert.X509Certificate clientLeaf = + PemLoader.loadCertificates(fx.client).get(0); + java.security.PrivateKey clientKey = PemLoader.loadPrivateKey(fx.clientKey, null); + char[] pw = "ks-pass".toCharArray(); + java.security.KeyStore ks = java.security.KeyStore.getInstance("PKCS12"); + ks.load(null, pw); + ks.setKeyEntry("client", clientKey, pw, new java.security.cert.X509Certificate[] {clientLeaf}); + + Config.ServiceConfig cfg = + new Config.ServiceConfig() + .setName("s") + .setTls(new Config.TlsConfig().setCaCert(fx.ca.toString())) + .setCredentials( + new Config.CredentialsConfig() + .setClientTls( + new Config.ClientTlsConfig() + .setKeystore( + new Config.KeystoreConfig() + .setKeyStore(ks) + .setKeyPassword("ks-pass")))); + SslContextBuilder.Tls tls = SslContextBuilder.build(cfg, null, LOG); + + assertNotNull(tls.getSslContext()); + } + + private static X509TrustManager acceptAll() { + return new X509TrustManager() { + public void checkClientTrusted( + java.security.cert.X509Certificate[] chain, String authType) {} + + public void checkServerTrusted( + java.security.cert.X509Certificate[] chain, String authType) {} + + public java.security.cert.X509Certificate[] getAcceptedIssuers() { + return new java.security.cert.X509Certificate[0]; + } + }; + } +} diff --git a/opa-services/src/test/java/io/github/open_policy_agent/opa/tls/TlsFixtures.java b/opa-services/src/test/java/io/github/open_policy_agent/opa/tls/TlsFixtures.java new file mode 100644 index 00000000..6d1451fd --- /dev/null +++ b/opa-services/src/test/java/io/github/open_policy_agent/opa/tls/TlsFixtures.java @@ -0,0 +1,137 @@ +package io.github.open_policy_agent.opa.tls; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Test helper that generates a tiny self-signed CA plus server and client leaf certificates, using + * an {@code openssl} subprocess. Keeps crypto material out of the repository. + * + *

Tests that use this helper assume {@code openssl} (or LibreSSL) is on PATH. + */ +public final class TlsFixtures { + + public final Path ca; + public final Path caKey; + public final Path server; + public final Path serverKey; + public final Path client; + public final Path clientKey; + public final Path clientKeyEncrypted; + public final String clientKeyPassphrase; + + private TlsFixtures( + Path ca, + Path caKey, + Path server, + Path serverKey, + Path client, + Path clientKey, + Path clientKeyEncrypted, + String passphrase) { + this.ca = ca; + this.caKey = caKey; + this.server = server; + this.serverKey = serverKey; + this.client = client; + this.clientKey = clientKey; + this.clientKeyEncrypted = clientKeyEncrypted; + this.clientKeyPassphrase = passphrase; + } + + public static TlsFixtures generate(Path dir) throws IOException, InterruptedException { + Path ca = dir.resolve("ca.pem"); + Path caKey = dir.resolve("ca-key.pem"); + Path caSrl = dir.resolve("ca.srl"); + + // CA key + self-signed cert. + run(dir, "openssl", "genpkey", "-algorithm", "RSA", "-out", caKey.toString(), + "-pkeyopt", "rsa_keygen_bits:2048"); + run(dir, "openssl", "req", "-x509", "-new", "-key", caKey.toString(), "-out", ca.toString(), + "-days", "1", "-subj", "/CN=opa-test-ca"); + + Path server = dir.resolve("server.pem"); + Path serverKey = dir.resolve("server-key.pem"); + issueLeaf(dir, ca, caKey, caSrl, server, serverKey, "localhost", + "subjectAltName=DNS:localhost,IP:127.0.0.1"); + + Path client = dir.resolve("client.pem"); + Path clientKey = dir.resolve("client-key.pem"); + issueLeaf(dir, ca, caKey, caSrl, client, clientKey, "opa-test-client", null); + + // Produce a passphrase-protected copy of the client key (PKCS#8 encrypted PEM). + String passphrase = "correct-horse-battery-staple"; + Path clientKeyEnc = dir.resolve("client-key-encrypted.pem"); + run( + dir, + "openssl", + "pkcs8", + "-topk8", + "-in", + clientKey.toString(), + "-out", + clientKeyEnc.toString(), + "-passout", + "pass:" + passphrase); + + return new TlsFixtures(ca, caKey, server, serverKey, client, clientKey, clientKeyEnc, passphrase); + } + + private static void issueLeaf( + Path dir, + Path ca, + Path caKey, + Path caSrl, + Path certOut, + Path keyOut, + String cn, + String extensions) + throws IOException, InterruptedException { + Path csr = dir.resolve(cn + ".csr"); + run(dir, "openssl", "genpkey", "-algorithm", "RSA", "-out", keyOut.toString(), + "-pkeyopt", "rsa_keygen_bits:2048"); + run(dir, "openssl", "req", "-new", "-key", keyOut.toString(), "-out", csr.toString(), + "-subj", "/CN=" + cn); + + java.util.List cmd = + new java.util.ArrayList<>( + java.util.Arrays.asList( + "openssl", + "x509", + "-req", + "-in", + csr.toString(), + "-CA", + ca.toString(), + "-CAkey", + caKey.toString(), + "-CAcreateserial", + "-CAserial", + caSrl.toString(), + "-out", + certOut.toString(), + "-days", + "1")); + if (extensions != null) { + // LibreSSL's openssl x509 needs extensions via an extfile. + Path extFile = dir.resolve(cn + ".ext"); + Files.write(extFile, extensions.getBytes()); + cmd.add("-extfile"); + cmd.add(extFile.toString()); + } + run(dir, cmd.toArray(new String[0])); + Files.deleteIfExists(csr); + } + + private static void run(Path dir, String... command) throws IOException, InterruptedException { + ProcessBuilder pb = new ProcessBuilder(command).directory(dir.toFile()).redirectErrorStream(true); + Process p = pb.start(); + byte[] out = p.getInputStream().readAllBytes(); + int rc = p.waitFor(); + if (rc != 0) { + throw new IOException( + "openssl command failed (rc=" + rc + "): " + String.join(" ", command) + "\n" + new String(out)); + } + } +}