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..7bc1916bff --- /dev/null +++ b/.github/workflows/compose/docker-compose.single-eku-no-mtls.yml @@ -0,0 +1,31 @@ +# 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. 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: + volumes: + - ./single-eku-no-mtls/gateway-site.xml:/knox-runtime/conf/gateway-site.xml: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.single-eku.yml b/.github/workflows/compose/docker-compose.single-eku.yml new file mode 100644 index 0000000000..a5a0c62bbf --- /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. +# +# 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 +# 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/docker-compose.yml b/.github/workflows/compose/docker-compose.yml index 35036921d9..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 --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/gen-single-eku-keystores.sh b/.github/workflows/compose/gen-single-eku-keystores.sh new file mode 100755 index 0000000000..5e59c6c3b4 --- /dev/null +++ b/.github/workflows/compose/gen-single-eku-keystores.sh @@ -0,0 +1,75 @@ +#!/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) +# - server-identity_keystore.jks: a key pair whose certificate carries ONLY the +# serverAuth EKU (the single-EKU server identity). +# single-EKU mode refuses to start unless the +# server identity is serverAuth-only, and the +# image's auto-generated identity cert has no +# EKU at all, so a dedicated fixture is required. +# - 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" + +# Server identity: a key pair whose cert carries ONLY the serverAuth EKU. The +# alias must match the gateway identity key alias (gateway-identity) so Knox +# uses it as the inbound TLS server certificate. SANs cover the compose +# hostname so it is a valid (if self-signed) cert for https://knox:8443. +keytool -genkeypair -alias gateway-identity -keyalg RSA -keysize 2048 \ + -dname "CN=knox" -ext "eku=serverAuth" -ext "san=dns:knox,dns:localhost" \ + -validity 3650 \ + -keystore "$DIR/server-identity_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-no-mtls/gateway-site.xml b/.github/workflows/compose/single-eku-no-mtls/gateway-site.xml new file mode 100644 index 0000000000..bdc90706f9 --- /dev/null +++ b/.github/workflows/compose/single-eku-no-mtls/gateway-site.xml @@ -0,0 +1,207 @@ + + + + + + + gateway.tls.single.eku.enabled + 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/gateway-single-eku.sh b/.github/workflows/compose/single-eku/gateway-single-eku.sh new file mode 100755 index 0000000000..1762c0839a --- /dev/null +++ b/.github/workflows/compose/single-eku/gateway-single-eku.sh @@ -0,0 +1,47 @@ +#!/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. +# +# KNOX-3359: single-EKU container entrypoint. +# +# The dev keystore fixtures (host-client_keystore.jks / server-identity_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. The mounted fixtures do NOT use the master secret as their password, +# so relying on the fallback would fail to open them. We therefore create every +# alias single-EKU resolves explicitly, 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-httpclient-truststore-password : outbound HTTP client truststore +# - gateway-truststore-password : server truststore password +# - gateway-identity-keystore-password : server identity keystore password +# - gateway-identity-passphrase : server identity key passphrase +for alias in \ + gateway-httpclient-keystore-password \ + gateway-httpclient-truststore-password \ + gateway-truststore-password \ + gateway-identity-keystore-password \ + gateway-identity-passphrase; 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..27eaf9501c --- /dev/null +++ b/.github/workflows/compose/single-eku/gateway-site.xml @@ -0,0 +1,261 @@ + + + + + + + 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.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 + + + + 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 0000000000..5b13995f48 Binary files /dev/null and b/.github/workflows/compose/single-eku/keystores/global_truststore.jks differ 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 0000000000..7ee2e10950 Binary files /dev/null and b/.github/workflows/compose/single-eku/keystores/host-client_keystore.jks differ 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 0000000000..49101aa7d6 Binary files /dev/null and b/.github/workflows/compose/single-eku/keystores/server-identity_keystore.jks differ diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bb91ae7658..da1a7c78fc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -69,12 +69,99 @@ jobs: # Run the tests service defined in docker-compose.yml docker compose -f ./.github/workflows/compose/docker-compose.yml up --exit-code-from tests tests + # Single-EKU mTLS runs as its own pass. Its override turns on + # gateway.client.auth.needed=true, which would break the default + # (no-client-cert) tests above, so the gateway is recreated with the + # single-EKU config and the mTLS suite runs against that instance. The + # already-built apache/knox-dev image is reused -- no second Maven build. + - name: Restart Knox in single-EKU mode + run: | + docker compose \ + -f ./.github/workflows/compose/docker-compose.yml \ + -f ./.github/workflows/compose/docker-compose.single-eku.yml \ + up -d knox + + - name: Wait for single-EKU gateway to stabilize + run: sleep 30 # Adjust as needed for services startup time + + - name: Run Single-EKU mTLS Tests + id: single_eku_tests + run: | + # KNOX_SINGLE_EKU=true (set by the override) un-skips the suite. + # Emit a distinct JUnit file so it reports as its own test suite. + docker compose \ + -f ./.github/workflows/compose/docker-compose.yml \ + -f ./.github/workflows/compose/docker-compose.single-eku.yml \ + run --rm tests bash -c "pip install -r requirements.txt \ + && pytest test_single_eku_mtls.py --junitxml=test-results-single-eku.xml" + + # Evidence gathering: a "Connection refused" from the suite means the + # single-EKU gateway never listened. Dump container status, the knox + # container's stdout/stderr (includes the entrypoint's create-alias output + # and any Java startup stacktrace), and the gateway log so we can tell a + # failed entrypoint apart from single-EKU fail-fast validation. + - name: Dump single-EKU diagnostics on failure + if: failure() && steps.single_eku_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.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.yml \ + logs --no-color knox || true + 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 with: name: test-results - path: .github/workflows/tests/test-results.xml + 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_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/.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() 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/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 - + 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..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 @@ -111,6 +111,8 @@ 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(); + expect(gatewayConfig.isHttpClientTwoWaySslEnabled()).andReturn(false).once(); GatewayServices gatewayServices = createMock(GatewayServices.class); expect(gatewayServices.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(keystoreService).once(); @@ -691,6 +693,8 @@ 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(); + expect(gatewayConfig.isHttpClientTwoWaySslEnabled()).andReturn(false).once(); GatewayServices gatewayServices = createMock(GatewayServices.class); expect(gatewayServices.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(keystoreService).once(); 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/GatewayMessages.java b/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java index 21e0d7032b..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 @@ -205,6 +205,15 @@ 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.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/config/impl/GatewayConfigImpl.java b/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java index e1884db4e4..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 @@ -818,6 +818,41 @@ 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 boolean isHttpClientTwoWaySslEnabled() { + return Boolean.parseBoolean(get(HTTP_CLIENT_TWO_WAY_SSL_ENABLED, HTTP_CLIENT_TWO_WAY_SSL_ENABLED_DEFAULT)); + } + @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..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 @@ -152,6 +152,21 @@ public KeyStore getTruststoreForHttpClient() throws KeystoreServiceException { } } + @Override + 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; + } + return getKeystore(Paths.get(keystorePath), config.getHttpClientKeystoreType(), + config.getHttpClientKeystorePasswordAlias(), true); + } + @Override public KeyStore getSigningKeystore() throws KeystoreServiceException { return getSigningKeystore(null); @@ -185,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 { @@ -198,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/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..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 @@ -17,9 +17,12 @@ */ 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; +import java.security.cert.CertificateParsingException; import java.security.cert.X509Certificate; import java.util.Date; import java.util.List; @@ -43,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; @@ -87,12 +93,32 @@ 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(); } 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 +158,108 @@ private void logAndValidateCertificate(GatewayConfig config) throws ServiceLifec } } + // Package private for unit test access. + // 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 { + // ALWAYS: the server identity certificate is presented on every inbound TLS connection. + 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"); + + // 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 clientKeyAlias = config.getHttpClientKeyAlias(); + KeyStore clientKeystore; + try { + clientKeystore = keystoreService.getKeystoreForHttpClient(); + if (clientKeystore == null || !clientKeystore.isKeyEntry(clientKeyAlias)) { + 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 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: + // 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 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..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 @@ -544,6 +544,63 @@ 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 testHttpClientTwoWaySslEnabledOption() { + GatewayConfigImpl config = new GatewayConfigImpl(); + + // Default: off, independent of single-EKU. + assertFalse(config.isHttpClientTwoWaySslEnabled()); + + // Enabling single-EKU must NOT turn on two-way SSL. + config.set("gateway.tls.single.eku.enabled", "true"); + assertFalse(config.isHttpClientTwoWaySslEnabled()); + + // 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 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..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; @@ -54,7 +55,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 +205,91 @@ 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.isSingleEkuEnabled()).andReturn(false).atLeastOnce(); // NEW: no fallback when single-EKU off + 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 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(); + 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 +912,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..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 @@ -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; @@ -44,9 +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 @@ -599,4 +615,291 @@ private GatewayConfig createGatewayConfigForExcludeTopologyTest(boolean isClient return config; } + private GatewayConfig singleEkuConfig(String clientKeystorePath, String clientKeyAlias, + String truststorePath, boolean clientAuthNeeded, + boolean twoWaySsl, String httpClientTruststorePath) { + GatewayConfig config = createMock(GatewayConfig.class); + expect(config.isSingleEkuEnabled()).andReturn(true).anyTimes(); + expect(config.getIdentityKeyAlias()).andReturn("server").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; + } + + // 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; + } + + // 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 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); + + 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 { + sslServiceWith(keystoreService).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); + } + + @Test + public void testSingleEkuValidationFailsWhenServerCertHasNoEku() throws Exception { + GatewayConfig config = singleEkuConfig(null, "server", null, false, false, null); + KeystoreService keystoreService = createMock(KeystoreService.class); + expect(keystoreService.getKeystoreForGateway()) + .andReturn(keystoreWithEku("server")).anyTimes(); + replay(config, keystoreService); + try { + sslServiceWith(keystoreService).validateSingleEkuConfig(config); + fail("Expected ServiceLifecycleException when server cert has no EKU"); + } catch (ServiceLifecycleException e) { + assertTrue(e.getMessage().contains("serverAuth")); + } + verify(config, keystoreService); + } + + // ---- INBOUND mTLS: server truststore required only when client-auth is on ---- + + @Test + public void testSingleEkuValidationFailsWhenInboundOnAndTruststoreMissing() throws Exception { + GatewayConfig config = singleEkuConfig(null, "server", null, true, false, null); + KeystoreService keystoreService = createMock(KeystoreService.class); + expect(keystoreService.getKeystoreForGateway()).andReturn(serverAuthKeystore()).anyTimes(); + replay(config, keystoreService); + try { + sslServiceWith(keystoreService).validateSingleEkuConfig(config); + fail("Expected ServiceLifecycleException for missing server truststore with inbound client-auth"); + } catch (ServiceLifecycleException e) { + assertTrue(e.getMessage().contains(GatewayConfig.GATEWAY_TRUSTSTORE_PATH)); + } + verify(config, keystoreService); + } + + @Test + public void testSingleEkuValidationPassesWhenInboundOnAndTruststorePresent() throws Exception { + GatewayConfig config = singleEkuConfig(null, "server", "some-truststore.jks", true, 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); + } + + // ---- OUTBOUND mTLS: client identity + httpclient truststore required only when two-way SSL is on ---- + + @Test + 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 when no client key entry is available"); + } catch (ServiceLifecycleException e) { + assertTrue(e.getMessage().contains("server")); // names the alias + assertTrue(e.getMessage().contains(GatewayConfig.HTTP_CLIENT_KEY_ALIAS)); // guidance + } + verify(config, 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 { + sslServiceWith(keystoreService).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")); + } + verify(config, keystoreService); + } + + @Test + 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.getKeystoreForGateway()).andReturn(serverAuthKeystore()).anyTimes(); + expect(keystoreService.getKeystoreForHttpClient()) + .andReturn(keystoreWithEku("server")).anyTimes(); + replay(config, keystoreService); + try { + sslServiceWith(keystoreService).validateSingleEkuConfig(config); + fail("Expected ServiceLifecycleException when client cert has no EKU"); + } catch (ServiceLifecycleException e) { + assertTrue(e.getMessage().contains("clientAuth")); + } + verify(config, keystoreService); + } + + @Test + 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.getKeystoreForGateway()).andReturn(serverAuthKeystore()).anyTimes(); + expect(keystoreService.getKeystoreForHttpClient()) + .andReturn(keystoreWithEku("server", KeyPurposeId.id_kp_clientAuth, KeyPurposeId.id_kp_serverAuth)) + .anyTimes(); + replay(config, keystoreService); + try { + sslServiceWith(keystoreService).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 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", null, false, true, "client-trust.jks"); + KeystoreService keystoreService = createMock(KeystoreService.class); + expect(keystoreService.getKeystoreForGateway()).andReturn(serverAuthKeystore()).anyTimes(); + expect(keystoreService.getKeystoreForHttpClient()).andReturn(ksWithCertEntry).anyTimes(); + replay(config, keystoreService); + try { + sslServiceWith(keystoreService).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); + } + + @Test + public void testSingleEkuValidationFailsWhenOutboundOnAndHttpClientTruststoreMissing() throws Exception { + GatewayConfig config = singleEkuConfig("client-keystore.jks", "server", null, false, true, null); + 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); + try { + 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)); + } + verify(config, keystoreService); + } + + @Test + 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.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); + } + } 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..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"; @@ -467,6 +468,45 @@ 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 boolean isHttpClientTwoWaySslEnabled() { + return httpClientTwoWaySslEnabled; + } + + public void setHttpClientTwoWaySslEnabled(boolean httpClientTwoWaySslEnabled) { + this.httpClientTwoWaySslEnabled = httpClientTwoWaySslEnabled; + } + @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..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 @@ -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 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}" ) 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..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 @@ -90,6 +90,21 @@ 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 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"; String SELF_SIGNING_CERT_ALG = "gateway.self.signing.cert.alg"; @@ -282,6 +297,48 @@ 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 {@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(); + /** * @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..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 @@ -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,36 @@ 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 { - if (Boolean.parseBoolean(filterConfig.getInitParameter(PARAMETER_USE_TWO_WAY_SSL))) { + boolean singleEku = gatewayConfig != null && gatewayConfig.isSingleEkuEnabled(); + 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); - // 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 +212,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..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; /** @@ -44,6 +53,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..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 @@ -48,6 +48,8 @@ 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(); + 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/dispatch/DefaultHttpClientFactoryTest.java b/gateway-spi/src/test/java/org/apache/knox/gateway/dispatch/DefaultHttpClientFactoryTest.java index d8fe488ff8..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 @@ -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,8 @@ public void testCreateHttpClientSSLContextDefaults() throws Exception { expect(keystoreService.getTruststoreForHttpClient()).andReturn(null).once(); 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(); @@ -98,16 +101,20 @@ 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(); + expect(gatewayConfig.isHttpClientTwoWaySslEnabled()).andReturn(false).once(); + 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 +132,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 +163,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 +186,20 @@ 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(); + expect(gatewayConfig.isHttpClientTwoWaySslEnabled()).andReturn(false).once(); + 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 +212,144 @@ 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(); + expect(gatewayConfig.isHttpClientTwoWaySslEnabled()).andReturn(false).once(); + + 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(); + 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, gatewayConfig, filterConfig, "service"); + assertNotNull(context); + + 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(); + expect(gatewayConfig.isHttpClientTwoWaySslEnabled()).andReturn(false).once(); + 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"); + // 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 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, gatewayServices, filterConfig); + verify(keystoreService, aliasService, 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 +392,8 @@ public void testHttpRetriesValues() throws Exception { expect(keystoreService.getTruststoreForHttpClient()).andReturn(null).anyTimes(); 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(); 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..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 @@ -510,6 +510,8 @@ 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(); + expect(gatewayConfig.isHttpClientTwoWaySslEnabled()).andReturn(false).once(); GatewayServices gatewayServices = createMock(GatewayServices.class); expect(gatewayServices.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(keystoreService).once(); 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 + } } 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.