From 64f41eb3f7dccfe20a6ea095cba343de22e27461 Mon Sep 17 00:00:00 2001 From: Sebastian Spaink Date: Fri, 15 May 2026 15:05:22 -0500 Subject: [PATCH 1/3] Add mTLS support Signed-off-by: Sebastian Spaink --- README.md | 8 + opa-services/README.md | 68 +++++ opa-services/build.gradle.kts | 2 + .../open_policy_agent/opa/config/Config.java | 176 ++++++++++- .../opa/plugins/BundleDownloader.java | 147 ++++++++- .../opa/plugins/BundlePlugin.java | 38 ++- .../opa/plugins/DiscoveryPlugin.java | 31 +- .../opa/plugins/ServicePlugin.java | 204 +++++++++---- .../open_policy_agent/opa/tls/KeyStores.java | 33 ++ .../open_policy_agent/opa/tls/PemLoader.java | 143 +++++++++ .../opa/tls/ReloadingX509KeyManager.java | 206 +++++++++++++ .../opa/tls/SslContextBuilder.java | 197 ++++++++++++ .../opa/config/ConfigTest.java | 60 ++++ .../opa/plugins/BundlePluginMtlsIT.java | 284 ++++++++++++++++++ .../opa/plugins/BundlePluginTest.java | 72 +++++ .../opa/plugins/ServicePluginTest.java | 196 ++++++++++++ .../opa/tls/PemLoaderTest.java | 115 +++++++ .../opa/tls/ReloadingX509KeyManagerTest.java | 274 +++++++++++++++++ .../opa/tls/SslContextBuilderTest.java | 160 ++++++++++ .../opa/tls/TlsFixtures.java | 137 +++++++++ 20 files changed, 2464 insertions(+), 87 deletions(-) create mode 100644 opa-services/src/main/java/io/github/open_policy_agent/opa/tls/KeyStores.java create mode 100644 opa-services/src/main/java/io/github/open_policy_agent/opa/tls/PemLoader.java create mode 100644 opa-services/src/main/java/io/github/open_policy_agent/opa/tls/ReloadingX509KeyManager.java create mode 100644 opa-services/src/main/java/io/github/open_policy_agent/opa/tls/SslContextBuilder.java create mode 100644 opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/BundlePluginMtlsIT.java create mode 100644 opa-services/src/test/java/io/github/open_policy_agent/opa/tls/PemLoaderTest.java create mode 100644 opa-services/src/test/java/io/github/open_policy_agent/opa/tls/ReloadingX509KeyManagerTest.java create mode 100644 opa-services/src/test/java/io/github/open_policy_agent/opa/tls/SslContextBuilderTest.java create mode 100644 opa-services/src/test/java/io/github/open_policy_agent/opa/tls/TlsFixtures.java diff --git a/README.md b/README.md index ca3f57b3..2ec18417 100644 --- a/README.md +++ b/README.md @@ -264,9 +264,17 @@ nd_builtin_cache: true |-------|------|---------|-------------| | `url` | string | - | Base URL of the service | | `credentials.bearer.token` | string | - | Bearer token for authentication | +| `credentials.client_tls.cert` | string | - | PEM file with the client certificate (mTLS) | +| `credentials.client_tls.private_key` | string | - | PKCS#8 PEM file with the client private key | +| `credentials.client_tls.private_key_passphrase` | string | - | Passphrase for an encrypted PKCS#8 key | +| `credentials.client_tls.cert_reread_interval_seconds` | int | - | Interval to reload the client cert/key from disk for rotation | +| `tls.ca_cert` | string | - | PEM file 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` | | `response_header_timeout_seconds` | int | 10 | HTTP response header timeout | | `allow_insecure_tls` | boolean | false | Allow insecure TLS (dev only) | +See [opa-services/README.md](opa-services/README.md#tls-and-mtls) for a full mTLS walkthrough, including 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 d8e487f9..9034732e 100644 --- a/opa-services/README.md +++ b/opa-services/README.md @@ -91,6 +91,74 @@ 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 + private_key_passphrase: "key-passphrase" + cert_reread_interval_seconds: 3600 +``` + +| Field | Description | +|-------|-------------| +| `tls.ca_cert` | PEM file containing one or more trust roots for verifying the server. | +| `tls.system_ca_required` | When `true`, the JVM's default trust store is also trusted in addition to `ca_cert`. | +| `credentials.client_tls.cert` | PEM file containing the client certificate (and any intermediates). | +| `credentials.client_tls.private_key` | PKCS#8 PEM file with the client private key. | +| `credentials.client_tls.private_key_passphrase` | Passphrase for an encrypted PKCS#8 key. Omit for unencrypted keys. | +| `credentials.client_tls.cert_reread_interval_seconds` | If set, the cert and key are reloaded from disk on this interval to support runtime rotation. | + +Only PKCS#8 PEM private keys are accepted. Convert PKCS#1 keys with: + +```sh +openssl pkcs8 -topk8 -nocrypt -in key.pem -out key-pkcs8.pem +``` + +Programmatic equivalent: + +```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") + .setPrivateKeyPassphrase("key-passphrase") + .setCertRereadIntervalSeconds(3600)))); +``` + +For keystores that cannot be expressed as files (HSM-backed keys, secret-manager-driven rotation, custom `KeyManager` chains), supply a fully constructed `SSLContext` directly. When set, file-based 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); +``` + ### Lifecycle Management ```java diff --git a/opa-services/build.gradle.kts b/opa-services/build.gradle.kts index a595538c..326e6ba2 100644 --- a/opa-services/build.gradle.kts +++ b/opa-services/build.gradle.kts @@ -13,6 +13,8 @@ dependencies { implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml") implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") implementation("org.apache.commons:commons-compress:1.28.0") + implementation("org.bouncycastle:bcprov-jdk18on:1.84") + implementation("org.bouncycastle:bcpkix-jdk18on:1.84") // opa-jackson provides the PolicyReader SPI implementation at runtime. runtimeOnly(project(":opa-jackson")) 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..a040bfd0 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,11 @@ 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.util.HashMap; import java.util.Map; +import javax.net.ssl.SSLContext; public class Config { @@ -445,6 +447,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 +471,39 @@ public ServiceConfig setAllowInsecureTLS(boolean allowInsecureTLS) { return this; } + public TlsConfig getTls() { + return tls; + } + + public ServiceConfig setTls(TlsConfig tls) { + this.tls = tls; + return this; + } + + /** + * 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 (PKCS12 from an enterprise cert manager, HSM-backed keys, runtime rotation + * from an external secret manager, etc.). + */ + public SSLContext getSslContext() { + return sslContext; + } + + 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 +524,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,13 +551,54 @@ public String toString() { + ", url='" + url + '\'' + + ", tls=" + + tls + '}'; } } + /** + * Server-TLS configuration for a service (trust roots). + * + *

Mirrors Go-OPA's {@code services..tls} block. + */ + public static class TlsConfig { + @JsonProperty("ca_cert") + private String caCert; + + @JsonProperty("system_ca_required") + private boolean systemCaRequired = false; + + public String getCaCert() { + return caCert; + } + + public TlsConfig setCaCert(String caCert) { + this.caCert = caCert; + return this; + } + + public boolean isSystemCaRequired() { + return systemCaRequired; + } + + public TlsConfig setSystemCaRequired(boolean systemCaRequired) { + this.systemCaRequired = systemCaRequired; + return this; + } + + @Override + public String toString() { + return "TlsConfig{caCert='" + caCert + "', systemCaRequired=" + systemCaRequired + '}'; + } + } + public static class CredentialsConfig { private BearerConfig bearer; + @JsonProperty("client_tls") + private ClientTlsConfig clientTls; + public BearerConfig getBearer() { return bearer; } @@ -516,9 +608,87 @@ 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 + '}'; + 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. Only PKCS#8 + * (encrypted or unencrypted) PEM private keys are supported; convert PKCS#1 keys with {@code + * openssl pkcs8 -topk8 -nocrypt -in key.pem -out key-pkcs8.pem}. + */ + 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; + + public String getCert() { + return cert; + } + + public ClientTlsConfig setCert(String cert) { + this.cert = cert; + return this; + } + + public String getPrivateKey() { + return privateKey; + } + + public ClientTlsConfig setPrivateKey(String privateKey) { + this.privateKey = privateKey; + return this; + } + + public String getPrivateKeyPassphrase() { + return privateKeyPassphrase; + } + + public ClientTlsConfig setPrivateKeyPassphrase(String privateKeyPassphrase) { + this.privateKeyPassphrase = privateKeyPassphrase; + return this; + } + + public Integer getCertRereadIntervalSeconds() { + return certRereadIntervalSeconds; + } + + public ClientTlsConfig setCertRereadIntervalSeconds(Integer certRereadIntervalSeconds) { + this.certRereadIntervalSeconds = certRereadIntervalSeconds; + return this; + } + + @Override + public String toString() { + return "ClientTlsConfig{cert='" + + cert + + "', privateKey='" + + privateKey + + "', privateKeyPassphrase=" + + (privateKeyPassphrase == null ? "null" : "") + + ", certRereadIntervalSeconds=" + + certRereadIntervalSeconds + + '}'; } } @@ -536,7 +706,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/plugins/BundleDownloader.java b/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/BundleDownloader.java index d894c003..5f8c2d94 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,14 @@ import java.nio.file.Paths; import java.nio.file.attribute.FileTime; import java.time.Duration; +import java.util.HashSet; +import java.util.Map; +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; @@ -37,23 +43,96 @@ public abstract class BundleDownloader { protected final HttpClient httpClient; protected final CompletableFuture initialActivation; + private final ServicePlugin.Service authService; + protected String service; protected String resource; protected Config.PollingConfig polling; 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 httpClient the HTTP client to use for HTTP/HTTPS downloads; may be {@code null} if this + * downloader only ever handles {@code file://} URIs or filesystem paths + * @param authService the {@link ServicePlugin.Service} providing credentials for HTTP downloads; + * may be {@code null} to skip auth + */ + protected BundleDownloader( + String name, + PluginManager manager, + HttpClient httpClient, + ServicePlugin.Service authService) { this.name = name; this.manager = manager; - this.httpClient = - HttpClient.newBuilder() - .followRedirects(HttpClient.Redirect.NORMAL) - .connectTimeout(Duration.ofSeconds(10)) - .build(); + this.httpClient = httpClient != null ? httpClient : defaultHttpClient(); + this.authService = authService; 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 +148,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 +172,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 +302,20 @@ private void handleHttpDownload(URI uri) throws IOException, InterruptedExceptio requestBuilder.header("If-None-Match", etag); } + if (authService != null) { + requestBuilder = authService.applyCredentials(requestBuilder); + } + + Config.ServiceConfig serviceConfig = manager.getConfig().getService(service); + if (serviceConfig != null && serviceConfig.getHeaders() != null) { + for (Map.Entry h : serviceConfig.getHeaders().entrySet()) { + // setHeader (not header) so user-supplied entries replace built-in headers like + // Authorization rather than producing duplicates — RFC 7230 forbids multiple + // Authorization headers, and HttpRequest.Builder#header is additive. + requestBuilder.setHeader(h.getKey(), h.getValue()); + } + } + 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..29f8eaa1 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 @@ -1,11 +1,11 @@ package io.github.open_policy_agent.opa.plugins; +import java.net.http.HttpClient; import java.util.HashMap; import java.util.HashSet; 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 +36,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 +54,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 +71,13 @@ public Plugin initialize(PluginManager manager) { String name = entry.getKey(); Config.BundleConfig bundleConfig = entry.getValue(); + ServicePlugin.Service svc = + servicePlugin == null ? null : servicePlugin.getService(bundleConfig.getService()); + HttpClient client = svc == null ? null : svc.getClient(); + plugin.bundles.put( name, - new Bundle(name, manager) + new Bundle(name, manager, client, svc) .setService(bundleConfig.getService()) .setResource(bundleConfig.getResource()) .setPolling(bundleConfig.getPolling())); @@ -102,6 +117,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 +143,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, HttpClient client, ServicePlugin.Service authService) { + super(name, manager, client, 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..7021f70c 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 @@ -4,10 +4,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.net.http.HttpClient; 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 +62,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 +71,24 @@ 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; + HttpClient client = null; + Plugin raw = manager.getPlugin("services"); + if (raw instanceof ServicePlugin) { + svc = ((ServicePlugin) raw).getService(discoveryConfig.getService()); + if (svc != null) { + client = svc.getClient(); + } + } + plugin.discoveryBundle = - new DiscoveryBundle(name, manager) + new DiscoveryBundle(name, manager, client, svc) .setService(discoveryConfig.getService()) .setResource(discoveryConfig.getResource()) .setPolling(discoveryConfig.getPolling()); @@ -141,8 +150,12 @@ 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, + HttpClient client, + ServicePlugin.Service service) { + super(name, manager, client, 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..e9f497cd 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,23 +5,22 @@ 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 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() {} @@ -49,10 +48,79 @@ 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<>(); + java.util.function.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; remove one")); + } + + if (hasProgrammatic && (hasServerTls || hasClientTls)) { + errors.add(err.apply("sets programmatic SSLContext alongside file-based TLS config; remove one")); + } + + Config.TlsConfig tlsBlock = service.getTls(); + if (tlsBlock != null + && (tlsBlock.getCaCert() == null || tlsBlock.getCaCert().isEmpty()) + && !tlsBlock.isSystemCaRequired()) { + errors.add( + err.apply( + "tls block has no effect: set ca_cert or system_ca_required=true, or remove the" + + " block")); + } + + if (clientTls != null) { + boolean certSet = clientTls.getCert() != null && !clientTls.getCert().isEmpty(); + boolean keySet = clientTls.getPrivateKey() != null && !clientTls.getPrivateKey().isEmpty(); + if (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 (service.getCredentials() != null + && service.getCredentials().getBearer() != null + && hasClientTls) { + errors.add(err.apply("sets both bearer and client_tls credentials; only one is allowed")); + } + + // 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; + } + public Plugin initialize(PluginManager manager) { ServicePlugin plugin = new ServicePlugin(); plugin.manager = manager; @@ -61,63 +129,62 @@ 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(); + HttpClient client = clientBuilder.build(); - plugin.services.put( - service.getName(), - new Service(client, manager.getLogger()) - .setName(service.getName()) - .setUrl(service.getUrl()) - .setCredentials(getCredential(service))); + plugin.services.put( + service.getName(), + new Service(client, manager.getLogger()) + .setName(service.getName()) + .setUrl(service.getUrl()) + .setCredentials(getCredential(service))); + } + } 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 +197,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 +229,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() { @@ -200,9 +282,7 @@ void post(String path, String body) { .header("Accept", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)); - if (credentials != null) { - builder = credentials.modifyRequest(builder); - } + builder = applyCredentials(builder); HttpRequest request = builder.build(); client @@ -218,6 +298,18 @@ 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 Builder applyCredentials(Builder builder) { + if (credentials != null) { + return credentials.modifyRequest(builder); + } + return builder; + } + /** * Build a properly formatted URI by combining the base URL with the path. * 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..7b1610fe --- /dev/null +++ b/opa-services/src/main/java/io/github/open_policy_agent/opa/tls/PemLoader.java @@ -0,0 +1,143 @@ +package io.github.open_policy_agent.opa.tls; + +import java.io.IOException; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.PrivateKey; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openssl.PEMEncryptedKeyPair; +import org.bouncycastle.openssl.PEMKeyPair; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +import org.bouncycastle.openssl.jcajce.JceOpenSSLPKCS8DecryptorProviderBuilder; +import org.bouncycastle.openssl.jcajce.JcePEMDecryptorProviderBuilder; +import org.bouncycastle.operator.InputDecryptorProvider; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo; +import org.bouncycastle.pkcs.PKCSException; + +/** + * PEM parsing utilities for X.509 certificates and private keys. + * + *

Backed by Bouncy Castle: handles PKCS#8 (encrypted and unencrypted), PKCS#1 RSA, SEC1 EC, and + * legacy OpenSSL-style encrypted PEM ({@code Proc-Type: 4,ENCRYPTED}). + */ +public final class PemLoader { + + private static final BouncyCastleProvider BC_PROVIDER = new BouncyCastleProvider(); + + 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<>(); + JcaX509CertificateConverter converter = new JcaX509CertificateConverter(); + try (PEMParser parser = new PEMParser(reader(data))) { + Object obj; + while ((obj = parser.readObject()) != null) { + if (obj instanceof X509CertificateHolder) { + try { + certs.add(converter.getCertificate((X509CertificateHolder) obj)); + } 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 a private key from a PEM file. + * + * @param path path to a PEM file + * @param passphrase passphrase for encrypted keys; ignored for unencrypted keys (pass {@code + * null} when the key is unencrypted) + * @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 { + JcaPEMKeyConverter converter = new JcaPEMKeyConverter(); + try (PEMParser parser = new PEMParser(reader(data))) { + Object obj; + while ((obj = parser.readObject()) != null) { + try { + if (obj instanceof PEMEncryptedKeyPair) { + requirePassphrase(passphrase, source); + PEMKeyPair kp = + ((PEMEncryptedKeyPair) obj) + .decryptKeyPair( + new JcePEMDecryptorProviderBuilder() + .setProvider(BC_PROVIDER) + .build(passphrase)); + return converter.getKeyPair(kp).getPrivate(); + } + if (obj instanceof PKCS8EncryptedPrivateKeyInfo) { + requirePassphrase(passphrase, source); + InputDecryptorProvider decryptor = + new JceOpenSSLPKCS8DecryptorProviderBuilder() + .setProvider(BC_PROVIDER) + .build(passphrase); + PrivateKeyInfo info = + ((PKCS8EncryptedPrivateKeyInfo) obj).decryptPrivateKeyInfo(decryptor); + return converter.getPrivateKey(info); + } + if (obj instanceof PEMKeyPair) { + return converter.getKeyPair((PEMKeyPair) obj).getPrivate(); + } + if (obj instanceof PrivateKeyInfo) { + return converter.getPrivateKey((PrivateKeyInfo) obj); + } + // Anything else (e.g. an X509CertificateHolder when cert+key share a file) is skipped. + } catch (OperatorCreationException | PKCSException e) { + throw new IOException( + "Failed to decrypt key in " + + source + + " (wrong passphrase or unsupported algorithm): " + + e.getMessage(), + e); + } + } + } + throw new IOException("No private-key PEM block found in " + source); + } + + private static void requirePassphrase(char[] passphrase, String source) throws IOException { + if (passphrase == null) { + throw new IOException( + "Key in " + source + " is encrypted but no private_key_passphrase was provided"); + } + } + + private static StringReader reader(byte[] data) { + // PEM is ASCII-only by spec, but reading as UTF-8 tolerates a BOM or stray non-ASCII + // bytes outside the base64 blocks (e.g. comments) without failing parsing. + return new StringReader(new String(data, StandardCharsets.UTF_8)); + } +} 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..497fc0ed --- /dev/null +++ b/opa-services/src/main/java/io/github/open_policy_agent/opa/tls/ReloadingX509KeyManager.java @@ -0,0 +1,206 @@ +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.util.Arrays; +import java.util.List; +import java.util.concurrent.ScheduledExecutorService; +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 (CertManagerV2 issues 24-hour certs) where the + * deployment refreshes them in place. Mirrors swift-opa-sdk's {@code + * cert_reread_interval_seconds} semantics: 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. + */ +public final class ReloadingX509KeyManager extends X509ExtendedKeyManager { + + 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<>(); + + /** + * Build and start a reloading key manager. + * + * @param certPath PEM cert-chain path + * @param keyPath PEM PKCS#8 private-key path + * @param keyPassphrase passphrase for encrypted keys (nullable) + * @param scheduler executor used to schedule periodic reloads + * @param rereadInterval how often to check the on-disk bytes + * @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, + long rereadIntervalSeconds, + Logger logger, + String serviceName) + throws IOException, GeneralSecurityException { + ReloadingX509KeyManager mgr = + new ReloadingX509KeyManager(certPath, keyPath, keyPassphrase, logger, serviceName); + mgr.loadOrThrow(); + scheduler.scheduleAtFixedRate( + mgr::reloadIfChanged, + rereadIntervalSeconds, + rereadIntervalSeconds, + TimeUnit.SECONDS); + 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()); + } + } + + 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..8feafa95 --- /dev/null +++ b/opa-services/src/main/java/io/github/open_policy_agent/opa/tls/SslContextBuilder.java @@ -0,0 +1,197 @@ +package io.github.open_policy_agent.opa.tls; + +import java.io.IOException; +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.concurrent.ScheduledExecutorService; +import javax.net.ssl.KeyManager; +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-based config ({@code tls.ca_cert}, {@code credentials.client_tls.*}). + *
  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 a custom CA for server trust. */ + public static boolean hasServerTls(Config.ServiceConfig service) { + Config.TlsConfig tls = service.getTls(); + return tls != null && tls.getCaCert() != null && !tls.getCaCert().isEmpty(); + } + + /** True when the service configures a client certificate for mTLS. */ + public static boolean hasClientTls(Config.ServiceConfig service) { + if (service.getCredentials() == null) { + return false; + } + Config.ClientTlsConfig clientTls = service.getCredentials().getClientTls(); + return clientTls != null && clientTls.getCert() != null && !clientTls.getCert().isEmpty(); + } + + private static KeyManager[] buildKeyManagers( + Config.ServiceConfig service, ScheduledExecutorService reloadScheduler, Logger logger) + throws IOException, GeneralSecurityException { + Config.ClientTlsConfig clientTls = service.getCredentials().getClientTls(); + 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, reread, logger, service.getName()); + return new KeyManager[] {km}; + } + + return KeyStores.keyManagers( + PemLoader.loadCertificates(certPath), PemLoader.loadPrivateKey(keyPath, passphrase)); + } + + // Package-private for tests. + static TrustManager[] buildTrustManagers(Config.TlsConfig tls) + throws IOException, GeneralSecurityException { + Path caPath = Paths.get(tls.getCaCert()); + List userCAs = PemLoader.loadCertificates(caPath); + + char[] storePass = new char[0]; + KeyStore ts = KeyStore.getInstance("PKCS12"); + ts.load(null, storePass); + 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); + 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(); + } + + /** 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..a9f7f687 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()); + assertEquals(null, tls.getCaCert()); + + Config.ClientTlsConfig clientTls = new Config.ClientTlsConfig(); + assertEquals(null, clientTls.getCert()); + assertEquals(null, clientTls.getPrivateKey()); + assertEquals(null, clientTls.getPrivateKeyPassphrase()); + assertEquals(null, 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/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..c61bdd82 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 @@ -309,4 +309,200 @@ void validate_multipleServices_oneInvalid_returnsError() { assertTrue(errors.stream().anyMatch(e -> e.contains("service2"))); assertTrue(errors.stream().anyMatch(e -> e.contains("missing or empty URL"))); } + + @Test + void validate_tls_allowInsecureTlsConflict_returnsError() { + Config.ServiceConfig service = + new Config.ServiceConfig() + .setName("s") + .setUrl("https://example.com") + .setAllowInsecureTLS(true) + .setSslContext(mock(javax.net.ssl.SSLContext.class)); + 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("allow_insecure_tls=true alongside"))); + } + + @Test + void validate_tls_programmaticAndFileConflict_returnsError() { + Config.ServiceConfig service = + new Config.ServiceConfig() + .setName("s") + .setUrl("https://example.com") + .setTls(new Config.TlsConfig().setCaCert("/nonexistent/ca.pem")) + .setSslContext(mock(javax.net.ssl.SSLContext.class)); + 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("programmatic SSLContext alongside"))); + } + + @Test + void validate_tls_emptyBlock_returnsError() { + // tls block with neither ca_cert nor system_ca_required=true is a no-op — likely a typo. + Config.ServiceConfig service = + new Config.ServiceConfig() + .setName("s") + .setUrl("https://example.com") + .setTls(new Config.TlsConfig()); + 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("tls block has no effect"))); + } + + @Test + void validate_tls_certWithoutKey_returnsError() { + Config.ServiceConfig service = + new Config.ServiceConfig() + .setName("s") + .setUrl("https://example.com") + .setCredentials( + new Config.CredentialsConfig() + .setClientTls(new Config.ClientTlsConfig().setCert("/some/cert.pem"))); + 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("must set both cert and private_key"))); + } + + @Test + void validate_tls_bearerAndClientTls_returnsError() { + 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.stream().anyMatch(e -> e.contains("both bearer and client_tls")), + "expected bearer/client_tls conflict but got " + errors); + } + + @Test + void validate_tls_negativeRereadInterval_returnsError() { + Config.ServiceConfig service = + new Config.ServiceConfig() + .setName("s") + .setUrl("https://example.com") + .setCredentials( + new Config.CredentialsConfig() + .setClientTls( + new Config.ClientTlsConfig() + .setCert("/c.pem") + .setPrivateKey("/k.pem") + .setCertRereadIntervalSeconds(-1))); + 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("cert_reread_interval_seconds must be >= 0"))); + } + + @Test + void validate_tls_rereadIntervalWithoutCert_returnsError() { + Config.ServiceConfig service = + new Config.ServiceConfig() + .setName("s") + .setUrl("https://example.com") + .setCredentials( + new Config.CredentialsConfig() + .setClientTls(new Config.ClientTlsConfig().setCertRereadIntervalSeconds(60))); + 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("cert_reread_interval_seconds requires both cert")), + "expected reread-without-cert error, 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..6ff686df --- /dev/null +++ b/opa-services/src/test/java/io/github/open_policy_agent/opa/tls/PemLoaderTest.java @@ -0,0 +1,115 @@ +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_encryptedWithPassphrase() throws IOException { + PrivateKey key = + PemLoader.loadPrivateKey(fx.clientKeyEncrypted, fx.clientKeyPassphrase.toCharArray()); + assertNotNull(key); + assertEquals("RSA", key.getAlgorithm()); + } + + @Test + void loadPrivateKey_encryptedMissingPassphrase_throws() { + IOException e = + assertThrows(IOException.class, () -> PemLoader.loadPrivateKey(fx.clientKeyEncrypted, null)); + assertTrue(e.getMessage().contains("no private_key_passphrase")); + } + + @Test + void loadPrivateKey_wrongPassphrase_throws() { + IOException e = + assertThrows( + IOException.class, + () -> PemLoader.loadPrivateKey(fx.clientKeyEncrypted, "wrong".toCharArray())); + assertTrue(e.getMessage().contains("Failed to decrypt")); + } + + @Test + void loadPrivateKey_pkcs1Rsa_loadsViaBouncyCastle() throws IOException, InterruptedException { + Path pkcs1 = dir.resolve("pkcs1.pem"); + // `openssl rsa -in pkcs8.pem -out out.pem` produces PKCS#1 on LibreSSL and on OpenSSL <3, + // and OpenSSL 3.x accepts `-traditional` for the same effect. Try both. + 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))); + + PrivateKey key = PemLoader.loadPrivateKey(pkcs1, null); + assertNotNull(key); + assertEquals("RSA", key.getAlgorithm()); + } + + @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..e6ab3031 --- /dev/null +++ b/opa-services/src/test/java/io/github/open_policy_agent/opa/tls/ReloadingX509KeyManagerTest.java @@ -0,0 +1,274 @@ +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.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*/ Long.MAX_VALUE, 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, Long.MAX_VALUE, 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, Long.MAX_VALUE, 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 create_encryptedKey_loadsWithPassphrase() throws Exception { + Logger logger = mock(Logger.class); + ReloadingX509KeyManager km = + ReloadingX509KeyManager.create( + fx.client, + fx.clientKeyEncrypted, + fx.clientKeyPassphrase.toCharArray(), + scheduler, + Long.MAX_VALUE, + logger, + "svc"); + + assertNotNull(km.getCertificateChain("key")); + assertNotNull(km.getPrivateKey("key")); + } + + @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, Long.MAX_VALUE, 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..e694a800 --- /dev/null +++ b/opa-services/src/test/java/io/github/open_policy_agent/opa/tls/SslContextBuilderTest.java @@ -0,0 +1,160 @@ +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"); + } + + 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)); + } + } +} From bf05047d039f9f35d28a12f31e1081696c0ad0e8 Mon Sep 17 00:00:00 2001 From: Sebastian Spaink Date: Mon, 15 Jun 2026 17:57:11 -0500 Subject: [PATCH 2/3] resolve feedback Signed-off-by: Sebastian Spaink --- .../opa/config/EnvInterpolator.java | 54 ++++++++++++++++ .../opa/config/EnvInterpolatorTest.java | 62 +++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 opa-services/src/main/java/io/github/open_policy_agent/opa/config/EnvInterpolator.java create mode 100644 opa-services/src/test/java/io/github/open_policy_agent/opa/config/EnvInterpolatorTest.java 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/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); + } +} From 990ab9dc816d7a8c44e4861d4c30b12bfc1bcafb Mon Sep 17 00:00:00 2001 From: Sebastian Spaink Date: Mon, 15 Jun 2026 18:09:03 -0500 Subject: [PATCH 3/3] Resolve feedback Signed-off-by: Sebastian Spaink --- README.md | 21 +- opa-services/README.md | 75 ++++- .../io/github/open_policy_agent/opa/Opa.java | 12 +- .../open_policy_agent/opa/config/Config.java | 263 +++++++++++++++++- .../opa/plugins/BundleDownloader.java | 30 +- .../opa/plugins/BundlePlugin.java | 8 +- .../opa/plugins/DiscoveryPlugin.java | 25 +- .../opa/plugins/ServicePlugin.java | 224 ++++++++++----- .../open_policy_agent/opa/tls/PemLoader.java | 170 +++++------ .../opa/tls/ReloadingX509KeyManager.java | 46 ++- .../opa/tls/SslContextBuilder.java | 96 ++++++- .../opa/config/ConfigTest.java | 10 +- .../opa/plugins/ServicePluginTest.java | 224 ++++++--------- .../opa/tls/PemLoaderTest.java | 39 ++- .../opa/tls/ReloadingX509KeyManagerTest.java | 26 +- .../opa/tls/SslContextBuilderTest.java | 70 +++++ 16 files changed, 921 insertions(+), 418 deletions(-) diff --git a/README.md b/README.md index d43bb5d6..27786d78 100644 --- a/README.md +++ b/README.md @@ -275,16 +275,25 @@ nd_builtin_cache: true |-------|------|---------|-------------| | `url` | string | - | Base URL of the service | | `credentials.bearer.token` | string | - | Bearer token for authentication | -| `credentials.client_tls.cert` | string | - | PEM file with the client certificate (mTLS) | -| `credentials.client_tls.private_key` | string | - | PKCS#8 PEM file with the client private key | -| `credentials.client_tls.private_key_passphrase` | string | - | Passphrase for an encrypted PKCS#8 key | +| `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 | -| `tls.ca_cert` | string | - | PEM file 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` | +| `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) | -See [opa-services/README.md](opa-services/README.md#tls-and-mtls) for a full mTLS walkthrough, including the programmatic `setSslContext` escape hatch for HSM-backed or rotated keys. +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 diff --git a/opa-services/README.md b/opa-services/README.md index b7838ac2..6d35ecf5 100644 --- a/opa-services/README.md +++ b/opa-services/README.md @@ -111,26 +111,45 @@ services: client_tls: cert: /etc/ssl/client.pem private_key: /etc/ssl/client-key.pem - private_key_passphrase: "key-passphrase" cert_reread_interval_seconds: 3600 ``` | Field | Description | |-------|-------------| -| `tls.ca_cert` | PEM file containing one or more trust roots for verifying the server. | -| `tls.system_ca_required` | When `true`, the JVM's default trust store is also trusted in addition to `ca_cert`. | -| `credentials.client_tls.cert` | PEM file containing the client certificate (and any intermediates). | -| `credentials.client_tls.private_key` | PKCS#8 PEM file with the client private key. | -| `credentials.client_tls.private_key_passphrase` | Passphrase for an encrypted PKCS#8 key. Omit for unencrypted keys. | +| `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 PKCS#8 PEM private keys are accepted. Convert PKCS#1 keys with: +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 ``` -Programmatic equivalent: +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() @@ -144,11 +163,28 @@ Config config = new Config() .setClientTls(new Config.ClientTlsConfig() .setCert("/etc/ssl/client.pem") .setPrivateKey("/etc/ssl/client-key.pem") - .setPrivateKeyPassphrase("key-passphrase") .setCertRereadIntervalSeconds(3600)))); ``` -For keystores that cannot be expressed as files (HSM-backed keys, secret-manager-driven rotation, custom `KeyManager` chains), supply a fully constructed `SSLContext` directly. When set, file-based TLS fields are rejected during validation: +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(); @@ -159,6 +195,25 @@ Config.ServiceConfig service = new Config.ServiceConfig() .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 a040bfd0..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 @@ -3,6 +3,7 @@ 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; @@ -475,23 +476,28 @@ 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; } - /** - * 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 (PKCS12 from an enterprise cert manager, HSM-backed keys, runtime rotation - * from an external secret manager, etc.). - */ 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; @@ -560,7 +566,9 @@ public String toString() { /** * Server-TLS configuration for a service (trust roots). * - *

Mirrors Go-OPA's {@code services..tls} block. + *

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") @@ -569,10 +577,16 @@ public static class TlsConfig { @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; @@ -582,14 +596,108 @@ 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 + '}'; + 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" : "") + + '}'; } } @@ -626,9 +734,15 @@ public String toString() { /** * Client-TLS credentials for mTLS bundle downloads (and all service HTTP traffic). * - *

Mirrors Go-OPA's {@code services..credentials.client_tls} block. Only PKCS#8 - * (encrypted or unencrypted) PEM private keys are supported; convert PKCS#1 keys with {@code - * openssl pkcs8 -topk8 -nocrypt -in key.pem -out key-pkcs8.pem}. + *

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; @@ -642,10 +756,16 @@ public static class ClientTlsConfig { @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; @@ -655,6 +775,11 @@ 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; @@ -664,6 +789,10 @@ 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; @@ -673,11 +802,29 @@ 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='" @@ -688,6 +835,96 @@ public String toString() { + (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 "KeystoreConfig{path='" + + path + + "', type='" + + type + + "', password=" + + (password == null ? "null" : "") + + ", keyPassword=" + + (keyPassword == null ? "null" : "") + + ", keyStore=" + + (keyStore == null ? "null" : "") + '}'; } } 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 5f8c2d94..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 @@ -11,7 +11,6 @@ import java.nio.file.attribute.FileTime; import java.time.Duration; import java.util.HashSet; -import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.RejectedExecutionException; @@ -40,11 +39,10 @@ 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; - private final ServicePlugin.Service authService; - protected String service; protected String resource; protected Config.PollingConfig polling; @@ -56,20 +54,17 @@ public abstract class BundleDownloader { * * @param name bundle name (used in log messages) * @param manager the owning plugin manager - * @param httpClient the HTTP client to use for HTTP/HTTPS downloads; may be {@code null} if this - * downloader only ever handles {@code file://} URIs or filesystem paths - * @param authService the {@link ServicePlugin.Service} providing credentials for HTTP downloads; - * may be {@code null} to skip auth + * @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, - HttpClient httpClient, - ServicePlugin.Service authService) { + String name, PluginManager manager, ServicePlugin.Service authService) { this.name = name; this.manager = manager; - this.httpClient = httpClient != null ? httpClient : defaultHttpClient(); this.authService = authService; + this.httpClient = authService != null ? authService.getClient() : defaultHttpClient(); this.initialActivation = new CompletableFuture<>(); } @@ -304,16 +299,7 @@ private void handleHttpDownload(URI uri) throws IOException, InterruptedExceptio if (authService != null) { requestBuilder = authService.applyCredentials(requestBuilder); - } - - Config.ServiceConfig serviceConfig = manager.getConfig().getService(service); - if (serviceConfig != null && serviceConfig.getHeaders() != null) { - for (Map.Entry h : serviceConfig.getHeaders().entrySet()) { - // setHeader (not header) so user-supplied entries replace built-in headers like - // Authorization rather than producing duplicates — RFC 7230 forbids multiple - // Authorization headers, and HttpRequest.Builder#header is additive. - requestBuilder.setHeader(h.getKey(), h.getValue()); - } + requestBuilder = authService.applyHeaders(requestBuilder); } HttpRequest request = requestBuilder.build(); 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 29f8eaa1..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 @@ -1,6 +1,5 @@ package io.github.open_policy_agent.opa.plugins; -import java.net.http.HttpClient; import java.util.HashMap; import java.util.HashSet; import java.util.Map; @@ -73,11 +72,10 @@ public Plugin initialize(PluginManager manager) { ServicePlugin.Service svc = servicePlugin == null ? null : servicePlugin.getService(bundleConfig.getService()); - HttpClient client = svc == null ? null : svc.getClient(); plugin.bundles.put( name, - new Bundle(name, manager, client, svc) + new Bundle(name, manager, svc) .setService(bundleConfig.getService()) .setResource(bundleConfig.getResource()) .setPolling(bundleConfig.getPolling())); @@ -143,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, HttpClient client, ServicePlugin.Service authService) { - super(name, manager, client, authService); + 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 7021f70c..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 @@ -4,7 +4,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.io.ByteArrayInputStream; import java.io.IOException; -import java.net.http.HttpClient; import java.util.HashSet; import java.util.Set; import java.util.concurrent.CompletableFuture; @@ -78,17 +77,25 @@ public Plugin initialize(PluginManager manager) { String name = discoveryConfig.getName() != null ? discoveryConfig.getName() : "discovery"; ServicePlugin.Service svc = null; - HttpClient client = null; Plugin raw = manager.getPlugin("services"); if (raw instanceof ServicePlugin) { svc = ((ServicePlugin) raw).getService(discoveryConfig.getService()); - if (svc != null) { - client = svc.getClient(); + // 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, client, svc) + new DiscoveryBundle(name, manager, svc) .setService(discoveryConfig.getService()) .setResource(discoveryConfig.getResource()) .setPolling(discoveryConfig.getPolling()); @@ -150,12 +157,8 @@ private static class DiscoveryBundle extends BundleDownloader { private Config discoveredConfig; // Store the last successfully loaded config - private DiscoveryBundle( - String name, - PluginManager manager, - HttpClient client, - ServicePlugin.Service service) { - super(name, manager, client, service); + 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 e9f497cd..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 @@ -12,6 +12,7 @@ import java.util.Set; 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; @@ -24,7 +25,7 @@ public final class ServicePlugin implements Plugin { 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()) { @@ -56,8 +57,7 @@ public Set validate(PluginManager manager) { private Set validateTls(String serviceName, Config.ServiceConfig service) { Set errors = new HashSet<>(); - java.util.function.Function err = - msg -> "Service '" + serviceName + "' " + msg; + Function err = msg -> "Service '" + serviceName + "' " + msg; Config.ClientTlsConfig clientTls = service.getCredentials() == null ? null : service.getCredentials().getClientTls(); @@ -66,27 +66,48 @@ private Set validateTls(String serviceName, Config.ServiceConfig 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; remove one")); + 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; remove one")); + errors.add(err.apply("sets programmatic SSLContext alongside file-based TLS config; choose one")); } Config.TlsConfig tlsBlock = service.getTls(); - if (tlsBlock != null - && (tlsBlock.getCaCert() == null || tlsBlock.getCaCert().isEmpty()) - && !tlsBlock.isSystemCaRequired()) { - errors.add( - err.apply( - "tls block has no effect: set ca_cert or system_ca_required=true, or remove the" - + " block")); + 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(); - if (certSet != keySet) { + 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 @@ -106,12 +127,9 @@ private Set validateTls(String serviceName, Config.ServiceConfig service "credentials.client_tls.cert_reread_interval_seconds requires both cert" + " and private_key")); } - } - - if (service.getCredentials() != null - && service.getCredentials().getBearer() != null - && hasClientTls) { - errors.add(err.apply("sets both bearer and client_tls credentials; only one is allowed")); + if (keystoreSet) { + errors.addAll(validateKeystore(serviceName, keystore)); + } } // Path existence is intentionally NOT checked here. Certificate files may be rotated into @@ -121,6 +139,28 @@ private Set validateTls(String serviceName, Config.ServiceConfig service 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; + } + public Plugin initialize(PluginManager manager) { ServicePlugin plugin = new ServicePlugin(); plugin.manager = manager; @@ -174,10 +214,14 @@ public Plugin initialize(PluginManager manager) { plugin.services.put( service.getName(), - new Service(client, manager.getLogger()) - .setName(service.getName()) - .setUrl(service.getUrl()) - .setCredentials(getCredential(service))); + 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 @@ -259,34 +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(HttpClient client, Logger logger) { - this.client = client; - this.logger = logger; + 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); + } + + 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)); - builder = applyCredentials(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()); @@ -303,11 +364,25 @@ void post(String path, String body) { * configured. Exposed so other plugins (e.g. bundle downloads) can use the same auth path as * {@link #post}. */ - public Builder applyCredentials(Builder builder) { + public HttpRequest.Builder applyCredentials(HttpRequest.Builder request) { if (credentials != null) { - return credentials.modifyRequest(builder); + return credentials.modifyRequest(request); } - return builder; + 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; } /** @@ -350,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/PemLoader.java b/opa-services/src/main/java/io/github/open_policy_agent/opa/tls/PemLoader.java index 7b1610fe..cbca22d4 100644 --- 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 @@ -1,39 +1,39 @@ package io.github.open_policy_agent.opa.tls; import java.io.IOException; -import java.io.StringReader; 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 org.bouncycastle.asn1.pkcs.PrivateKeyInfo; -import org.bouncycastle.cert.X509CertificateHolder; -import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; -import org.bouncycastle.jce.provider.BouncyCastleProvider; -import org.bouncycastle.openssl.PEMEncryptedKeyPair; -import org.bouncycastle.openssl.PEMKeyPair; -import org.bouncycastle.openssl.PEMParser; -import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; -import org.bouncycastle.openssl.jcajce.JceOpenSSLPKCS8DecryptorProviderBuilder; -import org.bouncycastle.openssl.jcajce.JcePEMDecryptorProviderBuilder; -import org.bouncycastle.operator.InputDecryptorProvider; -import org.bouncycastle.operator.OperatorCreationException; -import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo; -import org.bouncycastle.pkcs.PKCSException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** - * PEM parsing utilities for X.509 certificates and private keys. + * PEM parsing utilities for X.509 certificates and private keys, using only the JDK standard + * library. * - *

Backed by Bouncy Castle: handles PKCS#8 (encrypted and unencrypted), PKCS#1 RSA, SEC1 EC, and - * legacy OpenSSL-style encrypted PEM ({@code Proc-Type: 4,ENCRYPTED}). + *

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 BouncyCastleProvider BC_PROVIDER = new BouncyCastleProvider(); + 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() {} @@ -49,18 +49,29 @@ public static List loadCertificates(Path path) throws IOExcepti static List parseCertificates(byte[] data, String source) throws IOException { List certs = new ArrayList<>(); - JcaX509CertificateConverter converter = new JcaX509CertificateConverter(); - try (PEMParser parser = new PEMParser(reader(data))) { - Object obj; - while ((obj = parser.readObject()) != null) { - if (obj instanceof X509CertificateHolder) { - try { - certs.add(converter.getCertificate((X509CertificateHolder) obj)); - } catch (CertificateException e) { - throw new IOException( - "Failed to parse certificate in " + source + ": " + e.getMessage(), e); - } + 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()) { @@ -70,11 +81,12 @@ static List parseCertificates(byte[] data, String source) throw } /** - * Load a private key from a PEM file. + * Load an unencrypted PKCS#8 private key from a PEM file. * * @param path path to a PEM file - * @param passphrase passphrase for encrypted keys; ignored for unencrypted keys (pass {@code - * null} when the key is unencrypted) + * @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 { @@ -83,61 +95,63 @@ public static PrivateKey loadPrivateKey(Path path, char[] passphrase) throws IOE static PrivateKey parsePrivateKey(byte[] data, char[] passphrase, String source) throws IOException { - JcaPEMKeyConverter converter = new JcaPEMKeyConverter(); - try (PEMParser parser = new PEMParser(reader(data))) { - Object obj; - while ((obj = parser.readObject()) != null) { - try { - if (obj instanceof PEMEncryptedKeyPair) { - requirePassphrase(passphrase, source); - PEMKeyPair kp = - ((PEMEncryptedKeyPair) obj) - .decryptKeyPair( - new JcePEMDecryptorProviderBuilder() - .setProvider(BC_PROVIDER) - .build(passphrase)); - return converter.getKeyPair(kp).getPrivate(); - } - if (obj instanceof PKCS8EncryptedPrivateKeyInfo) { - requirePassphrase(passphrase, source); - InputDecryptorProvider decryptor = - new JceOpenSSLPKCS8DecryptorProviderBuilder() - .setProvider(BC_PROVIDER) - .build(passphrase); - PrivateKeyInfo info = - ((PKCS8EncryptedPrivateKeyInfo) obj).decryptPrivateKeyInfo(decryptor); - return converter.getPrivateKey(info); - } - if (obj instanceof PEMKeyPair) { - return converter.getKeyPair((PEMKeyPair) obj).getPrivate(); - } - if (obj instanceof PrivateKeyInfo) { - return converter.getPrivateKey((PrivateKeyInfo) obj); + 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."); } - // Anything else (e.g. an X509CertificateHolder when cert+key share a file) is skipped. - } catch (OperatorCreationException | PKCSException e) { + return decodePkcs8(decodeBase64(m.group(2), source), source); + case "ENCRYPTED PRIVATE KEY": throw new IOException( - "Failed to decrypt key in " + "Encrypted PEM private keys are not supported in " + source - + " (wrong passphrase or unsupported algorithm): " - + e.getMessage(), - e); - } + + ". 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 void requirePassphrase(char[] passphrase, String source) throws IOException { - if (passphrase == null) { - throw new IOException( - "Key in " + source + " is encrypted but no private_key_passphrase was provided"); + 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 StringReader reader(byte[] data) { - // PEM is ASCII-only by spec, but reading as UTF-8 tolerates a BOM or stray non-ASCII - // bytes outside the base64 blocks (e.g. comments) without failing parsing. - return new StringReader(new String(data, StandardCharsets.UTF_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 index 497fc0ed..e5360766 100644 --- 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 @@ -10,9 +10,11 @@ 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; @@ -23,14 +25,16 @@ * 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 (CertManagerV2 issues 24-hour certs) where the - * deployment refreshes them in place. Mirrors swift-opa-sdk's {@code - * cert_reread_interval_seconds} semantics: polled reload, SHA-256 dedupe, parse only on 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 { +public final class ReloadingX509KeyManager extends X509ExtendedKeyManager implements AutoCloseable { private final Path certPath; private final Path keyPath; @@ -39,15 +43,16 @@ public final class ReloadingX509KeyManager extends X509ExtendedKeyManager { 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 passphrase for encrypted keys (nullable) + * @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 + * @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) */ @@ -56,18 +61,23 @@ public static ReloadingX509KeyManager create( Path keyPath, char[] keyPassphrase, ScheduledExecutorService scheduler, - long rereadIntervalSeconds, + 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(); - scheduler.scheduleAtFixedRate( - mgr::reloadIfChanged, - rereadIntervalSeconds, - rereadIntervalSeconds, - TimeUnit.SECONDS); + 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; } @@ -120,6 +130,18 @@ synchronized void reloadIfChanged() { } } + /** + * 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 = 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 index 8feafa95..95dc77eb 100644 --- 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 @@ -1,6 +1,8 @@ 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; @@ -8,8 +10,10 @@ 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; @@ -27,7 +31,8 @@ *

    *
  1. Programmatic override ({@link Config.ServiceConfig#getSslContext()}). *
  2. {@code allow_insecure_tls: true} — trust-all context (development only). - *
  3. File-based config ({@code tls.ca_cert}, {@code credentials.client_tls.*}). + *
  4. File / keystore-based config ({@code tls.ca_cert}, {@code tls.truststore}, {@code + * credentials.client_tls.cert} / {@code .keystore}). *
  5. Nothing configured → {@code null} (caller keeps the HttpClient default). *
* @@ -86,25 +91,44 @@ public static Tls build( return new Tls(ctx, params); } - /** True when the service configures a custom CA for server trust. */ + /** 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(); - return tls != null && tls.getCaCert() != null && !tls.getCaCert().isEmpty(); + 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 for mTLS. */ + /** 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(); - return clientTls != null && clientTls.getCert() != null && !clientTls.getCert().isEmpty(); + 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 = @@ -116,7 +140,13 @@ private static KeyManager[] buildKeyManagers( if (reread != null && reread > 0) { ReloadingX509KeyManager km = ReloadingX509KeyManager.create( - certPath, keyPath, passphrase, reloadScheduler, reread, logger, service.getName()); + certPath, + keyPath, + passphrase, + reloadScheduler, + java.time.Duration.ofSeconds(reread), + logger, + service.getName()); return new KeyManager[] {km}; } @@ -124,18 +154,36 @@ private static KeyManager[] buildKeyManagers( 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 { - Path caPath = Paths.get(tls.getCaCert()); - List userCAs = PemLoader.loadCertificates(caPath); - - char[] storePass = new char[0]; - KeyStore ts = KeyStore.getInstance("PKCS12"); - ts.load(null, storePass); - int idx = 0; - for (X509Certificate ca : userCAs) { - ts.setCertificateEntry("user-ca-" + idx++, ca); + 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 @@ -146,6 +194,7 @@ static TrustManager[] buildTrustManagers(Config.TlsConfig tls) 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()) { @@ -161,6 +210,23 @@ static TrustManager[] buildTrustManagers(Config.TlsConfig tls) 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; 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 a9f7f687..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 @@ -364,13 +364,13 @@ void config_loadsMtlsFromYaml() throws Exception { void config_mtlsDefaults() { Config.TlsConfig tls = new Config.TlsConfig(); assertFalse(tls.isSystemCaRequired()); - assertEquals(null, tls.getCaCert()); + assertNull(tls.getCaCert()); Config.ClientTlsConfig clientTls = new Config.ClientTlsConfig(); - assertEquals(null, clientTls.getCert()); - assertEquals(null, clientTls.getPrivateKey()); - assertEquals(null, clientTls.getPrivateKeyPassphrase()); - assertEquals(null, clientTls.getCertRereadIntervalSeconds()); + assertNull(clientTls.getCert()); + assertNull(clientTls.getPrivateKey()); + assertNull(clientTls.getPrivateKeyPassphrase()); + assertNull(clientTls.getCertRereadIntervalSeconds()); } @Test 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 c61bdd82..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); @@ -310,36 +308,13 @@ void validate_multipleServices_oneInvalid_returnsError() { assertTrue(errors.stream().anyMatch(e -> e.contains("missing or empty URL"))); } - @Test - void validate_tls_allowInsecureTlsConflict_returnsError() { - Config.ServiceConfig service = - new Config.ServiceConfig() - .setName("s") - .setUrl("https://example.com") - .setAllowInsecureTLS(true) - .setSslContext(mock(javax.net.ssl.SSLContext.class)); - 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("allow_insecure_tls=true alongside"))); - } - - @Test - void validate_tls_programmaticAndFileConflict_returnsError() { + @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") - .setTls(new Config.TlsConfig().setCaCert("/nonexistent/ca.pem")) - .setSslContext(mock(javax.net.ssl.SSLContext.class)); + new Config.ServiceConfig().setName("s").setUrl("https://example.com"); + mutator.accept(service); config.setServices(Collections.singletonMap("s", service)); manager = @@ -351,56 +326,86 @@ void validate_tls_programmaticAndFileConflict_returnsError() { .build(); Set errors = new ServicePlugin().validate(manager); - assertTrue(errors.stream().anyMatch(e -> e.contains("programmatic SSLContext alongside"))); + assertTrue( + errors.stream().anyMatch(e -> e.contains(expectedFragment)), + "expected error containing '" + expectedFragment + "' but got " + errors); } - @Test - void validate_tls_emptyBlock_returnsError() { - // tls block with neither ca_cert nor system_ca_required=true is a no-op — likely a typo. - Config.ServiceConfig service = - new Config.ServiceConfig() - .setName("s") - .setUrl("https://example.com") - .setTls(new Config.TlsConfig()); - 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("tls block has no effect"))); + 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_certWithoutKey_returnsError() { - Config.ServiceConfig service = - new Config.ServiceConfig() - .setName("s") - .setUrl("https://example.com") - .setCredentials( - new Config.CredentialsConfig() - .setClientTls(new Config.ClientTlsConfig().setCert("/some/cert.pem"))); - 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("must set both cert and private_key"))); - } - - @Test - void validate_tls_bearerAndClientTls_returnsError() { + 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 = @@ -420,62 +425,7 @@ void validate_tls_bearerAndClientTls_returnsError() { .build(); Set errors = new ServicePlugin().validate(manager); - assertTrue( - errors.stream().anyMatch(e -> e.contains("both bearer and client_tls")), - "expected bearer/client_tls conflict but got " + errors); - } - - @Test - void validate_tls_negativeRereadInterval_returnsError() { - Config.ServiceConfig service = - new Config.ServiceConfig() - .setName("s") - .setUrl("https://example.com") - .setCredentials( - new Config.CredentialsConfig() - .setClientTls( - new Config.ClientTlsConfig() - .setCert("/c.pem") - .setPrivateKey("/k.pem") - .setCertRereadIntervalSeconds(-1))); - 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("cert_reread_interval_seconds must be >= 0"))); - } - - @Test - void validate_tls_rereadIntervalWithoutCert_returnsError() { - Config.ServiceConfig service = - new Config.ServiceConfig() - .setName("s") - .setUrl("https://example.com") - .setCredentials( - new Config.CredentialsConfig() - .setClientTls(new Config.ClientTlsConfig().setCertRereadIntervalSeconds(60))); - 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("cert_reread_interval_seconds requires both cert")), - "expected reread-without-cert error, got " + errors); + assertTrue(errors.isEmpty(), "expected no errors, got: " + errors); } @Test 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 index 6ff686df..9d88211c 100644 --- 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 @@ -47,34 +47,32 @@ void loadPrivateKey_pkcs8Unencrypted() throws IOException { } @Test - void loadPrivateKey_encryptedWithPassphrase() throws IOException { - PrivateKey key = - PemLoader.loadPrivateKey(fx.clientKeyEncrypted, fx.clientKeyPassphrase.toCharArray()); - assertNotNull(key); - assertEquals("RSA", key.getAlgorithm()); - } - - @Test - void loadPrivateKey_encryptedMissingPassphrase_throws() { + 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, null)); - assertTrue(e.getMessage().contains("no private_key_passphrase")); + 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_wrongPassphrase_throws() { + 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.clientKeyEncrypted, "wrong".toCharArray())); - assertTrue(e.getMessage().contains("Failed to decrypt")); + () -> PemLoader.loadPrivateKey(fx.clientKey, "ignored".toCharArray())); + assertTrue(e.getMessage().contains("unencrypted PKCS#8 but a passphrase was supplied")); } @Test - void loadPrivateKey_pkcs1Rsa_loadsViaBouncyCastle() throws IOException, InterruptedException { + void loadPrivateKey_pkcs1Rsa_throwsWithConversionHint() throws IOException, InterruptedException { Path pkcs1 = dir.resolve("pkcs1.pem"); - // `openssl rsa -in pkcs8.pem -out out.pem` produces PKCS#1 on LibreSSL and on OpenSSL <3, - // and OpenSSL 3.x accepts `-traditional` for the same effect. Try both. int rc1 = new ProcessBuilder( "openssl", "rsa", "-in", fx.clientKey.toString(), "-out", pkcs1.toString()) @@ -101,9 +99,10 @@ void loadPrivateKey_pkcs1Rsa_loadsViaBouncyCastle() throws IOException, Interrup header.contains("BEGIN RSA PRIVATE KEY"), "expected PKCS#1 key but got:\n" + header.substring(0, Math.min(header.length(), 120))); - PrivateKey key = PemLoader.loadPrivateKey(pkcs1, null); - assertNotNull(key); - assertEquals("RSA", key.getAlgorithm()); + IOException e = assertThrows(IOException.class, () -> PemLoader.loadPrivateKey(pkcs1, null)); + assertTrue( + e.getMessage().contains("Convert to PKCS#8"), + "expected conversion hint, got: " + e.getMessage()); } @Test 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 index e6ab3031..d416c6bf 100644 --- 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 @@ -13,6 +13,7 @@ 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; @@ -56,7 +57,7 @@ void reload_bytesUnchanged_delegateNotSwapped() throws Exception { Logger logger = mock(Logger.class); ReloadingX509KeyManager km = ReloadingX509KeyManager.create( - fx.client, fx.clientKey, null, scheduler, /*interval*/ Long.MAX_VALUE, logger, "svc"); + fx.client, fx.clientKey, null, scheduler, /*interval*/ Duration.ofDays(365), logger, "svc"); X509Certificate[] first = km.getCertificateChain("key"); assertNotNull(first); @@ -86,7 +87,7 @@ void reload_bytesChanged_delegateSwapped() throws Exception { Logger logger = mock(Logger.class); ReloadingX509KeyManager km = ReloadingX509KeyManager.create( - certCopy, keyCopy, null, scheduler, Long.MAX_VALUE, logger, "svc"); + certCopy, keyCopy, null, scheduler, Duration.ofDays(365), logger, "svc"); X509Certificate[] before = km.getCertificateChain("key"); assertNotNull(before); @@ -158,7 +159,7 @@ void reload_partialRotation_certOnly_delegateSwapped() throws Exception { Logger logger = mock(Logger.class); ReloadingX509KeyManager km = ReloadingX509KeyManager.create( - certCopy, keyCopy, null, scheduler, Long.MAX_VALUE, logger, "svc"); + certCopy, keyCopy, null, scheduler, Duration.ofDays(365), logger, "svc"); X509Certificate[] before = km.getCertificateChain("key"); assertNotNull(before); @@ -213,23 +214,6 @@ void reload_partialRotation_certOnly_delegateSwapped() throws Exception { + " once the key file catches up"); } - @Test - void create_encryptedKey_loadsWithPassphrase() throws Exception { - Logger logger = mock(Logger.class); - ReloadingX509KeyManager km = - ReloadingX509KeyManager.create( - fx.client, - fx.clientKeyEncrypted, - fx.clientKeyPassphrase.toCharArray(), - scheduler, - Long.MAX_VALUE, - logger, - "svc"); - - assertNotNull(km.getCertificateChain("key")); - assertNotNull(km.getPrivateKey("key")); - } - @Test void reload_corruptedFile_delegateRetained() throws Exception { Path certCopy = dir.resolve("client-bad-test.pem"); @@ -240,7 +224,7 @@ void reload_corruptedFile_delegateRetained() throws Exception { Logger logger = mock(Logger.class); ReloadingX509KeyManager km = ReloadingX509KeyManager.create( - certCopy, keyCopy, null, scheduler, Long.MAX_VALUE, logger, "svc"); + certCopy, keyCopy, null, scheduler, Duration.ofDays(365), logger, "svc"); X509Certificate[] before = km.getCertificateChain("key"); assertNotNull(before); 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 index e694a800..1766a7f8 100644 --- 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 @@ -144,6 +144,76 @@ void systemCaRequired_userTrustedChain_passesValidation() throws Exception { 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(