From e0e9c1d1af54ff3f9dcc692850021a667cf388d2 Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Tue, 1 Sep 2026 09:55:11 +0200 Subject: [PATCH] CAMEL-24571: camel-spiffe - add SpiffeSSLContextParameters for Workload-API-backed mTLS SpiffeSSLContextParameters is an SSLContextParameters whose SSLContext is backed by the SPIFFE Workload API (via io.spiffe:java-spiffe-provider) with automatic X.509-SVID rotation, so any Camel component accepting sslContextParameters gets zero-trust mTLS. The built context is wrapped with the same decorator as the parent, so inherited clientAuthentication, cipherSuites and secureSocketProtocols still apply. Peer authentication is fail-closed: an acceptedSpiffeIds allow-list or acceptAnySpiffeId (mutually exclusive). The X509Source is created lazily (bounded by initTimeout), closed on CamelContext shutdown, and the cached context is invalidated then so a restarted context rebuilds a fresh source. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019DBEWFBQvRgMrg6ukvv5iV Signed-off-by: Andrea Cosentino --- .../camel/catalog/docs/spiffe-component.adoc | 30 ++ components/camel-spiffe/pom.xml | 5 + .../src/main/docs/spiffe-component.adoc | 30 ++ .../spiffe/SpiffeSSLContextParameters.java | 270 ++++++++++++++++++ .../SpiffeSSLContextParametersTest.java | 76 +++++ 5 files changed, 411 insertions(+) create mode 100644 components/camel-spiffe/src/main/java/org/apache/camel/component/spiffe/SpiffeSSLContextParameters.java create mode 100644 components/camel-spiffe/src/test/java/org/apache/camel/component/spiffe/SpiffeSSLContextParametersTest.java diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spiffe-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spiffe-component.adoc index 61eb1bd1009d3..93f8dafde4358 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spiffe-component.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spiffe-component.adoc @@ -86,4 +86,34 @@ from("direct:start") .to("http://backend.example.org/api"); ------------------------------------------------------------ +== Mutual TLS with SPIFFE (SSLContextParameters) + +For X.509-based zero-trust mTLS, the component provides +`org.apache.camel.component.spiffe.SpiffeSSLContextParameters`, an `SSLContextParameters` whose `SSLContext` is +backed by the SPIFFE Workload API. The X.509-SVID and trust bundles are fetched live and rotated automatically, +so any Camel component that accepts an `sslContextParameters` reference (camel-http, camel-netty-http, +camel-jetty, camel-vertx-http, ...) can obtain SPIFFE mTLS. + +Peer authentication must be constrained explicitly: set `acceptedSpiffeIds` to an allow-list of peer SPIFFE IDs, +or `acceptAnySpiffeId=true` to accept any SVID that validates against the trust bundle. The two are mutually +exclusive, and setting neither fails closed. + +Because it extends `SSLContextParameters`, the inherited configuration is still honoured: set +`serverParameters.clientAuthentication` (for a server that must require client certificates), `cipherSuites` and +`secureSocketProtocols` as usual, and the base handshake protocol comes from `secureSocketProtocol` (default +`TLSv1.3`). The underlying `X509Source` is created lazily (bounded by `initTimeout`, default 30s), closed on +`CamelContext` shutdown, and the cached context is invalidated at the same time so a restarted context rebuilds a +fresh source. + +[source,java] +------------------------------------------------------------ +SpiffeSSLContextParameters ssl = new SpiffeSSLContextParameters(); +// ssl.setSpiffeSocketPath("unix:///tmp/spire-agent/public/api.sock"); // or SPIFFE_ENDPOINT_SOCKET +ssl.setAcceptedSpiffeIds("spiffe://example.org/backend"); +getCamelContext().getRegistry().bind("spiffeSsl", ssl); + +from("direct:start") + .to("https://backend.example.org/api?sslContextParameters=#spiffeSsl"); +------------------------------------------------------------ + include::spring-boot:partial$starter.adoc[] diff --git a/components/camel-spiffe/pom.xml b/components/camel-spiffe/pom.xml index 17fa6345a0baa..068c44928b8cb 100644 --- a/components/camel-spiffe/pom.xml +++ b/components/camel-spiffe/pom.xml @@ -43,6 +43,11 @@ java-spiffe-core ${java-spiffe-version} + + io.spiffe + java-spiffe-provider + ${java-spiffe-version} + diff --git a/components/camel-spiffe/src/main/docs/spiffe-component.adoc b/components/camel-spiffe/src/main/docs/spiffe-component.adoc index 61eb1bd1009d3..93f8dafde4358 100644 --- a/components/camel-spiffe/src/main/docs/spiffe-component.adoc +++ b/components/camel-spiffe/src/main/docs/spiffe-component.adoc @@ -86,4 +86,34 @@ from("direct:start") .to("http://backend.example.org/api"); ------------------------------------------------------------ +== Mutual TLS with SPIFFE (SSLContextParameters) + +For X.509-based zero-trust mTLS, the component provides +`org.apache.camel.component.spiffe.SpiffeSSLContextParameters`, an `SSLContextParameters` whose `SSLContext` is +backed by the SPIFFE Workload API. The X.509-SVID and trust bundles are fetched live and rotated automatically, +so any Camel component that accepts an `sslContextParameters` reference (camel-http, camel-netty-http, +camel-jetty, camel-vertx-http, ...) can obtain SPIFFE mTLS. + +Peer authentication must be constrained explicitly: set `acceptedSpiffeIds` to an allow-list of peer SPIFFE IDs, +or `acceptAnySpiffeId=true` to accept any SVID that validates against the trust bundle. The two are mutually +exclusive, and setting neither fails closed. + +Because it extends `SSLContextParameters`, the inherited configuration is still honoured: set +`serverParameters.clientAuthentication` (for a server that must require client certificates), `cipherSuites` and +`secureSocketProtocols` as usual, and the base handshake protocol comes from `secureSocketProtocol` (default +`TLSv1.3`). The underlying `X509Source` is created lazily (bounded by `initTimeout`, default 30s), closed on +`CamelContext` shutdown, and the cached context is invalidated at the same time so a restarted context rebuilds a +fresh source. + +[source,java] +------------------------------------------------------------ +SpiffeSSLContextParameters ssl = new SpiffeSSLContextParameters(); +// ssl.setSpiffeSocketPath("unix:///tmp/spire-agent/public/api.sock"); // or SPIFFE_ENDPOINT_SOCKET +ssl.setAcceptedSpiffeIds("spiffe://example.org/backend"); +getCamelContext().getRegistry().bind("spiffeSsl", ssl); + +from("direct:start") + .to("https://backend.example.org/api?sslContextParameters=#spiffeSsl"); +------------------------------------------------------------ + include::spring-boot:partial$starter.adoc[] diff --git a/components/camel-spiffe/src/main/java/org/apache/camel/component/spiffe/SpiffeSSLContextParameters.java b/components/camel-spiffe/src/main/java/org/apache/camel/component/spiffe/SpiffeSSLContextParameters.java new file mode 100644 index 0000000000000..d574a2ffb1182 --- /dev/null +++ b/components/camel-spiffe/src/main/java/org/apache/camel/component/spiffe/SpiffeSSLContextParameters.java @@ -0,0 +1,270 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.spiffe; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.time.Duration; +import java.util.LinkedHashSet; +import java.util.Set; + +import javax.net.ssl.SSLContext; + +import io.spiffe.provider.SpiffeSslContextFactory; +import io.spiffe.provider.SpiffeSslContextFactory.SslContextOptions; +import io.spiffe.spiffeid.SpiffeId; +import io.spiffe.workloadapi.DefaultX509Source; +import io.spiffe.workloadapi.DefaultX509Source.X509SourceOptions; +import io.spiffe.workloadapi.X509Source; +import org.apache.camel.CamelContext; +import org.apache.camel.RuntimeCamelException; +import org.apache.camel.Service; +import org.apache.camel.spi.Metadata; +import org.apache.camel.support.jsse.SSLContextParameters; +import org.apache.camel.util.ObjectHelper; + +/** + * An {@link SSLContextParameters} whose {@link SSLContext} is backed by the SPIFFE Workload API. + *

+ * The X.509-SVID (certificate chain and private key) and the trust bundles are sourced live from a SPIFFE Workload API + * endpoint - for example the one exposed by a SPIRE agent - and rotated automatically, providing zero-trust mutual TLS + * to any Camel component that accepts an {@code sslContextParameters} reference (camel-http, camel-netty-http, + * camel-jetty, camel-vertx-http, ...). + *

+ * Peer authentication must be constrained explicitly: set {@link #setAcceptedSpiffeIds(String)} to an allow-list of + * peer SPIFFE IDs, or {@link #setAcceptAnySpiffeId(boolean)} to {@code true} to accept any SVID that validates against + * the trust bundle. Setting both is rejected, and setting neither fails closed. + *

+ * The inherited {@code SSLContextParameters} configuration still applies: the built context is wrapped with the same + * decorator as the parent, so {@code serverParameters.clientAuthentication}, {@code cipherSuites} and + * {@code secureSocketProtocols} are honoured. The base handshake protocol is taken from + * {@link #getSecureSocketProtocol()} (default {@code TLSv1.3}). + *

+ * The underlying {@code X509Source} is created lazily on the first call, closed when the {@link CamelContext} shuts + * down, and the cached context is invalidated at the same time so a restarted context rebuilds a fresh source. + */ +public class SpiffeSSLContextParameters extends SSLContextParameters { + + private static final String DEFAULT_PROTOCOL = "TLSv1.3"; + + @Metadata(label = "security", + description = "Path to the SPIFFE Workload API endpoint (for example unix:///tmp/agent.sock)." + + " When not set, the SPIFFE_ENDPOINT_SOCKET environment variable is used.") + private String spiffeSocketPath; + @Metadata(label = "security", + description = "Comma-separated allow-list of peer SPIFFE IDs to accept during the TLS handshake" + + " (for example spiffe://example.org/client). Mutually exclusive with acceptAnySpiffeId.") + private String acceptedSpiffeIds; + @Metadata(label = "security", defaultValue = "false", + description = "Accept any peer SPIFFE ID that validates against the trust bundle, instead of an" + + " explicit acceptedSpiffeIds allow-list. Use with care; mutually exclusive with" + + " acceptedSpiffeIds.") + private boolean acceptAnySpiffeId; + @Metadata(label = "security", defaultValue = "30000", + description = "Timeout in milliseconds to wait for the first SVID from the Workload API when creating" + + " the source, so a slow or unreachable endpoint cannot block indefinitely.") + private long initTimeout = 30000L; + + private volatile SSLContext sslContext; + + public String getSpiffeSocketPath() { + return spiffeSocketPath; + } + + /** + * Path to the SPIFFE Workload API endpoint. When not set, the {@code SPIFFE_ENDPOINT_SOCKET} environment variable + * is used. + */ + public void setSpiffeSocketPath(String spiffeSocketPath) { + this.spiffeSocketPath = spiffeSocketPath; + } + + public String getAcceptedSpiffeIds() { + return acceptedSpiffeIds; + } + + /** + * Comma-separated allow-list of peer SPIFFE IDs to accept during the TLS handshake. Mutually exclusive with + * {@code acceptAnySpiffeId}. + */ + public void setAcceptedSpiffeIds(String acceptedSpiffeIds) { + this.acceptedSpiffeIds = acceptedSpiffeIds; + } + + public boolean isAcceptAnySpiffeId() { + return acceptAnySpiffeId; + } + + /** + * Accept any peer SPIFFE ID that validates against the trust bundle, instead of an explicit allow-list. Mutually + * exclusive with {@code acceptedSpiffeIds}. + */ + public void setAcceptAnySpiffeId(boolean acceptAnySpiffeId) { + this.acceptAnySpiffeId = acceptAnySpiffeId; + } + + public long getInitTimeout() { + return initTimeout; + } + + /** + * Timeout in milliseconds to wait for the first SVID from the Workload API when creating the source (default + * {@code 30000}). + */ + public void setInitTimeout(long initTimeout) { + this.initTimeout = initTimeout; + } + + @Override + public SSLContext createSSLContext(CamelContext camelContext) throws GeneralSecurityException, IOException { + if (camelContext != null) { + setCamelContext(camelContext); + } + SSLContext existing = sslContext; + if (existing != null) { + return existing; + } + + String acceptedIds = parsePropertyValue(acceptedSpiffeIds); + boolean haveAllowList = ObjectHelper.isNotEmpty(acceptedIds); + if (acceptAnySpiffeId && haveAllowList) { + throw new IllegalStateException( + "acceptAnySpiffeId and acceptedSpiffeIds are mutually exclusive; set only one"); + } + if (!acceptAnySpiffeId && !haveAllowList) { + throw new IllegalStateException( + "A SPIFFE-backed SSLContext requires either acceptAnySpiffeId=true or a non-empty" + + " acceptedSpiffeIds allow-list"); + } + + synchronized (this) { + if (sslContext != null) { + return sslContext; + } + // parse the (cheap, non-IO) allow-list before opening a Workload API connection, so a malformed id + // fails fast without a wasted round-trip + Set acceptedSet = haveAllowList ? parseSpiffeIds(acceptedIds) : Set.of(); + if (!acceptAnySpiffeId && acceptedSet.isEmpty()) { + throw new IllegalStateException( + "acceptedSpiffeIds did not contain any SPIFFE ID after trimming"); + } + + String protocol = parsePropertyValue(getSecureSocketProtocol()); + if (ObjectHelper.isEmpty(protocol)) { + protocol = DEFAULT_PROTOCOL; + } + + X509Source source = createX509Source(); + try { + SslContextOptions.SslContextOptionsBuilder options + = SslContextOptions.builder().x509Source(source).sslProtocol(protocol); + if (acceptAnySpiffeId) { + options.acceptAnySpiffeId(); + } else { + options.acceptedSpiffeIdsSupplier(() -> acceptedSet); + } + SSLContext spiffeContext = SpiffeSslContextFactory.getSslContext(options.build()); + // wrap with the same decorator the parent uses, so clientAuthentication / cipherSuites / + // secureSocketProtocols configured on this instance are still applied to every engine and socket + SSLContext decorated = new SSLContextDecorator( + new SSLContextSpiDecorator( + spiffeContext, + getSSLEngineConfigurers(spiffeContext), + getSSLSocketFactoryConfigurers(spiffeContext), + getSSLServerSocketFactoryConfigurers(spiffeContext))); + registerForShutdown(camelContext, source); + this.sslContext = decorated; + return decorated; + } catch (GeneralSecurityException | RuntimeException e) { + closeQuietly(source); + throw e; + } + } + } + + private X509Source createX509Source() { + try { + X509SourceOptions.Builder builder = X509SourceOptions.builder().initTimeout(Duration.ofMillis(initTimeout)); + if (ObjectHelper.isNotEmpty(spiffeSocketPath)) { + builder.spiffeSocketPath(parsePropertyValue(spiffeSocketPath)); + } + return DefaultX509Source.newSource(builder.build()); + } catch (Exception e) { + throw new RuntimeCamelException( + "Could not create the SPIFFE X509Source (is a Workload API endpoint reachable?)", e); + } + } + + private void registerForShutdown(CamelContext camelContext, X509Source source) { + // fall back to the context this instance already knows (JsseParameters is CamelContextAware), so callers + // that pass null - e.g. some HTTP server factories - still get the source closed on shutdown + CamelContext context = camelContext != null ? camelContext : getCamelContext(); + if (context != null) { + try { + context.addService(new X509SourceService(source)); + } catch (Exception e) { + throw new RuntimeCamelException(e); + } + } + } + + static Set parseSpiffeIds(String csv) { + Set ids = new LinkedHashSet<>(); + for (String part : csv.split(",")) { + String trimmed = part.trim(); + if (!trimmed.isEmpty()) { + ids.add(SpiffeId.parse(trimmed)); + } + } + return ids; + } + + private static void closeQuietly(X509Source source) { + if (source != null) { + try { + source.close(); + } catch (Exception e) { + // ignore on cleanup + } + } + } + + /** + * Closes the {@link X509Source} and invalidates the cached context when the {@link CamelContext} shuts down, so the + * Workload API watcher is not leaked and a restarted context rebuilds a fresh source. + */ + private final class X509SourceService implements Service { + private final X509Source source; + + X509SourceService(X509Source source) { + this.source = source; + } + + @Override + public void start() { + // nothing to start; the X509Source is already active once created + } + + @Override + public void stop() { + closeQuietly(source); + synchronized (SpiffeSSLContextParameters.this) { + sslContext = null; + } + } + } +} diff --git a/components/camel-spiffe/src/test/java/org/apache/camel/component/spiffe/SpiffeSSLContextParametersTest.java b/components/camel-spiffe/src/test/java/org/apache/camel/component/spiffe/SpiffeSSLContextParametersTest.java new file mode 100644 index 0000000000000..5fffc19c84f1d --- /dev/null +++ b/components/camel-spiffe/src/test/java/org/apache/camel/component/spiffe/SpiffeSSLContextParametersTest.java @@ -0,0 +1,76 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.spiffe; + +import java.util.Set; + +import io.spiffe.spiffeid.SpiffeId; +import org.apache.camel.RuntimeCamelException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SpiffeSSLContextParametersTest { + + @Test + void createSSLContextFailsClosedWithoutTrustConfig() { + SpiffeSSLContextParameters params = new SpiffeSSLContextParameters(); + // neither acceptAnySpiffeId nor acceptedSpiffeIds set -> must refuse to build a context + assertThatThrownBy(() -> params.createSSLContext(null)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("acceptAnySpiffeId"); + } + + @Test + void acceptedSpiffeIdsAreParsedAndBlanksDropped() { + Set ids = SpiffeSSLContextParameters.parseSpiffeIds( + "spiffe://example.org/a, , spiffe://example.org/b"); + assertThat(ids).extracting(SpiffeId::toString) + .containsExactly("spiffe://example.org/a", "spiffe://example.org/b"); + } + + @Test + void createSSLContextWrapsAnUnreachableWorkloadApi() { + SpiffeSSLContextParameters params = new SpiffeSSLContextParameters(); + params.setAcceptAnySpiffeId(true); + // an unsupported scheme fails synchronously while parsing the endpoint address (no network wait) + params.setSpiffeSocketPath("http://localhost"); + assertThatThrownBy(() -> params.createSSLContext(null)) + .isInstanceOf(RuntimeCamelException.class) + .hasMessageContaining("X509Source"); + } + + @Test + void acceptAnyAndAllowListAreMutuallyExclusive() { + SpiffeSSLContextParameters params = new SpiffeSSLContextParameters(); + params.setAcceptAnySpiffeId(true); + params.setAcceptedSpiffeIds("spiffe://example.org/a"); + assertThatThrownBy(() -> params.createSSLContext(null)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("mutually exclusive"); + } + + @Test + void blankOnlyAcceptedIdsAreRejected() { + SpiffeSSLContextParameters params = new SpiffeSSLContextParameters(); + params.setAcceptedSpiffeIds(", ,"); + assertThatThrownBy(() -> params.createSSLContext(null)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("did not contain any SPIFFE ID"); + } +}