This is an automated email from the ASF dual-hosted git repository.

oscerd pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 4f4ac22fde9d CAMEL-24571: camel-spiffe - add 
SpiffeSSLContextParameters for Workload-API-backed mTLS (#26098)
4f4ac22fde9d is described below

commit 4f4ac22fde9d94aababa81740853d66fa47994a4
Author: Andrea Cosentino <[email protected]>
AuthorDate: Fri Sep 4 09:23:43 2026 +0200

    CAMEL-24571: camel-spiffe - add SpiffeSSLContextParameters for 
Workload-API-backed mTLS (#26098)
    
    SpiffeSSLContextParameters is an SSLContextParameters whose SSLContext is 
backed by the SPIFFE Workload API, providing rotating mTLS.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
---
 .../camel/catalog/docs/spiffe-component.adoc       |  30 +++
 components/camel-spiffe/pom.xml                    |   5 +
 .../src/main/docs/spiffe-component.adoc            |  30 +++
 .../spiffe/SpiffeSSLContextParameters.java         | 270 +++++++++++++++++++++
 .../spiffe/SpiffeSSLContextParametersTest.java     |  76 ++++++
 5 files changed, 411 insertions(+)

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 61eb1bd1009d..93f8dafde435 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 17fa6345a0ba..068c44928b8c 100644
--- a/components/camel-spiffe/pom.xml
+++ b/components/camel-spiffe/pom.xml
@@ -43,6 +43,11 @@
             <artifactId>java-spiffe-core</artifactId>
             <version>${java-spiffe-version}</version>
         </dependency>
+        <dependency>
+            <groupId>io.spiffe</groupId>
+            <artifactId>java-spiffe-provider</artifactId>
+            <version>${java-spiffe-version}</version>
+        </dependency>
 
         <!-- for testing -->
         <dependency>
diff --git a/components/camel-spiffe/src/main/docs/spiffe-component.adoc 
b/components/camel-spiffe/src/main/docs/spiffe-component.adoc
index 61eb1bd1009d..93f8dafde435 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 000000000000..d574a2ffb118
--- /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.
+ * <p>
+ * 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, ...).
+ * <p>
+ * 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.
+ * <p>
+ * 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}).
+ * <p>
+ * 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<SpiffeId> 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<SpiffeId> parseSpiffeIds(String csv) {
+        Set<SpiffeId> 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 000000000000..5fffc19c84f1
--- /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<SpiffeId> 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");
+    }
+}

Reply via email to