From a87b1eca810537d6217a1562f48eeebbb5670bc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sandeep=20More=CC=81?= Date: Tue, 23 Jun 2026 21:22:13 -0400 Subject: [PATCH 01/16] KNOX-3359: Add design spec for single-EKU certificate support Design for separate client/server keystores and truststores to support single-purpose (single-EKU) certificates, gated behind an explicit gateway.tls.single.eku.enabled toggle with fail-fast startup validation. # Conflicts: # gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/AliasBasedTokenStateServiceTest.java --- .../compose/docker-compose.single-eku.yml | 62 +++++ .../compose/gen-single-eku-keystores.sh | 59 +++++ .../compose/single-eku/gateway-single-eku.sh | 38 +++ .../compose/single-eku/gateway-site.xml | 233 ++++++++++++++++++ .../single-eku/keystores/client-cert.pem | 18 ++ .../single-eku/keystores/client-key.pem | 28 +++ .../keystores/global_truststore.jks | Bin 0 -> 812 bytes .../keystores/host-client_keystore.jks | Bin 0 -> 2112 bytes .../workflows/tests/test_single_eku_mtls.py | 130 ++++++++++ .gitignore | 3 + .../ha/dispatch/SSEHaDispatchTest.java | 2 + .../apache/knox/gateway/GatewayMessages.java | 3 + .../config/impl/GatewayConfigImpl.java | 30 +++ .../security/impl/DefaultAliasService.java | 14 ++ .../security/impl/DefaultKeystoreService.java | 11 + .../security/impl/JettySSLService.java | 51 ++++ .../security/impl/RemoteAliasService.java | 14 ++ .../impl/ZookeeperRemoteAliasService.java | 5 + .../config/impl/GatewayConfigImplTest.java | 36 +++ .../services/security/CryptoServiceTest.java | 5 + .../impl/DefaultAliasServiceTest.java | 81 ++++++ .../impl/DefaultKeystoreServiceTest.java | 69 ++++++ .../security/impl/JettySSLServiceTest.java | 176 +++++++++++++ .../impl/RemoteAliasServiceTestProvider.java | 5 + .../vault/HashicorpVaultAliasService.java | 5 + .../knox/gateway/GatewayTestConfig.java | 30 +++ .../knox/gateway/SpiGatewayMessages.java | 4 + .../knox/gateway/config/GatewayConfig.java | 46 ++++ .../DefaultHttpAsyncClientFactory.java | 2 +- .../dispatch/DefaultHttpClientFactory.java | 30 ++- .../services/security/AliasService.java | 2 + .../services/security/KeystoreService.java | 10 + .../DefaultHttpAsyncClientFactoryTest.java | 1 + .../DefaultHttpClientFactoryTest.java | 138 +++++++++-- .../knox/gateway/sse/SSEDispatchTest.java | 1 + 35 files changed, 1318 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/compose/docker-compose.single-eku.yml create mode 100755 .github/workflows/compose/gen-single-eku-keystores.sh create mode 100755 .github/workflows/compose/single-eku/gateway-single-eku.sh create mode 100644 .github/workflows/compose/single-eku/gateway-site.xml create mode 100644 .github/workflows/compose/single-eku/keystores/client-cert.pem create mode 100644 .github/workflows/compose/single-eku/keystores/client-key.pem create mode 100644 .github/workflows/compose/single-eku/keystores/global_truststore.jks create mode 100644 .github/workflows/compose/single-eku/keystores/host-client_keystore.jks create mode 100644 .github/workflows/tests/test_single_eku_mtls.py create mode 100644 gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/DefaultAliasServiceTest.java diff --git a/.github/workflows/compose/docker-compose.single-eku.yml b/.github/workflows/compose/docker-compose.single-eku.yml new file mode 100644 index 0000000000..e5bcaa43bc --- /dev/null +++ b/.github/workflows/compose/docker-compose.single-eku.yml @@ -0,0 +1,62 @@ +# 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. +# +# CDPD-103449: single-EKU compose OVERRIDE. +# +# This file is intentionally separate from docker-compose.yml. The default +# stack's existing integration tests make requests WITHOUT a client cert, so +# turning on gateway.client.auth.needed=true on the shared `knox` service would +# break every one of them. This override is only applied for the single-EKU +# test run. +# +# Run the single-EKU integration test (from repo root): +# +# docker compose \ +# -f .github/workflows/compose/docker-compose.yml \ +# -f .github/workflows/compose/docker-compose.single-eku.yml \ +# up -d knox +# docker compose \ +# -f .github/workflows/compose/docker-compose.yml \ +# -f .github/workflows/compose/docker-compose.single-eku.yml \ +# run --rm tests python -m unittest test_single_eku_mtls -v +# docker compose \ +# -f .github/workflows/compose/docker-compose.yml \ +# -f .github/workflows/compose/docker-compose.single-eku.yml down +# +# The override: +# - mounts the single-EKU gateway-site.xml over the baked one, +# - mounts the dev keystores into /knox-runtime/conf/single-eku-keystores, +# - sets KNOX_SINGLE_EKU=true on the tests service so test_single_eku_mtls.py +# un-skips (it is skipped by default in the normal stack), and +# - mounts + points the PEM client material for the mTLS happy-path test. +services: + knox: + # Create the keystore-password aliases (so the dev "horton" password is + # resolved deterministically) before starting the gateway. + command: /gateway-single-eku.sh + volumes: + - ./single-eku/gateway-site.xml:/knox-runtime/conf/gateway-site.xml:ro + - ./single-eku/keystores:/knox-runtime/conf/single-eku-keystores:ro + - ./single-eku/gateway-single-eku.sh:/gateway-single-eku.sh:ro + + tests: + volumes: + - ../tests:/tests + - ./single-eku/keystores:/single-eku-keystores:ro + environment: + - KNOX_GATEWAY_URL=https://knox:8443/ + - KNOX_SINGLE_EKU=true + - KNOX_CLIENT_CERT=/single-eku-keystores/client-cert.pem + - KNOX_CLIENT_KEY=/single-eku-keystores/client-key.pem diff --git a/.github/workflows/compose/gen-single-eku-keystores.sh b/.github/workflows/compose/gen-single-eku-keystores.sh new file mode 100755 index 0000000000..0a3b365bb1 --- /dev/null +++ b/.github/workflows/compose/gen-single-eku-keystores.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# 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. +# +# Generates the dev-only single-EKU keystore fixtures used by the +# test_single_eku_mtls.py integration test: +# - host-client_keystore.jks : a key pair whose certificate carries ONLY the +# clientAuth EKU (the single-EKU client identity) +# - global_truststore.jks : the server truststore trusting that client cert +# +# These are committed, self-signed, dev-only fixtures (NOT secrets). The store +# password is the same dev password the apache/knox-dev keystores use so the +# master-secret fallback can resolve them. JKS is used only for this dev +# fixture; production keystore type comes from config and is never hardcoded in +# Knox Java code. +set -euo pipefail +DIR="${1:?usage: gen-single-eku-keystores.sh }" +PASS="horton" +mkdir -p "$DIR" + +# Client identity: a key pair whose cert carries ONLY the clientAuth EKU +keytool -genkeypair -alias gateway-httpclient-key -keyalg RSA -keysize 2048 \ + -dname "CN=knox-client" -ext "eku=clientAuth" -validity 3650 \ + -keystore "$DIR/host-client_keystore.jks" -storetype JKS \ + -storepass "$PASS" -keypass "$PASS" + +# Export the client cert and import it into the server truststore (clients trusted by Knox) +keytool -exportcert -alias gateway-httpclient-key -rfc \ + -keystore "$DIR/host-client_keystore.jks" -storepass "$PASS" \ + -file "$DIR/client.cer" +keytool -importcert -noprompt -alias knox-client \ + -keystore "$DIR/global_truststore.jks" -storetype JKS -storepass "$PASS" \ + -file "$DIR/client.cer" +rm -f "$DIR/client.cer" + +# PEM client material for the Python tests container (requests cert=(cert, key)). +keytool -exportcert -alias gateway-httpclient-key -rfc \ + -keystore "$DIR/host-client_keystore.jks" -storepass "$PASS" \ + -file "$DIR/client-cert.pem" +keytool -importkeystore -srckeystore "$DIR/host-client_keystore.jks" \ + -srcstoretype JKS -srcstorepass "$PASS" -srcalias gateway-httpclient-key \ + -destkeystore "$DIR/client.p12" -deststoretype PKCS12 -deststorepass "$PASS" +openssl pkcs12 -in "$DIR/client.p12" -nocerts -nodes -passin pass:"$PASS" \ + | openssl pkey -out "$DIR/client-key.pem" +rm -f "$DIR/client.p12" + +echo "Generated single-EKU dev keystores in $DIR" diff --git a/.github/workflows/compose/single-eku/gateway-single-eku.sh b/.github/workflows/compose/single-eku/gateway-single-eku.sh new file mode 100755 index 0000000000..218afb3dd9 --- /dev/null +++ b/.github/workflows/compose/single-eku/gateway-single-eku.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# 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. +# +# CDPD-103449: single-EKU container entrypoint. +# +# The dev keystore fixtures (host-client_keystore.jks / global_truststore.jks) +# are protected with the dev password "horton". The baked apache/knox-dev image +# resolves keystore passwords from credential-store aliases, falling back to the +# (image-specific) master secret when an alias is absent. Rather than depend on +# the master secret value, we create the aliases explicitly so the password is +# deterministic, then start the gateway. Keystore TYPE (JKS) comes from +# gateway-site.xml (config), never hardcoded in Knox Java code. +set -e + +PASS="horton" + +# Create the credential-store aliases the single-EKU config resolves: +# - gateway-httpclient-keystore-password : client keystore store password +# (the client key passphrase alias falls back to this one) +# - gateway-truststore-password : server truststore password +for alias in gateway-httpclient-keystore-password gateway-truststore-password; do + /knox-runtime/bin/knoxcli.sh create-alias "$alias" --value "$PASS" +done + +exec java -jar /knox-runtime/bin/gateway.jar diff --git a/.github/workflows/compose/single-eku/gateway-site.xml b/.github/workflows/compose/single-eku/gateway-site.xml new file mode 100644 index 0000000000..0b185f2ddf --- /dev/null +++ b/.github/workflows/compose/single-eku/gateway-site.xml @@ -0,0 +1,233 @@ + + + + + + + gateway.tls.single.eku.enabled + true + + + gateway.httpclient.keystore.path + /knox-runtime/conf/single-eku-keystores/host-client_keystore.jks + + + + gateway.httpclient.keystore.type + JKS + + + gateway.truststore.path + /knox-runtime/conf/single-eku-keystores/global_truststore.jks + + + gateway.truststore.type + JKS + + + gateway.client.auth.needed + true + + + + gateway.service.alias.impl + org.apache.knox.gateway.services.security.impl.DefaultAliasService + + + gateway.port + 8443 + The HTTP port for the Gateway. + + + + gateway.path + gateway + The default context path for the gateway. + + + + gateway.gateway.conf.dir + deployments + The directory within GATEWAY_HOME that contains gateway topology files and deployments. + + + + + gateway.websocket.feature.enabled + true + Enable/Disable websocket feature. + + + + gateway.scope.cookies.feature.enabled + false + Enable/Disable cookie scoping feature. + + + + + + gateway.webshell.feature.enabled + true + Enable/Disable webshell feature. + + + gateway.webshell.max.concurrent.sessions + 20 + Maximum number of total concurrent webshell sessions + + + gateway.webshell.read.buffer.size + 1024 + Web Shell buffer size for reading + + + + + gateway.websocket.JWT.validation.feature.enabled + true + Enable/Disable websocket JWT validation at websocket layer. + + + + + knox.homepage.logout.enabled + true + Enable/disable logout from the Knox Homepage. + + + + + gateway.knox.token.eviction.grace.period + 0 + A duration (in seconds) beyond a token’s expiration to wait before evicting its state. This configuration only applies when server-managed token state is enabled either in gateway-site or at the topology level. + + + + + gateway.knox.admin.groups + admin + + + + + gateway.group.config.hadoop.security.group.mapping + org.apache.hadoop.security.LdapGroupsMapping + + + gateway.group.config.use.ldap.service + true + + + gateway.dispatch.whitelist.services + DATANODE,HBASEUI,HDFSUI,JOBHISTORYUI,NODEUI,YARNUI,knoxauth + The comma-delimited list of service roles for which the gateway.dispatch.whitelist should be applied. + + + gateway.dispatch.whitelist + ^https?:\/\/(www\.local\.com|localhost|127\.0\.0\.1|0:0:0:0:0:0:0:1|::1):[0-9].*$ + The whitelist to be applied for dispatches associated with the service roles specified by gateway.dispatch.whitelist.services. + If the value is DEFAULT, a domain-based whitelist will be derived from the Knox host. + + + gateway.xforwarded.header.context.append.servicename + LIVYSERVER + Add service name to x-forward-context header for the list of services defined above. + + + gateway.strict.transport.enabled + true + + + gateway.strict.transport.option + max-age=300; includeSubDomains + + + + + gateway.ldap.enabled + true + + + gateway.ldap.port + 33390 + + + gateway.ldap.base.dn + dc=hadoop,dc=apache,dc=org + + + gateway.ldap.recursive.group.resolution + true + + + gateway.ldap.interceptor.names + demoldap + + + + + gateway.ldap.interceptor.demoldap.interceptorType + backend + + + gateway.ldap.interceptor.demoldap.backendType + ldap + + + gateway.ldap.interceptor.demoldap.url + ldap://ldap:33389 + + + gateway.ldap.interceptor.demoldap.remoteBaseDn + dc=hadoop,dc=apache,dc=org + + + gateway.ldap.interceptor.demoldap.systemUsername + uid=guest,ou=people,dc=hadoop,dc=apache,dc=org + + + gateway.ldap.interceptor.demoldap.systemPassword + guest-password + + + gateway.ldap.interceptor.demoldap.userSearchBase + ou=people,dc=hadoop,dc=apache,dc=org + + + gateway.ldap.interceptor.demoldap.groupSearchBase + ou=groups,dc=hadoop,dc=apache,dc=org + + + gateway.ldap.interceptor.demoldap.groupMemberAttribute + member + + + diff --git a/.github/workflows/compose/single-eku/keystores/client-cert.pem b/.github/workflows/compose/single-eku/keystores/client-cert.pem new file mode 100644 index 0000000000..ac8b6cf102 --- /dev/null +++ b/.github/workflows/compose/single-eku/keystores/client-cert.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC5DCCAcygAwIBAgIIVNw+HiQN3Z4wDQYJKoZIhvcNAQELBQAwFjEUMBIGA1UE +AxMLa25veC1jbGllbnQwHhcNMjYwNjI0MTE1NzA3WhcNMzYwNjIxMTE1NzA3WjAW +MRQwEgYDVQQDEwtrbm94LWNsaWVudDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBAMdZ9dSPqDkbEUn/vtpLQNT/4sWAmQa7AwWpxDsXuxoxls723sMExRhf +XSZQaXc83AlBV2oO4zLi3eIDimRDNipoU78FexD8s8dzhTKHlubptYUoRE5qfw+z +m7hmzUaJOwgr4XlSwvABh6eKy3DVhrrP/8MvWUp8QEcPbMalOCdpLape6HT/74qL +e0DIjD7jbuV9iffI2AbYTq6MKexcLIGLRRfMnv5gD0s3hDf939Xsxt2terLe98y8 +OKlwtxoLuW63HCQDcXytzti+GPtR9POwdJOHZ9Vtj5BkdBpSUiX4aPaT0twGE1/b +R58A3PD/BiT26AKBm4lEuvYTVudRLDECAwEAAaM2MDQwHQYDVR0OBBYEFBS1Vp6X +6jHALJ2+IREb7zRHDDgvMBMGA1UdJQQMMAoGCCsGAQUFBwMCMA0GCSqGSIb3DQEB +CwUAA4IBAQCaDay/aWezvS43b7zm0aBYC0G4Pf/Otzi/8lSwRhkcLqFIOCDnTg4L +dSHjTp783RZMahI287KwqvB2IT/oSRNE2OILs4+dgiBLZ5X9TWVAnHWCfhOvwoq7 +8o3BWQlpWn99YVzriX/wEu3y/t6+xd6rXP/nc3v60uMmlGlxVG0orkQvdggFXaOQ +PRRoJr9Z6rCZ0u1HOGS6gSHPNiRSCb5jLHoagLBnCeXA/rxTqukPbP8O1KLpo0lZ +TgdJILJqSWF7ZQgOo5oQddWwjankHV3ndl+YsMP5wG40WGDfpAST6ou8Oucap+kU +8qyvPORXK4Z7UUvvAsQhgNdNg8wcoGRH +-----END CERTIFICATE----- diff --git a/.github/workflows/compose/single-eku/keystores/client-key.pem b/.github/workflows/compose/single-eku/keystores/client-key.pem new file mode 100644 index 0000000000..26094e6f2d --- /dev/null +++ b/.github/workflows/compose/single-eku/keystores/client-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDHWfXUj6g5GxFJ +/77aS0DU/+LFgJkGuwMFqcQ7F7saMZbO9t7DBMUYX10mUGl3PNwJQVdqDuMy4t3i +A4pkQzYqaFO/BXsQ/LPHc4Uyh5bm6bWFKEROan8Ps5u4Zs1GiTsIK+F5UsLwAYen +istw1Ya6z//DL1lKfEBHD2zGpTgnaS2qXuh0/++Ki3tAyIw+427lfYn3yNgG2E6u +jCnsXCyBi0UXzJ7+YA9LN4Q3/d/V7MbdrXqy3vfMvDipcLcaC7lutxwkA3F8rc7Y +vhj7UfTzsHSTh2fVbY+QZHQaUlIl+Gj2k9LcBhNf20efANzw/wYk9ugCgZuJRLr2 +E1bnUSwxAgMBAAECggEAAhTUHn1GfBwmfM+NAlH1UhP+3eGHF30l3a4FVLmddDtO +0AffUQlwdl3uhsshosNRp84VF9AieqJIP0car4vNGbm/oBeyTn3Y8azo19t3DZ+k +i8idF/WAMJ2HM3BtVb5yjZYJs17UFrXpYOwvzhSIpNQtny/UHpU4BZXpZJSOhsjL +WeD8W61gi/THI2mOh8b1bCjig/I50Ct75kXcpR6c97TRKmEu9F3KdTHVWdV94SFQ +al9xxv5vWtBtvkEKsct4WkDzdA4yJ6HGOeR6A1aJWE1B2K5hJknYBb82vOu4fOkk +z1Ra9a99WkNXOkgGa2WgRAuD++PL/Zt+ONHQoYDy0QKBgQDMem8YdjL9y/oz+6ll +chvGNuk0JgPWgBbLnaAg63028BiT488tSe7aczBosg5YhIIo2WYRo4cMU/phCKfY +S6d3OhzVNH+9uzDvhLmUbXvIzx1BDeRDEvPGhxkQ+ECHW05lhd+uY66Wwom0rkGR +4U+Ovnsr6eqNkrcUTveud4IySQKBgQD5lNOSA/NNeBvz3GKcPvzl5t1pnjxxtijX +DmRxZKrsGGc8ToQxnbckM0z1rFfU56bV4Sm0SJmLChhWw8DAkAl59gJNSx77X33c +XcNEXb348n8hNmx9dbAC/itPoLqPWVimkmhFwKloURxDfDQ6WReB/8W1ZaZYW5M7 +uJW8tS4qqQKBgQCalrmlCb+VRwAbCtlPm8xJt7vpNBBUu7QALmQgX0jkHvLF5EX+ +XFXbC5j+nhbgbxWkYm+bSEFSXa7+azfR+6hZKDMiMTWeMIZXnsVa5Mbf002voBwB +ZbOtVPfrb3QBoVMMyACK7EvTKLJJSjDCZE/sgC/IzGkKrXACR41TdoCVGQKBgAuL +7x3aQO9cly55C7be0yRvwd6ZC4LXQpxwdgUo+x9hIaWQnF0PRuHN3cmf55BaB0Xt +3NgSY8gi921MMSa3gzi5QPICf7RxfokrbVVEYP2benY25l4Hi9UXnTlZ3kezgn5y +V5CikTPaTMxyepgYdxX0l1kFEuUTl4QzzfmlCDXBAoGAdCsBZMm3nbR4Z3uIzOWQ +2o3pxGRTKrUQVt9wDZgtiofsKzAJe27CdkIqjQFp9KnCaCtr34a7FYxiqkxYHe5L +R9CHaLqrvBAoStOzJehaBTikAfjsOhpP4PUKC/FivF1Ae1nB7e+Gg6Wengbn12XM +y5UtMrNZnhvACwn8i9grq1Q= +-----END PRIVATE KEY----- diff --git a/.github/workflows/compose/single-eku/keystores/global_truststore.jks b/.github/workflows/compose/single-eku/keystores/global_truststore.jks new file mode 100644 index 0000000000000000000000000000000000000000..5b13995f48e9576223432bd23eebe7439902da5a GIT binary patch literal 812 zcmezO_TO6u1_mY|W(3pR*?IXDy2&}2sd*(pv3Wmhcl$H2M(CLuSOS&3Flb_WV$j5R zW&tx3BNG!x$Q?U572dn^40zc%wc0$|zVk9NauH8)zUW&TC|5 zU}j`uXlQC~U>+sTYYgHVLb*8gH8Cn7TfxZ6z}&>h&j54@7gG}>Bg65?uUGn4SV{|e z{@-`Y+u_RpM@JiGvh8MOU3tV>e7BV0v~%C?9cDQy5g)4-kXdeXhtn}Ui|?_~qq~oo zyHcFZv@(MCvsMfI*?hdX)u?^ivzJ?2HC+6%>iIX%-jR0Jt<#!A`(b6!p%0Ah%ezh& zTy5KR{{Lb9NUs_PcmAAXOD)thbyvl`DEa@stGn9aM33F$yr;FD-%s3NyWzL4NApdL zPGh&L_?dbC68OE%Tg?C7zxw9b-L+Ml?tMSA$6{r{b}8au(+Vk{yeTf^o}e`R<;XYM{lLFxAF5-yT$%bAsgHzW%L$$ zSSURAeC#*x&s{N}EFrVcn>MWaP^M`A!c*Ag#v|^{{d1cXywj)t^-Xn{Q`%G~ zy#7$v?oYi3BRMmp>T45YUU$}i5PJLR-@ScD@2!sc|Gc>R*QLj5Q!)!fay8bu=$CP@ z#x9;>VM99S(*ytZ1h0C@pYxyZ%A%Kx zJtO_tJry=(c_vn;a_}vlB~W^GL+{Ebva!$0;%96){PRGbNkqc^B`lL)b?>oyF17rn z$fq^yZJvZ{w^avvzh^q4*l^vq`Hakh6!(5R`^!x7$~MR3^KF*8rMa8vxD)^Y`ng8( literal 0 HcmV?d00001 diff --git a/.github/workflows/compose/single-eku/keystores/host-client_keystore.jks b/.github/workflows/compose/single-eku/keystores/host-client_keystore.jks new file mode 100644 index 0000000000000000000000000000000000000000..7ee2e1095030c3a66484a48a813d0ee53d10ec2d GIT binary patch literal 2112 zcmZ{lc{tPy7sux}W5zHB8QJTST_$568j@t^+7hlcLrB&^l5Mh-bxO1-$)qdplo^*w zjD7IhHA-b48e3UnD9c>;zV9FRpZAaR$M^X@=Q-#3p6B_T{jd99K_C$Lpn!i$?4lnb zAkHry9Yi2R_=g4ugb~n}0^$$sk{^ln*&q-Uzz})%FoH1t<1l~+Py-(V04Ruu$g{Ay zl-i9GZLq2Oq8(SVS;nXji#&IWUt&9o?$us+){$WY6e|g~NAX#ks11E|FZ)l{@(MkU z8Hlt{r>8IGjms2^pLCPKd>inrqeH7r(~9rPn4VFrkQ43$c~9>j#yeg|iNtoGhwhU< znI;+2hH?xW?Vd6q#`FRX?{kf=o{qB%_btdSgf)+}$#SOM9#iieYmOb0)2xnS<`0OG z?TtiIDQf}#2xGB=ogxL-c?e%czpSB%V)mr2{JTc`OW-H@^?B<2E3v_)6>~}Fil9v1 zh#pWrs-lcEoLxMkYGvrq?ScA?bt`1xg5p`-OH{AWa^k#cq5N%s<Fg5{klR8MITv-k4f50ge$X9>1?EG zE2|z#rcm(^4lQoQ4DV5if_CC2ojJFfDKO2j9J3PmqM#`-VHBwHR1rHiX!YTV^`D$p z=`VLy(mp4I3ajXsslL}To$;{TK_ELnthw}tkN-CNa~*CD-9P0Dy76eFDJG(tAx_^8 zb&l7gM&pguVv%`8X|*B}ML@TTA0lm?sys-U5O8}>_YQfvaaeHgDuOmEesal&!u6zG zK_JtfEh+IgQU?pBMIFer4w$zzqpO^IYFxW++tFvhJKPArIm{V5&l(vzg2TALLvo*0 z<$g|RRi*e*0)Oe$DMTefT zG%?xUASg|KRD}~+waD&bDmRBFewWhq8c&x*7Q@#r=2&y5` zEiNw7_inF&SC|d}gd%1xh@X5jCegPP6fVccIgJ*vCroI0@zn;CAD& zmbj^h3*%pwcWQgqilq%2YURf-4B@B@b}dpYAfr9mw6`?3?{*6*62SZ8!*q685No27 zFm!q#r>30ul|bW2Kh->U&J@>nTP=}%I4XiNoC|f`6qzc!<$FQYx2h^Wyj4I9b-XfZ z$P!fr&NON9i4s2x0kuaH8RF3XYb_&Jdhf{mVQG7OT!d;ce77-kQBLr~q@9?|*;8cdCrj-I?p8Tu z)YMR^RI{JZVghb~Y>(|RrC#8%CTtBae~)!q9ue*z0EbruXif>xUkEi zkulm>@k5BNMF)HTU4%1~c(pC6j<6S*d)q$#!sYGE;i=fy&h7WwcYLVVU-yXxI_Rs+ zjz36@lGd2JI@FxqI?K?1FG6X!60FLY8jRpTSx#vKtUKWO;L;p z404VKfwDELE_;@Kf z1jpgV_)eb>5oBr2jL$%_@MrYYf}ES63BtQIozdx9nMGd~YSUHB>_d`;YKmV6_L<*2 z2|qp^@6@^qWd5Gj6ETueKfrC%aJRl@Vj&dDD90)Xqbt4V3EY*e>;#jp9Aj44T;k2G zu2I;i{fivcWl#0g?6cy%CHuZYHu`D$dy^x}jB(0U+Qe3G1NM2uD=EaAuvan)kjQJ4 z{!zNbj^oDnYC=Kg#gWVTBs@XN$w_fLh*K~;21EILws;I0TjjzOIP>7t;+tmm9F!~D zQC$-Z0YCs*52J%Qa*+BFK^`$4QPEo0lDmtV&FYWna)+c>bSwn08W_~ywjz%Jh93q$ z_-RlmF9iJWWDkyakSqXtfc&E=_+m}t3H|VfFGHnnh|{l)xc#rNP3tbz=8`feo>*dK z+4h2n7&(@G$?mwAZHS28cUpDjYOLI;c`KCJ=nSGJ{}EBv=3?QVU4Y5M7-ACYWoy>k z_1qSBzF?2!L_g1^o5`ypU)T31=p7SPo?Lcx!p~ur(w*Q)m&+P z$$hc<{_s}|EWSQfZa`1LiI48DepM=^`Xb+4^L~SK<$_QsSCCn@K(=zX=e3fhg;@C| z1i%H!4}@bzs&k*u9`Rzw`rNB-`_UYxWB0{5D|O^cM^U%rF1| literal 0 HcmV?d00001 diff --git a/.github/workflows/tests/test_single_eku_mtls.py b/.github/workflows/tests/test_single_eku_mtls.py new file mode 100644 index 0000000000..b29751da52 --- /dev/null +++ b/.github/workflows/tests/test_single_eku_mtls.py @@ -0,0 +1,130 @@ +# 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. + +"""Integration tests for Knox single-EKU mode and inbound mTLS enforcement. + +These tests only make sense against a Knox gateway started in single-EKU mode +(gateway.tls.single.eku.enabled=true + gateway.client.auth.needed=true). That +configuration is supplied by the docker-compose.single-eku.yml OVERRIDE, which +also sets KNOX_SINGLE_EKU=true on the tests container. + +The default compose stack does NOT enable single-EKU mode (its other tests make +requests without a client cert), so this whole class is skipped unless +KNOX_SINGLE_EKU=true. To run it (from repo root): + + docker compose \ + -f .github/workflows/compose/docker-compose.yml \ + -f .github/workflows/compose/docker-compose.single-eku.yml \ + run --rm tests python -m unittest test_single_eku_mtls -v +""" + +import os +import unittest + +import requests +import urllib3 + +# Knox base URL inside the compose network. The tests service receives +# KNOX_GATEWAY_URL=https://knox:8443/ (see docker-compose.yml). +KNOX_URL = os.environ.get("KNOX_GATEWAY_URL", "https://knox:8443/").rstrip("/") +HEALTH_PATH = "/gateway/health/v1/ping" + +# PEM client material mounted into the tests container by the single-EKU +# compose override (its cert carries only the clientAuth EKU). +CLIENT_CERT = os.environ.get("KNOX_CLIENT_CERT") # PEM cert +CLIENT_KEY = os.environ.get("KNOX_CLIENT_KEY") # PEM key + +# Self-signed dev TLS in CI; silence the noisy InsecureRequestWarning. +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + +@unittest.skipUnless( + os.environ.get("KNOX_SINGLE_EKU") == "true", + "single-EKU mode not configured (apply docker-compose.single-eku.yml)", +) +class SingleEkuMtlsTest(unittest.TestCase): + """Asserts single-EKU startup and inbound client-auth enforcement.""" + + def test_server_is_up_in_single_eku_mode(self): + """Knox listening in single-EKU mode proves fail-fast validation passed. + + With gateway.client.auth.needed=true an UNauthenticated request must NOT + complete the handshake -- the server demands a client cert -- so "server + up" cannot be probed with a plain request (that is the no-cert rejection + case below). Instead: + + * if a client cert is available, a completed mTLS handshake proves the + server is up AND single-EKU fail-fast validation passed; + * otherwise, a "certificate required" TLS alert (rather than a refused + connection) is itself proof the server is listening and enforcing + client auth -- whereas a ConnectionError means Knox never came up + (e.g. fail-fast validation aborted startup). + """ + if CLIENT_CERT and CLIENT_KEY: + try: + resp = requests.get( + KNOX_URL + "/gateway/", + cert=(CLIENT_CERT, CLIENT_KEY), + verify=False, + timeout=10, + ) + except (requests.exceptions.SSLError, + requests.exceptions.ConnectionError) as exc: + self.fail(f"Knox not reachable via mTLS in single-EKU mode: {exc}") + self.assertIsNotNone(resp.status_code) + else: + # No client material: a TLS-layer rejection proves the server is up + # and enforcing client auth; a ConnectionError means it never started. + try: + requests.get(KNOX_URL + "/gateway/", verify=False, timeout=10) + except requests.exceptions.SSLError: + pass # server up + demanding a client cert -> proof of life + except requests.exceptions.ConnectionError as exc: + self.fail(f"Knox is not reachable in single-EKU mode: {exc}") + + def test_inbound_request_without_client_cert_is_rejected(self): + """gateway.client.auth.needed=true rejects a request with no client cert. + + Depending on the Jetty/JSSE TLS behavior this surfaces either as an + SSLError (handshake failure) or a ConnectionError (peer reset), so accept + either. + """ + with self.assertRaises( + (requests.exceptions.SSLError, requests.exceptions.ConnectionError) + ): + requests.get(KNOX_URL + HEALTH_PATH, verify=False, timeout=10) + + @unittest.skipUnless( + CLIENT_CERT and CLIENT_KEY, + "client cert/key not provided; mTLS happy-path skipped", + ) + def test_inbound_request_with_client_cert_completes_handshake(self): + """A trusted client cert lets the mTLS handshake complete. + + Only the handshake is asserted (not the HTTP status), since this proves + inbound client authentication negotiates successfully. + """ + resp = requests.get( + KNOX_URL + HEALTH_PATH, + cert=(CLIENT_CERT, CLIENT_KEY), + verify=False, + timeout=10, + ) + self.assertIsNotNone(resp.status_code) + + +if __name__ == "__main__": + unittest.main() diff --git a/.gitignore b/.gitignore index c8dba32d8b..17236cdcdc 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,6 @@ velocity.log #System Files Thumbs.db + +# Test-generated keystore files (accidentally tracked; see KNOX-3328) +gateway-server/data/security/keystores/*.jceks diff --git a/gateway-provider-ha/src/test/java/org/apache/knox/gateway/ha/dispatch/SSEHaDispatchTest.java b/gateway-provider-ha/src/test/java/org/apache/knox/gateway/ha/dispatch/SSEHaDispatchTest.java index 6e74177f01..01ab89ee22 100644 --- a/gateway-provider-ha/src/test/java/org/apache/knox/gateway/ha/dispatch/SSEHaDispatchTest.java +++ b/gateway-provider-ha/src/test/java/org/apache/knox/gateway/ha/dispatch/SSEHaDispatchTest.java @@ -111,6 +111,7 @@ public void testHADispatchURL() throws Exception { expect(gatewayConfig.getHttpClientCookieSpec()).andReturn(CookieSpecs.STANDARD).anyTimes(); expect(gatewayConfig.isAsyncSupported()).andReturn(true).anyTimes(); expect(gatewayConfig.isTopologyAsyncSupported("SSE")).andReturn(false).anyTimes(); + expect(gatewayConfig.isSingleEkuEnabled()).andReturn(false).once(); GatewayServices gatewayServices = createMock(GatewayServices.class); expect(gatewayServices.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(keystoreService).once(); @@ -691,6 +692,7 @@ private SSEHaDispatch createDispatch(boolean failoverNeeded, HaServiceConfig ser expect(gatewayConfig.getHttpClientCookieSpec()).andReturn(CookieSpecs.STANDARD).anyTimes(); expect(gatewayConfig.isAsyncSupported()).andReturn(true).anyTimes(); expect(gatewayConfig.isTopologyAsyncSupported("SSE")).andReturn(false).once(); + expect(gatewayConfig.isSingleEkuEnabled()).andReturn(false).once(); GatewayServices gatewayServices = createMock(GatewayServices.class); expect(gatewayServices.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(keystoreService).once(); diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java b/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java index 21e0d7032b..6f10e89445 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java @@ -205,6 +205,9 @@ public interface GatewayMessages { @Message( level = MessageLevel.ERROR, text = "Unable to obtain the password for the gateway truststore using the alias {0}: {1}" ) void failedToGetPasswordForGatewayTruststore(String alias, Exception e); + @Message( level = MessageLevel.ERROR, text = "Single-EKU mode is enabled but {0} is not configured. Knox cannot verify TLS certificates of upstream services. Server will not start." ) + void singleEkuHttpClientTruststoreNotConfigured(String property); + @Message( level = MessageLevel.DEBUG, text = "Received request: {0} {1}" ) void receivedRequest( String method, String uri ); diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java b/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java index e1884db4e4..1af0908571 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java @@ -818,6 +818,36 @@ public String getHttpClientTruststorePasswordAlias() { return get(HTTP_CLIENT_TRUSTSTORE_PASSWORD_ALIAS, DEFAULT_HTTP_CLIENT_TRUSTSTORE_PASSWORD_ALIAS); } + @Override + public String getHttpClientKeystorePath() { + return get(HTTP_CLIENT_KEYSTORE_PATH); + } + + @Override + public String getHttpClientKeystoreType() { + return get(HTTP_CLIENT_KEYSTORE_TYPE, DEFAULT_HTTP_CLIENT_KEYSTORE_TYPE); + } + + @Override + public String getHttpClientKeystorePasswordAlias() { + return get(HTTP_CLIENT_KEYSTORE_PASSWORD_ALIAS, DEFAULT_HTTP_CLIENT_KEYSTORE_PASSWORD_ALIAS); + } + + @Override + public String getHttpClientKeyAlias() { + return get(HTTP_CLIENT_KEY_ALIAS, DEFAULT_HTTP_CLIENT_KEY_ALIAS); + } + + @Override + public String getHttpClientKeyPassphraseAlias() { + return get(HTTP_CLIENT_KEY_PASSPHRASE_ALIAS, DEFAULT_HTTP_CLIENT_KEY_PASSPHRASE_ALIAS); + } + + @Override + public boolean isSingleEkuEnabled() { + return getBoolean(TLS_SINGLE_EKU_ENABLED, DEFAULT_TLS_SINGLE_EKU_ENABLED); + } + @Override public String getCredentialStoreAlgorithm() { final String alg = get(CREDENTIAL_STORE_ALG, DEFAULT_CREDENTIAL_STORE_ALG); diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/DefaultAliasService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/DefaultAliasService.java index 0496d123ff..38662fa238 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/DefaultAliasService.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/DefaultAliasService.java @@ -73,6 +73,20 @@ public char[] getGatewayIdentityPassphrase() throws AliasServiceException { return passphrase; } + @Override + public char[] getHttpClientKeyPassphrase() throws AliasServiceException { + char[] passphrase = getPasswordFromAliasForGateway(config.getHttpClientKeyPassphraseAlias()); + if (passphrase == null) { + // Fall back to the keystore password if a key-specific password was not explicitly set. + passphrase = getPasswordFromAliasForGateway(config.getHttpClientKeystorePasswordAlias()); + } + if (passphrase == null) { + // Use the master password if no password was found + passphrase = masterService.getMasterSecret(); + } + return passphrase; + } + @Override public char[] getGatewayIdentityKeystorePassword() throws AliasServiceException { char[] passphrase = getPasswordFromAliasForGateway(config.getIdentityKeystorePasswordAlias()); diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/DefaultKeystoreService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/DefaultKeystoreService.java index e1e6a9b811..44c546083d 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/DefaultKeystoreService.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/DefaultKeystoreService.java @@ -152,6 +152,17 @@ public KeyStore getTruststoreForHttpClient() throws KeystoreServiceException { } } + @Override + public KeyStore getKeystoreForHttpClient() throws KeystoreServiceException { + String keystorePath = config.getHttpClientKeystorePath(); + if (keystorePath == null) { + return null; + } else { + return getKeystore(Paths.get(keystorePath), config.getHttpClientKeystoreType(), + config.getHttpClientKeystorePasswordAlias(), true); + } + } + @Override public KeyStore getSigningKeystore() throws KeystoreServiceException { return getSigningKeystore(null); diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/JettySSLService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/JettySSLService.java index c8e6841f91..99e5561fca 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/JettySSLService.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/JettySSLService.java @@ -17,6 +17,8 @@ */ package org.apache.knox.gateway.services.security.impl; +import java.security.KeyStore; +import java.security.KeyStoreException; import java.security.cert.Certificate; import java.security.cert.CertificateExpiredException; import java.security.cert.CertificateNotYetValidException; @@ -93,6 +95,9 @@ public void init(GatewayConfig config, Map options) log.keyStoreForGatewayFoundNotCreating(); } logAndValidateCertificate(config); + if (config.isSingleEkuEnabled()) { + validateSingleEkuConfig(config); + } } catch (KeystoreServiceException e) { throw new ServiceLifecycleException("The identity keystore was not loaded properly - the provided password may not match the password for the keystore.", e); } @@ -132,6 +137,52 @@ private void logAndValidateCertificate(GatewayConfig config) throws ServiceLifec } } + // Package private for unit test access. + // When single-EKU mode is enabled, refuse to start unless the outbound client identity keystore + // is present and usable, and inbound client authentication is enforced. Fail closed: never fall + // back to the server identity certificate. + void validateSingleEkuConfig(GatewayConfig config) throws ServiceLifecycleException { + // 1. Outbound client-identity keystore must be configured and contain the configured key entry. + String clientKeystorePath = config.getHttpClientKeystorePath(); + if (clientKeystorePath == null || clientKeystorePath.isEmpty()) { + throw new ServiceLifecycleException("Single-EKU mode is enabled (" + GatewayConfig.TLS_SINGLE_EKU_ENABLED + + "=true) but the HTTP client keystore path (" + GatewayConfig.HTTP_CLIENT_KEYSTORE_PATH + + ") is not configured. Server will not start."); + } + String clientKeyAlias = config.getHttpClientKeyAlias(); + try { + KeyStore clientKeystore = keystoreService.getKeystoreForHttpClient(); + if (clientKeystore == null || !clientKeystore.isKeyEntry(clientKeyAlias)) { + throw new ServiceLifecycleException("Single-EKU mode is enabled but the HTTP client keystore at " + + clientKeystorePath + " does not contain a key entry for alias '" + clientKeyAlias + + "' (" + GatewayConfig.HTTP_CLIENT_KEY_ALIAS + "). Server will not start."); + } + } catch (KeystoreServiceException | KeyStoreException e) { + throw new ServiceLifecycleException("Single-EKU mode is enabled but the HTTP client keystore at " + + clientKeystorePath + " could not be loaded. Server will not start.", e); + } + + // 2. Inbound client authentication must be enforced (server truststore + client-auth). + if (config.getTruststorePath() == null) { + throw new ServiceLifecycleException("Single-EKU mode is enabled but the server truststore (" + + GatewayConfig.GATEWAY_TRUSTSTORE_PATH + ") is not configured. Server will not start."); + } + if (!config.isClientAuthNeeded() && !config.isClientAuthWanted()) { + throw new ServiceLifecycleException("Single-EKU mode is enabled but client authentication is not " + + "enabled (set gateway.client.auth.needed=true). Server will not start."); + } + + // 3. Outbound HTTP client truststore must be configured so Knox can verify upstream TLS certs. + String httpClientTruststorePath = config.getHttpClientTruststorePath(); + if (httpClientTruststorePath == null || httpClientTruststorePath.isEmpty()) { + log.singleEkuHttpClientTruststoreNotConfigured(GatewayConfig.HTTP_CLIENT_TRUSTSTORE_PATH); + throw new ServiceLifecycleException("Single-EKU mode is enabled but " + + GatewayConfig.HTTP_CLIENT_TRUSTSTORE_PATH + + " is not configured. Knox cannot verify TLS certificates of upstream services. " + + "Server will not start."); + } + } + @Override public Object buildSslContextFactory(GatewayConfig config) throws AliasServiceException { String identityKeystorePath = config.getIdentityKeystorePath(); diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/RemoteAliasService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/RemoteAliasService.java index 4403822041..4954011b63 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/RemoteAliasService.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/RemoteAliasService.java @@ -208,6 +208,20 @@ public char[] getGatewayIdentityPassphrase() throws AliasServiceException { return password; } + @Override + public char[] getHttpClientKeyPassphrase() throws AliasServiceException { + char[] password = null; + if(remoteAliasServiceImpl != null) { + password = remoteAliasServiceImpl.getHttpClientKeyPassphrase(); + } + + if(password == null) { + password = localAliasService.getHttpClientKeyPassphrase(); + } + + return password; + } + @Override public char[] getGatewayIdentityKeystorePassword() throws AliasServiceException { char[] password = null; diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/ZookeeperRemoteAliasService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/ZookeeperRemoteAliasService.java index d4f325c1b2..9e1bf27399 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/ZookeeperRemoteAliasService.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/ZookeeperRemoteAliasService.java @@ -345,6 +345,11 @@ public char[] getGatewayIdentityPassphrase() throws AliasServiceException { return getPasswordFromAliasForGateway(config.getIdentityKeyPassphraseAlias()); } + @Override + public char[] getHttpClientKeyPassphrase() throws AliasServiceException { + return getPasswordFromAliasForGateway(config.getHttpClientKeyPassphraseAlias()); + } + @Override public char[] getGatewayIdentityKeystorePassword() throws AliasServiceException { return getPasswordFromAliasForGateway(config.getIdentityKeystorePasswordAlias()); diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/config/impl/GatewayConfigImplTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/config/impl/GatewayConfigImplTest.java index 88b52a1482..711e811860 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/config/impl/GatewayConfigImplTest.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/config/impl/GatewayConfigImplTest.java @@ -544,6 +544,42 @@ public void testHttpClientTruststoreOptions() { assertEquals("custom_type", config.getHttpClientTruststoreType()); } + @Test + public void testHttpClientKeystoreOptions() { + GatewayConfigImpl config = new GatewayConfigImpl(); + + // Validate default options + assertNull(config.getHttpClientKeystorePath()); + assertEquals(KeyStore.getDefaultType(), config.getHttpClientKeystoreType()); + assertEquals("gateway-httpclient-keystore-password", config.getHttpClientKeystorePasswordAlias()); + assertEquals("gateway-httpclient-key", config.getHttpClientKeyAlias()); + assertEquals("gateway-httpclient-key-passphrase", config.getHttpClientKeyPassphraseAlias()); + + // Validate changed options + config.set("gateway.httpclient.keystore.path", "custom_path"); + config.set("gateway.httpclient.keystore.type", "custom_type"); + config.set("gateway.httpclient.keystore.password.alias", "custom_keystore_password_alias"); + config.set("gateway.httpclient.key.alias", "custom_key_alias"); + config.set("gateway.httpclient.key.passphrase.alias", "custom_key_passphrase_alias"); + + assertEquals("custom_path", config.getHttpClientKeystorePath()); + assertEquals("custom_type", config.getHttpClientKeystoreType()); + assertEquals("custom_keystore_password_alias", config.getHttpClientKeystorePasswordAlias()); + assertEquals("custom_key_alias", config.getHttpClientKeyAlias()); + assertEquals("custom_key_passphrase_alias", config.getHttpClientKeyPassphraseAlias()); + } + + @Test + public void testSingleEkuEnabledOption() { + GatewayConfigImpl config = new GatewayConfigImpl(); + + // Default is multi-purpose (single-EKU disabled) + assertFalse(config.isSingleEkuEnabled()); + + config.set("gateway.tls.single.eku.enabled", "true"); + assertTrue(config.isSingleEkuEnabled()); + } + @Test public void testGatewayTruststoreOptions() { GatewayConfigImpl config = new GatewayConfigImpl(); diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/security/CryptoServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/CryptoServiceTest.java index 927519a11e..2044c1937e 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/services/security/CryptoServiceTest.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/CryptoServiceTest.java @@ -127,6 +127,11 @@ public char[] getGatewayIdentityPassphrase() throws AliasServiceException { return null; } + @Override + public char[] getHttpClientKeyPassphrase() throws AliasServiceException { + return null; + } + @Override public char[] getGatewayIdentityKeystorePassword() throws AliasServiceException { return null; diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/DefaultAliasServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/DefaultAliasServiceTest.java new file mode 100644 index 0000000000..297b494fae --- /dev/null +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/DefaultAliasServiceTest.java @@ -0,0 +1,81 @@ +/* + * 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.knox.gateway.services.security.impl; + +import static org.easymock.EasyMock.createMock; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.replay; +import static org.easymock.EasyMock.verify; +import static org.junit.Assert.assertArrayEquals; + +import org.apache.knox.gateway.config.GatewayConfig; +import org.apache.knox.gateway.services.security.KeystoreService; +import org.apache.knox.gateway.services.security.MasterService; +import org.junit.Test; + +public class DefaultAliasServiceTest { + + @Test + public void testGetHttpClientKeyPassphraseUsesKeyPassphraseAlias() throws Exception { + GatewayConfig config = createMock(GatewayConfig.class); + expect(config.getHttpClientKeyPassphraseAlias()).andReturn("gateway-httpclient-key-passphrase").atLeastOnce(); + + KeystoreService keystoreService = createMock(KeystoreService.class); + expect(keystoreService.getCredentialForCluster("__gateway", "gateway-httpclient-key-passphrase")) + .andReturn("keypass".toCharArray()).atLeastOnce(); + + MasterService masterService = createMock(MasterService.class); + + replay(config, keystoreService, masterService); + + DefaultAliasService aliasService = new DefaultAliasService(); + aliasService.setKeystoreService(keystoreService); + aliasService.setMasterService(masterService); + aliasService.init(config, null); + + assertArrayEquals("keypass".toCharArray(), aliasService.getHttpClientKeyPassphrase()); + verify(config, keystoreService, masterService); + } + + @Test + public void testGetHttpClientKeyPassphraseFallsBackToKeystorePasswordThenMaster() throws Exception { + GatewayConfig config = createMock(GatewayConfig.class); + expect(config.getHttpClientKeyPassphraseAlias()).andReturn("gateway-httpclient-key-passphrase").atLeastOnce(); + expect(config.getHttpClientKeystorePasswordAlias()).andReturn("gateway-httpclient-keystore-password").atLeastOnce(); + + KeystoreService keystoreService = createMock(KeystoreService.class); + // Neither alias resolves -> both return null + expect(keystoreService.getCredentialForCluster("__gateway", "gateway-httpclient-key-passphrase")) + .andReturn(null).atLeastOnce(); + expect(keystoreService.getCredentialForCluster("__gateway", "gateway-httpclient-keystore-password")) + .andReturn(null).atLeastOnce(); + + MasterService masterService = createMock(MasterService.class); + expect(masterService.getMasterSecret()).andReturn("master".toCharArray()).atLeastOnce(); + + replay(config, keystoreService, masterService); + + DefaultAliasService aliasService = new DefaultAliasService(); + aliasService.setKeystoreService(keystoreService); + aliasService.setMasterService(masterService); + aliasService.init(config, null); + + assertArrayEquals("master".toCharArray(), aliasService.getHttpClientKeyPassphrase()); + verify(config, keystoreService, masterService); + } +} diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/DefaultKeystoreServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/DefaultKeystoreServiceTest.java index 5e85444948..881a460676 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/DefaultKeystoreServiceTest.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/DefaultKeystoreServiceTest.java @@ -54,7 +54,9 @@ import org.junit.Test; import org.junit.rules.TemporaryFolder; +import java.io.File; import java.io.IOException; +import java.io.OutputStream; import java.net.InetAddress; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -202,6 +204,61 @@ public void testGetTruststoreForHttpClientCustomTrustStoreMissingPasswordAlias() verify(keystore, keystoreService, masterService); } + @Test + public void testGetKeystoreForHttpClientReturnsNullWhenPathNotConfigured() throws Exception { + MasterService masterService = createMock(MasterService.class); + expect(masterService.getMasterSecret()).andReturn("horton".toCharArray()).anyTimes(); + replay(masterService); + + GatewayConfig config = createMock(GatewayConfig.class); + expect(config.getHttpClientKeystorePath()).andReturn(null).atLeastOnce(); + expect(config.getGatewayKeystoreDir()).andReturn(testFolder.newFolder().getAbsolutePath()).anyTimes(); + expectInitConfig(config); + replay(config); + + DefaultKeystoreService keystoreService = new DefaultKeystoreService(); + keystoreService.setMasterService(masterService); + keystoreService.init(config, Collections.emptyMap()); + + assertNull(keystoreService.getKeystoreForHttpClient()); + verify(config); + } + + @Test + public void testGetKeystoreForHttpClientLoadsConfiguredKeystore() throws Exception { + char[] password = "horton".toCharArray(); + File keystoreDir = testFolder.newFolder(); + File clientKeystore = new File(keystoreDir, "client.jks"); + + // Create an empty keystore of the default type at the configured path + KeyStore created = KeyStore.getInstance(KeyStore.getDefaultType()); + created.load(null, password); + try (OutputStream out = Files.newOutputStream(clientKeystore.toPath())) { + created.store(out, password); + } + + MasterService masterService = createMock(MasterService.class); + expect(masterService.getMasterSecret()).andReturn("horton".toCharArray()).anyTimes(); + replay(masterService); + + GatewayConfig config = createMock(GatewayConfig.class); + expect(config.getHttpClientKeystorePath()).andReturn(clientKeystore.getAbsolutePath()).atLeastOnce(); + expect(config.getHttpClientKeystoreType()).andReturn(KeyStore.getDefaultType()).atLeastOnce(); + expect(config.getHttpClientKeystorePasswordAlias()).andReturn("gateway-httpclient-keystore-password").atLeastOnce(); + expect(config.getGatewayKeystoreDir()).andReturn(keystoreDir.getAbsolutePath()).anyTimes(); + expectInitConfig(config); + replay(config); + + // masterService returns the keystore password so getKeyStorePassword(alias) resolves it + DefaultKeystoreService keystoreService = new DefaultKeystoreService(); + keystoreService.setMasterService(masterService); + keystoreService.init(config, Collections.emptyMap()); + + KeyStore loaded = keystoreService.getKeystoreForHttpClient(); + assertNotNull(loaded); + verify(config); + } + @Test public void testKeystoreForGateway() throws Exception { char[] masterPassword = "master_password".toCharArray(); @@ -824,6 +881,18 @@ private GatewayConfigImpl createGatewayConfig(Path baseDir) { } + /** + * Stubs the {@link GatewayConfig} getters that {@link DefaultKeystoreService#init} invokes, so a + * strict mock survives initialization. Caller must still call {@code replay(config)}. + */ + private void expectInitConfig(GatewayConfig config) { + expect(config.getKeystoreCacheEntryTimeToLiveInMinutes()).andReturn(1L).anyTimes(); + expect(config.getKeystoreCacheSizeLimit()).andReturn(1L).anyTimes(); + expect(config.getCredentialStoreAlgorithm()).andReturn("AES").anyTimes(); + expect(config.getCredentialStoreType()).andReturn(KeyStore.getDefaultType()).anyTimes(); + expect(config.getSelfSigningCertificateAlgorithm()).andReturn("SHA256withRSA").anyTimes(); + } + private void createKeystore(DefaultKeystoreService keystoreService, char[] password, final GatewayConfig config) throws KeystoreServiceException, KeyStoreException, CertificateException, NoSuchAlgorithmException, IOException { Path keystoreFilePath = Paths.get(config.getIdentityKeystorePath()); diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/JettySSLServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/JettySSLServiceTest.java index f21a9a6e1f..d9c0dae7c9 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/JettySSLServiceTest.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/JettySSLServiceTest.java @@ -37,6 +37,7 @@ import java.util.Set; import org.apache.knox.gateway.config.GatewayConfig; +import org.apache.knox.gateway.services.ServiceLifecycleException; import org.apache.knox.gateway.services.security.AliasService; import org.apache.knox.gateway.services.security.AliasServiceException; import org.apache.knox.gateway.services.security.KeystoreService; @@ -46,6 +47,7 @@ import java.io.File; import java.nio.file.Path; import java.nio.file.Paths; +import java.security.KeyStore; import java.security.UnrecoverableKeyException; public class JettySSLServiceTest { @@ -599,4 +601,178 @@ private GatewayConfig createGatewayConfigForExcludeTopologyTest(boolean isClient return config; } + private GatewayConfig singleEkuConfig(String clientKeystorePath, String clientKeyAlias, + String truststorePath, boolean clientAuthNeeded, + String httpClientTruststorePath) { + GatewayConfig config = createMock(GatewayConfig.class); + expect(config.isSingleEkuEnabled()).andReturn(true).anyTimes(); + expect(config.getHttpClientKeystorePath()).andReturn(clientKeystorePath).anyTimes(); + expect(config.getHttpClientKeyAlias()).andReturn(clientKeyAlias).anyTimes(); + expect(config.getTruststorePath()).andReturn(truststorePath).anyTimes(); + expect(config.isClientAuthNeeded()).andReturn(clientAuthNeeded).anyTimes(); + expect(config.isClientAuthWanted()).andReturn(false).anyTimes(); + expect(config.getHttpClientTruststorePath()).andReturn(httpClientTruststorePath).anyTimes(); + return config; + } + + @Test + public void testSingleEkuValidationFailsWhenClientKeystorePathMissing() { + GatewayConfig config = singleEkuConfig(null, "server", "truststore.jks", true, "client-trust.jks"); + KeystoreService keystoreService = createMock(KeystoreService.class); + replay(config, keystoreService); + + JettySSLService sslService = new JettySSLService(); + sslService.setKeystoreService(keystoreService); + try { + sslService.validateSingleEkuConfig(config); + fail("Expected ServiceLifecycleException for missing client keystore path"); + } catch (ServiceLifecycleException e) { + assertTrue(e.getMessage().contains("gateway.httpclient.keystore.path")); + } + verify(config, keystoreService); + } + + @Test + public void testSingleEkuValidationFailsWhenTruststoreMissing() throws Exception { + String basedir = System.getProperty("basedir"); + if (basedir == null) { + basedir = new java.io.File(".").getCanonicalPath(); + } + String clientKeystorePath = java.nio.file.Paths.get(basedir, "target", "test-classes", "keystores", "server-keystore.jks").toString(); + KeyStore clientKeystore = KeyStore.getInstance("JKS"); + try (java.io.InputStream in = java.nio.file.Files.newInputStream(java.nio.file.Paths.get(clientKeystorePath))) { + clientKeystore.load(in, "horton".toCharArray()); + } + + GatewayConfig config = singleEkuConfig(clientKeystorePath, "server", null, true, "client-trust.jks"); + KeystoreService keystoreService = createMock(KeystoreService.class); + expect(keystoreService.getKeystoreForHttpClient()).andReturn(clientKeystore).anyTimes(); + replay(config, keystoreService); + + JettySSLService sslService = new JettySSLService(); + sslService.setKeystoreService(keystoreService); + try { + sslService.validateSingleEkuConfig(config); + fail("Expected ServiceLifecycleException for missing server truststore"); + } catch (ServiceLifecycleException e) { + assertTrue(e.getMessage().contains("gateway.truststore.path")); + } + verify(config, keystoreService); + } + + @Test + public void testSingleEkuValidationFailsWhenClientAuthDisabled() throws Exception { + String basedir = System.getProperty("basedir"); + if (basedir == null) { + basedir = new java.io.File(".").getCanonicalPath(); + } + String clientKeystorePath = java.nio.file.Paths.get(basedir, "target", "test-classes", "keystores", "server-keystore.jks").toString(); + KeyStore clientKeystore = KeyStore.getInstance("JKS"); + try (java.io.InputStream in = java.nio.file.Files.newInputStream(java.nio.file.Paths.get(clientKeystorePath))) { + clientKeystore.load(in, "horton".toCharArray()); + } + + GatewayConfig config = singleEkuConfig(clientKeystorePath, "server", "some-truststore.jks", false, "client-trust.jks"); + KeystoreService keystoreService = createMock(KeystoreService.class); + expect(keystoreService.getKeystoreForHttpClient()).andReturn(clientKeystore).anyTimes(); + replay(config, keystoreService); + + JettySSLService sslService = new JettySSLService(); + sslService.setKeystoreService(keystoreService); + try { + sslService.validateSingleEkuConfig(config); + fail("Expected ServiceLifecycleException when client auth disabled"); + } catch (ServiceLifecycleException e) { + assertTrue(e.getMessage().contains("client authentication")); + } + verify(config, keystoreService); + } + + @Test + public void testSingleEkuValidationPassesWhenAllPresent() throws Exception { + String basedir = System.getProperty("basedir"); + if (basedir == null) { + basedir = new java.io.File(".").getCanonicalPath(); + } + String clientKeystorePath = java.nio.file.Paths.get(basedir, "target", "test-classes", "keystores", "server-keystore.jks").toString(); + KeyStore clientKeystore = KeyStore.getInstance("JKS"); + try (java.io.InputStream in = java.nio.file.Files.newInputStream(java.nio.file.Paths.get(clientKeystorePath))) { + clientKeystore.load(in, "horton".toCharArray()); + } + + GatewayConfig config = singleEkuConfig(clientKeystorePath, "server", "some-truststore.jks", true, "client-trust.jks"); + KeystoreService keystoreService = createMock(KeystoreService.class); + expect(keystoreService.getKeystoreForHttpClient()).andReturn(clientKeystore).anyTimes(); + replay(config, keystoreService); + + JettySSLService sslService = new JettySSLService(); + sslService.setKeystoreService(keystoreService); + sslService.validateSingleEkuConfig(config); // must not throw + verify(config, keystoreService); + } + + @Test + public void testSingleEkuValidationFailsWhenHttpClientTruststoreMissing() throws Exception { + String basedir = System.getProperty("basedir"); + if (basedir == null) { + basedir = new java.io.File(".").getCanonicalPath(); + } + String clientKeystorePath = java.nio.file.Paths.get(basedir, "target", "test-classes", "keystores", "server-keystore.jks").toString(); + KeyStore clientKeystore = KeyStore.getInstance("JKS"); + try (java.io.InputStream in = java.nio.file.Files.newInputStream(java.nio.file.Paths.get(clientKeystorePath))) { + clientKeystore.load(in, "horton".toCharArray()); + } + + GatewayConfig config = singleEkuConfig(clientKeystorePath, "server", "some-truststore.jks", true, null); + KeystoreService keystoreService = createMock(KeystoreService.class); + expect(keystoreService.getKeystoreForHttpClient()).andReturn(clientKeystore).anyTimes(); + replay(config, keystoreService); + + JettySSLService sslService = new JettySSLService(); + sslService.setKeystoreService(keystoreService); + try { + sslService.validateSingleEkuConfig(config); + fail("Expected ServiceLifecycleException for missing HTTP client truststore"); + } catch (ServiceLifecycleException e) { + assertTrue(e.getMessage().contains(GatewayConfig.HTTP_CLIENT_TRUSTSTORE_PATH)); + } + verify(config, keystoreService); + } + + @Test + public void testSingleEkuValidationFailsWhenAliasIsTrustedCertNotPrivateKey() throws Exception { + String basedir = System.getProperty("basedir"); + if (basedir == null) { + basedir = new java.io.File(".").getCanonicalPath(); + } + // Extract the public cert from the server keystore and plant it as a TrustedCertEntry at the + // same alias — so isKeyEntry("server") returns false even though the alias exists. + KeyStore sourceKeystore = KeyStore.getInstance("JKS"); + try (java.io.InputStream in = java.nio.file.Files.newInputStream( + java.nio.file.Paths.get(basedir, "target", "test-classes", "keystores", "server-keystore.jks"))) { + sourceKeystore.load(in, "horton".toCharArray()); + } + java.security.cert.Certificate cert = sourceKeystore.getCertificate("server"); + + KeyStore ksWithCertEntry = KeyStore.getInstance("JKS"); + ksWithCertEntry.load(null, null); + ksWithCertEntry.setCertificateEntry("server", cert); + + String clientKeystorePath = java.nio.file.Paths.get(basedir, "target", "test-classes", "keystores", "server-keystore.jks").toString(); + GatewayConfig config = singleEkuConfig(clientKeystorePath, "server", "some-truststore.jks", true, "client-trust.jks"); + KeystoreService keystoreService = createMock(KeystoreService.class); + expect(keystoreService.getKeystoreForHttpClient()).andReturn(ksWithCertEntry).anyTimes(); + replay(config, keystoreService); + + JettySSLService sslService = new JettySSLService(); + sslService.setKeystoreService(keystoreService); + try { + sslService.validateSingleEkuConfig(config); + fail("Expected ServiceLifecycleException when alias is a TrustedCertEntry, not a PrivateKeyEntry"); + } catch (ServiceLifecycleException e) { + assertTrue(e.getMessage().contains("key entry")); + } + verify(config, keystoreService); + } + } diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/RemoteAliasServiceTestProvider.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/RemoteAliasServiceTestProvider.java index 67db9483be..5460973578 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/RemoteAliasServiceTestProvider.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/RemoteAliasServiceTestProvider.java @@ -115,6 +115,11 @@ public char[] getGatewayIdentityPassphrase() { return getPasswordFromAliasForGateway(config.getIdentityKeyPassphraseAlias()); } + @Override + public char[] getHttpClientKeyPassphrase() { + return getPasswordFromAliasForGateway(config.getHttpClientKeyPassphraseAlias()); + } + @Override public char[] getGatewayIdentityKeystorePassword() { return getPasswordFromAliasForGateway(config.getIdentityKeystorePasswordAlias()); diff --git a/gateway-service-hashicorp-vault/src/main/java/org/apache/knox/gateway/backend/hashicorp/vault/HashicorpVaultAliasService.java b/gateway-service-hashicorp-vault/src/main/java/org/apache/knox/gateway/backend/hashicorp/vault/HashicorpVaultAliasService.java index 540b76fbc0..a53b148fd6 100644 --- a/gateway-service-hashicorp-vault/src/main/java/org/apache/knox/gateway/backend/hashicorp/vault/HashicorpVaultAliasService.java +++ b/gateway-service-hashicorp-vault/src/main/java/org/apache/knox/gateway/backend/hashicorp/vault/HashicorpVaultAliasService.java @@ -176,6 +176,11 @@ public char[] getGatewayIdentityPassphrase() throws AliasServiceException { return getPasswordFromAliasForGateway(config.getIdentityKeyPassphraseAlias()); } + @Override + public char[] getHttpClientKeyPassphrase() throws AliasServiceException { + return getPasswordFromAliasForGateway(config.getHttpClientKeyPassphraseAlias()); + } + @Override public char[] getGatewayIdentityKeystorePassword() throws AliasServiceException { return getPasswordFromAliasForGateway(config.getIdentityKeystorePasswordAlias()); diff --git a/gateway-spi-common/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java b/gateway-spi-common/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java index 1fd140290f..7a849e9db1 100644 --- a/gateway-spi-common/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java +++ b/gateway-spi-common/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java @@ -467,6 +467,36 @@ public String getHttpClientTruststorePasswordAlias() { return null; } + @Override + public String getHttpClientKeystorePath() { + return null; + } + + @Override + public String getHttpClientKeystoreType() { + return DEFAULT_HTTP_CLIENT_KEYSTORE_TYPE; + } + + @Override + public String getHttpClientKeystorePasswordAlias() { + return DEFAULT_HTTP_CLIENT_KEYSTORE_PASSWORD_ALIAS; + } + + @Override + public String getHttpClientKeyAlias() { + return DEFAULT_HTTP_CLIENT_KEY_ALIAS; + } + + @Override + public String getHttpClientKeyPassphraseAlias() { + return DEFAULT_HTTP_CLIENT_KEY_PASSPHRASE_ALIAS; + } + + @Override + public boolean isSingleEkuEnabled() { + return DEFAULT_TLS_SINGLE_EKU_ENABLED; + } + @Override public String getCredentialStoreAlgorithm() { return DEFAULT_CREDENTIAL_STORE_ALG; diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/SpiGatewayMessages.java b/gateway-spi/src/main/java/org/apache/knox/gateway/SpiGatewayMessages.java index 5404e3ebe4..a13b7eb3f3 100644 --- a/gateway-spi/src/main/java/org/apache/knox/gateway/SpiGatewayMessages.java +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/SpiGatewayMessages.java @@ -106,6 +106,10 @@ public interface SpiGatewayMessages { @Message( level = MessageLevel.DEBUG, text = "Using two way SSL in {0}" ) void usingTwoWaySsl(String serviceRole); + @Message( level = MessageLevel.INFO, + text = "Single-EKU mode is enabled but two-way SSL is not configured for {0}; no client certificate will be presented for outbound requests." ) + void singleEkuEnabledWithoutTwoWaySsl(String serviceRole); + @Message( level = MessageLevel.DEBUG, text = "Adding outbound header {0} and value {1}" ) void addedOutboundheader(String header, String value); diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java b/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java index 065952d968..122437cdf6 100644 --- a/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java @@ -90,6 +90,19 @@ public interface GatewayConfig { String DEFAULT_HTTP_CLIENT_TRUSTSTORE_TYPE = KeyStore.getDefaultType(); String DEFAULT_HTTP_CLIENT_TRUSTSTORE_PASSWORD_ALIAS = "gateway-httpclient-truststore-password"; + String HTTP_CLIENT_KEYSTORE_PASSWORD_ALIAS = "gateway.httpclient.keystore.password.alias"; + String HTTP_CLIENT_KEYSTORE_PATH = "gateway.httpclient.keystore.path"; + String HTTP_CLIENT_KEYSTORE_TYPE = "gateway.httpclient.keystore.type"; + String HTTP_CLIENT_KEY_ALIAS = "gateway.httpclient.key.alias"; + String HTTP_CLIENT_KEY_PASSPHRASE_ALIAS = "gateway.httpclient.key.passphrase.alias"; + String DEFAULT_HTTP_CLIENT_KEYSTORE_TYPE = KeyStore.getDefaultType(); + String DEFAULT_HTTP_CLIENT_KEYSTORE_PASSWORD_ALIAS = "gateway-httpclient-keystore-password"; + String DEFAULT_HTTP_CLIENT_KEY_ALIAS = "gateway-httpclient-key"; + String DEFAULT_HTTP_CLIENT_KEY_PASSPHRASE_ALIAS = "gateway-httpclient-key-passphrase"; + + String TLS_SINGLE_EKU_ENABLED = "gateway.tls.single.eku.enabled"; + boolean DEFAULT_TLS_SINGLE_EKU_ENABLED = false; + String CREDENTIAL_STORE_ALG = "gateway.credential.store.alg"; String DEFAULT_CREDENTIAL_STORE_ALG = "AES"; String SELF_SIGNING_CERT_ALG = "gateway.self.signing.cert.alg"; @@ -282,6 +295,39 @@ public interface GatewayConfig { */ String getHttpClientTruststorePasswordAlias(); + /** + * @return the path to the keystore holding the Gateway's client identity for outbound + * mutual-TLS connections; or null if not set + */ + String getHttpClientKeystorePath(); + + /** + * @return the type of the keystore specified by {@link #getHttpClientKeystorePath()} + */ + String getHttpClientKeystoreType(); + + /** + * @return the alias used to look up the password for the HTTP client keystore + */ + String getHttpClientKeystorePasswordAlias(); + + /** + * @return the alias of the key entry to present from the HTTP client keystore + */ + String getHttpClientKeyAlias(); + + /** + * @return the alias used to look up the passphrase for the HTTP client key + */ + String getHttpClientKeyPassphraseAlias(); + + /** + * @return true when single-purpose (single-EKU) certificates are in use, so the + * Gateway presents a dedicated client keystore for outbound mTLS instead of its server + * identity keystore. Defaults to false (multi-purpose certificates). + */ + boolean isSingleEkuEnabled(); + /** * @return the algorithm that is used when creating a SecretKey when adding an * alias into a credential store diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/dispatch/DefaultHttpAsyncClientFactory.java b/gateway-spi/src/main/java/org/apache/knox/gateway/dispatch/DefaultHttpAsyncClientFactory.java index 24840233fb..efd5864ff8 100644 --- a/gateway-spi/src/main/java/org/apache/knox/gateway/dispatch/DefaultHttpAsyncClientFactory.java +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/dispatch/DefaultHttpAsyncClientFactory.java @@ -54,7 +54,7 @@ public HttpAsyncClient createAsyncHttpClient(FilterConfig filterConfig) { } // Conditionally set a custom SSLContext - SSLContext sslContext = createSSLContext(services, filterConfig, serviceRole); + SSLContext sslContext = createSSLContext(services, gatewayConfig, filterConfig, serviceRole); if (sslContext != null) { builder.setSSLContext(sslContext); } diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/dispatch/DefaultHttpClientFactory.java b/gateway-spi/src/main/java/org/apache/knox/gateway/dispatch/DefaultHttpClientFactory.java index 206a349e1a..85f0f19f50 100644 --- a/gateway-spi/src/main/java/org/apache/knox/gateway/dispatch/DefaultHttpClientFactory.java +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/dispatch/DefaultHttpClientFactory.java @@ -93,7 +93,7 @@ public HttpClient createHttpClient(FilterConfig filterConfig) { builder = HttpClients.custom(); } - SSLContext sslContext = createSSLContext(services, filterConfig, serviceRole); + SSLContext sslContext = createSSLContext(services, gatewayConfig, filterConfig, serviceRole); setSSLSocketFactory(sslContext, filterConfig, builder); if (Boolean.parseBoolean(System.getProperty(GatewayConfig.HADOOP_KERBEROS_SECURED))) { @@ -173,25 +173,34 @@ private boolean doesRetryParamExist(final FilterConfig filterConfig) { *

* This method is package private to allow access to unit tests * - * @param services the {@link GatewayServices} - * @param filterConfig a {@link FilterConfig} used to query for parameters for this operation - * @param serviceRole the name of the service role to whom this HTTP client is being created for + * @param services the {@link GatewayServices} + * @param gatewayConfig the {@link GatewayConfig} used to determine single-EKU mode + * @param filterConfig a {@link FilterConfig} used to query for parameters for this operation + * @param serviceRole the name of the service role to whom this HTTP client is being created for * @return a {@link SSLContext} or null if a custom {@link SSLContext} is not needed. */ - SSLContext createSSLContext(GatewayServices services, FilterConfig filterConfig, String serviceRole) { + SSLContext createSSLContext(GatewayServices services, GatewayConfig gatewayConfig, FilterConfig filterConfig, String serviceRole) { KeyStore identityKeystore; char[] identityKeyPassphrase; KeyStore trustKeystore; KeystoreService ks = services.getService(ServiceType.KEYSTORE_SERVICE); try { + boolean singleEku = gatewayConfig != null && gatewayConfig.isSingleEkuEnabled(); if (Boolean.parseBoolean(filterConfig.getInitParameter(PARAMETER_USE_TWO_WAY_SSL))) { LOG.usingTwoWaySsl(serviceRole); AliasService as = services.getService(ServiceType.ALIAS_SERVICE); - // Get the Gateway's configured identity keystore and key passphrase - identityKeystore = ks.getKeystoreForGateway(); - identityKeyPassphrase = as.getGatewayIdentityPassphrase(); + if (singleEku) { + // Single-EKU mode: present the dedicated client-identity keystore (clientAuth EKU) + // instead of the Gateway's server identity keystore. + identityKeystore = ks.getKeystoreForHttpClient(); + identityKeyPassphrase = as.getHttpClientKeyPassphrase(); + } else { + // Multi-purpose (default): present the Gateway's server identity keystore. + identityKeystore = ks.getKeystoreForGateway(); + identityKeyPassphrase = as.getGatewayIdentityPassphrase(); + } // The trustKeystore will be the same as the identityKeystore if a truststore was not explicitly // configured in gateway-site (gateway.truststore.password.alias, gateway.truststore.path, gateway.truststore.type) @@ -201,6 +210,11 @@ SSLContext createSSLContext(GatewayServices services, FilterConfig filterConfig, trustKeystore = identityKeystore; } } else { + if (singleEku) { + // Single-EKU mode is enabled but this dispatch is not configured for two-way SSL, + // so no client certificate is presented. Surface this so it is never a silent surprise. + LOG.singleEkuEnabledWithoutTwoWaySsl(serviceRole); + } // If not using twoWaySsl, there is no need to calculate the Gateway's identity keystore or // identity key. identityKeystore = null; diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/AliasService.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/AliasService.java index 5be9f82ca3..222c73a84f 100644 --- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/AliasService.java +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/AliasService.java @@ -67,6 +67,8 @@ char[] getPasswordFromAliasForGateway(String alias) char[] getGatewayIdentityPassphrase() throws AliasServiceException; + default char[] getHttpClientKeyPassphrase() throws AliasServiceException { return getGatewayIdentityPassphrase(); } + char[] getGatewayIdentityKeystorePassword() throws AliasServiceException; char[] getSigningKeyPassphrase() throws AliasServiceException; diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/KeystoreService.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/KeystoreService.java index dc8069a867..ce024cff5b 100644 --- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/KeystoreService.java +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/KeystoreService.java @@ -44,6 +44,16 @@ public interface KeystoreService extends Service { */ KeyStore getTruststoreForHttpClient() throws KeystoreServiceException; + /** + * Gets the keystore holding the Gateway's client identity used for outbound mutual-TLS + * connections (single-EKU mode). + * + * @return a {@link KeyStore}; or null if {@code gateway.httpclient.keystore.path} + * is not configured + * @throws KeystoreServiceException if the configured keystore cannot be loaded + */ + default KeyStore getKeystoreForHttpClient() throws KeystoreServiceException { return null; } + KeyStore getSigningKeystore() throws KeystoreServiceException; KeyStore getSigningKeystore(String keystoreName) throws KeystoreServiceException; diff --git a/gateway-spi/src/test/java/org/apache/knox/gateway/dispatch/DefaultHttpAsyncClientFactoryTest.java b/gateway-spi/src/test/java/org/apache/knox/gateway/dispatch/DefaultHttpAsyncClientFactoryTest.java index 001f830c2b..65216b8c33 100644 --- a/gateway-spi/src/test/java/org/apache/knox/gateway/dispatch/DefaultHttpAsyncClientFactoryTest.java +++ b/gateway-spi/src/test/java/org/apache/knox/gateway/dispatch/DefaultHttpAsyncClientFactoryTest.java @@ -48,6 +48,7 @@ public void testCreateHttpAsyncClientSSLContextDefaults() throws Exception { expect(gatewayConfig.getHttpClientConnectionTimeout()).andReturn(20000).once(); expect(gatewayConfig.getHttpClientSocketTimeout()).andReturn(20000).once(); expect(gatewayConfig.getHttpClientCookieSpec()).andReturn(CookieSpecs.STANDARD).anyTimes(); + expect(gatewayConfig.isSingleEkuEnabled()).andReturn(false).anyTimes(); GatewayServices gatewayServices = createMock(GatewayServices.class); expect(gatewayServices.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(keystoreService).once(); diff --git a/gateway-spi/src/test/java/org/apache/knox/gateway/dispatch/DefaultHttpClientFactoryTest.java b/gateway-spi/src/test/java/org/apache/knox/gateway/dispatch/DefaultHttpClientFactoryTest.java index d8fe488ff8..eeb6ad3847 100644 --- a/gateway-spi/src/test/java/org/apache/knox/gateway/dispatch/DefaultHttpClientFactoryTest.java +++ b/gateway-spi/src/test/java/org/apache/knox/gateway/dispatch/DefaultHttpClientFactoryTest.java @@ -27,6 +27,7 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; import org.apache.http.client.HttpClient; import org.apache.http.client.config.CookieSpecs; @@ -58,6 +59,7 @@ public void testCreateHttpClientSSLContextDefaults() throws Exception { expect(keystoreService.getTruststoreForHttpClient()).andReturn(null).once(); GatewayConfig gatewayConfig = createMock(GatewayConfig.class); + expect(gatewayConfig.isSingleEkuEnabled()).andReturn(false).anyTimes(); expect(gatewayConfig.isMetricsEnabled()).andReturn(false).once(); expect(gatewayConfig.getHttpClientMaxConnections()).andReturn(32).once(); expect(gatewayConfig.getHttpClientConnectionTimeout()).andReturn(20000).once(); @@ -98,16 +100,19 @@ public void testCreateSSLContextDefaults() throws Exception { GatewayServices gatewayServices = createMock(GatewayServices.class); expect(gatewayServices.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(keystoreService).once(); + GatewayConfig gatewayConfig = createMock(GatewayConfig.class); + expect(gatewayConfig.isSingleEkuEnabled()).andReturn(false).anyTimes(); + FilterConfig filterConfig = createMock(FilterConfig.class); expect(filterConfig.getInitParameter(PARAMETER_USE_TWO_WAY_SSL)).andReturn("false").once(); - replay(keystoreService, gatewayServices, filterConfig); + replay(keystoreService, gatewayServices, gatewayConfig, filterConfig); DefaultHttpClientFactory factory = new DefaultHttpClientFactory(); - SSLContext context = factory.createSSLContext(gatewayServices, filterConfig, "service"); + SSLContext context = factory.createSSLContext(gatewayServices, gatewayConfig, filterConfig, "service"); assertNull(context); - verify(keystoreService, gatewayServices, filterConfig); + verify(keystoreService, gatewayServices, gatewayConfig, filterConfig); } @Test @@ -125,16 +130,19 @@ public void testCreateSSLContextTwoWaySslNoCustomTrustStore() throws Exception { expect(gatewayServices.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(keystoreService).once(); expect(gatewayServices.getService(ServiceType.ALIAS_SERVICE)).andReturn(aliasService).once(); + GatewayConfig gatewayConfig = createMock(GatewayConfig.class); + expect(gatewayConfig.isSingleEkuEnabled()).andReturn(false).anyTimes(); + FilterConfig filterConfig = createMock(FilterConfig.class); expect(filterConfig.getInitParameter(PARAMETER_USE_TWO_WAY_SSL)).andReturn("true").once(); - replay(keystoreService, aliasService, gatewayServices, filterConfig); + replay(keystoreService, aliasService, gatewayServices, gatewayConfig, filterConfig); DefaultHttpClientFactory factory = new DefaultHttpClientFactory(); - SSLContext context = factory.createSSLContext(gatewayServices, filterConfig, "service"); + SSLContext context = factory.createSSLContext(gatewayServices, gatewayConfig, filterConfig, "service"); assertNotNull(context); - verify(keystoreService, aliasService, gatewayServices, filterConfig); + verify(keystoreService, aliasService, gatewayServices, gatewayConfig, filterConfig); } @Test @@ -153,16 +161,19 @@ public void testCreateSSLContextTwoWaySslWithCustomTrustStore() throws Exception expect(gatewayServices.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(keystoreService).once(); expect(gatewayServices.getService(ServiceType.ALIAS_SERVICE)).andReturn(aliasService).once(); + GatewayConfig gatewayConfig = createMock(GatewayConfig.class); + expect(gatewayConfig.isSingleEkuEnabled()).andReturn(false).anyTimes(); + FilterConfig filterConfig = createMock(FilterConfig.class); expect(filterConfig.getInitParameter(PARAMETER_USE_TWO_WAY_SSL)).andReturn("true").once(); - replay(keystoreService, aliasService, gatewayServices, filterConfig); + replay(keystoreService, aliasService, gatewayServices, gatewayConfig, filterConfig); DefaultHttpClientFactory factory = new DefaultHttpClientFactory(); - SSLContext context = factory.createSSLContext(gatewayServices, filterConfig, "service"); + SSLContext context = factory.createSSLContext(gatewayServices, gatewayConfig, filterConfig, "service"); assertNotNull(context); - verify(keystoreService, aliasService, gatewayServices, filterConfig); + verify(keystoreService, aliasService, gatewayServices, gatewayConfig, filterConfig); } @Test @@ -173,16 +184,19 @@ public void testCreateSSLContextOneWaySslNoCustomTrustStore() throws Exception { GatewayServices gatewayServices = createMock(GatewayServices.class); expect(gatewayServices.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(keystoreService).once(); + GatewayConfig gatewayConfig = createMock(GatewayConfig.class); + expect(gatewayConfig.isSingleEkuEnabled()).andReturn(false).anyTimes(); + FilterConfig filterConfig = createMock(FilterConfig.class); expect(filterConfig.getInitParameter(PARAMETER_USE_TWO_WAY_SSL)).andReturn("false").once(); - replay(keystoreService, gatewayServices, filterConfig); + replay(keystoreService, gatewayServices, gatewayConfig, filterConfig); DefaultHttpClientFactory factory = new DefaultHttpClientFactory(); - SSLContext context = factory.createSSLContext(gatewayServices, filterConfig, "service"); + SSLContext context = factory.createSSLContext(gatewayServices, gatewayConfig, filterConfig, "service"); assertNull(context); - verify(keystoreService, gatewayServices, filterConfig); + verify(keystoreService, gatewayServices, gatewayConfig, filterConfig); } @Test @@ -195,16 +209,109 @@ public void testCreateSSLContextOneWaySslWithCustomTrustStore() throws Exception GatewayServices gatewayServices = createMock(GatewayServices.class); expect(gatewayServices.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(keystoreService).once(); + GatewayConfig gatewayConfig = createMock(GatewayConfig.class); + expect(gatewayConfig.isSingleEkuEnabled()).andReturn(false).anyTimes(); + FilterConfig filterConfig = createMock(FilterConfig.class); expect(filterConfig.getInitParameter(PARAMETER_USE_TWO_WAY_SSL)).andReturn("false").once(); - replay(keystoreService, gatewayServices, filterConfig); + replay(keystoreService, gatewayServices, gatewayConfig, filterConfig); + + DefaultHttpClientFactory factory = new DefaultHttpClientFactory(); + SSLContext context = factory.createSSLContext(gatewayServices, gatewayConfig, filterConfig, "service"); + assertNotNull(context); + + verify(keystoreService, gatewayServices, gatewayConfig, filterConfig); + } + + @Test + public void testCreateSSLContextSingleEkuTwoWayUsesClientKeystore() throws Exception { + KeyStore clientKeyStore = loadKeyStore("target/test-classes/keystores/server-keystore.jks", "horton", "JKS"); + + KeystoreService keystoreService = createMock(KeystoreService.class); + expect(keystoreService.getTruststoreForHttpClient()).andReturn(null).once(); + expect(keystoreService.getKeystoreForHttpClient()).andReturn(clientKeyStore).once(); + + AliasService aliasService = createMock(AliasService.class); + expect(aliasService.getHttpClientKeyPassphrase()).andReturn("horton".toCharArray()).once(); + + GatewayServices gatewayServices = createMock(GatewayServices.class); + expect(gatewayServices.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(keystoreService).once(); + expect(gatewayServices.getService(ServiceType.ALIAS_SERVICE)).andReturn(aliasService).once(); + + GatewayConfig gatewayConfig = createMock(GatewayConfig.class); + expect(gatewayConfig.isSingleEkuEnabled()).andReturn(true).anyTimes(); + + FilterConfig filterConfig = createMock(FilterConfig.class); + expect(filterConfig.getInitParameter(PARAMETER_USE_TWO_WAY_SSL)).andReturn("true").once(); + + replay(keystoreService, aliasService, gatewayServices, gatewayConfig, filterConfig); DefaultHttpClientFactory factory = new DefaultHttpClientFactory(); - SSLContext context = factory.createSSLContext(gatewayServices, filterConfig, "service"); + SSLContext context = factory.createSSLContext(gatewayServices, gatewayConfig, filterConfig, "service"); assertNotNull(context); - verify(keystoreService, gatewayServices, filterConfig); + verify(keystoreService, aliasService, gatewayServices, gatewayConfig, filterConfig); + } + + @Test + public void testCreateSSLContextSingleEkuOneWayDoesNotPresentClientCert() throws Exception { + KeystoreService keystoreService = createMock(KeystoreService.class); + expect(keystoreService.getTruststoreForHttpClient()).andReturn(null).once(); + // getKeystoreForHttpClient must NOT be called in the one-way path + + GatewayServices gatewayServices = createMock(GatewayServices.class); + expect(gatewayServices.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(keystoreService).once(); + + GatewayConfig gatewayConfig = createMock(GatewayConfig.class); + expect(gatewayConfig.isSingleEkuEnabled()).andReturn(true).anyTimes(); + + FilterConfig filterConfig = createMock(FilterConfig.class); + expect(filterConfig.getInitParameter(PARAMETER_USE_TWO_WAY_SSL)).andReturn("false").once(); + + replay(keystoreService, gatewayServices, gatewayConfig, filterConfig); + + DefaultHttpClientFactory factory = new DefaultHttpClientFactory(); + // No identity keystore and no truststore -> null SSLContext (default behavior), no client cert presented + SSLContext context = factory.createSSLContext(gatewayServices, gatewayConfig, filterConfig, "service"); + assertNull(context); + + verify(keystoreService, gatewayServices, gatewayConfig, filterConfig); + } + + @Test + public void testCreateSSLContextSingleEkuTwoWayWrongPassphraseThrows() throws Exception { + KeyStore clientKeyStore = loadKeyStore("target/test-classes/keystores/server-keystore.jks", "horton", "JKS"); + + KeystoreService keystoreService = createMock(KeystoreService.class); + expect(keystoreService.getTruststoreForHttpClient()).andReturn(null).once(); + expect(keystoreService.getKeystoreForHttpClient()).andReturn(clientKeyStore).once(); + + AliasService aliasService = createMock(AliasService.class); + expect(aliasService.getHttpClientKeyPassphrase()).andReturn("wrongpassword".toCharArray()).once(); + + GatewayServices gatewayServices = createMock(GatewayServices.class); + expect(gatewayServices.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(keystoreService).once(); + expect(gatewayServices.getService(ServiceType.ALIAS_SERVICE)).andReturn(aliasService).once(); + + GatewayConfig gatewayConfig = createMock(GatewayConfig.class); + expect(gatewayConfig.isSingleEkuEnabled()).andReturn(true).anyTimes(); + + FilterConfig filterConfig = createMock(FilterConfig.class); + expect(filterConfig.getInitParameter(PARAMETER_USE_TWO_WAY_SSL)).andReturn("true").once(); + + replay(keystoreService, aliasService, gatewayServices, gatewayConfig, filterConfig); + + DefaultHttpClientFactory factory = new DefaultHttpClientFactory(); + try { + factory.createSSLContext(gatewayServices, gatewayConfig, filterConfig, "service"); + fail("Expected IllegalArgumentException for wrong key passphrase"); + } catch (IllegalArgumentException e) { + assertTrue("cause should be UnrecoverableKeyException", + e.getCause() instanceof java.security.UnrecoverableKeyException); + } + + verify(keystoreService, aliasService, gatewayServices, gatewayConfig, filterConfig); } @Test @@ -247,6 +354,7 @@ public void testHttpRetriesValues() throws Exception { expect(keystoreService.getTruststoreForHttpClient()).andReturn(null).anyTimes(); GatewayConfig gatewayConfig = createMock(GatewayConfig.class); + expect(gatewayConfig.isSingleEkuEnabled()).andReturn(false).anyTimes(); expect(gatewayConfig.isMetricsEnabled()).andReturn(false).anyTimes(); expect(gatewayConfig.getHttpClientMaxConnections()).andReturn(32).anyTimes(); expect(gatewayConfig.getHttpClientConnectionTimeout()).andReturn(20000).anyTimes(); diff --git a/gateway-spi/src/test/java/org/apache/knox/gateway/sse/SSEDispatchTest.java b/gateway-spi/src/test/java/org/apache/knox/gateway/sse/SSEDispatchTest.java index f0c6336dc9..fb800fe566 100644 --- a/gateway-spi/src/test/java/org/apache/knox/gateway/sse/SSEDispatchTest.java +++ b/gateway-spi/src/test/java/org/apache/knox/gateway/sse/SSEDispatchTest.java @@ -510,6 +510,7 @@ private SSEDispatch createDispatch(boolean asyncSupported, boolean asyncSupporte expect(gatewayConfig.getHttpClientCookieSpec()).andReturn(CookieSpecs.STANDARD).anyTimes(); expect(gatewayConfig.isAsyncSupported()).andReturn(asyncSupported).once(); expect(gatewayConfig.isTopologyAsyncSupported("SSE")).andReturn(asyncSupportedTopology).once(); + expect(gatewayConfig.isSingleEkuEnabled()).andReturn(false).anyTimes(); GatewayServices gatewayServices = createMock(GatewayServices.class); expect(gatewayServices.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(keystoreService).once(); From 8513f4c1655b938c9e088fa0e409b17b171f2796 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sandeep=20More=CC=81?= Date: Wed, 1 Jul 2026 07:30:34 -0400 Subject: [PATCH 02/16] KNOX-3359: Add gateway.httpclient.twoWaySsl.enabled global flag, defaulting to true when single-EKU is on wire gateway.httpclient.twoWaySsl.enabled into DefaultHttpClientFactory; update log message --- .../config/impl/GatewayConfigImpl.java | 9 +++++ .../config/impl/GatewayConfigImplTest.java | 21 ++++++++++ .../knox/gateway/SpiGatewayMessages.java | 2 +- .../knox/gateway/config/GatewayConfig.java | 9 +++++ .../dispatch/DefaultHttpClientFactory.java | 4 +- .../DefaultHttpClientFactoryTest.java | 39 +++++++++++++++++++ 6 files changed, 82 insertions(+), 2 deletions(-) diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java b/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java index 1af0908571..cf2d67cf1e 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java @@ -848,6 +848,15 @@ public boolean isSingleEkuEnabled() { return getBoolean(TLS_SINGLE_EKU_ENABLED, DEFAULT_TLS_SINGLE_EKU_ENABLED); } + @Override + public boolean isHttpClientTwoWaySslEnabled() { + String configured = get(HTTP_CLIENT_TWO_WAY_SSL_ENABLED); + if (configured != null) { + return Boolean.parseBoolean(configured); + } + return isSingleEkuEnabled(); + } + @Override public String getCredentialStoreAlgorithm() { final String alg = get(CREDENTIAL_STORE_ALG, DEFAULT_CREDENTIAL_STORE_ALG); diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/config/impl/GatewayConfigImplTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/config/impl/GatewayConfigImplTest.java index 711e811860..eda7d68499 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/config/impl/GatewayConfigImplTest.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/config/impl/GatewayConfigImplTest.java @@ -580,6 +580,27 @@ public void testSingleEkuEnabledOption() { assertTrue(config.isSingleEkuEnabled()); } + @Test + public void testHttpClientTwoWaySslEnabledOption() { + GatewayConfigImpl config = new GatewayConfigImpl(); + + // Default: single-EKU off - two-way SSL also off + assertFalse(config.isHttpClientTwoWaySslEnabled()); + + // When single-EKU is on, two-way SSL defaults to true (sane default) + config.set("gateway.tls.single.eku.enabled", "true"); + assertTrue(config.isHttpClientTwoWaySslEnabled()); + + // Explicit false overrides the single-EKU-derived default + config.set("gateway.httpclient.twoWaySsl.enabled", "false"); + assertFalse(config.isHttpClientTwoWaySslEnabled()); + + // Explicit true works regardless of single-EKU state + config.set("gateway.tls.single.eku.enabled", "false"); + config.set("gateway.httpclient.twoWaySsl.enabled", "true"); + assertTrue(config.isHttpClientTwoWaySslEnabled()); + } + @Test public void testGatewayTruststoreOptions() { GatewayConfigImpl config = new GatewayConfigImpl(); diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/SpiGatewayMessages.java b/gateway-spi/src/main/java/org/apache/knox/gateway/SpiGatewayMessages.java index a13b7eb3f3..c8ff1bdbc2 100644 --- a/gateway-spi/src/main/java/org/apache/knox/gateway/SpiGatewayMessages.java +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/SpiGatewayMessages.java @@ -107,7 +107,7 @@ public interface SpiGatewayMessages { void usingTwoWaySsl(String serviceRole); @Message( level = MessageLevel.INFO, - text = "Single-EKU mode is enabled but two-way SSL is not configured for {0}; no client certificate will be presented for outbound requests." ) + text = "Single-EKU mode is enabled but two-way SSL is disabled for {0} (set gateway.httpclient.twoWaySsl.enabled=true or use-two-way-ssl=\"true\" on the dispatch to present a client certificate)." ) void singleEkuEnabledWithoutTwoWaySsl(String serviceRole); @Message( level = MessageLevel.DEBUG, text = "Adding outbound header {0} and value {1}" ) diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java b/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java index 122437cdf6..4106816664 100644 --- a/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java @@ -102,6 +102,7 @@ public interface GatewayConfig { String TLS_SINGLE_EKU_ENABLED = "gateway.tls.single.eku.enabled"; boolean DEFAULT_TLS_SINGLE_EKU_ENABLED = false; + String HTTP_CLIENT_TWO_WAY_SSL_ENABLED = "gateway.httpclient.twoWaySsl.enabled"; String CREDENTIAL_STORE_ALG = "gateway.credential.store.alg"; String DEFAULT_CREDENTIAL_STORE_ALG = "AES"; @@ -328,6 +329,14 @@ public interface GatewayConfig { */ boolean isSingleEkuEnabled(); + /** + * @return {@code true} when outbound mTLS is enabled globally for all dispatches. + * Defaults to {@code true} when {@link #isSingleEkuEnabled()} is {@code true}; + * otherwise defaults to {@code false}. An explicit value in gateway-site.xml + * always wins regardless of the single-EKU toggle. + */ + boolean isHttpClientTwoWaySslEnabled(); + /** * @return the algorithm that is used when creating a SecretKey when adding an * alias into a credential store diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/dispatch/DefaultHttpClientFactory.java b/gateway-spi/src/main/java/org/apache/knox/gateway/dispatch/DefaultHttpClientFactory.java index 85f0f19f50..da41f81634 100644 --- a/gateway-spi/src/main/java/org/apache/knox/gateway/dispatch/DefaultHttpClientFactory.java +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/dispatch/DefaultHttpClientFactory.java @@ -187,7 +187,9 @@ SSLContext createSSLContext(GatewayServices services, GatewayConfig gatewayConfi KeystoreService ks = services.getService(ServiceType.KEYSTORE_SERVICE); try { boolean singleEku = gatewayConfig != null && gatewayConfig.isSingleEkuEnabled(); - if (Boolean.parseBoolean(filterConfig.getInitParameter(PARAMETER_USE_TWO_WAY_SSL))) { + boolean twoWaySsl = Boolean.parseBoolean(filterConfig.getInitParameter(PARAMETER_USE_TWO_WAY_SSL)) + || (gatewayConfig != null && gatewayConfig.isHttpClientTwoWaySslEnabled()); + if (twoWaySsl) { LOG.usingTwoWaySsl(serviceRole); AliasService as = services.getService(ServiceType.ALIAS_SERVICE); diff --git a/gateway-spi/src/test/java/org/apache/knox/gateway/dispatch/DefaultHttpClientFactoryTest.java b/gateway-spi/src/test/java/org/apache/knox/gateway/dispatch/DefaultHttpClientFactoryTest.java index eeb6ad3847..6ec8ccf79a 100644 --- a/gateway-spi/src/test/java/org/apache/knox/gateway/dispatch/DefaultHttpClientFactoryTest.java +++ b/gateway-spi/src/test/java/org/apache/knox/gateway/dispatch/DefaultHttpClientFactoryTest.java @@ -60,6 +60,7 @@ public void testCreateHttpClientSSLContextDefaults() throws Exception { GatewayConfig gatewayConfig = createMock(GatewayConfig.class); expect(gatewayConfig.isSingleEkuEnabled()).andReturn(false).anyTimes(); + expect(gatewayConfig.isHttpClientTwoWaySslEnabled()).andReturn(false).once(); expect(gatewayConfig.isMetricsEnabled()).andReturn(false).once(); expect(gatewayConfig.getHttpClientMaxConnections()).andReturn(32).once(); expect(gatewayConfig.getHttpClientConnectionTimeout()).andReturn(20000).once(); @@ -102,6 +103,7 @@ public void testCreateSSLContextDefaults() throws Exception { GatewayConfig gatewayConfig = createMock(GatewayConfig.class); expect(gatewayConfig.isSingleEkuEnabled()).andReturn(false).anyTimes(); + expect(gatewayConfig.isHttpClientTwoWaySslEnabled()).andReturn(false).once(); FilterConfig filterConfig = createMock(FilterConfig.class); expect(filterConfig.getInitParameter(PARAMETER_USE_TWO_WAY_SSL)).andReturn("false").once(); @@ -186,6 +188,7 @@ public void testCreateSSLContextOneWaySslNoCustomTrustStore() throws Exception { GatewayConfig gatewayConfig = createMock(GatewayConfig.class); expect(gatewayConfig.isSingleEkuEnabled()).andReturn(false).anyTimes(); + expect(gatewayConfig.isHttpClientTwoWaySslEnabled()).andReturn(false).once(); FilterConfig filterConfig = createMock(FilterConfig.class); expect(filterConfig.getInitParameter(PARAMETER_USE_TWO_WAY_SSL)).andReturn("false").once(); @@ -211,6 +214,7 @@ public void testCreateSSLContextOneWaySslWithCustomTrustStore() throws Exception GatewayConfig gatewayConfig = createMock(GatewayConfig.class); expect(gatewayConfig.isSingleEkuEnabled()).andReturn(false).anyTimes(); + expect(gatewayConfig.isHttpClientTwoWaySslEnabled()).andReturn(false).once(); FilterConfig filterConfig = createMock(FilterConfig.class); expect(filterConfig.getInitParameter(PARAMETER_USE_TWO_WAY_SSL)).andReturn("false").once(); @@ -265,6 +269,7 @@ public void testCreateSSLContextSingleEkuOneWayDoesNotPresentClientCert() throws GatewayConfig gatewayConfig = createMock(GatewayConfig.class); expect(gatewayConfig.isSingleEkuEnabled()).andReturn(true).anyTimes(); + expect(gatewayConfig.isHttpClientTwoWaySslEnabled()).andReturn(false).once(); FilterConfig filterConfig = createMock(FilterConfig.class); expect(filterConfig.getInitParameter(PARAMETER_USE_TWO_WAY_SSL)).andReturn("false").once(); @@ -279,6 +284,39 @@ public void testCreateSSLContextSingleEkuOneWayDoesNotPresentClientCert() throws verify(keystoreService, gatewayServices, gatewayConfig, filterConfig); } + @Test + public void testCreateSSLContextGlobalTwoWaySslActivatesClientKeystore() throws Exception { + // Global flag = true overrides per-dispatch param = false → client keystore is presented + KeyStore clientKeyStore = loadKeyStore("target/test-classes/keystores/server-keystore.jks", "horton", "JKS"); + + KeystoreService keystoreService = createMock(KeystoreService.class); + expect(keystoreService.getTruststoreForHttpClient()).andReturn(null).once(); + expect(keystoreService.getKeystoreForHttpClient()).andReturn(clientKeyStore).once(); + + AliasService aliasService = createMock(AliasService.class); + expect(aliasService.getHttpClientKeyPassphrase()).andReturn("horton".toCharArray()).once(); + + GatewayServices gatewayServices = createMock(GatewayServices.class); + expect(gatewayServices.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(keystoreService).once(); + expect(gatewayServices.getService(ServiceType.ALIAS_SERVICE)).andReturn(aliasService).once(); + + GatewayConfig gatewayConfig = createMock(GatewayConfig.class); + expect(gatewayConfig.isSingleEkuEnabled()).andReturn(true).anyTimes(); + expect(gatewayConfig.isHttpClientTwoWaySslEnabled()).andReturn(true).once(); + + FilterConfig filterConfig = createMock(FilterConfig.class); + // per-dispatch param is false — global flag must carry it + expect(filterConfig.getInitParameter(PARAMETER_USE_TWO_WAY_SSL)).andReturn("false").once(); + + replay(keystoreService, aliasService, gatewayServices, gatewayConfig, filterConfig); + + DefaultHttpClientFactory factory = new DefaultHttpClientFactory(); + SSLContext context = factory.createSSLContext(gatewayServices, gatewayConfig, filterConfig, "service"); + assertNotNull(context); + + verify(keystoreService, aliasService, gatewayServices, gatewayConfig, filterConfig); + } + @Test public void testCreateSSLContextSingleEkuTwoWayWrongPassphraseThrows() throws Exception { KeyStore clientKeyStore = loadKeyStore("target/test-classes/keystores/server-keystore.jks", "horton", "JKS"); @@ -355,6 +393,7 @@ public void testHttpRetriesValues() throws Exception { GatewayConfig gatewayConfig = createMock(GatewayConfig.class); expect(gatewayConfig.isSingleEkuEnabled()).andReturn(false).anyTimes(); + expect(gatewayConfig.isHttpClientTwoWaySslEnabled()).andReturn(false).anyTimes(); expect(gatewayConfig.isMetricsEnabled()).andReturn(false).anyTimes(); expect(gatewayConfig.getHttpClientMaxConnections()).andReturn(32).anyTimes(); expect(gatewayConfig.getHttpClientConnectionTimeout()).andReturn(20000).anyTimes(); From 53ceb43e51b63b058240bfee1a6f293a8fc67165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sandeep=20More=CC=81?= Date: Wed, 1 Jul 2026 15:14:09 -0400 Subject: [PATCH 03/16] Fix build --- .../apache/knox/gateway/ha/dispatch/SSEHaDispatchTest.java | 2 ++ .../main/java/org/apache/knox/gateway/GatewayTestConfig.java | 5 +++++ .../gateway/dispatch/DefaultHttpAsyncClientFactoryTest.java | 1 + .../java/org/apache/knox/gateway/sse/SSEDispatchTest.java | 1 + 4 files changed, 9 insertions(+) diff --git a/gateway-provider-ha/src/test/java/org/apache/knox/gateway/ha/dispatch/SSEHaDispatchTest.java b/gateway-provider-ha/src/test/java/org/apache/knox/gateway/ha/dispatch/SSEHaDispatchTest.java index 01ab89ee22..c0ca0f2e95 100644 --- a/gateway-provider-ha/src/test/java/org/apache/knox/gateway/ha/dispatch/SSEHaDispatchTest.java +++ b/gateway-provider-ha/src/test/java/org/apache/knox/gateway/ha/dispatch/SSEHaDispatchTest.java @@ -112,6 +112,7 @@ public void testHADispatchURL() throws Exception { expect(gatewayConfig.isAsyncSupported()).andReturn(true).anyTimes(); expect(gatewayConfig.isTopologyAsyncSupported("SSE")).andReturn(false).anyTimes(); expect(gatewayConfig.isSingleEkuEnabled()).andReturn(false).once(); + expect(gatewayConfig.isHttpClientTwoWaySslEnabled()).andReturn(false).once(); GatewayServices gatewayServices = createMock(GatewayServices.class); expect(gatewayServices.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(keystoreService).once(); @@ -693,6 +694,7 @@ private SSEHaDispatch createDispatch(boolean failoverNeeded, HaServiceConfig ser expect(gatewayConfig.isAsyncSupported()).andReturn(true).anyTimes(); expect(gatewayConfig.isTopologyAsyncSupported("SSE")).andReturn(false).once(); expect(gatewayConfig.isSingleEkuEnabled()).andReturn(false).once(); + expect(gatewayConfig.isHttpClientTwoWaySslEnabled()).andReturn(false).once(); GatewayServices gatewayServices = createMock(GatewayServices.class); expect(gatewayServices.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(keystoreService).once(); diff --git a/gateway-spi-common/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java b/gateway-spi-common/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java index 7a849e9db1..ae2cd7f2f8 100644 --- a/gateway-spi-common/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java +++ b/gateway-spi-common/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java @@ -497,6 +497,11 @@ public boolean isSingleEkuEnabled() { return DEFAULT_TLS_SINGLE_EKU_ENABLED; } + @Override + public boolean isHttpClientTwoWaySslEnabled() { + return isSingleEkuEnabled(); + } + @Override public String getCredentialStoreAlgorithm() { return DEFAULT_CREDENTIAL_STORE_ALG; diff --git a/gateway-spi/src/test/java/org/apache/knox/gateway/dispatch/DefaultHttpAsyncClientFactoryTest.java b/gateway-spi/src/test/java/org/apache/knox/gateway/dispatch/DefaultHttpAsyncClientFactoryTest.java index 65216b8c33..891b39660a 100644 --- a/gateway-spi/src/test/java/org/apache/knox/gateway/dispatch/DefaultHttpAsyncClientFactoryTest.java +++ b/gateway-spi/src/test/java/org/apache/knox/gateway/dispatch/DefaultHttpAsyncClientFactoryTest.java @@ -49,6 +49,7 @@ public void testCreateHttpAsyncClientSSLContextDefaults() throws Exception { expect(gatewayConfig.getHttpClientSocketTimeout()).andReturn(20000).once(); expect(gatewayConfig.getHttpClientCookieSpec()).andReturn(CookieSpecs.STANDARD).anyTimes(); expect(gatewayConfig.isSingleEkuEnabled()).andReturn(false).anyTimes(); + expect(gatewayConfig.isHttpClientTwoWaySslEnabled()).andReturn(false).once(); GatewayServices gatewayServices = createMock(GatewayServices.class); expect(gatewayServices.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(keystoreService).once(); diff --git a/gateway-spi/src/test/java/org/apache/knox/gateway/sse/SSEDispatchTest.java b/gateway-spi/src/test/java/org/apache/knox/gateway/sse/SSEDispatchTest.java index fb800fe566..491235c46b 100644 --- a/gateway-spi/src/test/java/org/apache/knox/gateway/sse/SSEDispatchTest.java +++ b/gateway-spi/src/test/java/org/apache/knox/gateway/sse/SSEDispatchTest.java @@ -511,6 +511,7 @@ private SSEDispatch createDispatch(boolean asyncSupported, boolean asyncSupporte expect(gatewayConfig.isAsyncSupported()).andReturn(asyncSupported).once(); expect(gatewayConfig.isTopologyAsyncSupported("SSE")).andReturn(asyncSupportedTopology).once(); expect(gatewayConfig.isSingleEkuEnabled()).andReturn(false).anyTimes(); + expect(gatewayConfig.isHttpClientTwoWaySslEnabled()).andReturn(false).once(); GatewayServices gatewayServices = createMock(GatewayServices.class); expect(gatewayServices.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(keystoreService).once(); From e535b31a70225b5a326bc37fc8d2819241180d34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sandeep=20More=CC=81?= Date: Mon, 6 Jul 2026 10:42:10 -0400 Subject: [PATCH 04/16] Added proper client and server identity validation + some minor changes --- gateway-server/pom.xml | 13 ++ .../security/impl/JettySSLService.java | 76 ++++++++- .../security/impl/JettySSLServiceTest.java | 145 ++++++++++++++++-- 3 files changed, 221 insertions(+), 13 deletions(-) diff --git a/gateway-server/pom.xml b/gateway-server/pom.xml index c789937810..3ddfb07ab8 100644 --- a/gateway-server/pom.xml +++ b/gateway-server/pom.xml @@ -551,6 +551,19 @@ test + + + org.bouncycastle + bcprov-jdk18on + test + + + org.bouncycastle + bcpkix-jdk18on + test + + org.apache.knox gateway-shell diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/JettySSLService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/JettySSLService.java index 99e5561fca..18cd19989c 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/JettySSLService.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/JettySSLService.java @@ -22,6 +22,7 @@ import java.security.cert.Certificate; import java.security.cert.CertificateExpiredException; import java.security.cert.CertificateNotYetValidException; +import java.security.cert.CertificateParsingException; import java.security.cert.X509Certificate; import java.util.Date; import java.util.List; @@ -45,6 +46,9 @@ public class JettySSLService implements SSLService { private static final String EPHEMERAL_DH_KEY_SIZE_PROPERTY = "jdk.tls.ephemeralDHKeySize"; private static final String GATEWAY_CREDENTIAL_STORE_NAME = "__gateway"; + // Extended Key Usage OIDs (RFC 5280) used to enforce single-purpose certificates in single-EKU mode. + private static final String EKU_SERVER_AUTH = "1.3.6.1.5.5.7.3.1"; + private static final String EKU_CLIENT_AUTH = "1.3.6.1.5.5.7.3.2"; private static GatewayMessages log = MessagesFactory.get( GatewayMessages.class ); private KeystoreService keystoreService; @@ -138,9 +142,14 @@ private void logAndValidateCertificate(GatewayConfig config) throws ServiceLifec } // Package private for unit test access. - // When single-EKU mode is enabled, refuse to start unless the outbound client identity keystore - // is present and usable, and inbound client authentication is enforced. Fail closed: never fall - // back to the server identity certificate. + // When single-EKU mode is enabled, refuse to start unless: + // - the outbound client-identity keystore is present and holds a usable key entry, + // - inbound client authentication is enforced (server truststore + client-auth), + // - the outbound HTTP client truststore is configured, and + // - both identities are single-purpose: the client identity carries clientAuth (not serverAuth) + // and the server identity carries serverAuth (not clientAuth). + // Fail closed: never fall back to the server identity certificate for outbound calls, and never + // defer a cross-wired/dual-purpose certificate to a runtime handshake failure. void validateSingleEkuConfig(GatewayConfig config) throws ServiceLifecycleException { // 1. Outbound client-identity keystore must be configured and contain the configured key entry. String clientKeystorePath = config.getHttpClientKeystorePath(); @@ -150,8 +159,9 @@ void validateSingleEkuConfig(GatewayConfig config) throws ServiceLifecycleExcept + ") is not configured. Server will not start."); } String clientKeyAlias = config.getHttpClientKeyAlias(); + KeyStore clientKeystore; try { - KeyStore clientKeystore = keystoreService.getKeystoreForHttpClient(); + clientKeystore = keystoreService.getKeystoreForHttpClient(); if (clientKeystore == null || !clientKeystore.isKeyEntry(clientKeyAlias)) { throw new ServiceLifecycleException("Single-EKU mode is enabled but the HTTP client keystore at " + clientKeystorePath + " does not contain a key entry for alias '" + clientKeyAlias @@ -181,6 +191,64 @@ void validateSingleEkuConfig(GatewayConfig config) throws ServiceLifecycleExcept + " is not configured. Knox cannot verify TLS certificates of upstream services. " + "Server will not start."); } + + // 4. Enforce single-purpose Extended Key Usage on both identities. This is the guarantee the + // feature exists to provide: the outbound client identity must be clientAuth-only and the + // inbound server identity must be serverAuth-only, so a cross-wired or dual-purpose cert cannot + // be presented in the wrong direction. Fail closed rather than defer the discovery to a runtime + // handshake failure. + Certificate clientCert; + try { + clientCert = clientKeystore.getCertificate(clientKeyAlias); + } catch (KeyStoreException e) { + throw new ServiceLifecycleException("Single-EKU mode is enabled but the certificate for alias '" + + clientKeyAlias + "' in the HTTP client keystore could not be read. Server will not start.", e); + } + validateSinglePurposeEku(clientCert, clientKeyAlias, "HTTP client keystore", + EKU_CLIENT_AUTH, "clientAuth", EKU_SERVER_AUTH, "serverAuth"); + + String identityKeyAlias = config.getIdentityKeyAlias(); + Certificate serverCert; + try { + KeyStore serverKeystore = keystoreService.getKeystoreForGateway(); + serverCert = serverKeystore == null ? null : serverKeystore.getCertificate(identityKeyAlias); + } catch (KeystoreServiceException | KeyStoreException e) { + throw new ServiceLifecycleException("Single-EKU mode is enabled but the server identity certificate for " + + "alias '" + identityKeyAlias + "' could not be read. Server will not start.", e); + } + validateSinglePurposeEku(serverCert, identityKeyAlias, "server identity keystore", + EKU_SERVER_AUTH, "serverAuth", EKU_CLIENT_AUTH, "clientAuth"); + } + + // Fail closed unless the given certificate declares exactly the required single Extended Key Usage: + // it must carry requiredEku and must NOT carry forbiddenEku. A certificate with no EKU extension is + // rejected because single-EKU mode is an explicit opt-in to purpose-restricted certificates, and an + // unrestricted (EKU-less) cert is usable for any purpose — defeating the guarantee. + private void validateSinglePurposeEku(Certificate cert, String alias, String storeDesc, + String requiredEku, String requiredEkuName, String forbiddenEku, String forbiddenEkuName) + throws ServiceLifecycleException { + if (!(cert instanceof X509Certificate)) { + throw new ServiceLifecycleException("Single-EKU mode is enabled but the certificate for alias '" + + alias + "' in the " + storeDesc + " is missing or is not an X.509 certificate. Server will not start."); + } + List ekus; + try { + ekus = ((X509Certificate) cert).getExtendedKeyUsage(); + } catch (CertificateParsingException e) { + throw new ServiceLifecycleException("Single-EKU mode is enabled but the Extended Key Usage of the " + + "certificate for alias '" + alias + "' in the " + storeDesc + " could not be parsed. " + + "Server will not start.", e); + } + if (ekus == null || !ekus.contains(requiredEku)) { + throw new ServiceLifecycleException("Single-EKU mode is enabled but the certificate for alias '" + + alias + "' in the " + storeDesc + " does not declare the required " + requiredEkuName + + " Extended Key Usage. Server will not start."); + } + if (ekus.contains(forbiddenEku)) { + throw new ServiceLifecycleException("Single-EKU mode is enabled but the certificate for alias '" + + alias + "' in the " + storeDesc + " also declares the " + forbiddenEkuName + + " Extended Key Usage; single-EKU mode requires a single-purpose certificate. Server will not start."); + } } @Override diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/JettySSLServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/JettySSLServiceTest.java index d9c0dae7c9..1d3aba082e 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/JettySSLServiceTest.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/JettySSLServiceTest.java @@ -45,10 +45,24 @@ import org.junit.Test; import java.io.File; +import java.math.BigInteger; import java.nio.file.Path; import java.nio.file.Paths; +import java.security.KeyPair; +import java.security.KeyPairGenerator; import java.security.KeyStore; import java.security.UnrecoverableKeyException; +import java.security.cert.X509Certificate; +import java.util.Date; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; public class JettySSLServiceTest { @Test @@ -608,6 +622,7 @@ private GatewayConfig singleEkuConfig(String clientKeystorePath, String clientKe expect(config.isSingleEkuEnabled()).andReturn(true).anyTimes(); expect(config.getHttpClientKeystorePath()).andReturn(clientKeystorePath).anyTimes(); expect(config.getHttpClientKeyAlias()).andReturn(clientKeyAlias).anyTimes(); + expect(config.getIdentityKeyAlias()).andReturn("server").anyTimes(); expect(config.getTruststorePath()).andReturn(truststorePath).anyTimes(); expect(config.isClientAuthNeeded()).andReturn(clientAuthNeeded).anyTimes(); expect(config.isClientAuthWanted()).andReturn(false).anyTimes(); @@ -615,6 +630,33 @@ private GatewayConfig singleEkuConfig(String clientKeystorePath, String clientKe return config; } + // Build an in-memory JKS keystore holding a self-signed cert (key entry) at the given alias with the + // supplied Extended Key Usages. Pass no purposes to produce a cert with NO EKU extension. + private KeyStore keystoreWithEku(String alias, KeyPurposeId... purposes) throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair keyPair = kpg.generateKeyPair(); + + X500Name dn = new X500Name("CN=" + alias + ",OU=Test,O=Knox,C=US"); + Date from = new Date(); + Date to = new Date(from.getTime() + 86_400_000L); + BigInteger sn = BigInteger.valueOf(from.getTime()); + + JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + dn, sn, from, to, dn, keyPair.getPublic()); + if (purposes != null && purposes.length > 0) { + builder.addExtension(Extension.extendedKeyUsage, true, new ExtendedKeyUsage(purposes)); + } + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate()); + X509Certificate cert = new JcaX509CertificateConverter().getCertificate(builder.build(signer)); + + KeyStore ks = KeyStore.getInstance("JKS"); + ks.load(null, null); + ks.setKeyEntry(alias, keyPair.getPrivate(), "horton".toCharArray(), + new java.security.cert.Certificate[]{cert}); + return ks; + } + @Test public void testSingleEkuValidationFailsWhenClientKeystorePathMissing() { GatewayConfig config = singleEkuConfig(null, "server", "truststore.jks", true, "client-trust.jks"); @@ -690,24 +732,109 @@ public void testSingleEkuValidationFailsWhenClientAuthDisabled() throws Exceptio @Test public void testSingleEkuValidationPassesWhenAllPresent() throws Exception { - String basedir = System.getProperty("basedir"); - if (basedir == null) { - basedir = new java.io.File(".").getCanonicalPath(); + // Client identity is clientAuth-only, server identity is serverAuth-only — the valid single-EKU setup. + KeyStore clientKeystore = keystoreWithEku("server", KeyPurposeId.id_kp_clientAuth); + KeyStore serverKeystore = keystoreWithEku("server", KeyPurposeId.id_kp_serverAuth); + + GatewayConfig config = singleEkuConfig("client-keystore.jks", "server", "some-truststore.jks", true, "client-trust.jks"); + KeystoreService keystoreService = createMock(KeystoreService.class); + expect(keystoreService.getKeystoreForHttpClient()).andReturn(clientKeystore).anyTimes(); + expect(keystoreService.getKeystoreForGateway()).andReturn(serverKeystore).anyTimes(); + replay(config, keystoreService); + + JettySSLService sslService = new JettySSLService(); + sslService.setKeystoreService(keystoreService); + sslService.validateSingleEkuConfig(config); // must not throw + verify(config, keystoreService); + } + + @Test + public void testSingleEkuValidationFailsWhenClientCertNotClientAuth() throws Exception { + // Client keystore holds a serverAuth-ONLY cert — cannot do outbound mTLS. + KeyStore clientKeystore = keystoreWithEku("server", KeyPurposeId.id_kp_serverAuth); + + GatewayConfig config = singleEkuConfig("client-keystore.jks", "server", "some-truststore.jks", true, "client-trust.jks"); + KeystoreService keystoreService = createMock(KeystoreService.class); + expect(keystoreService.getKeystoreForHttpClient()).andReturn(clientKeystore).anyTimes(); + replay(config, keystoreService); + + JettySSLService sslService = new JettySSLService(); + sslService.setKeystoreService(keystoreService); + try { + sslService.validateSingleEkuConfig(config); + fail("Expected ServiceLifecycleException when client cert lacks clientAuth EKU"); + } catch (ServiceLifecycleException e) { + assertTrue(e.getMessage().contains("clientAuth")); + assertTrue(e.getMessage().contains("HTTP client keystore")); } - String clientKeystorePath = java.nio.file.Paths.get(basedir, "target", "test-classes", "keystores", "server-keystore.jks").toString(); - KeyStore clientKeystore = KeyStore.getInstance("JKS"); - try (java.io.InputStream in = java.nio.file.Files.newInputStream(java.nio.file.Paths.get(clientKeystorePath))) { - clientKeystore.load(in, "horton".toCharArray()); + verify(config, keystoreService); + } + + @Test + public void testSingleEkuValidationFailsWhenClientCertHasNoEku() throws Exception { + // Client keystore holds a cert with NO EKU extension — unrestricted, defeats single-EKU intent. + KeyStore clientKeystore = keystoreWithEku("server"); + + GatewayConfig config = singleEkuConfig("client-keystore.jks", "server", "some-truststore.jks", true, "client-trust.jks"); + KeystoreService keystoreService = createMock(KeystoreService.class); + expect(keystoreService.getKeystoreForHttpClient()).andReturn(clientKeystore).anyTimes(); + replay(config, keystoreService); + + JettySSLService sslService = new JettySSLService(); + sslService.setKeystoreService(keystoreService); + try { + sslService.validateSingleEkuConfig(config); + fail("Expected ServiceLifecycleException when client cert has no EKU"); + } catch (ServiceLifecycleException e) { + assertTrue(e.getMessage().contains("clientAuth")); } + verify(config, keystoreService); + } - GatewayConfig config = singleEkuConfig(clientKeystorePath, "server", "some-truststore.jks", true, "client-trust.jks"); + @Test + public void testSingleEkuValidationFailsWhenClientCertIsDualPurpose() throws Exception { + // Client keystore holds a dual-purpose cert (clientAuth + serverAuth) — not single-purpose. + KeyStore clientKeystore = keystoreWithEku("server", + KeyPurposeId.id_kp_clientAuth, KeyPurposeId.id_kp_serverAuth); + + GatewayConfig config = singleEkuConfig("client-keystore.jks", "server", "some-truststore.jks", true, "client-trust.jks"); KeystoreService keystoreService = createMock(KeystoreService.class); expect(keystoreService.getKeystoreForHttpClient()).andReturn(clientKeystore).anyTimes(); replay(config, keystoreService); JettySSLService sslService = new JettySSLService(); sslService.setKeystoreService(keystoreService); - sslService.validateSingleEkuConfig(config); // must not throw + try { + sslService.validateSingleEkuConfig(config); + fail("Expected ServiceLifecycleException when client cert is dual-purpose"); + } catch (ServiceLifecycleException e) { + assertTrue(e.getMessage().contains("also declares")); + assertTrue(e.getMessage().contains("serverAuth")); + } + verify(config, keystoreService); + } + + @Test + public void testSingleEkuValidationFailsWhenServerCertNotServerAuth() throws Exception { + // Client identity is valid (clientAuth), but server identity is clientAuth-only — cannot serve TLS. + KeyStore clientKeystore = keystoreWithEku("server", KeyPurposeId.id_kp_clientAuth); + KeyStore serverKeystore = keystoreWithEku("server", KeyPurposeId.id_kp_clientAuth); + + GatewayConfig config = singleEkuConfig("client-keystore.jks", "server", "some-truststore.jks", true, "client-trust.jks"); + KeystoreService keystoreService = createMock(KeystoreService.class); + expect(keystoreService.getKeystoreForHttpClient()).andReturn(clientKeystore).anyTimes(); + expect(keystoreService.getKeystoreForGateway()).andReturn(serverKeystore).anyTimes(); + replay(config, keystoreService); + + JettySSLService sslService = new JettySSLService(); + sslService.setKeystoreService(keystoreService); + try { + sslService.validateSingleEkuConfig(config); + fail("Expected ServiceLifecycleException when server cert lacks serverAuth EKU"); + } catch (ServiceLifecycleException e) { + assertTrue(e.getMessage().contains("serverAuth")); + assertTrue(e.getMessage().contains("server identity keystore")); + } verify(config, keystoreService); } From 29993ee1a27731bb9aa9f93bb420a014b4a689d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sandeep=20More=CC=81?= Date: Mon, 6 Jul 2026 14:27:06 -0400 Subject: [PATCH 05/16] Update knox credentials --- build.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.xml b/build.xml index 9c4a2afc28..28db20d8e2 100644 --- a/build.xml +++ b/build.xml @@ -393,7 +393,7 @@ Release build file for the Apache Knox Gateway - + From 55827287dbb561d7f5ad780f147d2254ac4cee85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sandeep=20More=CC=81?= Date: Mon, 6 Jul 2026 17:12:00 -0400 Subject: [PATCH 06/16] Minor changes --- .github/workflows/compose/docker-compose.single-eku.yml | 2 +- .github/workflows/compose/single-eku/gateway-single-eku.sh | 2 +- .github/workflows/compose/single-eku/gateway-site.xml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/compose/docker-compose.single-eku.yml b/.github/workflows/compose/docker-compose.single-eku.yml index e5bcaa43bc..a5a0c62bbf 100644 --- a/.github/workflows/compose/docker-compose.single-eku.yml +++ b/.github/workflows/compose/docker-compose.single-eku.yml @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # -# CDPD-103449: single-EKU compose OVERRIDE. +# KNOX-3359: single-EKU compose OVERRIDE. # # This file is intentionally separate from docker-compose.yml. The default # stack's existing integration tests make requests WITHOUT a client cert, so diff --git a/.github/workflows/compose/single-eku/gateway-single-eku.sh b/.github/workflows/compose/single-eku/gateway-single-eku.sh index 218afb3dd9..7c5f925033 100755 --- a/.github/workflows/compose/single-eku/gateway-single-eku.sh +++ b/.github/workflows/compose/single-eku/gateway-single-eku.sh @@ -14,7 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # -# CDPD-103449: single-EKU container entrypoint. +# KNOX-3359: single-EKU container entrypoint. # # The dev keystore fixtures (host-client_keystore.jks / global_truststore.jks) # are protected with the dev password "horton". The baked apache/knox-dev image diff --git a/.github/workflows/compose/single-eku/gateway-site.xml b/.github/workflows/compose/single-eku/gateway-site.xml index 0b185f2ddf..00274df272 100644 --- a/.github/workflows/compose/single-eku/gateway-site.xml +++ b/.github/workflows/compose/single-eku/gateway-site.xml @@ -19,7 +19,7 @@ limitations under the License. + + gateway.httpclient.truststore.path + /knox-runtime/conf/single-eku-keystores/global_truststore.jks + + + gateway.httpclient.truststore.type + JKS + + + + gateway.tls.keystore.path + /knox-runtime/conf/single-eku-keystores/server-identity_keystore.jks + + + gateway.tls.keystore.type + JKS + + + gateway.tls.key.alias + gateway-identity + gateway.client.auth.needed true diff --git a/.github/workflows/compose/single-eku/keystores/server-identity_keystore.jks b/.github/workflows/compose/single-eku/keystores/server-identity_keystore.jks new file mode 100644 index 0000000000000000000000000000000000000000..49101aa7d6fff420568936eec347df61fa4ec9f7 GIT binary patch literal 2120 zcmZWqX*AS}8=l`7`%Z(VkwS!vACoOh;mR`6P_``D8Oue|%8 z#LeIfIxX({SOP+pbwQd*Z}-DUC}w*#0TChfII30WZbsqfE>^E<7R|o+${2o*lmP{X zaSFbvdE4gI<3{5&uacuR^(u%8gyihq_3=V&Eq&lbIzF~NO*@&!+=A#dLGsHxyn66( z7Kh2Uj_yhgyWZL6vhB~D>UEYV$fyqwPpImR%O5qHhVfsNh+jTj+iTJh4M8{vZbXv$ zduwm#)%ZC0m9@3s=C{2$!oX;ZxDlNE@ClnIJNqQ}kikso#q{{xf<-(6(|RZDCeBOL zzo0x}KwuI@E5_Ct4nfXH(}zad*j1VQg%a#S+A6WlyO9sk1s`%Y@_h`)75fHVnz1ujrQCeO<#?7FqH=wwSD0Y}ne{G4xJrba+|~Cjg$S7^AN%W339> zIzo30($$EKbG8jGQ}_5wv}G6@b*4&3XG`qYrWAYYZ$BKmcWs?mYQZuNun}USJayKT z$())sDICWgC{7ats|DFas|?$0tj9`}SH_l-L!9bQET2I;IY)mz`l)uuIIUR`HN`q) zEwMnUVe585z%<3SULUrzMGVM?J6hOwK9cFHI*@GIKToA@%=uT}>!9Hoxi!tgb4_^g zA6!U}Kq`HJ6&jlVK(4MR40T~^aN%L49<4|?!iIV7$tO1zq-$P_JLSSF%9og0jl7A3ZD(#TgK@I z;&YUbRdAMM2(lm5u!X~9-Y&DNhS9EX!|~NxW{=9bKHXIIRA6EuAE=jE^QW5l$q#%} zd*`2XsV%Jr#Zo^UH(5R~KwMnZNQF(AP>@kr)1az?b&-_) zEM8Gw%)B9D4mVm|fek6$cvYMwl+E07F$)-DlTd9@GxDgh!mez5-52IrGYQ$V8SpiE zP3AJPw3;zdcR?4wPjoGn`7Wo3QS?E!YF9)CD*)c;h}BojgGv2sR{wNfU}u5-$QPwc z>VZUz=rj3ILwwuC&m^C}uNcQWc+GEpd()uV_Vsg%yG??-zGmJxUQkM}?S2fezPGg& zwYa=cT-PLKIpUY+2NXavMW!}%{^ssMX>y*>`#7iZWqQp`?_}jU{j|v9&1_bXRDD|T zR#j_9KfjHlmm5+SbihFZoD2G}X6;(72K9u+!GvWd4oNi|+YMq?Z>RR-2U-u)+B<>nq* z=O9J7&AL-nm?@*ZmWLIsP;`q3K-A_UPz>~+DZ0IX*2%e7;_bE*pVD{kcSX{gNy&jq zr=__K5}+F(0T{`lfdWt{!+GNmg1804>mVx~qc~BAxWftn%(M`YU4|88rlT>2(Qw1u zaGw6)d0tjoc~AjVkWrGkf|9?>%ZmC%ul$ex{{R6H{O{7!+NB`??2yyjOi%&ueC|nXo)7l3J40~QX&O*5#DKHG zQ|r?)#ZQ-Cu4nzZiG1>gakdD`+wfztV;@fPU2o1He7OT&_GsM4FXL86wqTb}{9aM> zG>S+PKUX%haHVjr%6+xUnml;whs{+i%9j|%JwH#*J3j7zD;Q{mM^jUvGynw1GXgOn z|EbUXY%m_!nTm9>k;~ZDi#lW;p1gF}Ig;gM3nG5WoG@;XlkhtV0;9X9y{)^8r?(Ht z{Y$$9V*#1y7*0PNEiF9_@L$(Yqdax}^w5~6cyP(xBk{4eiP)2J_HNyTn6QoiH zv%?_|>>E{Pzp0cf^nC2~8lBCFmRk8kw_iv!I(-wCV#T3=OhGc(PVHMNG(ivGYe8Y= zQRA}$zSxzfZa4^Z_WP?#2ZnImT+AQ4y)BkBD>BURjqGxsOz}2iQ9&ffefcT?k;Kyc z5i}8rWJDj5N(HCaJFa|5DvVIA-@HOMR0gXG^smrtpzCo-B;?2{3ZulpnV0G1Tq3zU t+lKmRYp%c3G6U_(C7I$VpO Date: Wed, 8 Jul 2026 07:55:17 -0400 Subject: [PATCH 09/16] KNOX-3359: Decouple outbound two-way SSL default from single-EKU --- .../gateway/config/impl/GatewayConfigImpl.java | 6 +----- .../config/impl/GatewayConfigImplTest.java | 16 ++++++++-------- .../apache/knox/gateway/GatewayTestConfig.java | 7 ++++++- .../knox/gateway/config/GatewayConfig.java | 10 ++++++---- 4 files changed, 21 insertions(+), 18 deletions(-) diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java b/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java index cf2d67cf1e..d401f30802 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java @@ -850,11 +850,7 @@ public boolean isSingleEkuEnabled() { @Override public boolean isHttpClientTwoWaySslEnabled() { - String configured = get(HTTP_CLIENT_TWO_WAY_SSL_ENABLED); - if (configured != null) { - return Boolean.parseBoolean(configured); - } - return isSingleEkuEnabled(); + return Boolean.parseBoolean(get(HTTP_CLIENT_TWO_WAY_SSL_ENABLED, HTTP_CLIENT_TWO_WAY_SSL_ENABLED_DEFAULT)); } @Override diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/config/impl/GatewayConfigImplTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/config/impl/GatewayConfigImplTest.java index eda7d68499..0303150be3 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/config/impl/GatewayConfigImplTest.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/config/impl/GatewayConfigImplTest.java @@ -584,21 +584,21 @@ public void testSingleEkuEnabledOption() { public void testHttpClientTwoWaySslEnabledOption() { GatewayConfigImpl config = new GatewayConfigImpl(); - // Default: single-EKU off - two-way SSL also off + // Default: off, independent of single-EKU. assertFalse(config.isHttpClientTwoWaySslEnabled()); - // When single-EKU is on, two-way SSL defaults to true (sane default) + // Enabling single-EKU must NOT turn on two-way SSL. config.set("gateway.tls.single.eku.enabled", "true"); - assertTrue(config.isHttpClientTwoWaySslEnabled()); - - // Explicit false overrides the single-EKU-derived default - config.set("gateway.httpclient.twoWaySsl.enabled", "false"); assertFalse(config.isHttpClientTwoWaySslEnabled()); - // Explicit true works regardless of single-EKU state - config.set("gateway.tls.single.eku.enabled", "false"); + // Explicit true works regardless of single-EKU state. config.set("gateway.httpclient.twoWaySsl.enabled", "true"); assertTrue(config.isHttpClientTwoWaySslEnabled()); + + // Explicit false works with single-EKU off too. + config.set("gateway.tls.single.eku.enabled", "false"); + config.set("gateway.httpclient.twoWaySsl.enabled", "false"); + assertFalse(config.isHttpClientTwoWaySslEnabled()); } @Test diff --git a/gateway-spi-common/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java b/gateway-spi-common/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java index ae2cd7f2f8..73fcf1bfd0 100644 --- a/gateway-spi-common/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java +++ b/gateway-spi-common/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java @@ -74,6 +74,7 @@ public class GatewayTestConfig extends Configuration implements GatewayConfig { private List includedSSLCiphers; private List excludedSSLCiphers; private boolean sslEnabled; + private boolean httpClientTwoWaySslEnabled; private String truststoreType = "jks"; private String keystoreType = "jks"; private String databaseSslTruststoreType = "JKS"; @@ -499,7 +500,11 @@ public boolean isSingleEkuEnabled() { @Override public boolean isHttpClientTwoWaySslEnabled() { - return isSingleEkuEnabled(); + return httpClientTwoWaySslEnabled; + } + + public void setHttpClientTwoWaySslEnabled(boolean httpClientTwoWaySslEnabled) { + this.httpClientTwoWaySslEnabled = httpClientTwoWaySslEnabled; } @Override diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java b/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java index 4106816664..2fdea677e5 100644 --- a/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java @@ -103,6 +103,7 @@ public interface GatewayConfig { String TLS_SINGLE_EKU_ENABLED = "gateway.tls.single.eku.enabled"; boolean DEFAULT_TLS_SINGLE_EKU_ENABLED = false; String HTTP_CLIENT_TWO_WAY_SSL_ENABLED = "gateway.httpclient.twoWaySsl.enabled"; + String HTTP_CLIENT_TWO_WAY_SSL_ENABLED_DEFAULT = "false"; String CREDENTIAL_STORE_ALG = "gateway.credential.store.alg"; String DEFAULT_CREDENTIAL_STORE_ALG = "AES"; @@ -330,10 +331,11 @@ public interface GatewayConfig { boolean isSingleEkuEnabled(); /** - * @return {@code true} when outbound mTLS is enabled globally for all dispatches. - * Defaults to {@code true} when {@link #isSingleEkuEnabled()} is {@code true}; - * otherwise defaults to {@code false}. An explicit value in gateway-site.xml - * always wins regardless of the single-EKU toggle. + * @return {@code true} when outbound mutual TLS (two-way SSL) is enabled globally for all + * dispatches. Defaults to {@code false} and is independent of the single-EKU feature + * ({@link #isSingleEkuEnabled()}); enable it explicitly via + * {@code gateway.httpclient.twoWaySsl.enabled} or the per-service {@code useTwoWaySsl} + * dispatch init-parameter. */ boolean isHttpClientTwoWaySslEnabled(); From 875a16296b6e800e3ebcb2e2f4534b257a5eab2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sandeep=20More=CC=81?= Date: Wed, 8 Jul 2026 08:00:55 -0400 Subject: [PATCH 10/16] KNOX-3359: Re-scope single-EKU validation to validate only what mTLS uses --- .../security/impl/JettySSLService.java | 123 ++++---- .../security/impl/JettySSLServiceTest.java | 269 ++++++++---------- 2 files changed, 177 insertions(+), 215 deletions(-) diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/JettySSLService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/JettySSLService.java index 18cd19989c..7c0c69506b 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/JettySSLService.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/JettySSLService.java @@ -142,71 +142,16 @@ private void logAndValidateCertificate(GatewayConfig config) throws ServiceLifec } // Package private for unit test access. - // When single-EKU mode is enabled, refuse to start unless: - // - the outbound client-identity keystore is present and holds a usable key entry, - // - inbound client authentication is enforced (server truststore + client-auth), - // - the outbound HTTP client truststore is configured, and - // - both identities are single-purpose: the client identity carries clientAuth (not serverAuth) - // and the server identity carries serverAuth (not clientAuth). - // Fail closed: never fall back to the server identity certificate for outbound calls, and never - // defer a cross-wired/dual-purpose certificate to a runtime handshake failure. + // Single-EKU mode guarantees every certificate Knox uses is single-purpose. It is INDEPENDENT of + // whether mTLS is enabled in either direction — validate only what is actually used: + // - ALWAYS: the server identity certificate must be serverAuth-only (Knox always presents it). + // - INBOUND mTLS (client.auth.needed/wanted): the server truststore must be configured. + // - OUTBOUND mTLS (httpclient.twoWaySsl.enabled): the client-identity keystore must hold a usable + // clientAuth-only key entry, and the outbound HTTP client truststore must be configured. + // Fail closed: reject a cross-wired / dual-purpose / EKU-less certificate at startup rather than + // deferring the discovery to a runtime handshake failure. void validateSingleEkuConfig(GatewayConfig config) throws ServiceLifecycleException { - // 1. Outbound client-identity keystore must be configured and contain the configured key entry. - String clientKeystorePath = config.getHttpClientKeystorePath(); - if (clientKeystorePath == null || clientKeystorePath.isEmpty()) { - throw new ServiceLifecycleException("Single-EKU mode is enabled (" + GatewayConfig.TLS_SINGLE_EKU_ENABLED - + "=true) but the HTTP client keystore path (" + GatewayConfig.HTTP_CLIENT_KEYSTORE_PATH - + ") is not configured. Server will not start."); - } - String clientKeyAlias = config.getHttpClientKeyAlias(); - KeyStore clientKeystore; - try { - clientKeystore = keystoreService.getKeystoreForHttpClient(); - if (clientKeystore == null || !clientKeystore.isKeyEntry(clientKeyAlias)) { - throw new ServiceLifecycleException("Single-EKU mode is enabled but the HTTP client keystore at " - + clientKeystorePath + " does not contain a key entry for alias '" + clientKeyAlias - + "' (" + GatewayConfig.HTTP_CLIENT_KEY_ALIAS + "). Server will not start."); - } - } catch (KeystoreServiceException | KeyStoreException e) { - throw new ServiceLifecycleException("Single-EKU mode is enabled but the HTTP client keystore at " - + clientKeystorePath + " could not be loaded. Server will not start.", e); - } - - // 2. Inbound client authentication must be enforced (server truststore + client-auth). - if (config.getTruststorePath() == null) { - throw new ServiceLifecycleException("Single-EKU mode is enabled but the server truststore (" - + GatewayConfig.GATEWAY_TRUSTSTORE_PATH + ") is not configured. Server will not start."); - } - if (!config.isClientAuthNeeded() && !config.isClientAuthWanted()) { - throw new ServiceLifecycleException("Single-EKU mode is enabled but client authentication is not " - + "enabled (set gateway.client.auth.needed=true). Server will not start."); - } - - // 3. Outbound HTTP client truststore must be configured so Knox can verify upstream TLS certs. - String httpClientTruststorePath = config.getHttpClientTruststorePath(); - if (httpClientTruststorePath == null || httpClientTruststorePath.isEmpty()) { - log.singleEkuHttpClientTruststoreNotConfigured(GatewayConfig.HTTP_CLIENT_TRUSTSTORE_PATH); - throw new ServiceLifecycleException("Single-EKU mode is enabled but " - + GatewayConfig.HTTP_CLIENT_TRUSTSTORE_PATH - + " is not configured. Knox cannot verify TLS certificates of upstream services. " - + "Server will not start."); - } - - // 4. Enforce single-purpose Extended Key Usage on both identities. This is the guarantee the - // feature exists to provide: the outbound client identity must be clientAuth-only and the - // inbound server identity must be serverAuth-only, so a cross-wired or dual-purpose cert cannot - // be presented in the wrong direction. Fail closed rather than defer the discovery to a runtime - // handshake failure. - Certificate clientCert; - try { - clientCert = clientKeystore.getCertificate(clientKeyAlias); - } catch (KeyStoreException e) { - throw new ServiceLifecycleException("Single-EKU mode is enabled but the certificate for alias '" - + clientKeyAlias + "' in the HTTP client keystore could not be read. Server will not start.", e); - } - validateSinglePurposeEku(clientCert, clientKeyAlias, "HTTP client keystore", - EKU_CLIENT_AUTH, "clientAuth", EKU_SERVER_AUTH, "serverAuth"); - + // ALWAYS: the server identity certificate is presented on every inbound TLS connection. String identityKeyAlias = config.getIdentityKeyAlias(); Certificate serverCert; try { @@ -218,6 +163,56 @@ void validateSingleEkuConfig(GatewayConfig config) throws ServiceLifecycleExcept } validateSinglePurposeEku(serverCert, identityKeyAlias, "server identity keystore", EKU_SERVER_AUTH, "serverAuth", EKU_CLIENT_AUTH, "clientAuth"); + + // INBOUND mTLS: only when client authentication is enabled does Knox need a truststore to + // validate incoming client certificates. + if ((config.isClientAuthNeeded() || config.isClientAuthWanted()) && config.getTruststorePath() == null) { + throw new ServiceLifecycleException("Single-EKU mode is enabled with inbound client authentication but " + + "the server truststore (" + GatewayConfig.GATEWAY_TRUSTSTORE_PATH + ") is not configured. " + + "Server will not start."); + } + + // OUTBOUND mTLS: only when two-way SSL is enabled is the client identity presented, so only then + // must it exist and be clientAuth-only, and only then is an outbound truststore required. + if (config.isHttpClientTwoWaySslEnabled()) { + String clientKeystorePath = config.getHttpClientKeystorePath(); + if (clientKeystorePath == null || clientKeystorePath.isEmpty()) { + throw new ServiceLifecycleException("Single-EKU mode is enabled with outbound two-way SSL (" + + GatewayConfig.HTTP_CLIENT_TWO_WAY_SSL_ENABLED + "=true) but the HTTP client keystore path (" + + GatewayConfig.HTTP_CLIENT_KEYSTORE_PATH + ") is not configured. Server will not start."); + } + String clientKeyAlias = config.getHttpClientKeyAlias(); + KeyStore clientKeystore; + try { + clientKeystore = keystoreService.getKeystoreForHttpClient(); + if (clientKeystore == null || !clientKeystore.isKeyEntry(clientKeyAlias)) { + throw new ServiceLifecycleException("Single-EKU mode is enabled with outbound two-way SSL but the " + + "HTTP client keystore at " + clientKeystorePath + " does not contain a key entry for alias '" + + clientKeyAlias + "' (" + GatewayConfig.HTTP_CLIENT_KEY_ALIAS + "). Server will not start."); + } + } catch (KeystoreServiceException | KeyStoreException e) { + throw new ServiceLifecycleException("Single-EKU mode is enabled with outbound two-way SSL but the " + + "HTTP client keystore at " + clientKeystorePath + " could not be loaded. Server will not start.", e); + } + + Certificate clientCert; + try { + clientCert = clientKeystore.getCertificate(clientKeyAlias); + } catch (KeyStoreException e) { + throw new ServiceLifecycleException("Single-EKU mode is enabled but the certificate for alias '" + + clientKeyAlias + "' in the HTTP client keystore could not be read. Server will not start.", e); + } + validateSinglePurposeEku(clientCert, clientKeyAlias, "HTTP client keystore", + EKU_CLIENT_AUTH, "clientAuth", EKU_SERVER_AUTH, "serverAuth"); + + String httpClientTruststorePath = config.getHttpClientTruststorePath(); + if (httpClientTruststorePath == null || httpClientTruststorePath.isEmpty()) { + log.singleEkuHttpClientTruststoreNotConfigured(GatewayConfig.HTTP_CLIENT_TRUSTSTORE_PATH); + throw new ServiceLifecycleException("Single-EKU mode is enabled with outbound two-way SSL but " + + GatewayConfig.HTTP_CLIENT_TRUSTSTORE_PATH + " is not configured. Knox cannot verify TLS " + + "certificates of upstream services. Server will not start."); + } + } } // Fail closed unless the given certificate declares exactly the required single Extended Key Usage: diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/JettySSLServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/JettySSLServiceTest.java index 1d3aba082e..627ee093c9 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/JettySSLServiceTest.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/JettySSLServiceTest.java @@ -617,15 +617,16 @@ private GatewayConfig createGatewayConfigForExcludeTopologyTest(boolean isClient private GatewayConfig singleEkuConfig(String clientKeystorePath, String clientKeyAlias, String truststorePath, boolean clientAuthNeeded, - String httpClientTruststorePath) { + boolean twoWaySsl, String httpClientTruststorePath) { GatewayConfig config = createMock(GatewayConfig.class); expect(config.isSingleEkuEnabled()).andReturn(true).anyTimes(); - expect(config.getHttpClientKeystorePath()).andReturn(clientKeystorePath).anyTimes(); - expect(config.getHttpClientKeyAlias()).andReturn(clientKeyAlias).anyTimes(); expect(config.getIdentityKeyAlias()).andReturn("server").anyTimes(); - expect(config.getTruststorePath()).andReturn(truststorePath).anyTimes(); expect(config.isClientAuthNeeded()).andReturn(clientAuthNeeded).anyTimes(); expect(config.isClientAuthWanted()).andReturn(false).anyTimes(); + expect(config.getTruststorePath()).andReturn(truststorePath).anyTimes(); + expect(config.isHttpClientTwoWaySslEnabled()).andReturn(twoWaySsl).anyTimes(); + expect(config.getHttpClientKeystorePath()).andReturn(clientKeystorePath).anyTimes(); + expect(config.getHttpClientKeyAlias()).andReturn(clientKeyAlias).anyTimes(); expect(config.getHttpClientTruststorePath()).andReturn(httpClientTruststorePath).anyTimes(); return config; } @@ -657,111 +658,120 @@ private KeyStore keystoreWithEku(String alias, KeyPurposeId... purposes) throws return ks; } + // Convenience: a valid serverAuth-only server identity keystore (alias "server"). + private KeyStore serverAuthKeystore() throws Exception { + return keystoreWithEku("server", KeyPurposeId.id_kp_serverAuth); + } + + private JettySSLService sslServiceWith(KeystoreService keystoreService) { + JettySSLService sslService = new JettySSLService(); + sslService.setKeystoreService(keystoreService); + return sslService; + } + + // ---- ALWAYS: server identity must be serverAuth-only ---- + @Test - public void testSingleEkuValidationFailsWhenClientKeystorePathMissing() { - GatewayConfig config = singleEkuConfig(null, "server", "truststore.jks", true, "client-trust.jks"); + public void testSingleEkuValidationPassesWhenBothMtlsOff() throws Exception { + // single-EKU on, inbound OFF, outbound OFF -> only the serverAuth-only server identity is validated. + GatewayConfig config = singleEkuConfig(null, "server", null, false, false, null); KeystoreService keystoreService = createMock(KeystoreService.class); + expect(keystoreService.getKeystoreForGateway()).andReturn(serverAuthKeystore()).anyTimes(); replay(config, keystoreService); - JettySSLService sslService = new JettySSLService(); - sslService.setKeystoreService(keystoreService); + sslServiceWith(keystoreService).validateSingleEkuConfig(config); // must not throw + verify(config, keystoreService); + } + + @Test + public void testSingleEkuValidationFailsWhenServerCertNotServerAuth() throws Exception { + // Server identity is clientAuth-only -> cannot serve TLS under single-EKU. + GatewayConfig config = singleEkuConfig(null, "server", null, false, false, null); + KeystoreService keystoreService = createMock(KeystoreService.class); + expect(keystoreService.getKeystoreForGateway()) + .andReturn(keystoreWithEku("server", KeyPurposeId.id_kp_clientAuth)).anyTimes(); + replay(config, keystoreService); try { - sslService.validateSingleEkuConfig(config); - fail("Expected ServiceLifecycleException for missing client keystore path"); + sslServiceWith(keystoreService).validateSingleEkuConfig(config); + fail("Expected ServiceLifecycleException when server cert lacks serverAuth EKU"); } catch (ServiceLifecycleException e) { - assertTrue(e.getMessage().contains("gateway.httpclient.keystore.path")); + assertTrue(e.getMessage().contains("serverAuth")); + assertTrue(e.getMessage().contains("server identity keystore")); } verify(config, keystoreService); } @Test - public void testSingleEkuValidationFailsWhenTruststoreMissing() throws Exception { - String basedir = System.getProperty("basedir"); - if (basedir == null) { - basedir = new java.io.File(".").getCanonicalPath(); - } - String clientKeystorePath = java.nio.file.Paths.get(basedir, "target", "test-classes", "keystores", "server-keystore.jks").toString(); - KeyStore clientKeystore = KeyStore.getInstance("JKS"); - try (java.io.InputStream in = java.nio.file.Files.newInputStream(java.nio.file.Paths.get(clientKeystorePath))) { - clientKeystore.load(in, "horton".toCharArray()); - } - - GatewayConfig config = singleEkuConfig(clientKeystorePath, "server", null, true, "client-trust.jks"); + public void testSingleEkuValidationFailsWhenServerCertHasNoEku() throws Exception { + GatewayConfig config = singleEkuConfig(null, "server", null, false, false, null); KeystoreService keystoreService = createMock(KeystoreService.class); - expect(keystoreService.getKeystoreForHttpClient()).andReturn(clientKeystore).anyTimes(); + expect(keystoreService.getKeystoreForGateway()) + .andReturn(keystoreWithEku("server")).anyTimes(); replay(config, keystoreService); - - JettySSLService sslService = new JettySSLService(); - sslService.setKeystoreService(keystoreService); try { - sslService.validateSingleEkuConfig(config); - fail("Expected ServiceLifecycleException for missing server truststore"); + sslServiceWith(keystoreService).validateSingleEkuConfig(config); + fail("Expected ServiceLifecycleException when server cert has no EKU"); } catch (ServiceLifecycleException e) { - assertTrue(e.getMessage().contains("gateway.truststore.path")); + assertTrue(e.getMessage().contains("serverAuth")); } verify(config, keystoreService); } - @Test - public void testSingleEkuValidationFailsWhenClientAuthDisabled() throws Exception { - String basedir = System.getProperty("basedir"); - if (basedir == null) { - basedir = new java.io.File(".").getCanonicalPath(); - } - String clientKeystorePath = java.nio.file.Paths.get(basedir, "target", "test-classes", "keystores", "server-keystore.jks").toString(); - KeyStore clientKeystore = KeyStore.getInstance("JKS"); - try (java.io.InputStream in = java.nio.file.Files.newInputStream(java.nio.file.Paths.get(clientKeystorePath))) { - clientKeystore.load(in, "horton".toCharArray()); - } + // ---- INBOUND mTLS: server truststore required only when client-auth is on ---- - GatewayConfig config = singleEkuConfig(clientKeystorePath, "server", "some-truststore.jks", false, "client-trust.jks"); + @Test + public void testSingleEkuValidationFailsWhenInboundOnAndTruststoreMissing() throws Exception { + GatewayConfig config = singleEkuConfig(null, "server", null, true, false, null); KeystoreService keystoreService = createMock(KeystoreService.class); - expect(keystoreService.getKeystoreForHttpClient()).andReturn(clientKeystore).anyTimes(); + expect(keystoreService.getKeystoreForGateway()).andReturn(serverAuthKeystore()).anyTimes(); replay(config, keystoreService); - - JettySSLService sslService = new JettySSLService(); - sslService.setKeystoreService(keystoreService); try { - sslService.validateSingleEkuConfig(config); - fail("Expected ServiceLifecycleException when client auth disabled"); + sslServiceWith(keystoreService).validateSingleEkuConfig(config); + fail("Expected ServiceLifecycleException for missing server truststore with inbound client-auth"); } catch (ServiceLifecycleException e) { - assertTrue(e.getMessage().contains("client authentication")); + assertTrue(e.getMessage().contains(GatewayConfig.GATEWAY_TRUSTSTORE_PATH)); } verify(config, keystoreService); } @Test - public void testSingleEkuValidationPassesWhenAllPresent() throws Exception { - // Client identity is clientAuth-only, server identity is serverAuth-only — the valid single-EKU setup. - KeyStore clientKeystore = keystoreWithEku("server", KeyPurposeId.id_kp_clientAuth); - KeyStore serverKeystore = keystoreWithEku("server", KeyPurposeId.id_kp_serverAuth); - - GatewayConfig config = singleEkuConfig("client-keystore.jks", "server", "some-truststore.jks", true, "client-trust.jks"); + public void testSingleEkuValidationPassesWhenInboundOnAndTruststorePresent() throws Exception { + GatewayConfig config = singleEkuConfig(null, "server", "some-truststore.jks", true, false, null); KeystoreService keystoreService = createMock(KeystoreService.class); - expect(keystoreService.getKeystoreForHttpClient()).andReturn(clientKeystore).anyTimes(); - expect(keystoreService.getKeystoreForGateway()).andReturn(serverKeystore).anyTimes(); + expect(keystoreService.getKeystoreForGateway()).andReturn(serverAuthKeystore()).anyTimes(); replay(config, keystoreService); - JettySSLService sslService = new JettySSLService(); - sslService.setKeystoreService(keystoreService); - sslService.validateSingleEkuConfig(config); // must not throw + sslServiceWith(keystoreService).validateSingleEkuConfig(config); // must not throw verify(config, keystoreService); } - @Test - public void testSingleEkuValidationFailsWhenClientCertNotClientAuth() throws Exception { - // Client keystore holds a serverAuth-ONLY cert — cannot do outbound mTLS. - KeyStore clientKeystore = keystoreWithEku("server", KeyPurposeId.id_kp_serverAuth); + // ---- OUTBOUND mTLS: client identity + httpclient truststore required only when two-way SSL is on ---- - GatewayConfig config = singleEkuConfig("client-keystore.jks", "server", "some-truststore.jks", true, "client-trust.jks"); + @Test + public void testSingleEkuValidationFailsWhenOutboundOnAndClientKeystorePathMissing() throws Exception { + GatewayConfig config = singleEkuConfig(null, "server", null, false, true, "client-trust.jks"); KeystoreService keystoreService = createMock(KeystoreService.class); - expect(keystoreService.getKeystoreForHttpClient()).andReturn(clientKeystore).anyTimes(); + expect(keystoreService.getKeystoreForGateway()).andReturn(serverAuthKeystore()).anyTimes(); replay(config, keystoreService); + try { + sslServiceWith(keystoreService).validateSingleEkuConfig(config); + fail("Expected ServiceLifecycleException for missing client keystore path with two-way SSL"); + } catch (ServiceLifecycleException e) { + assertTrue(e.getMessage().contains(GatewayConfig.HTTP_CLIENT_KEYSTORE_PATH)); + } + verify(config, keystoreService); + } - JettySSLService sslService = new JettySSLService(); - sslService.setKeystoreService(keystoreService); + @Test + public void testSingleEkuValidationFailsWhenOutboundOnAndClientCertNotClientAuth() throws Exception { + GatewayConfig config = singleEkuConfig("client-keystore.jks", "server", null, false, true, "client-trust.jks"); + KeystoreService keystoreService = createMock(KeystoreService.class); + expect(keystoreService.getKeystoreForGateway()).andReturn(serverAuthKeystore()).anyTimes(); + expect(keystoreService.getKeystoreForHttpClient()) + .andReturn(keystoreWithEku("server", KeyPurposeId.id_kp_serverAuth)).anyTimes(); + replay(config, keystoreService); try { - sslService.validateSingleEkuConfig(config); + sslServiceWith(keystoreService).validateSingleEkuConfig(config); fail("Expected ServiceLifecycleException when client cert lacks clientAuth EKU"); } catch (ServiceLifecycleException e) { assertTrue(e.getMessage().contains("clientAuth")); @@ -771,19 +781,15 @@ public void testSingleEkuValidationFailsWhenClientCertNotClientAuth() throws Exc } @Test - public void testSingleEkuValidationFailsWhenClientCertHasNoEku() throws Exception { - // Client keystore holds a cert with NO EKU extension — unrestricted, defeats single-EKU intent. - KeyStore clientKeystore = keystoreWithEku("server"); - - GatewayConfig config = singleEkuConfig("client-keystore.jks", "server", "some-truststore.jks", true, "client-trust.jks"); + public void testSingleEkuValidationFailsWhenOutboundOnAndClientCertHasNoEku() throws Exception { + GatewayConfig config = singleEkuConfig("client-keystore.jks", "server", null, false, true, "client-trust.jks"); KeystoreService keystoreService = createMock(KeystoreService.class); - expect(keystoreService.getKeystoreForHttpClient()).andReturn(clientKeystore).anyTimes(); + expect(keystoreService.getKeystoreForGateway()).andReturn(serverAuthKeystore()).anyTimes(); + expect(keystoreService.getKeystoreForHttpClient()) + .andReturn(keystoreWithEku("server")).anyTimes(); replay(config, keystoreService); - - JettySSLService sslService = new JettySSLService(); - sslService.setKeystoreService(keystoreService); try { - sslService.validateSingleEkuConfig(config); + sslServiceWith(keystoreService).validateSingleEkuConfig(config); fail("Expected ServiceLifecycleException when client cert has no EKU"); } catch (ServiceLifecycleException e) { assertTrue(e.getMessage().contains("clientAuth")); @@ -792,20 +798,16 @@ public void testSingleEkuValidationFailsWhenClientCertHasNoEku() throws Exceptio } @Test - public void testSingleEkuValidationFailsWhenClientCertIsDualPurpose() throws Exception { - // Client keystore holds a dual-purpose cert (clientAuth + serverAuth) — not single-purpose. - KeyStore clientKeystore = keystoreWithEku("server", - KeyPurposeId.id_kp_clientAuth, KeyPurposeId.id_kp_serverAuth); - - GatewayConfig config = singleEkuConfig("client-keystore.jks", "server", "some-truststore.jks", true, "client-trust.jks"); + public void testSingleEkuValidationFailsWhenOutboundOnAndClientCertDualPurpose() throws Exception { + GatewayConfig config = singleEkuConfig("client-keystore.jks", "server", null, false, true, "client-trust.jks"); KeystoreService keystoreService = createMock(KeystoreService.class); - expect(keystoreService.getKeystoreForHttpClient()).andReturn(clientKeystore).anyTimes(); + expect(keystoreService.getKeystoreForGateway()).andReturn(serverAuthKeystore()).anyTimes(); + expect(keystoreService.getKeystoreForHttpClient()) + .andReturn(keystoreWithEku("server", KeyPurposeId.id_kp_clientAuth, KeyPurposeId.id_kp_serverAuth)) + .anyTimes(); replay(config, keystoreService); - - JettySSLService sslService = new JettySSLService(); - sslService.setKeystoreService(keystoreService); try { - sslService.validateSingleEkuConfig(config); + sslServiceWith(keystoreService).validateSingleEkuConfig(config); fail("Expected ServiceLifecycleException when client cert is dual-purpose"); } catch (ServiceLifecycleException e) { assertTrue(e.getMessage().contains("also declares")); @@ -815,51 +817,39 @@ public void testSingleEkuValidationFailsWhenClientCertIsDualPurpose() throws Exc } @Test - public void testSingleEkuValidationFailsWhenServerCertNotServerAuth() throws Exception { - // Client identity is valid (clientAuth), but server identity is clientAuth-only — cannot serve TLS. - KeyStore clientKeystore = keystoreWithEku("server", KeyPurposeId.id_kp_clientAuth); - KeyStore serverKeystore = keystoreWithEku("server", KeyPurposeId.id_kp_clientAuth); + public void testSingleEkuValidationFailsWhenOutboundOnAndAliasIsTrustedCertNotPrivateKey() throws Exception { + // Alias exists but as a TrustedCertEntry (no private key) -> isKeyEntry(alias) is false. + java.security.cert.Certificate cert = + keystoreWithEku("server", KeyPurposeId.id_kp_clientAuth).getCertificate("server"); + KeyStore ksWithCertEntry = KeyStore.getInstance("JKS"); + ksWithCertEntry.load(null, null); + ksWithCertEntry.setCertificateEntry("server", cert); - GatewayConfig config = singleEkuConfig("client-keystore.jks", "server", "some-truststore.jks", true, "client-trust.jks"); + GatewayConfig config = singleEkuConfig("client-keystore.jks", "server", null, false, true, "client-trust.jks"); KeystoreService keystoreService = createMock(KeystoreService.class); - expect(keystoreService.getKeystoreForHttpClient()).andReturn(clientKeystore).anyTimes(); - expect(keystoreService.getKeystoreForGateway()).andReturn(serverKeystore).anyTimes(); + expect(keystoreService.getKeystoreForGateway()).andReturn(serverAuthKeystore()).anyTimes(); + expect(keystoreService.getKeystoreForHttpClient()).andReturn(ksWithCertEntry).anyTimes(); replay(config, keystoreService); - - JettySSLService sslService = new JettySSLService(); - sslService.setKeystoreService(keystoreService); try { - sslService.validateSingleEkuConfig(config); - fail("Expected ServiceLifecycleException when server cert lacks serverAuth EKU"); + sslServiceWith(keystoreService).validateSingleEkuConfig(config); + fail("Expected ServiceLifecycleException when alias is a TrustedCertEntry, not a PrivateKeyEntry"); } catch (ServiceLifecycleException e) { - assertTrue(e.getMessage().contains("serverAuth")); - assertTrue(e.getMessage().contains("server identity keystore")); + assertTrue(e.getMessage().contains("key entry")); } verify(config, keystoreService); } @Test - public void testSingleEkuValidationFailsWhenHttpClientTruststoreMissing() throws Exception { - String basedir = System.getProperty("basedir"); - if (basedir == null) { - basedir = new java.io.File(".").getCanonicalPath(); - } - String clientKeystorePath = java.nio.file.Paths.get(basedir, "target", "test-classes", "keystores", "server-keystore.jks").toString(); - KeyStore clientKeystore = KeyStore.getInstance("JKS"); - try (java.io.InputStream in = java.nio.file.Files.newInputStream(java.nio.file.Paths.get(clientKeystorePath))) { - clientKeystore.load(in, "horton".toCharArray()); - } - - GatewayConfig config = singleEkuConfig(clientKeystorePath, "server", "some-truststore.jks", true, null); + public void testSingleEkuValidationFailsWhenOutboundOnAndHttpClientTruststoreMissing() throws Exception { + GatewayConfig config = singleEkuConfig("client-keystore.jks", "server", null, false, true, null); KeystoreService keystoreService = createMock(KeystoreService.class); - expect(keystoreService.getKeystoreForHttpClient()).andReturn(clientKeystore).anyTimes(); + expect(keystoreService.getKeystoreForGateway()).andReturn(serverAuthKeystore()).anyTimes(); + expect(keystoreService.getKeystoreForHttpClient()) + .andReturn(keystoreWithEku("server", KeyPurposeId.id_kp_clientAuth)).anyTimes(); replay(config, keystoreService); - - JettySSLService sslService = new JettySSLService(); - sslService.setKeystoreService(keystoreService); try { - sslService.validateSingleEkuConfig(config); - fail("Expected ServiceLifecycleException for missing HTTP client truststore"); + sslServiceWith(keystoreService).validateSingleEkuConfig(config); + fail("Expected ServiceLifecycleException for missing HTTP client truststore with two-way SSL"); } catch (ServiceLifecycleException e) { assertTrue(e.getMessage().contains(GatewayConfig.HTTP_CLIENT_TRUSTSTORE_PATH)); } @@ -867,38 +857,15 @@ public void testSingleEkuValidationFailsWhenHttpClientTruststoreMissing() throws } @Test - public void testSingleEkuValidationFailsWhenAliasIsTrustedCertNotPrivateKey() throws Exception { - String basedir = System.getProperty("basedir"); - if (basedir == null) { - basedir = new java.io.File(".").getCanonicalPath(); - } - // Extract the public cert from the server keystore and plant it as a TrustedCertEntry at the - // same alias — so isKeyEntry("server") returns false even though the alias exists. - KeyStore sourceKeystore = KeyStore.getInstance("JKS"); - try (java.io.InputStream in = java.nio.file.Files.newInputStream( - java.nio.file.Paths.get(basedir, "target", "test-classes", "keystores", "server-keystore.jks"))) { - sourceKeystore.load(in, "horton".toCharArray()); - } - java.security.cert.Certificate cert = sourceKeystore.getCertificate("server"); - - KeyStore ksWithCertEntry = KeyStore.getInstance("JKS"); - ksWithCertEntry.load(null, null); - ksWithCertEntry.setCertificateEntry("server", cert); - - String clientKeystorePath = java.nio.file.Paths.get(basedir, "target", "test-classes", "keystores", "server-keystore.jks").toString(); - GatewayConfig config = singleEkuConfig(clientKeystorePath, "server", "some-truststore.jks", true, "client-trust.jks"); + public void testSingleEkuValidationPassesWhenOutboundOnAndAllPresent() throws Exception { + GatewayConfig config = singleEkuConfig("client-keystore.jks", "server", null, false, true, "client-trust.jks"); KeystoreService keystoreService = createMock(KeystoreService.class); - expect(keystoreService.getKeystoreForHttpClient()).andReturn(ksWithCertEntry).anyTimes(); + expect(keystoreService.getKeystoreForGateway()).andReturn(serverAuthKeystore()).anyTimes(); + expect(keystoreService.getKeystoreForHttpClient()) + .andReturn(keystoreWithEku("server", KeyPurposeId.id_kp_clientAuth)).anyTimes(); replay(config, keystoreService); - JettySSLService sslService = new JettySSLService(); - sslService.setKeystoreService(keystoreService); - try { - sslService.validateSingleEkuConfig(config); - fail("Expected ServiceLifecycleException when alias is a TrustedCertEntry, not a PrivateKeyEntry"); - } catch (ServiceLifecycleException e) { - assertTrue(e.getMessage().contains("key entry")); - } + sslServiceWith(keystoreService).validateSingleEkuConfig(config); // must not throw verify(config, keystoreService); } From ff19db3f29afe91deed458cff6a1637425688167 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sandeep=20More=CC=81?= Date: Wed, 8 Jul 2026 08:06:12 -0400 Subject: [PATCH 11/16] KNOX-3359: Add single-EKU-without-mTLS integration scenario --- .../docker-compose.single-eku-no-mtls.yml | 35 +++ .github/workflows/compose/docker-compose.yml | 2 +- .../single-eku-no-mtls/gateway-site.xml | 222 ++++++++++++++++++ .github/workflows/tests.yml | 38 +++ .../tests/test_single_eku_no_mtls.py | 62 +++++ 5 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/compose/docker-compose.single-eku-no-mtls.yml create mode 100644 .github/workflows/compose/single-eku-no-mtls/gateway-site.xml create mode 100644 .github/workflows/tests/test_single_eku_no_mtls.py diff --git a/.github/workflows/compose/docker-compose.single-eku-no-mtls.yml b/.github/workflows/compose/docker-compose.single-eku-no-mtls.yml new file mode 100644 index 0000000000..94eae71a37 --- /dev/null +++ b/.github/workflows/compose/docker-compose.single-eku-no-mtls.yml @@ -0,0 +1,35 @@ +# 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. +# +# KNOX-3359: single-EKU WITHOUT mTLS compose OVERRIDE. +# +# single-EKU on, inbound client-auth OFF, outbound two-way SSL OFF. Reuses the +# serverAuth-only server identity keystore and the single-EKU entrypoint (which +# creates the identity password aliases; the extra aliases it also creates are +# unused here and harmless). +services: + knox: + command: /gateway-single-eku.sh + volumes: + - ./single-eku-no-mtls/gateway-site.xml:/knox-runtime/conf/gateway-site.xml:ro + - ./single-eku/keystores:/knox-runtime/conf/single-eku-keystores:ro + - ./single-eku/gateway-single-eku.sh:/gateway-single-eku.sh:ro + + tests: + volumes: + - ../tests:/tests + environment: + - KNOX_GATEWAY_URL=https://knox:8443/ + - KNOX_SINGLE_EKU_NO_MTLS=true diff --git a/.github/workflows/compose/docker-compose.yml b/.github/workflows/compose/docker-compose.yml index 4deb98dcb2..727bca2633 100644 --- a/.github/workflows/compose/docker-compose.yml +++ b/.github/workflows/compose/docker-compose.yml @@ -114,7 +114,7 @@ services: && pylint *.py && echo 'Waiting for knox...' && sleep 30 - && pytest --ignore=test_single_eku_mtls.py --junitxml=test-results.xml" + && pytest --ignore=test_single_eku_mtls.py --ignore=test_single_eku_no_mtls.py --junitxml=test-results.xml" depends_on: - knox diff --git a/.github/workflows/compose/single-eku-no-mtls/gateway-site.xml b/.github/workflows/compose/single-eku-no-mtls/gateway-site.xml new file mode 100644 index 0000000000..8774e67464 --- /dev/null +++ b/.github/workflows/compose/single-eku-no-mtls/gateway-site.xml @@ -0,0 +1,222 @@ + + + + + + + gateway.tls.single.eku.enabled + true + + + + gateway.tls.keystore.path + /knox-runtime/conf/single-eku-keystores/server-identity_keystore.jks + + + gateway.tls.keystore.type + JKS + + + gateway.tls.key.alias + gateway-identity + + + + gateway.service.alias.impl + org.apache.knox.gateway.services.security.impl.DefaultAliasService + + + gateway.port + 8443 + The HTTP port for the Gateway. + + + + gateway.path + gateway + The default context path for the gateway. + + + + gateway.gateway.conf.dir + deployments + The directory within GATEWAY_HOME that contains gateway topology files and deployments. + + + + + gateway.websocket.feature.enabled + true + Enable/Disable websocket feature. + + + + gateway.scope.cookies.feature.enabled + false + Enable/Disable cookie scoping feature. + + + + + + gateway.webshell.feature.enabled + true + Enable/Disable webshell feature. + + + gateway.webshell.max.concurrent.sessions + 20 + Maximum number of total concurrent webshell sessions + + + gateway.webshell.read.buffer.size + 1024 + Web Shell buffer size for reading + + + + + gateway.websocket.JWT.validation.feature.enabled + true + Enable/Disable websocket JWT validation at websocket layer. + + + + + knox.homepage.logout.enabled + true + Enable/disable logout from the Knox Homepage. + + + + + gateway.knox.token.eviction.grace.period + 0 + A duration (in seconds) beyond a token’s expiration to wait before evicting its state. This configuration only applies when server-managed token state is enabled either in gateway-site or at the topology level. + + + + + gateway.knox.admin.groups + admin + + + + + gateway.group.config.hadoop.security.group.mapping + org.apache.hadoop.security.LdapGroupsMapping + + + gateway.group.config.use.ldap.service + true + + + gateway.dispatch.whitelist.services + DATANODE,HBASEUI,HDFSUI,JOBHISTORYUI,NODEUI,YARNUI,knoxauth + The comma-delimited list of service roles for which the gateway.dispatch.whitelist should be applied. + + + gateway.dispatch.whitelist + ^https?:\/\/(www\.local\.com|localhost|127\.0\.0\.1|0:0:0:0:0:0:0:1|::1):[0-9].*$ + The whitelist to be applied for dispatches associated with the service roles specified by gateway.dispatch.whitelist.services. + If the value is DEFAULT, a domain-based whitelist will be derived from the Knox host. + + + gateway.xforwarded.header.context.append.servicename + LIVYSERVER + Add service name to x-forward-context header for the list of services defined above. + + + gateway.strict.transport.enabled + true + + + gateway.strict.transport.option + max-age=300; includeSubDomains + + + + + gateway.ldap.enabled + true + + + gateway.ldap.port + 33390 + + + gateway.ldap.base.dn + dc=hadoop,dc=apache,dc=org + + + gateway.ldap.recursive.group.resolution + true + + + gateway.ldap.interceptor.names + demoldap + + + + + gateway.ldap.interceptor.demoldap.interceptorType + backend + + + gateway.ldap.interceptor.demoldap.backendType + ldap + + + gateway.ldap.interceptor.demoldap.url + ldap://ldap:33389 + + + gateway.ldap.interceptor.demoldap.remoteBaseDn + dc=hadoop,dc=apache,dc=org + + + gateway.ldap.interceptor.demoldap.systemUsername + uid=guest,ou=people,dc=hadoop,dc=apache,dc=org + + + gateway.ldap.interceptor.demoldap.systemPassword + guest-password + + + gateway.ldap.interceptor.demoldap.userSearchBase + ou=people,dc=hadoop,dc=apache,dc=org + + + gateway.ldap.interceptor.demoldap.groupSearchBase + ou=groups,dc=hadoop,dc=apache,dc=org + + + gateway.ldap.interceptor.demoldap.groupMemberAttribute + member + + + diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c74724febb..da1a7c78fc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -116,6 +116,43 @@ jobs: echo '===== gateway.log =====' cat ./.github/workflows/compose/logs/gateway.log || true + # Second single-EKU scenario: mTLS OFF. Proves single-EKU does not force + # inbound client authentication -- a no-client-cert request must succeed. + - name: Restart Knox in single-EKU (no mTLS) mode + run: | + docker compose \ + -f ./.github/workflows/compose/docker-compose.yml \ + -f ./.github/workflows/compose/docker-compose.single-eku-no-mtls.yml \ + up -d knox + + - name: Wait for single-EKU (no mTLS) gateway to stabilize + run: sleep 30 # Adjust as needed for services startup time + + - name: Run Single-EKU (no mTLS) Tests + id: single_eku_no_mtls_tests + run: | + docker compose \ + -f ./.github/workflows/compose/docker-compose.yml \ + -f ./.github/workflows/compose/docker-compose.single-eku-no-mtls.yml \ + run --rm tests bash -c "pip install -r requirements.txt \ + && pytest test_single_eku_no_mtls.py --junitxml=test-results-single-eku-no-mtls.xml" + + - name: Dump single-EKU (no mTLS) diagnostics on failure + if: failure() && steps.single_eku_no_mtls_tests.outcome == 'failure' + run: | + echo '===== docker compose ps -a =====' + docker compose \ + -f ./.github/workflows/compose/docker-compose.yml \ + -f ./.github/workflows/compose/docker-compose.single-eku-no-mtls.yml \ + ps -a || true + echo '===== knox container logs =====' + docker compose \ + -f ./.github/workflows/compose/docker-compose.yml \ + -f ./.github/workflows/compose/docker-compose.single-eku-no-mtls.yml \ + logs --no-color knox || true + echo '===== gateway.log =====' + cat ./.github/workflows/compose/logs/gateway.log || true + - name: Upload Test Results if: (!cancelled()) uses: actions/upload-artifact@v4 @@ -124,6 +161,7 @@ jobs: path: | .github/workflows/tests/test-results.xml .github/workflows/tests/test-results-single-eku.xml + .github/workflows/tests/test-results-single-eku-no-mtls.xml - name: Upload Event File uses: actions/upload-artifact@v4 diff --git a/.github/workflows/tests/test_single_eku_no_mtls.py b/.github/workflows/tests/test_single_eku_no_mtls.py new file mode 100644 index 0000000000..b49311b1ff --- /dev/null +++ b/.github/workflows/tests/test_single_eku_no_mtls.py @@ -0,0 +1,62 @@ +# 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. + +"""Integration test for single-EKU mode with mTLS turned OFF. + +Started against a Knox gateway with gateway.tls.single.eku.enabled=true but +gateway.client.auth.needed=false and no outbound two-way SSL (supplied by +docker-compose.single-eku-no-mtls.yml, which also sets +KNOX_SINGLE_EKU_NO_MTLS=true). Proves single-EKU no longer forces inbound mTLS: +a request with NO client certificate must complete the TLS handshake. +""" + +import os +import unittest + +import requests +import urllib3 + +KNOX_URL = os.environ.get("KNOX_GATEWAY_URL", "https://knox:8443/").rstrip("/") +HEALTH_PATH = "/gateway/health/v1/ping" + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + +@unittest.skipUnless( + os.environ.get("KNOX_SINGLE_EKU_NO_MTLS") == "true", + "single-EKU/no-mTLS scenario not configured (apply docker-compose.single-eku-no-mtls.yml)", +) +class SingleEkuNoMtlsTest(unittest.TestCase): + """Asserts single-EKU startup does not force inbound client authentication.""" + + def test_plain_request_without_client_cert_completes_handshake(self): + """No client cert + client.auth off: the TLS handshake completes (not rejected). + + A completed handshake returning any HTTP status proves both that the server came + up in single-EKU mode (serverAuth-only identity validated) and that it does not + demand a client certificate. A ConnectionError/SSLError would mean either Knox + never started or it is still forcing mTLS. + """ + try: + resp = requests.get(KNOX_URL + HEALTH_PATH, verify=False, timeout=10) + except (requests.exceptions.SSLError, + requests.exceptions.ConnectionError) as exc: + self.fail(f"Knox rejected a no-client-cert request in single-EKU/no-mTLS mode: {exc}") + self.assertIsNotNone(resp.status_code) + + +if __name__ == "__main__": + unittest.main() From bf4c4e251fd252ff3fb409d7d41f87afb841a0ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sandeep=20More=CC=81?= Date: Wed, 8 Jul 2026 08:12:15 -0400 Subject: [PATCH 12/16] KNOX-3359: Document single-EKU / mTLS independence and config scenarios --- .../docs/config_mutual_authentication_ssl.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/knox-site/docs/config_mutual_authentication_ssl.md b/knox-site/docs/config_mutual_authentication_ssl.md index a562a64f09..adccd7e466 100644 --- a/knox-site/docs/config_mutual_authentication_ssl.md +++ b/knox-site/docs/config_mutual_authentication_ssl.md @@ -87,3 +87,60 @@ The below example excludes the `health` topology from mTLS on the 9443 port. An example how one can access the health topology on port 9443 without mTLS. https://{gateway-host}:9443/{gateway-path}/health + +#### Single-EKU certificates and mTLS #### + +The single-EKU feature (`gateway.tls.single.eku.enabled=true`) enforces that every certificate Knox uses is *single-purpose*: the server identity certificate must be `serverAuth`-only, and the outbound client identity certificate must be `clientAuth`-only. Certificates with a dual-purpose or missing Extended Key Usage (EKU) are rejected at startup. + +Single-EKU is **independent of mutual TLS**. Enabling it does not turn mTLS on in either direction; it only constrains the *purpose* of whichever certificates are actually used. Whether Knox requires client certificates on inbound connections, or presents one on outbound connections, is a separate, orthogonal deployment choice. + +The single-EKU invariant that always holds is that **the server identity certificate must be `serverAuth`-only**, because Knox always presents it as a TLS server. Everything else is validated only when the corresponding mTLS direction is enabled: + +* **Inbound mTLS** (Knox acting as a TLS server that requires client certificates) is controlled by `gateway.client.auth.needed` / `gateway.client.auth.wanted`. When enabled it requires an inbound truststore via `gateway.truststore.path`. +* **Outbound mTLS** (Knox acting as a client that presents a certificate to upstream services) is controlled by `gateway.httpclient.twoWaySsl.enabled`. It **defaults to `false`** and is **not** implied by single-EKU. When enabled it requires the `clientAuth`-only `gateway.httpclient.keystore.path` (holding a key entry for `gateway.httpclient.key.alias`) and `gateway.httpclient.truststore.path`. + +A deployment may therefore legitimately run single-EKU with mTLS off: Knox serves one-way TLS with its `serverAuth`-only identity, and any configured outbound client certificate simply goes unused. + +Passwords are not stored in `gateway-site.xml`; they resolve from credential-store aliases (`knoxcli.sh create-alias ...`) or the master-secret fallback. Keystore `*.type` defaults to `PKCS12`; `JKS` is shown below for the development fixtures. + +##### Scenario A — single-EKU WITH mTLS (inbound + outbound) ##### + + + gateway.tls.single.eku.enabledtrue + + + gateway.tls.keystore.path/path/server-identity_keystore.jks + gateway.tls.keystore.typeJKS + gateway.tls.key.aliasgateway-identity + + + gateway.client.auth.neededtrue + gateway.truststore.path/path/global_truststore.jks + gateway.truststore.typeJKS + + + gateway.httpclient.twoWaySsl.enabledtrue + gateway.httpclient.keystore.path/path/host-client_keystore.jks + gateway.httpclient.keystore.typeJKS + gateway.httpclient.key.aliasgateway-httpclient-key + gateway.httpclient.truststore.path/path/global_truststore.jks + gateway.httpclient.truststore.typeJKS + +Required aliases: `gateway-identity-keystore-password`, `gateway-identity-passphrase`, `gateway-truststore-password`, `gateway-httpclient-keystore-password`, `gateway-httpclient-truststore-password`. + +Inbound and outbound are independent: keep only the INBOUND block for inbound-only mTLS, or only the OUTBOUND block for outbound-only. + +##### Scenario B — single-EKU WITHOUT mTLS ##### + + + gateway.tls.single.eku.enabledtrue + + + gateway.tls.keystore.path/path/server-identity_keystore.jks + gateway.tls.keystore.typeJKS + gateway.tls.key.aliasgateway-identity + + + + +Required aliases: `gateway-identity-keystore-password`, `gateway-identity-passphrase`. Single-EKU validation only checks the `serverAuth`-only server identity; Knox serves one-way TLS and accepts requests with no client certificate. If Knox proxies to HTTPS upstreams in this mode, set `gateway.httpclient.truststore.path` for one-way upstream verification — general outbound-TLS config, not required by the single-EKU feature. From e722097a596b528bbd70439ef24574b97ff91045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sandeep=20More=CC=81?= Date: Wed, 8 Jul 2026 11:17:45 -0400 Subject: [PATCH 13/16] KNOX-3359: Make X509CertificateUtil.generateCertificate EKU-aware --- .../gateway/util/X509CertificateUtil.java | 14 ++++++- .../gateway/util/X509CertificateUtilTest.java | 39 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/X509CertificateUtil.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/X509CertificateUtil.java index 3bf07d8caa..a8811ab75f 100644 --- a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/X509CertificateUtil.java +++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/X509CertificateUtil.java @@ -50,10 +50,13 @@ import org.apache.commons.codec.binary.Base64; import org.apache.knox.gateway.i18n.GatewayUtilCommonMessages; import org.apache.knox.gateway.i18n.messages.MessagesFactory; +import org.bouncycastle.asn1.ASN1ObjectIdentifier; import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; import org.bouncycastle.asn1.x509.Extension; import org.bouncycastle.asn1.x509.GeneralName; import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.asn1.x509.KeyPurposeId; import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.cert.X509v3CertificateBuilder; import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; @@ -73,7 +76,7 @@ public class X509CertificateUtil { * @param algorithm the signing algorithm, eg "SHA256withRSA" * @return self-signed X.509 certificate */ - public static X509Certificate generateCertificate(String dn, KeyPair pair, int days, String algorithm) { + public static X509Certificate generateCertificate(String dn, KeyPair pair, int days, String algorithm, String... ekuOids) { try { Date from = new Date(); Date to = new Date(from.getTime() + days * 86400000L); @@ -121,6 +124,15 @@ public static X509Certificate generateCertificate(String dn, KeyPair pair, int d certBuilder.addExtension(Extension.subjectAlternativeName, false, subjectAltNames); } + // Add Extended Key Usage extension when purpose OIDs are supplied (single-EKU identities). + if (ekuOids != null && ekuOids.length > 0) { + KeyPurposeId[] purposes = new KeyPurposeId[ekuOids.length]; + for (int i = 0; i < ekuOids.length; i++) { + purposes[i] = KeyPurposeId.getInstance(new ASN1ObjectIdentifier(ekuOids[i])); + } + certBuilder.addExtension(Extension.extendedKeyUsage, false, new ExtendedKeyUsage(purposes)); + } + ContentSigner signer = new JcaContentSignerBuilder(algorithm).build(pair.getPrivate()); X509CertificateHolder certHolder = certBuilder.build(signer); diff --git a/gateway-util-common/src/test/java/org/apache/knox/gateway/util/X509CertificateUtilTest.java b/gateway-util-common/src/test/java/org/apache/knox/gateway/util/X509CertificateUtilTest.java index 506bf34121..dd3ad98296 100644 --- a/gateway-util-common/src/test/java/org/apache/knox/gateway/util/X509CertificateUtilTest.java +++ b/gateway-util-common/src/test/java/org/apache/knox/gateway/util/X509CertificateUtilTest.java @@ -23,6 +23,11 @@ import org.junit.Test; import org.junit.rules.TemporaryFolder; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; + import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLServerSocket; @@ -37,6 +42,7 @@ import java.security.Security; import java.security.cert.Certificate; import java.security.cert.X509Certificate; +import java.util.List; import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -126,4 +132,37 @@ public Void call() throws Exception { } } } + + @Test + public void testGenerateCertificateWithoutEkuHasNone() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + X509Certificate cert = X509CertificateUtil.generateCertificate( + "CN=localhost", kpg.generateKeyPair(), 30, "SHA256withRSA"); + assertNull(cert.getExtendedKeyUsage()); + } + + @Test + public void testGenerateCertificateWithServerAuthEku() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + X509Certificate cert = X509CertificateUtil.generateCertificate( + "CN=localhost", kpg.generateKeyPair(), 30, "SHA256withRSA", "1.3.6.1.5.5.7.3.1"); + List ekus = cert.getExtendedKeyUsage(); + assertNotNull(ekus); + assertTrue(ekus.contains("1.3.6.1.5.5.7.3.1")); // serverAuth + assertFalse(ekus.contains("1.3.6.1.5.5.7.3.2")); // clientAuth + } + + @Test + public void testGenerateCertificateWithClientAuthEku() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + X509Certificate cert = X509CertificateUtil.generateCertificate( + "CN=localhost", kpg.generateKeyPair(), 30, "SHA256withRSA", "1.3.6.1.5.5.7.3.2"); + List ekus = cert.getExtendedKeyUsage(); + assertNotNull(ekus); + assertTrue(ekus.contains("1.3.6.1.5.5.7.3.2")); // clientAuth + assertFalse(ekus.contains("1.3.6.1.5.5.7.3.1")); // serverAuth + } } From 81cd0470667fb75b765f9a0a71eb280606376adf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sandeep=20More=CC=81?= Date: Wed, 8 Jul 2026 11:23:02 -0400 Subject: [PATCH 14/16] KNOX-3359: EKU-aware self-signed generation + single-EKU httpclient keystore fallback --- .../security/impl/DefaultKeystoreService.java | 22 +++++++++---- .../impl/DefaultKeystoreServiceTest.java | 31 +++++++++++++++++++ .../services/security/KeystoreService.java | 9 ++++++ 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/DefaultKeystoreService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/DefaultKeystoreService.java index 44c546083d..94097aa1ea 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/DefaultKeystoreService.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/DefaultKeystoreService.java @@ -156,11 +156,15 @@ public KeyStore getTruststoreForHttpClient() throws KeystoreServiceException { public KeyStore getKeystoreForHttpClient() throws KeystoreServiceException { String keystorePath = config.getHttpClientKeystorePath(); if (keystorePath == null) { + // Single-EKU with no dedicated client keystore: the clientAuth identity is + // co-located in the gateway keystore. + if (config.isSingleEkuEnabled()) { + return getKeystoreForGateway(); + } return null; - } else { - return getKeystore(Paths.get(keystorePath), config.getHttpClientKeystoreType(), - config.getHttpClientKeystorePasswordAlias(), true); } + return getKeystore(Paths.get(keystorePath), config.getHttpClientKeystoreType(), + config.getHttpClientKeystorePasswordAlias(), true); } @Override @@ -196,7 +200,13 @@ public void addSelfSignedCertForGateway(String alias, char[] passphrase, String addCertForGateway(alias, passphrase, hostname); } - private synchronized void addCertForGateway(String alias, char[] passphrase, String hostname) + @Override + public void addSelfSignedCertForGateway(String alias, char[] passphrase, String hostname, String... ekuOids) + throws KeystoreServiceException { + addCertForGateway(alias, passphrase, hostname, ekuOids); + } + + private synchronized void addCertForGateway(String alias, char[] passphrase, String hostname, String... ekuOids) throws KeystoreServiceException { KeyPairGenerator keyPairGenerator; try { @@ -209,11 +219,11 @@ private synchronized void addCertForGateway(String alias, char[] passphrase, Str X509Certificate cert; if(hostname.equals(CERT_GEN_MODE_HOSTNAME)) { String dn = buildDistinguishedName(InetAddress.getLocalHost().getHostName()); - cert = X509CertificateUtil.generateCertificate(dn, KPair, 365, this.selfSigningCertificateAlgorithm); + cert = X509CertificateUtil.generateCertificate(dn, KPair, 365, this.selfSigningCertificateAlgorithm, ekuOids); } else { String dn = buildDistinguishedName(hostname); - cert = X509CertificateUtil.generateCertificate(dn, KPair, 365, this.selfSigningCertificateAlgorithm); + cert = X509CertificateUtil.generateCertificate(dn, KPair, 365, this.selfSigningCertificateAlgorithm, ekuOids); } KeyStore privateKS = getKeystoreForGateway(); diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/DefaultKeystoreServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/DefaultKeystoreServiceTest.java index 881a460676..82efa7702a 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/DefaultKeystoreServiceTest.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/DefaultKeystoreServiceTest.java @@ -38,6 +38,7 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -212,6 +213,7 @@ public void testGetKeystoreForHttpClientReturnsNullWhenPathNotConfigured() throw GatewayConfig config = createMock(GatewayConfig.class); expect(config.getHttpClientKeystorePath()).andReturn(null).atLeastOnce(); + expect(config.isSingleEkuEnabled()).andReturn(false).atLeastOnce(); // NEW: no fallback when single-EKU off expect(config.getGatewayKeystoreDir()).andReturn(testFolder.newFolder().getAbsolutePath()).anyTimes(); expectInitConfig(config); replay(config); @@ -224,6 +226,35 @@ public void testGetKeystoreForHttpClientReturnsNullWhenPathNotConfigured() throw verify(config); } + @Test + public void testGetKeystoreForHttpClientFallsBackToGatewayKeystoreForSingleEku() throws Exception { + MasterService masterService = createMock(MasterService.class); + expect(masterService.getMasterSecret()).andReturn("horton".toCharArray()).anyTimes(); + replay(masterService); + + GatewayConfig config = createMock(GatewayConfig.class); + expect(config.getHttpClientKeystorePath()).andReturn(null).atLeastOnce(); + expect(config.isSingleEkuEnabled()).andReturn(true).atLeastOnce(); + expect(config.getGatewayKeystoreDir()).andReturn(testFolder.newFolder().getAbsolutePath()).anyTimes(); + expectInitConfig(config); + replay(config); + + KeyStore gatewayKeystore = createNiceMock(KeyStore.class); + replay(gatewayKeystore); + + DefaultKeystoreService keystoreService = createMockBuilder(DefaultKeystoreService.class) + .addMockedMethod("getKeystoreForGateway") + .createMock(); + expect(keystoreService.getKeystoreForGateway()).andReturn(gatewayKeystore).once(); + keystoreService.setMasterService(masterService); + replay(keystoreService); + + keystoreService.init(config, Collections.emptyMap()); + + assertSame(gatewayKeystore, keystoreService.getKeystoreForHttpClient()); + verify(config, keystoreService); + } + @Test public void testGetKeystoreForHttpClientLoadsConfiguredKeystore() throws Exception { char[] password = "horton".toCharArray(); diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/KeystoreService.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/KeystoreService.java index ce024cff5b..faef5f58f6 100644 --- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/KeystoreService.java +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/KeystoreService.java @@ -34,6 +34,15 @@ public interface KeystoreService extends Service { void addSelfSignedCertForGateway(String alias, char[] passphrase, String hostname) throws KeystoreServiceException; + /** + * Adds a self-signed certificate to the Gateway keystore, stamping the given Extended Key Usage + * purpose OIDs (single-EKU identities). The default implementation ignores the OIDs. + */ + default void addSelfSignedCertForGateway(String alias, char[] passphrase, String hostname, String... ekuOids) + throws KeystoreServiceException { + addSelfSignedCertForGateway(alias, passphrase, hostname); + } + KeyStore getKeystoreForGateway() throws KeystoreServiceException; /** From 69b41b7e9ade16fc0b0afeffabda6d9080efe559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sandeep=20More=CC=81?= Date: Wed, 8 Jul 2026 11:28:19 -0400 Subject: [PATCH 15/16] KNOX-3359: Self-bootstrap single-EKU identities at startup with logging --- .../apache/knox/gateway/GatewayMessages.java | 6 +++ .../security/impl/JettySSLService.java | 36 +++++++++++------ .../security/impl/JettySSLServiceTest.java | 39 +++++++++++++++++-- 3 files changed, 67 insertions(+), 14 deletions(-) diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java b/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java index 6f10e89445..d4639b0114 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java @@ -208,6 +208,12 @@ public interface GatewayMessages { @Message( level = MessageLevel.ERROR, text = "Single-EKU mode is enabled but {0} is not configured. Knox cannot verify TLS certificates of upstream services. Server will not start." ) void singleEkuHttpClientTruststoreNotConfigured(String property); + @Message( level = MessageLevel.ERROR, text = "Single-EKU mode is enabled with outbound two-way SSL but no HTTP client identity is available for alias {0}. If two-way SSL was enabled after the gateway keystore was already created, the client identity was not generated at bootstrap. Remove the gateway keystore to regenerate it, or configure gateway.httpclient.keystore.path. Server will not start." ) + void singleEkuClientIdentityNotAvailable(String alias); + + @Message( level = MessageLevel.INFO, text = "Single-EKU mode: generating self-signed {0} identity for alias {1}." ) + void generatingSingleEkuIdentity(String ekuName, String alias); + @Message( level = MessageLevel.DEBUG, text = "Received request: {0} {1}" ) void receivedRequest( String method, String uri ); diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/JettySSLService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/JettySSLService.java index 7c0c69506b..11530305cf 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/JettySSLService.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/JettySSLService.java @@ -93,7 +93,24 @@ public void init(GatewayConfig config, Map options) } catch (AliasServiceException e) { throw new ServiceLifecycleException("Error accessing credential store for the gateway.", e); } - keystoreService.addSelfSignedCertForGateway(config.getIdentityKeyAlias(), passphrase); + if (config.isSingleEkuEnabled()) { + // Self-bootstrap single-purpose identities, in-line with dual-EKU self-signing. + log.generatingSingleEkuIdentity("serverAuth", config.getIdentityKeyAlias()); + keystoreService.addSelfSignedCertForGateway(config.getIdentityKeyAlias(), passphrase, null, EKU_SERVER_AUTH); + // Co-locate a clientAuth identity only when outbound two-way SSL will use it. + if (config.isHttpClientTwoWaySslEnabled()) { + char[] httpClientPassphrase; + try { + httpClientPassphrase = aliasService.getHttpClientKeyPassphrase(); + } catch (AliasServiceException e) { + throw new ServiceLifecycleException("Error accessing credential store for the gateway.", e); + } + log.generatingSingleEkuIdentity("clientAuth", config.getHttpClientKeyAlias()); + keystoreService.addSelfSignedCertForGateway(config.getHttpClientKeyAlias(), httpClientPassphrase, null, EKU_CLIENT_AUTH); + } + } else { + keystoreService.addSelfSignedCertForGateway(config.getIdentityKeyAlias(), passphrase); + } } else { log.keyStoreForGatewayFoundNotCreating(); @@ -175,24 +192,21 @@ void validateSingleEkuConfig(GatewayConfig config) throws ServiceLifecycleExcept // OUTBOUND mTLS: only when two-way SSL is enabled is the client identity presented, so only then // must it exist and be clientAuth-only, and only then is an outbound truststore required. if (config.isHttpClientTwoWaySslEnabled()) { - String clientKeystorePath = config.getHttpClientKeystorePath(); - if (clientKeystorePath == null || clientKeystorePath.isEmpty()) { - throw new ServiceLifecycleException("Single-EKU mode is enabled with outbound two-way SSL (" - + GatewayConfig.HTTP_CLIENT_TWO_WAY_SSL_ENABLED + "=true) but the HTTP client keystore path (" - + GatewayConfig.HTTP_CLIENT_KEYSTORE_PATH + ") is not configured. Server will not start."); - } String clientKeyAlias = config.getHttpClientKeyAlias(); KeyStore clientKeystore; try { clientKeystore = keystoreService.getKeystoreForHttpClient(); if (clientKeystore == null || !clientKeystore.isKeyEntry(clientKeyAlias)) { - throw new ServiceLifecycleException("Single-EKU mode is enabled with outbound two-way SSL but the " - + "HTTP client keystore at " + clientKeystorePath + " does not contain a key entry for alias '" - + clientKeyAlias + "' (" + GatewayConfig.HTTP_CLIENT_KEY_ALIAS + "). Server will not start."); + log.singleEkuClientIdentityNotAvailable(clientKeyAlias); + throw new ServiceLifecycleException("Single-EKU mode is enabled with outbound two-way SSL but no " + + "HTTP client key entry for alias '" + clientKeyAlias + "' (" + + GatewayConfig.HTTP_CLIENT_KEY_ALIAS + ") is available (configure " + + GatewayConfig.HTTP_CLIENT_KEYSTORE_PATH + " or let single-EKU generate one). " + + "Server will not start."); } } catch (KeystoreServiceException | KeyStoreException e) { throw new ServiceLifecycleException("Single-EKU mode is enabled with outbound two-way SSL but the " - + "HTTP client keystore at " + clientKeystorePath + " could not be loaded. Server will not start.", e); + + "HTTP client keystore could not be loaded. Server will not start.", e); } Certificate clientCert; diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/JettySSLServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/JettySSLServiceTest.java index 627ee093c9..e7fa71957e 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/JettySSLServiceTest.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/JettySSLServiceTest.java @@ -748,16 +748,49 @@ public void testSingleEkuValidationPassesWhenInboundOnAndTruststorePresent() thr // ---- OUTBOUND mTLS: client identity + httpclient truststore required only when two-way SSL is on ---- @Test - public void testSingleEkuValidationFailsWhenOutboundOnAndClientKeystorePathMissing() throws Exception { + public void testSingleEkuValidationPassesWithGeneratedServerIdentityAndTwoWayOff() throws Exception { + // Simulates a self-generated serverAuth identity present in the gateway keystore, mTLS off. + GatewayConfig config = singleEkuConfig(null, "server", null, false, false, null); + KeystoreService keystoreService = createMock(KeystoreService.class); + expect(keystoreService.getKeystoreForGateway()).andReturn(serverAuthKeystore()).anyTimes(); + replay(config, keystoreService); + + sslServiceWith(keystoreService).validateSingleEkuConfig(config); // must not throw + verify(config, keystoreService); + } + + @Test + public void testSingleEkuValidationPassesWithGeneratedClientIdentityWhenTwoWayOn() throws Exception { + // Outbound two-way on, no configured client keystore path: the clientAuth identity is + // available via the (fallback) HTTP-client keystore. Outbound truststore is configured. + GatewayConfig config = singleEkuConfig(null, "server", null, false, true, "client-trust.jks"); + KeystoreService keystoreService = createMock(KeystoreService.class); + expect(keystoreService.getKeystoreForGateway()).andReturn(serverAuthKeystore()).anyTimes(); + expect(keystoreService.getKeystoreForHttpClient()) + .andReturn(keystoreWithEku("server", KeyPurposeId.id_kp_clientAuth)).anyTimes(); + replay(config, keystoreService); + + sslServiceWith(keystoreService).validateSingleEkuConfig(config); // must not throw + verify(config, keystoreService); + } + + @Test + public void testSingleEkuValidationFailsWhenTwoWayOnAndNoClientKeyEntry() throws Exception { + // Known-limitation case: two-way on, keystore present but no client key entry for the alias. GatewayConfig config = singleEkuConfig(null, "server", null, false, true, "client-trust.jks"); KeystoreService keystoreService = createMock(KeystoreService.class); expect(keystoreService.getKeystoreForGateway()).andReturn(serverAuthKeystore()).anyTimes(); + // A keystore that has NO key entry for alias "server". + KeyStore empty = KeyStore.getInstance("JKS"); + empty.load(null, null); + expect(keystoreService.getKeystoreForHttpClient()).andReturn(empty).anyTimes(); replay(config, keystoreService); try { sslServiceWith(keystoreService).validateSingleEkuConfig(config); - fail("Expected ServiceLifecycleException for missing client keystore path with two-way SSL"); + fail("Expected ServiceLifecycleException when no client key entry is available"); } catch (ServiceLifecycleException e) { - assertTrue(e.getMessage().contains(GatewayConfig.HTTP_CLIENT_KEYSTORE_PATH)); + assertTrue(e.getMessage().contains("server")); // names the alias + assertTrue(e.getMessage().contains(GatewayConfig.HTTP_CLIENT_KEY_ALIAS)); // guidance } verify(config, keystoreService); } From 694581513050c9dcfedb1a36e87986a642210025 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sandeep=20More=CC=81?= Date: Wed, 8 Jul 2026 11:32:18 -0400 Subject: [PATCH 16/16] KNOX-3359: Simplify single-EKU no-mTLS integration scenario to self-bootstrap --- .../docker-compose.single-eku-no-mtls.yml | 10 +++----- .../single-eku-no-mtls/gateway-site.xml | 25 ++++--------------- 2 files changed, 8 insertions(+), 27 deletions(-) diff --git a/.github/workflows/compose/docker-compose.single-eku-no-mtls.yml b/.github/workflows/compose/docker-compose.single-eku-no-mtls.yml index 94eae71a37..7bc1916bff 100644 --- a/.github/workflows/compose/docker-compose.single-eku-no-mtls.yml +++ b/.github/workflows/compose/docker-compose.single-eku-no-mtls.yml @@ -15,17 +15,13 @@ # # KNOX-3359: single-EKU WITHOUT mTLS compose OVERRIDE. # -# single-EKU on, inbound client-auth OFF, outbound two-way SSL OFF. Reuses the -# serverAuth-only server identity keystore and the single-EKU entrypoint (which -# creates the identity password aliases; the extra aliases it also creates are -# unused here and harmless). +# single-EKU on, inbound client-auth OFF, outbound two-way SSL OFF. No keystores +# are mounted: Knox self-generates a serverAuth-only server identity at startup, +# so the default baked /gateway.sh entrypoint is used as-is. services: knox: - command: /gateway-single-eku.sh volumes: - ./single-eku-no-mtls/gateway-site.xml:/knox-runtime/conf/gateway-site.xml:ro - - ./single-eku/keystores:/knox-runtime/conf/single-eku-keystores:ro - - ./single-eku/gateway-single-eku.sh:/gateway-single-eku.sh:ro tests: volumes: diff --git a/.github/workflows/compose/single-eku-no-mtls/gateway-site.xml b/.github/workflows/compose/single-eku-no-mtls/gateway-site.xml index 8774e67464..bdc90706f9 100644 --- a/.github/workflows/compose/single-eku-no-mtls/gateway-site.xml +++ b/.github/workflows/compose/single-eku-no-mtls/gateway-site.xml @@ -19,31 +19,16 @@ limitations under the License. gateway.tls.single.eku.enabled true - - - gateway.tls.keystore.path - /knox-runtime/conf/single-eku-keystores/server-identity_keystore.jks - - - gateway.tls.keystore.type - JKS - - - gateway.tls.key.alias - gateway-identity - gateway.service.alias.impl