diff --git a/docs/pki-server-admin-https.md b/docs/pki-server-admin-https.md index 3ce24fa..ac5b03e 100644 --- a/docs/pki-server-admin-https.md +++ b/docs/pki-server-admin-https.md @@ -11,13 +11,57 @@ zeroecho-pki-server --config server-config.json Use `--help` and `--version` for launcher information. Validation parses the strict versioned JSON and checks finite security bounds without opening a listener, realm, store, provider, or audit sink. -The loopback and production examples are [pki-server-loopback-example.json](pki-server-loopback-example.json) and [pki-server-production-example.json](pki-server-production-example.json). They contain no credentials or key material. Replace every illustrative commitment and identity with provisioned values before use. The mapped principal must already exist in the durable server-control store and have explicit scoped grants; a certificate mapping establishes identity only and never grants authority. +The direct examples are [pki-server-loopback-example.json](pki-server-loopback-example.json) and [pki-server-production-example.json](pki-server-production-example.json). Trusted-proxy examples are [pki-server-trusted-proxy-rfc9440-example.json](pki-server-trusted-proxy-rfc9440-example.json) and [pki-server-trusted-proxy-nginx-example.json](pki-server-trusted-proxy-nginx-example.json). They contain no credentials or key material. Replace every illustrative commitment and identity with provisioned values before use. Mapped principals must already exist in the durable server-control store and have explicit scoped grants; a certificate mapping establishes identity only and never grants authority. -## TLS and authentication +## TLS and authentication modes -Administrative access has no anonymous mode. The listener always requires a client certificate. The built-in `jsse-pkcs12` TLS provider reads server identity and client trust stores from configured files, while passwords are resolved exclusively from named environment variables. Provider exceptions, aliases, references, certificate subjects, SANs, fingerprints, and paths are not returned to clients or written to request audit details. +Administrative access has no anonymous mode. Schema version 2 requires exactly one explicit mode: `DIRECT_MTLS` or `TRUSTED_REVERSE_PROXY`. The listener always requires a client certificate in either mode. Schema version 1 is rejected; there is no inferred direct-mode compatibility decoder. + +In `DIRECT_MTLS`, the TLS peer is the end-client principal. Forwarded certificate headers are rejected. The built-in `jsse-pkcs12` TLS provider reads server identity and direct administrative-client trust stores from configured files, while passwords are resolved exclusively from named environment variables. + +In `TRUSTED_REVERSE_PROXY`, the TLS peer is a dedicated `SERVICE` transport principal. Its only enabled allowing authority must be `FORWARD_AUTHENTICATED_CLIENT_IDENTITY`, explicitly scoped to the realm. ZeroEcho first validates backend mutual TLS and that permission, then independently parses and validates the forwarded external client certificate against `administrativeClientTrust`. Authorization, approvals, break-glass ownership and PII auditing use only the mapped end-client principal. Neither identity inherits the other identity's permissions. + +The preferred forwarding representation is RFC 9440 `Client-Cert` plus optional `Client-Cert-Chain`. The compatibility representation `NGINX_ESCAPED_PEM_V1` accepts one explicitly named header containing only the URL-escaped, canonical single-certificate PEM emitted by `$ssl_client_escaped_cert`. Formats are never auto-detected. Duplicate leaf or chain headers, mixed representations, malformed escapes, raw PEM, non-canonical Base64, concatenated objects and oversized values fail closed. + +Client certificates map to persisted principals through an exact SHA-256 certificate, canonical SPKI, or issuer-and-positive-serial commitment. Proxy and forwarded-client mappings additionally require an exact certificate or SPKI commitment and cannot overlap. Subject and SAN strings, source addresses, `X-Forwarded-For`, `Forwarded`, `X-Real-IP`, PROXY protocol data and certificate-header possession are not trust identities. An absent, ambiguous, expired, malformed, untrusted, wrong-usage, unauthorized or disabled mapping fails before typed operation decoding. + +Direct mode obtains proof of possession from ZeroEcho's TLS handshake. Proxy mode relies on the mutually authenticated and narrowly authorized proxy's attestation that external mutual-TLS proof of possession succeeded. Independently parsing and validating the forwarded certificate does not itself prove possession. + +Provider exceptions, aliases, references, certificate subjects, SANs, fingerprints, forwarded header values and paths are not returned to clients or written to request audit details. Safe audit records the authentication mode and keeps the transport and end-client principal identities separate. + +## Trusted NGINX topology + +The secure topology is: + +```text +administrator --mTLS--> NGINX --mTLS--> ZeroEcho +``` + +RFC 9440 is preferred where the proxy can emit it. For the explicit NGINX compatibility mode, the essential NGINX policy is conceptually: + +```nginx +server { + listen 443 ssl; + ssl_verify_client on; + ssl_client_certificate /external/reference/admin-client-trust.pem; + + location /admin/ { + proxy_set_header Client-Cert ""; + proxy_set_header Client-Cert-Chain ""; + proxy_set_header X-ZeroEcho-Client-Cert ""; + proxy_set_header X-ZeroEcho-Client-Cert $ssl_client_escaped_cert; + + proxy_ssl_verify on; + proxy_ssl_trusted_certificate /external/reference/backend-server-trust.pem; + proxy_ssl_certificate /external/reference/proxy-client-certificate.pem; + proxy_ssl_certificate_key /external/reference/proxy-client-key.pem; + proxy_pass https://zeroecho_backend; + } +} +``` + +The paths and upstream name above are placeholders, not deployment values. The proxy must remove or replace all inbound forwarded-certificate headers, verify the external client certificate, and use a dedicated backend client certificate mapped to the narrowly authorized proxy principal. `proxy_pass` must remain HTTPS. `X-Forwarded-For` and the client source address are never authorization evidence. Separate mapping sets identify the proxy transport principal and forwarded end clients. -Client certificates map to persisted principals through an exact SHA-256 certificate, canonical SPKI, or issuer-and-positive-serial commitment. Subject and SAN strings are not trust identities. An absent, ambiguous, expired, malformed, untrusted, or disabled mapping fails before typed operation decoding. ## API diff --git a/docs/pki-server-architecture.md b/docs/pki-server-architecture.md index 34eded5..a049011 100644 --- a/docs/pki-server-architecture.md +++ b/docs/pki-server-architecture.md @@ -882,6 +882,141 @@ Each server remains authoritative for its own realm. Cross-realm UI aggregation is presentation and orchestration, not shared PKI authority. + +## Trusted reverse-proxy TLS termination + +The ZeroEcho PKI server MUST support exactly these two explicit administrative authentication deployment modes: + +```text +DIRECT_MTLS +TRUSTED_REVERSE_PROXY +``` + +### Direct mutual TLS + +In `DIRECT_MTLS` mode, ZeroEcho terminates TLS, validates the client certificate, verifies possession through the TLS handshake, and maps the validated certificate to one persisted `SecurityPrincipal`. + +Forwarded client-identity headers MUST be rejected in this mode. + +### Trusted reverse proxy + +In `TRUSTED_REVERSE_PROXY` mode, a configured TLS-terminating reverse proxy such as NGINX validates the external client certificate and forwards the authenticated client identity to ZeroEcho. + +The proxy-to-ZeroEcho connection MUST itself be authenticated. + +The preferred deployment is: + +```text +administrative client + -- mutual TLS --> +trusted reverse proxy + -- separate mutual TLS --> +ZeroEcho +``` + +The proxy MUST authenticate using a dedicated infrastructure identity that is distinct from every end-user principal. + +The server MUST distinguish: + +```text +transport principal +end-client principal +``` + +The transport principal identifies the trusted proxy. The end-client principal is used for administrative authorization. + +The proxy principal MUST NOT inherit the permissions of the end-client principal. It MAY only receive the narrowly scoped infrastructure authority required to forward an authenticated client identity. + +That infrastructure authority MUST be an explicit realm-scoped permission equivalent to `FORWARD_AUTHENTICATED_CLIENT_IDENTITY`. It grants no PKI, identity-administration, PII, approval, or end-client authority, and break-glass access MUST NOT create implicit forwarding authority. + +### Forwarded certificate formats + +The preferred forwarding format is the RFC 9440 `Client-Cert` header and optional `Client-Cert-Chain` header. + +ZeroEcho MAY additionally support one explicitly configured NGINX compatibility format based on URL-escaped PEM, but it MUST NOT auto-detect arbitrary proxy-specific identity headers. + +Every accepted format MUST have: + +* one stable configuration identifier; +* strict bounded decoding; +* duplicate-header rejection; +* exact certificate consumption; +* canonical certificate validation; +* no subject-string-only identity mapping. + +### Header sanitization + +The trusted reverse proxy MUST remove or overwrite every incoming forwarded client-certificate header. + +A request for which external mutual TLS authentication did not succeed MUST NOT contain a forwarded client identity. + +ZeroEcho MUST reject: + +* forwarded identity received from an untrusted transport principal; +* duplicate client-certificate headers; +* malformed certificate or chain data; +* absent client identity for an administrative operation; +* zero or multiple principal mappings; +* conflicting identity representations. + +### Independent backend validation + +ZeroEcho MUST independently validate the forwarded certificate and, when provided, its chain using the configured administrative-client trust policy. + +ZeroEcho MUST validate: + +* strict canonical certificate DER; +* certificate validity; +* configured trust; +* allowed certificate usage; +* exact certificate or SPKI commitment mapping; +* persisted principal enabled state. + +The proof of possession for the external client certificate is attested by the trusted reverse proxy. Therefore, proxy authentication and transport integrity are part of the authentication authority in this mode. + +### Backend transport policy + +Plain unauthenticated HTTP over TCP MUST NOT be an enterprise-grade trusted-proxy mode, including over loopback. + +A production trusted-proxy deployment MUST use either: + +* mutually authenticated TLS from proxy to ZeroEcho; or +* a future explicitly supported operating-system-authenticated local transport such as a permission-protected Unix domain socket. + +Client source-address headers, `X-Forwarded-For`, `Forwarded`, or PROXY protocol information MUST NOT establish authentication or authorization. + +### Audit + +Safe authentication audit MUST record: + +* authentication mode; +* transport-principal identity; +* end-client principal identity when resolved; +* request correlation ID; +* safe result classification. + +It MUST NOT record: + +* raw certificates; +* subjects or SAN values; +* forwarded certificate headers; +* private material; +* proxy credentials; +* unrestricted parser or provider errors. + +### Security invariant + +Possession of a forwarded identity header is never authentication authority by itself. + +Forwarded client identity is trusted only when all of the following hold: + +```text +configured trusted-proxy mode ++ authenticated authorized transport principal ++ valid forwarded-certificate format ++ successful certificate and principal validation +``` + ## 18. Server implementation constraints The future server implementation MUST: diff --git a/docs/pki-server-loopback-example.json b/docs/pki-server-loopback-example.json index ccbef4b..5232d97 100644 --- a/docs/pki-server-loopback-example.json +++ b/docs/pki-server-loopback-example.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 2, "serverName": "zeroecho-admin", "realm": { "realmId": "production", @@ -56,7 +56,8 @@ "maximumBodyBytes": 1048576 }, "authentication": { - "mappings": [{ + "mode": "DIRECT_MTLS", + "directClientMappings": [{ "mappingId": "bootstrap-administrator", "principalId": "bootstrap-admin", "certificateSha256": "0000000000000000000000000000000000000000000000000000000000000000" diff --git a/docs/pki-server-production-example.json b/docs/pki-server-production-example.json index 1061bc4..fce14cc 100644 --- a/docs/pki-server-production-example.json +++ b/docs/pki-server-production-example.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 2, "serverName": "zeroecho-admin", "realm": { "realmId": "production", @@ -56,7 +56,8 @@ "maximumBodyBytes": 1048576 }, "authentication": { - "mappings": [{ + "mode": "DIRECT_MTLS", + "directClientMappings": [{ "mappingId": "bootstrap-administrator", "principalId": "bootstrap-admin", "certificateSha256": "0000000000000000000000000000000000000000000000000000000000000000" diff --git a/docs/pki-server-trusted-proxy-nginx-example.json b/docs/pki-server-trusted-proxy-nginx-example.json new file mode 100644 index 0000000..7b387bd --- /dev/null +++ b/docs/pki-server-trusted-proxy-nginx-example.json @@ -0,0 +1,44 @@ +{ + "version": 2, + "serverName": "zeroecho-admin-nginx", + "realm": { + "realmId": "production", + "displayName": "ZeroEcho Production", + "authorityExposure": {"mode":"ALL_REALM_AUTHORITIES","authorityIds":[],"creationPermitted":false}, + "authorizationCommitment": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "approvalCommitment": "96f85d99cb556b16af9b963833a832f905010681f437a757d94aab4a22e4c29c", + "disclosureCommitment": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + "disclosureDefaults": {"rootCa":"PUBLIC","intermediateCa":"PUBLIC","caChain":"PUBLIC","crl":"PUBLIC","leaf":"OWNER_ONLY","sensitiveLeaf":"RESTRICTED"}, + "controlLog": "state/server-control.log", + "controlStoreId": "0123456789abcdef0123456789abcdef", + "approvalPolicy": {"policyId":"high-risk","threshold":1,"eligibleApprovers":["bootstrap-approver"],"requiredRoleTemplateIds":[],"requesterSeparation":true,"lifetimeMillis":3600000,"justificationRequired":true}, + "pkiSession": { + "version": 1, + "store": {"id":"fs","properties":{"root":"state/pki"}}, + "audit": {"id":"file","properties":{"root":"state/audit"}}, + "publishers": [], + "bindingProviders": [] + } + }, + "listener": { + "address": "127.0.0.1", + "port": 8443, + "tlsProvider": {"id":"jsse-pkcs12","properties":{"keyStore":"tls/server-identity.p12","keyStorePasswordEnvironment":"ZEROECHO_TLS_KEYSTORE_PASSWORD","trustStore":"tls/proxy-transport-trust.p12","trustStorePasswordEnvironment":"ZEROECHO_PROXY_TRUSTSTORE_PASSWORD"}}, + "clientCertificateRequired": true, + "maximumHeaderBytes": 65536, + "maximumBodyBytes": 1048576 + }, + "authentication": { + "mode": "TRUSTED_REVERSE_PROXY", + "proxyTransportMappings": [{"mappingId":"proxy-transport","principalId":"trusted-proxy","certificateSha256":"1111111111111111111111111111111111111111111111111111111111111111"}], + "forwardedClientMappings": [{"mappingId":"forwarded-admin","principalId":"bootstrap-admin","subjectPublicKeyInfoSha256":"2222222222222222222222222222222222222222222222222222222222222222"}], + "trustedProxyPrincipalIds": ["trusted-proxy"], + "forwardedCertificateFormat": "NGINX_ESCAPED_PEM_V1", + "forwardedCertificateHeaderName": "X-ZeroEcho-Client-Cert", + "administrativeClientTrust": {"id":"jsse-pkcs12-client-trust","properties":{"trustStore":"tls/administrators-trust.p12","trustStorePasswordEnvironment":"ZEROECHO_ADMIN_TRUSTSTORE_PASSWORD"}}, + "maximumForwardedCertificateBytes": 65536, + "maximumForwardedChainBytes": 524288 + }, + "execution": {"transportWorkers":8,"transportQueueCapacity":64,"operationWorkers":4,"operationQueueCapacity":32,"maximumAdmittedRequests":96,"defaultDeadlineMillis":30000,"maximumDeadlineMillis":120000,"gracefulShutdownMillis":30000,"forcedShutdownMillis":10000}, + "runtime": {} +} diff --git a/docs/pki-server-trusted-proxy-rfc9440-example.json b/docs/pki-server-trusted-proxy-rfc9440-example.json new file mode 100644 index 0000000..450a825 --- /dev/null +++ b/docs/pki-server-trusted-proxy-rfc9440-example.json @@ -0,0 +1,45 @@ +{ + "version": 2, + "serverName": "zeroecho-admin-proxy", + "realm": { + "realmId": "production", + "displayName": "ZeroEcho Production", + "authorityExposure": {"mode":"ALL_REALM_AUTHORITIES","authorityIds":[],"creationPermitted":false}, + "authorizationCommitment": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "approvalCommitment": "96f85d99cb556b16af9b963833a832f905010681f437a757d94aab4a22e4c29c", + "disclosureCommitment": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + "disclosureDefaults": {"rootCa":"PUBLIC","intermediateCa":"PUBLIC","caChain":"PUBLIC","crl":"PUBLIC","leaf":"OWNER_ONLY","sensitiveLeaf":"RESTRICTED"}, + "controlLog": "state/server-control.log", + "controlStoreId": "0123456789abcdef0123456789abcdef", + "approvalPolicy": {"policyId":"high-risk","threshold":1,"eligibleApprovers":["bootstrap-approver"],"requiredRoleTemplateIds":[],"requesterSeparation":true,"lifetimeMillis":3600000,"justificationRequired":true}, + "pkiSession": { + "version": 1, + "store": {"id":"fs","properties":{"root":"state/pki"}}, + "audit": {"id":"file","properties":{"root":"state/audit"}}, + "publishers": [], + "bindingProviders": [] + } + }, + "listener": { + "address": "127.0.0.1", + "port": 8443, + "tlsProvider": {"id":"jsse-pkcs12","properties":{"keyStore":"tls/server-identity.p12","keyStorePasswordEnvironment":"ZEROECHO_TLS_KEYSTORE_PASSWORD","trustStore":"tls/proxy-transport-trust.p12","trustStorePasswordEnvironment":"ZEROECHO_PROXY_TRUSTSTORE_PASSWORD"}}, + "clientCertificateRequired": true, + "maximumHeaderBytes": 65536, + "maximumBodyBytes": 1048576 + }, + "authentication": { + "mode": "TRUSTED_REVERSE_PROXY", + "proxyTransportMappings": [{"mappingId":"proxy-transport","principalId":"trusted-proxy","certificateSha256":"1111111111111111111111111111111111111111111111111111111111111111"}], + "forwardedClientMappings": [{"mappingId":"forwarded-admin","principalId":"bootstrap-admin","subjectPublicKeyInfoSha256":"2222222222222222222222222222222222222222222222222222222222222222"}], + "trustedProxyPrincipalIds": ["trusted-proxy"], + "forwardedCertificateFormat": "RFC9440", + "forwardedCertificateHeaderName": "Client-Cert", + "forwardedCertificateChainHeaderName": "Client-Cert-Chain", + "administrativeClientTrust": {"id":"jsse-pkcs12-client-trust","properties":{"trustStore":"tls/administrators-trust.p12","trustStorePasswordEnvironment":"ZEROECHO_ADMIN_TRUSTSTORE_PASSWORD"}}, + "maximumForwardedCertificateBytes": 65536, + "maximumForwardedChainBytes": 524288 + }, + "execution": {"transportWorkers":8,"transportQueueCapacity":64,"operationWorkers":4,"operationQueueCapacity":32,"maximumAdmittedRequests":96,"defaultDeadlineMillis":30000,"maximumDeadlineMillis":120000,"gracefulShutdownMillis":30000,"forcedShutdownMillis":10000}, + "runtime": {} +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/AdministrativeAuthenticationMode.java b/pki-server/src/main/java/zeroecho/pki/server/AdministrativeAuthenticationMode.java new file mode 100644 index 0000000..93f7924 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/AdministrativeAuthenticationMode.java @@ -0,0 +1,42 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.server; + +/** Explicit mutually exclusive administrative authentication deployment mode. */ +public enum AdministrativeAuthenticationMode { + /** ZeroEcho validates and maps the TLS peer as the administrative client. */ + DIRECT_MTLS, + /** A separately authenticated proxy forwards a validated external client certificate. */ + TRUSTED_REVERSE_PROXY +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/ForwardedCertificateFormat.java b/pki-server/src/main/java/zeroecho/pki/server/ForwardedCertificateFormat.java new file mode 100644 index 0000000..dd9dd67 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/ForwardedCertificateFormat.java @@ -0,0 +1,42 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.server; + +/** Closed forwarding representation selected explicitly by server configuration. */ +public enum ForwardedCertificateFormat { + /** RFC 9440 structured-field byte-sequence headers. */ + RFC9440, + /** Strict URL-escaped single-certificate PEM emitted by NGINX. */ + NGINX_ESCAPED_PEM_V1 +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/Permission.java b/pki-server/src/main/java/zeroecho/pki/server/Permission.java index 04a3d28..306a7b8 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/Permission.java +++ b/pki-server/src/main/java/zeroecho/pki/server/Permission.java @@ -43,7 +43,7 @@ import zeroecho.pki.api.PkiId; /** Closed permission vocabulary and immutable scoped grant model. */ @SuppressWarnings({ "PMD.ExcessivePublicCount", "PMD.ControlStatementBraces", - "PMD.AvoidLiteralsInIfCondition", "PMD.CommentDefaultAccessModifier" }) + "PMD.AvoidLiteralsInIfCondition", "PMD.CommentDefaultAccessModifier", "PMD.LongVariable" }) public final class Permission { private Permission() { } @@ -52,7 +52,8 @@ public final class Permission { public enum Action { REALM_READ(1), SERVER_HEALTH_READ(2), SERVER_CONFIGURATION_READ(3), SERVER_CONFIGURATION_UPDATE(4), IDENTITY_PROVIDER_MANAGE(5), PRINCIPAL_MANAGE(6), - ROLE_MANAGE(7), PERMISSION_GRANT(8), AUTHORITY_LIST(20), AUTHORITY_READ(21), + ROLE_MANAGE(7), PERMISSION_GRANT(8), FORWARD_AUTHENTICATED_CLIENT_IDENTITY(9), + AUTHORITY_LIST(20), AUTHORITY_READ(21), AUTHORITY_CREATE(22), AUTHORITY_IMPORT(23), AUTHORITY_ACTIVATE(24), AUTHORITY_SUSPEND(25), AUTHORITY_RETIRE(26), ISSUER_CREATE(27), ISSUER_ROTATE(28), ISSUER_RETIRE(29), CA_CHAIN_DOWNLOAD(30), PROFILE_READ(40), PROFILE_REGISTER(41), PROFILE_VALIDATE(42), diff --git a/pki-server/src/main/java/zeroecho/pki/server/PkiHttpsServer.java b/pki-server/src/main/java/zeroecho/pki/server/PkiHttpsServer.java index c076221..922785b 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/PkiHttpsServer.java +++ b/pki-server/src/main/java/zeroecho/pki/server/PkiHttpsServer.java @@ -43,7 +43,7 @@ import java.util.concurrent.atomic.AtomicReference; import javax.net.ssl.SSLContext; import zeroecho.pki.application.PkiSessionRuntimeDependencies; -import zeroecho.pki.server.http.MutualTlsAuthenticator; +import zeroecho.pki.server.http.AdministrativeAuthenticator; import zeroecho.pki.server.http.PkiHttpsTransport; import zeroecho.pki.server.spi.PkiServerAuthenticator; @@ -105,7 +105,17 @@ public final class PkiHttpsServer implements AutoCloseable { SSLContext tls = PkiHttpsTransport.resolveTls(exact, loader); realm = ServerRealmContext.open(exact.realm(), runtimeDependencies, clock, random); realm.auditTransport("SERVER_STARTUP", "system", Map.of("state", "STARTING")); - authenticator = new MutualTlsAuthenticator(exact.authentication().mappings(), realm::principal, clock); + if (exact.authentication().mode() == AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY) { + for (String principalId : exact.authentication().trustedProxyPrincipalIds()) { + SecurityPrincipal principal = realm.principal(principalId); + if (!principal.enabled() || principal.type() != SecurityPrincipal.Type.SERVICE) { + throw new IllegalArgumentException("Trusted proxy principal is unavailable"); + } + realm.gateway().validateForwardingPrincipal(principalId); + } + } + authenticator = AdministrativeAuthenticator.create(exact.authentication(), realm::principal, + realm.gateway()::authorizeForwardedIdentity, clock, loader); ServerRealmContext sharedRealm = realm; transport = PkiHttpsTransport.startResolved(exact, sharedRealm, authenticator, clock, random, tls, () -> state.get() == State.READY && sharedRealm.state() == ServerRealmContext.State.OPEN); @@ -169,8 +179,8 @@ public final class PkiHttpsServer implements AutoCloseable { } catch (Throwable failure) { primary = suppress(primary, failure); } - primary = close(realm, primary); primary = close(authenticator, primary); + primary = close(realm, primary); state.set(State.TERMINATED); rethrow(primary); } @@ -178,8 +188,8 @@ public final class PkiHttpsServer implements AutoCloseable { private static void closePartial(PkiHttpsTransport transport, ServerRealmContext realm, PkiServerAuthenticator authenticator, AtomicReference state, Throwable primary) { primary = close(transport, primary); - primary = close(realm, primary); - close(authenticator, primary); + primary = close(authenticator, primary); + close(realm, primary); state.set(State.TERMINATED); } diff --git a/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfiguration.java b/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfiguration.java index 6afb2f3..8117875 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfiguration.java +++ b/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfiguration.java @@ -39,6 +39,7 @@ import java.time.Duration; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.Set; import zeroecho.pki.spi.ProviderConfig; @@ -63,7 +64,7 @@ public record PkiServerConfiguration(int version, String serverName, ServerRealm Listener listener, Authentication authentication, Execution execution, RuntimeCapabilities runtime) { /** Current server configuration schema. */ - public static final int CURRENT_VERSION = 1; + public static final int CURRENT_VERSION = 2; /** Validates all security-sensitive fields before resource allocation. */ public PkiServerConfiguration { @@ -77,9 +78,6 @@ public record PkiServerConfiguration(int version, String serverName, ServerRealm if (!listener.clientCertificateRequired()) { throw new IllegalArgumentException("Administrative HTTPS requires client certificates"); } - if (authentication.mappings().isEmpty()) { - throw new IllegalArgumentException("Administrative HTTPS requires principal mappings"); - } } /** @@ -114,17 +112,152 @@ public record PkiServerConfiguration(int version, String serverName, ServerRealm } /** - * Client-certificate authentication mappings. + * Strict mode-separated administrative client-certificate authentication. * - * @param mappings finite explicit immutable mappings + * @param mode exact deployment mode + * @param directClientMappings direct TLS end-client mappings + * @param proxyTransportMappings backend TLS proxy mappings + * @param forwardedClientMappings externally authenticated end-client mappings + * @param trustedProxyPrincipalIds explicitly admitted infrastructure principals + * @param forwardedCertificateFormat selected forwarding representation + * @param forwardedCertificateHeaderName exact leaf header name + * @param forwardedCertificateChainHeaderName optional exact chain header name + * @param administrativeClientTrust independent forwarded-client trust provider + * @param maximumForwardedCertificateBytes maximum decoded leaf size + * @param maximumForwardedChainBytes maximum aggregate decoded chain size */ - public record Authentication(List mappings) { - /** Validates mapping uniqueness and snapshots input order. */ + public record Authentication(AdministrativeAuthenticationMode mode, + List directClientMappings, + List proxyTransportMappings, + List forwardedClientMappings, + Set trustedProxyPrincipalIds, + Optional forwardedCertificateFormat, + Optional forwardedCertificateHeaderName, + Optional forwardedCertificateChainHeaderName, + Optional administrativeClientTrust, + int maximumForwardedCertificateBytes, int maximumForwardedChainBytes) { + /** Validates strict mode separation, mappings, headers, and technical bounds. */ public Authentication { - mappings = List.copyOf(Objects.requireNonNull(mappings, "mappings")); - if (mappings.size() > 10_000) throw new IllegalArgumentException("Too many principal mappings"); - if (mappings.stream().map(ClientCertificateMapping::mappingId).distinct().count() != mappings.size()) { - throw new IllegalArgumentException("Duplicate principal mapping identity"); + Objects.requireNonNull(mode, "mode"); + directClientMappings = mappings(directClientMappings, "direct client"); + proxyTransportMappings = mappings(proxyTransportMappings, "proxy transport"); + forwardedClientMappings = mappings(forwardedClientMappings, "forwarded client"); + trustedProxyPrincipalIds = Set.copyOf(Objects.requireNonNull(trustedProxyPrincipalIds, + "trustedProxyPrincipalIds")); + trustedProxyPrincipalIds.forEach(Permission::requirePrincipal); + forwardedCertificateFormat = Objects.requireNonNull(forwardedCertificateFormat, + "forwardedCertificateFormat"); + forwardedCertificateHeaderName = header(forwardedCertificateHeaderName, + "forwardedCertificateHeaderName"); + forwardedCertificateChainHeaderName = header(forwardedCertificateChainHeaderName, + "forwardedCertificateChainHeaderName"); + administrativeClientTrust = Objects.requireNonNull(administrativeClientTrust, + "administrativeClientTrust"); + if (mode == AdministrativeAuthenticationMode.DIRECT_MTLS) { + requireDirect(directClientMappings, proxyTransportMappings, forwardedClientMappings, + trustedProxyPrincipalIds, forwardedCertificateFormat, forwardedCertificateHeaderName, + forwardedCertificateChainHeaderName, administrativeClientTrust, + maximumForwardedCertificateBytes, maximumForwardedChainBytes); + } else { + requireProxy(directClientMappings, proxyTransportMappings, forwardedClientMappings, + trustedProxyPrincipalIds, forwardedCertificateFormat, forwardedCertificateHeaderName, + forwardedCertificateChainHeaderName, administrativeClientTrust, + maximumForwardedCertificateBytes, maximumForwardedChainBytes); + } + } + + private static List mappings(List source, + String name) { + List result = List.copyOf(Objects.requireNonNull(source, name)); + if (result.size() > 10_000) throw new IllegalArgumentException("Too many " + name + " mappings"); + if (result.stream().map(ClientCertificateMapping::mappingId).distinct().count() != result.size()) { + throw new IllegalArgumentException("Duplicate " + name + " mapping identity"); + } + return result; + } + + private static Optional header(Optional source, String name) { + return Objects.requireNonNull(source, name).map(value -> { + if (!value.matches("[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}")) { + throw new IllegalArgumentException("Forwarded certificate header name is invalid"); + } + String lower = value.toLowerCase(java.util.Locale.ROOT); + if (Set.of("authorization", "connection", "content-length", "host", "transfer-encoding", + "x-forwarded-for", "forwarded", "x-real-ip").contains(lower)) { + throw new IllegalArgumentException("Forwarded certificate header name is prohibited"); + } + return value; + }); + } + + private static void requireDirect(List direct, + List proxy, List forwarded, + Set trusted, Optional format, Optional header, + Optional chainHeader, Optional trust, int maximumCertificate, + int maximumChain) { + if (direct.isEmpty() || !proxy.isEmpty() || !forwarded.isEmpty() || !trusted.isEmpty() + || format.isPresent() || header.isPresent() || chainHeader.isPresent() || trust.isPresent() + || maximumCertificate != 0 || maximumChain != 0) { + throw new IllegalArgumentException("Direct authentication configuration is contradictory"); + } + } + + private static void requireProxy(List direct, + List proxy, List forwarded, + Set trusted, Optional format, Optional header, + Optional chainHeader, Optional trust, int maximumCertificate, + int maximumChain) { + if (!direct.isEmpty() || proxy.isEmpty() || forwarded.isEmpty() || trusted.isEmpty() + || format.isEmpty() || header.isEmpty() || trust.isEmpty()) { + throw new IllegalArgumentException("Trusted-proxy authentication configuration is incomplete"); + } + if (maximumCertificate < 512 || maximumCertificate > 1_048_576 + || maximumChain < 512 || maximumChain > 4_194_304) { + throw new IllegalArgumentException("Forwarded certificate bound is invalid"); + } + if (proxy.stream().anyMatch(mapping -> !trusted.contains(mapping.principalId()))) { + throw new IllegalArgumentException("Proxy mapping is outside the trusted principal set"); + } + if (proxy.stream().anyMatch(mapping -> mapping.certificateSha256().isEmpty() + && mapping.subjectPublicKeyInfoSha256().isEmpty())) { + throw new IllegalArgumentException("Proxy mapping requires certificate or SPKI commitment"); + } + if (forwarded.stream().anyMatch(mapping -> mapping.certificateSha256().isEmpty() + && mapping.subjectPublicKeyInfoSha256().isEmpty())) { + throw new IllegalArgumentException("Forwarded client mapping requires certificate or SPKI commitment"); + } + Set proxyPrincipals = proxy.stream().map(ClientCertificateMapping::principalId) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + if (!trusted.equals(proxyPrincipals)) { + throw new IllegalArgumentException("Trusted proxy principals and mappings differ"); + } + if (forwarded.stream().map(ClientCertificateMapping::principalId).anyMatch(proxyPrincipals::contains)) { + throw new IllegalArgumentException("Proxy and end-client principal mappings overlap"); + } + Set mappingIds = new java.util.HashSet<>(); + if (java.util.stream.Stream.concat(proxy.stream(), forwarded.stream()) + .anyMatch(mapping -> !mappingIds.add(mapping.mappingId()))) { + throw new IllegalArgumentException("Proxy authentication mapping identity is duplicated"); + } + Set proxyCertificates = proxy.stream().flatMap(mapping -> mapping.certificateSha256().stream()) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + Set proxyKeys = proxy.stream().flatMap(mapping -> mapping.subjectPublicKeyInfoSha256().stream()) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + if (forwarded.stream().anyMatch(mapping -> mapping.certificateSha256() + .filter(proxyCertificates::contains).isPresent() + || mapping.subjectPublicKeyInfoSha256().filter(proxyKeys::contains).isPresent())) { + throw new IllegalArgumentException("Proxy and end-client mapping commitments overlap"); + } + if (format.orElseThrow() == ForwardedCertificateFormat.RFC9440) { + if (!"Client-Cert".equalsIgnoreCase(header.orElseThrow()) + || chainHeader.isEmpty() + || !"Client-Cert-Chain".equalsIgnoreCase(chainHeader.orElseThrow())) { + throw new IllegalArgumentException("RFC 9440 header names are fixed"); + } + } else if (chainHeader.isPresent() + || "Client-Cert".equalsIgnoreCase(header.orElseThrow()) + || "Client-Cert-Chain".equalsIgnoreCase(header.orElseThrow())) { + throw new IllegalArgumentException("NGINX compatibility header configuration is invalid"); } } } diff --git a/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfigurationCodec.java b/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfigurationCodec.java index 96d3ab2..5724f59 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfigurationCodec.java +++ b/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfigurationCodec.java @@ -55,7 +55,7 @@ import zeroecho.pki.server.http.StrictJson; import zeroecho.pki.spi.ProviderConfig; import zeroecho.pki.spi.store.MetadataStoreId; -/** Strict version-one server configuration decoder with closed field sets. */ +/** Strict current-version server configuration decoder with closed field sets. */ @SuppressWarnings("PMD") public final class PkiServerConfigurationCodec { /** Maximum server configuration document size. */ @@ -213,9 +213,37 @@ public final class PkiServerConfigurationCodec { } private static PkiServerConfiguration.Authentication authentication(Fields value) { - value.exact("mappings"); + AdministrativeAuthenticationMode mode = AdministrativeAuthenticationMode.valueOf(value.text("mode")); + if (mode == AdministrativeAuthenticationMode.DIRECT_MTLS) { + value.exact("mode", "directClientMappings"); + List direct = mappings( + value.list("directClientMappings")); + value.complete(); + return new PkiServerConfiguration.Authentication(mode, direct, List.of(), List.of(), Set.of(), + Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), 0, 0); + } + value.allowed(Set.of("mode", "proxyTransportMappings", "forwardedClientMappings", + "trustedProxyPrincipalIds", "forwardedCertificateFormat", "forwardedCertificateHeaderName", + "forwardedCertificateChainHeaderName", "administrativeClientTrust", + "maximumForwardedCertificateBytes", "maximumForwardedChainBytes")); + List proxy = mappings(value.list("proxyTransportMappings")); + List forwarded = mappings( + value.list("forwardedClientMappings")); + PkiServerConfiguration.Authentication result = new PkiServerConfiguration.Authentication(mode, + List.of(), proxy, forwarded, Set.copyOf(strings(value.list("trustedProxyPrincipalIds"))), + Optional.of(ForwardedCertificateFormat.valueOf(value.text("forwardedCertificateFormat"))), + Optional.of(value.text("forwardedCertificateHeaderName")), + value.optionalText("forwardedCertificateChainHeaderName"), + Optional.of(provider(value.object("administrativeClientTrust"))), + value.integer("maximumForwardedCertificateBytes"), value.integer("maximumForwardedChainBytes")); + value.complete(); + return result; + } + + private static List mappings( + List source) { List mappings = new ArrayList<>(); - for (PkiOperationValue item : value.list("mappings")) { + for (PkiOperationValue item : source) { Fields mapping = Fields.of(item); mapping.allowed(Set.of("mappingId", "principalId", "certificateSha256", "subjectPublicKeyInfoSha256", "issuerSerialSha256")); @@ -225,8 +253,7 @@ public final class PkiServerConfigurationCodec { mapping.optionalText("issuerSerialSha256"))); mapping.complete(); } - value.complete(); - return new PkiServerConfiguration.Authentication(mappings); + return mappings; } private static PkiServerConfiguration.Execution execution(Fields value) { diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerOperationGateway.java b/pki-server/src/main/java/zeroecho/pki/server/ServerOperationGateway.java index e5e701b..8e3aedc 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/ServerOperationGateway.java +++ b/pki-server/src/main/java/zeroecho/pki/server/ServerOperationGateway.java @@ -65,7 +65,9 @@ public final class ServerOperationGateway { * Complete transport-neutral request admission input. * * @param realmId exact realm identity - * @param principalId authenticated or public principal identity + * @param principalId authenticated end-client principal identity + * @param authenticationMode explicit administrative authentication mode + * @param transportPrincipalId trusted proxy identity, present only in proxy mode * @param operation existing typed operation * @param resource exact non-bearer resource reference * @param relationship established object relationship @@ -73,13 +75,24 @@ public final class ServerOperationGateway { * @param approvalId optional durable approval reference * @param correlationId safe finite request correlation identity */ - public record Request(RealmId realmId, String principalId, AdministrativeOperation operation, + public record Request(RealmId realmId, String principalId, + AdministrativeAuthenticationMode authenticationMode, Optional transportPrincipalId, + AdministrativeOperation operation, Permission.Resource resource, Permission.Relationship relationship, Permission.Context context, Optional approvalId, String correlationId) { /** Validates the immutable request. */ public Request { Objects.requireNonNull(realmId, "realmId"); Permission.requirePrincipal(principalId); + Objects.requireNonNull(authenticationMode, "authenticationMode"); + transportPrincipalId = Objects.requireNonNull(transportPrincipalId, "transportPrincipalId"); + transportPrincipalId.ifPresent(Permission::requirePrincipal); + if (authenticationMode == AdministrativeAuthenticationMode.DIRECT_MTLS + && transportPrincipalId.isPresent() + || authenticationMode == AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY + && transportPrincipalId.isEmpty()) { + throw new IllegalArgumentException("Gateway authentication identity context is invalid"); + } Objects.requireNonNull(operation, "operation"); Objects.requireNonNull(resource, "resource"); Objects.requireNonNull(relationship, "relationship"); @@ -92,8 +105,17 @@ public final class ServerOperationGateway { public Request(RealmId realmId, String principalId, PkiOperation operation, Permission.Resource resource, Permission.Relationship relationship, Permission.Context context, Optional approvalId, String correlationId) { - this(realmId, principalId, new AdministrativeOperation.Pki(operation), resource, relationship, - context, approvalId, correlationId); + this(realmId, principalId, AdministrativeAuthenticationMode.DIRECT_MTLS, Optional.empty(), + new AdministrativeOperation.Pki(operation), resource, relationship, context, approvalId, + correlationId); + } + + /** Creates a direct-mTLS request for existing embedded control callers. */ + public Request(RealmId realmId, String principalId, AdministrativeOperation operation, + Permission.Resource resource, Permission.Relationship relationship, Permission.Context context, + Optional approvalId, String correlationId) { + this(realmId, principalId, AdministrativeAuthenticationMode.DIRECT_MTLS, Optional.empty(), operation, + resource, relationship, context, approvalId, correlationId); } } @@ -272,8 +294,7 @@ public final class ServerOperationGateway { } if (claimedApproval.isPresent()) approvals.complete(claimedApproval.orElseThrow(), classification(backend)); audit.record("GATEWAY_RESULT", principal.principalId(), exact.resource().objectId(), - Map.of("operation", exact.operation().name(), "result", classification(backend), - "correlation", exact.correlationId())); + gatewayDetails(exact, "result", classification(backend))); return new Outcome.Executed(backend); } ServerControlOperation controlOperation = ((AdministrativeOperation.Control) exact.operation()).operation(); @@ -282,7 +303,7 @@ public final class ServerOperationGateway { String result = classification(controlResult); if (claimedApproval.isPresent()) approvals.complete(claimedApproval.orElseThrow(), result); audit.record("GATEWAY_RESULT", principal.principalId(), exact.resource().objectId(), - Map.of("operation", exact.operation().name(), "result", result, "correlation", exact.correlationId())); + gatewayDetails(exact, "result", result)); return new Outcome.ControlExecuted(controlResult); } @@ -316,6 +337,60 @@ public final class ServerOperationGateway { return decision; } + /** + * Authorizes a dedicated service principal to attest one forwarded client + * identity. Break-glass grants are deliberately excluded from this transport + * trust decision. + * + * @param principalId authenticated backend TLS peer identity + * @param correlationId bounded request correlation identity + * @return default-deny decision with explicit-deny precedence + */ + public AuthorizationEngine.Decision authorizeForwardedIdentity(String principalId, String correlationId) { + openCheck.run(); + Permission.requirePrincipal(principalId); + Permission.requireBounded(correlationId, 256, "correlation ID"); + SecurityPrincipal principal = control.requirePrincipal(principalId); + if (principal.type() != SecurityPrincipal.Type.SERVICE) { + return new AuthorizationEngine.Decision(AuthorizationEngine.Code.NO_MATCHING_GRANT, false); + } + List principalGrants = grants(principal); + if (principalGrants.stream().anyMatch(grant -> grant.enabled() + && grant.effect() == Permission.Effect.ALLOW + && grant.action() != Permission.Action.FORWARD_AUTHENTICATED_CLIENT_IDENTITY) + || !breakGlass.activeFor(principalId).grants().isEmpty()) { + return new AuthorizationEngine.Decision(AuthorizationEngine.Code.NO_MATCHING_GRANT, false); + } + Permission.Resource resource = new Permission.Resource(Permission.ResourceType.REALM, + new Permission.Scope(realmId, Optional.empty(), Optional.empty(), Optional.empty()), + Optional.empty(), Optional.empty()); + AuthorizationEngine.Decision decision = authorization.authorize(new AuthorizationEngine.Request(realmId, + exposure, principal, Permission.Action.FORWARD_AUTHENTICATED_CLIENT_IDENTITY, resource, + Permission.Relationship.ANY, Permission.DataView.METADATA_REDACTED, Permission.Context.empty(), + principalGrants, java.util.Set.of())); + audit.record(decision.allowed() ? "PROXY_FORWARD_ALLOW" : "PROXY_FORWARD_DENY", principalId, + Optional.empty(), Map.of("action", Permission.Action.FORWARD_AUTHENTICATED_CLIENT_IDENTITY.name(), + "code", decision.code().name(), "correlation", correlationId)); + return decision; + } + + /** + * Fails startup when a configured proxy service principal has any enabled + * allowing authority beyond forwarded-identity attestation. + * + * @param principalId configured trusted proxy principal + */ + public void validateForwardingPrincipal(String principalId) { + openCheck.run(); + SecurityPrincipal principal = control.requirePrincipal(principalId); + if (!principal.enabled() || principal.type() != SecurityPrincipal.Type.SERVICE + || grants(principal).stream().anyMatch(grant -> grant.enabled() + && grant.effect() == Permission.Effect.ALLOW + && grant.action() != Permission.Action.FORWARD_AUTHENTICATED_CLIENT_IDENTITY)) { + throw new IllegalArgumentException("Trusted proxy principal authority is not narrowly scoped"); + } + } + /** * Returns operation descriptors discoverable by one principal without * returning grant scopes or protected resource identities. @@ -463,11 +538,21 @@ public final class ServerOperationGateway { private Outcome denied(Request request, AuthorizationEngine.Code code) { audit.record("AUTHORIZATION_DENY", request.principalId(), Optional.empty(), - Map.of("operation", request.operation().name(), "code", code.name(), - "correlation", request.correlationId())); + gatewayDetails(request, "code", code.name())); return new Outcome.Denied(code); } + private static Map gatewayDetails(Request request, String resultName, String result) { + Map details = new LinkedHashMap<>(); + details.put("operation", request.operation().name()); + details.put(resultName, result); + details.put("correlation", request.correlationId()); + details.put("authenticationMode", request.authenticationMode().name()); + details.put("endClientPrincipal", request.principalId()); + request.transportPrincipalId().ifPresent(value -> details.put("transportPrincipal", value)); + return Map.copyOf(details); + } + private static Permission.Action action(PkiOperation operation, Permission.Action defaultAction) { if (operation instanceof PkiOperation.TransitionAuthority transition) { return switch (transition.state()) { diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/AdminHttpHandler.java b/pki-server/src/main/java/zeroecho/pki/server/http/AdminHttpHandler.java index ab9dfd3..5531d09 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/http/AdminHttpHandler.java +++ b/pki-server/src/main/java/zeroecho/pki/server/http/AdminHttpHandler.java @@ -104,6 +104,18 @@ final class AdminHttpHandler implements HttpHandler { try { requireHeadersBounded(exchange.getRequestHeaders()); requestId = requestIds.resolve(exchange.getRequestHeaders().get(RequestIds.HEADER)); + if (configuration.authentication().mode() + == zeroecho.pki.server.AdministrativeAuthenticationMode.DIRECT_MTLS + && ForwardedClientCertificateParser.containsForwardedIdentity( + authenticationHeaders(exchange.getRequestHeaders()))) { + realm.auditTransport("ADMIN_AUTHENTICATION_REJECTED", "system", + authenticationDetails(requestId, configuration.authentication().mode(), + Optional.empty(), Optional.empty(), "FORWARDED_IDENTITY_FORBIDDEN")); + response = HttpResponses.failure(400, requestId, "authentication", "VALIDATION_FAILURE", + "FORWARDED_IDENTITY_FORBIDDEN", "FORWARDED_IDENTITY_FORBIDDEN", false, false); + send(exchange, requestId, response); + return; + } URI uri = exchange.getRequestURI(); if (uri.getRawQuery() != null || uri.getRawFragment() != null) throw malformed(requestId, "route"); String path = uri.getPath(); @@ -146,12 +158,19 @@ final class AdminHttpHandler implements HttpHandler { private HttpResponses.Response admin(HttpsExchange exchange, String path, String requestId) throws IOException { PkiServerAuthenticationResult authentication = authenticate(exchange, requestId); if (!(authentication instanceof PkiServerAuthenticationResult.Authenticated authenticated)) { - realm.auditTransport("TLS_AUTHENTICATION_FAILURE", "system", Map.of("request", requestId)); + PkiServerAuthenticationResult.Rejected rejected = + (PkiServerAuthenticationResult.Rejected) authentication; + String actor = rejected.transportPrincipalId().orElse("system"); + realm.auditTransport("ADMIN_AUTHENTICATION_REJECTED", actor, + authenticationDetails(requestId, rejected.mode(), rejected.transportPrincipalId(), + rejected.endClientPrincipalId(), rejected.code().name())); return HttpResponses.failure(401, requestId, "authentication", "AUTHENTICATION_FAILURE", "CLIENT_AUTHENTICATION_FAILED", "CLIENT_AUTHENTICATION_FAILED", false, false); } - String principalId = authenticated.principalId(); - realm.auditTransport("TLS_AUTHENTICATION_SUCCESS", principalId, Map.of("request", requestId)); + String principalId = authenticated.endClientPrincipalId(); + realm.auditTransport("ADMIN_AUTHENTICATION_ACCEPTED", principalId, + authenticationDetails(requestId, authenticated.mode(), authenticated.transportPrincipalId(), + Optional.of(principalId), "ACCEPTED")); if ("/admin/v1/realm".equals(path)) return realm(exchange, requestId, principalId); if ("/admin/v1/operations".equals(path)) return operations(exchange, requestId, principalId); String prefix = "/admin/v1/operations/"; @@ -164,7 +183,7 @@ final class AdminHttpHandler implements HttpHandler { if (descriptor.isEmpty()) return notFound(requestId); if (requireMethod(exchange, "GET")) return descriptor(requestId, descriptor.orElseThrow()); if (!requireMethod(exchange, "POST")) return method(requestId, operationId); - return execute(exchange, requestId, principalId, operationId); + return execute(exchange, requestId, authenticated, operationId); } private HttpResponses.Response realm(HttpsExchange exchange, String requestId, String principalId) { @@ -200,7 +219,8 @@ final class AdminHttpHandler implements HttpHandler { } private HttpResponses.Response execute(HttpsExchange exchange, String requestId, - String principalId, String operationId) throws IOException { + PkiServerAuthenticationResult.Authenticated authenticated, String operationId) throws IOException { + String principalId = authenticated.endClientPrincipalId(); String contentType = exchange.getRequestHeaders().getFirst("Content-Type"); if (contentType == null || !(contentType.equalsIgnoreCase("application/json") || contentType.equalsIgnoreCase(JSON))) { @@ -241,7 +261,8 @@ final class AdminHttpHandler implements HttpHandler { submitted = runtime.submit(cancellation -> { if (!clock.instant().isBefore(deadline)) throw new DeadlineExceeded(); ServerOperationGateway.Request gateway = new ServerOperationGateway.Request( - configuration.realm().realmId(), principalId, decoded.operation(), decoded.resource(), + configuration.realm().realmId(), principalId, authenticated.mode(), + authenticated.transportPrincipalId(), decoded.operation(), decoded.resource(), Permission.Relationship.ANY, decoded.context(), decoded.approvalId(), requestId); return realm.gateway().execute(gateway, cancellation); }); @@ -290,13 +311,44 @@ final class AdminHttpHandler implements HttpHandler { } return authenticator.authenticate(new PkiServerAuthenticationContext(chain, exchange.getSSLSession().getProtocol(), exchange.getSSLSession().getCipherSuite(), - requestId, configuration.realm().realmId())); + requestId, configuration.realm().realmId(), authenticationHeaders(exchange.getRequestHeaders()))); } catch (SSLPeerUnverifiedException failure) { return new PkiServerAuthenticationResult.Rejected( + configuration.authentication().mode(), Optional.empty(), Optional.empty(), PkiServerAuthenticationResult.Code.CERTIFICATE_INVALID); + } catch (RuntimeException failure) { + return new PkiServerAuthenticationResult.Rejected( + configuration.authentication().mode(), Optional.empty(), Optional.empty(), + PkiServerAuthenticationResult.Code.INTERNAL_FAILURE); } } + private Map> authenticationHeaders(Headers headers) { + java.util.Set accepted = new java.util.TreeSet<>(String.CASE_INSENSITIVE_ORDER); + accepted.add(ForwardedClientCertificateParser.RFC_CERTIFICATE_HEADER); + accepted.add(ForwardedClientCertificateParser.RFC_CHAIN_HEADER); + accepted.add(ForwardedClientCertificateParser.DIRECT_REJECTED_NGINX_HEADER); + configuration.authentication().forwardedCertificateHeaderName().ifPresent(accepted::add); + configuration.authentication().forwardedCertificateChainHeaderName().ifPresent(accepted::add); + Map> result = new LinkedHashMap<>(); + for (Map.Entry> entry : headers.entrySet()) { + if (accepted.contains(entry.getKey())) result.put(entry.getKey(), List.copyOf(entry.getValue())); + } + return Map.copyOf(result); + } + + private static Map authenticationDetails(String requestId, + zeroecho.pki.server.AdministrativeAuthenticationMode mode, Optional transport, + Optional endClient, String classification) { + Map details = new LinkedHashMap<>(); + details.put("request", requestId); + details.put("mode", mode.name()); + transport.ifPresent(value -> details.put("transportPrincipal", value)); + endClient.ifPresent(value -> details.put("endClientPrincipal", value)); + details.put("classification", classification); + return Map.copyOf(details); + } + private byte[] body(InputStream input, String contentLength) throws IOException { int maximum = configuration.listener().maximumBodyBytes(); if (contentLength != null) { diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/AdministrativeAuthenticator.java b/pki-server/src/main/java/zeroecho/pki/server/http/AdministrativeAuthenticator.java new file mode 100644 index 0000000..f112168 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/http/AdministrativeAuthenticator.java @@ -0,0 +1,246 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.server.http; + +import java.security.MessageDigest; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.time.Clock; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.BiFunction; +import java.util.function.Function; + +import javax.net.ssl.X509TrustManager; + +import zeroecho.pki.server.AdministrativeAuthenticationMode; +import zeroecho.pki.server.AuthorizationEngine; +import zeroecho.pki.server.PkiServerConfiguration; +import zeroecho.pki.server.SecurityPrincipal; +import zeroecho.pki.server.spi.PkiServerAuthenticationContext; +import zeroecho.pki.server.spi.PkiServerAuthenticationResult; +import zeroecho.pki.server.spi.PkiServerAuthenticator; + +/** + * Composes direct mutual-TLS authentication or separately authenticated proxy + * transport with independently validated forwarded end-client identity. + * + *

Direct proof of possession comes from the ZeroEcho TLS handshake. In proxy + * mode proof of possession for the forwarded key is attested by the mutually + * authenticated, explicitly authorized proxy; parsing a certificate does not by + * itself prove possession.

+ */ +@SuppressWarnings("PMD.AvoidCatchingGenericException") +public final class AdministrativeAuthenticator implements PkiServerAuthenticator { + private static final String CLIENT_AUTH_EKU = "1.3.6.1.5.5.7.3.2"; + private static final String ANY_EKU = "2.5.29.37.0"; + private final PkiServerConfiguration.Authentication configuration; + private final MutualTlsAuthenticator transportMapper; + private final MutualTlsAuthenticator endClientMapper; + private final X509TrustManager forwardedTrust; + private final BiFunction forwardingAuthorization; + + private AdministrativeAuthenticator(PkiServerConfiguration.Authentication configuration, + MutualTlsAuthenticator transportMapper, MutualTlsAuthenticator endClientMapper, + X509TrustManager forwardedTrust, + BiFunction forwardingAuthorization) { + this.configuration = Objects.requireNonNull(configuration, "configuration"); + this.transportMapper = Objects.requireNonNull(transportMapper, "transportMapper"); + this.endClientMapper = Objects.requireNonNull(endClientMapper, "endClientMapper"); + this.forwardedTrust = forwardedTrust; + this.forwardingAuthorization = Objects.requireNonNull(forwardingAuthorization, + "forwardingAuthorization"); + } + + /** + * Creates the configured authentication composition before listener startup. + * + * @param configuration strict mode-specific configuration + * @param principalResolver durable enabled-state resolver + * @param forwardingAuthorization default-deny proxy forwarding authorization + * @param clock certificate validity clock + * @param loader explicitly scoped provider loader + * @return immutable request authenticator + */ + public static AdministrativeAuthenticator create(PkiServerConfiguration.Authentication configuration, + Function principalResolver, + BiFunction forwardingAuthorization, + Clock clock, ClassLoader loader) { + PkiServerConfiguration.Authentication exact = Objects.requireNonNull(configuration, "configuration"); + if (exact.mode() == AdministrativeAuthenticationMode.DIRECT_MTLS) { + MutualTlsAuthenticator direct = new MutualTlsAuthenticator(exact.directClientMappings(), + principalResolver, clock); + return new AdministrativeAuthenticator(exact, direct, direct, null, forwardingAuthorization); + } + MutualTlsAuthenticator transport = new MutualTlsAuthenticator(exact.proxyTransportMappings(), + principalResolver, clock); + MutualTlsAuthenticator endClient = new MutualTlsAuthenticator(exact.forwardedClientMappings(), + principalResolver, clock); + X509TrustManager trust = ClientTrustProviders.create(exact.administrativeClientTrust().orElseThrow(), loader); + return new AdministrativeAuthenticator(exact, transport, endClient, trust, forwardingAuthorization); + } + + @Override + public PkiServerAuthenticationResult authenticate(PkiServerAuthenticationContext context) { + Objects.requireNonNull(context, "context"); + if (configuration.mode() == AdministrativeAuthenticationMode.DIRECT_MTLS) { + return direct(context); + } + return proxy(context); + } + + private PkiServerAuthenticationResult direct(PkiServerAuthenticationContext context) { + if (ForwardedClientCertificateParser.containsForwardedIdentity(context.requestHeaders())) { + return rejected(AdministrativeAuthenticationMode.DIRECT_MTLS, Optional.empty(), + PkiServerAuthenticationResult.Code.FORWARDED_IDENTITY_FORBIDDEN); + } + PkiServerAuthenticationResult result = transportMapper.authenticate(context); + if (result instanceof PkiServerAuthenticationResult.Authenticated authenticated) { + return new PkiServerAuthenticationResult.Authenticated(AdministrativeAuthenticationMode.DIRECT_MTLS, + Optional.empty(), authenticated.endClientPrincipalId()); + } + return result; + } + + private PkiServerAuthenticationResult proxy(PkiServerAuthenticationContext context) { + PkiServerAuthenticationResult transportResult = transportMapper.authenticate(context); + if (!(transportResult instanceof PkiServerAuthenticationResult.Authenticated transport)) { + PkiServerAuthenticationResult.Rejected rejection = + (PkiServerAuthenticationResult.Rejected) transportResult; + return rejected(AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY, + rejection.endClientPrincipalId(), rejection.code()); + } + String transportId = transport.endClientPrincipalId(); + Optional resolvedTransport = Optional.of(transportId); + if (!configuration.trustedProxyPrincipalIds().contains(transportId)) { + return rejected(AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY, resolvedTransport, + PkiServerAuthenticationResult.Code.PROXY_NOT_TRUSTED); + } + AuthorizationEngine.Decision forwarding; + try { + forwarding = forwardingAuthorization.apply(transportId, context.requestId()); + } catch (RuntimeException unavailable) { + return rejected(AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY, resolvedTransport, + PkiServerAuthenticationResult.Code.INTERNAL_FAILURE); + } + if (!forwarding.allowed()) { + return rejected(AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY, resolvedTransport, + PkiServerAuthenticationResult.Code.FORWARDING_NOT_AUTHORIZED); + } + List forwarded; + try { + forwarded = ForwardedClientCertificateParser.parse(configuration, context.requestHeaders()); + } catch (IllegalArgumentException invalid) { + return rejected(AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY, resolvedTransport, + PkiServerAuthenticationResult.Code.FORWARDED_IDENTITY_INVALID); + } catch (RuntimeException unavailable) { + return rejected(AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY, resolvedTransport, + PkiServerAuthenticationResult.Code.INTERNAL_FAILURE); + } + try { + if (sameCredential(context.peerCertificates().getFirst(), forwarded.getFirst())) { + return rejected(AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY, resolvedTransport, + PkiServerAuthenticationResult.Code.IDENTITY_COLLISION); + } + } catch (CertificateException invalid) { + return rejected(AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY, resolvedTransport, + PkiServerAuthenticationResult.Code.FORWARDED_IDENTITY_INVALID); + } catch (RuntimeException unavailable) { + return rejected(AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY, resolvedTransport, + PkiServerAuthenticationResult.Code.INTERNAL_FAILURE); + } + try { + validateUsage(forwarded.getFirst()); + } catch (CertificateException invalidUsage) { + return rejected(AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY, resolvedTransport, + PkiServerAuthenticationResult.Code.CLIENT_USAGE_REJECTED); + } catch (RuntimeException unavailable) { + return rejected(AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY, resolvedTransport, + PkiServerAuthenticationResult.Code.INTERNAL_FAILURE); + } + try { + forwardedTrust.checkClientTrusted(forwarded.toArray(X509Certificate[]::new), + forwarded.getFirst().getPublicKey().getAlgorithm()); + } catch (CertificateException untrusted) { + return rejected(AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY, resolvedTransport, + PkiServerAuthenticationResult.Code.CLIENT_TRUST_REJECTED); + } catch (RuntimeException unavailable) { + return rejected(AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY, resolvedTransport, + PkiServerAuthenticationResult.Code.INTERNAL_FAILURE); + } + PkiServerAuthenticationContext forwardedContext = new PkiServerAuthenticationContext(forwarded, + context.protocol(), context.cipherSuite(), context.requestId(), context.realmId(), Map.of()); + PkiServerAuthenticationResult endResult = endClientMapper.authenticate(forwardedContext); + if (!(endResult instanceof PkiServerAuthenticationResult.Authenticated endClient)) { + PkiServerAuthenticationResult.Rejected rejection = + (PkiServerAuthenticationResult.Rejected) endResult; + return new PkiServerAuthenticationResult.Rejected( + AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY, resolvedTransport, + rejection.endClientPrincipalId(), rejection.code()); + } + String endId = endClient.endClientPrincipalId(); + if (transportId.equals(endId)) { + return new PkiServerAuthenticationResult.Rejected( + AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY, resolvedTransport, + Optional.of(endId), PkiServerAuthenticationResult.Code.IDENTITY_COLLISION); + } + return new PkiServerAuthenticationResult.Authenticated( + AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY, resolvedTransport, endId); + } + + private static boolean sameCredential(X509Certificate transport, X509Certificate forwarded) + throws CertificateException { + return MessageDigest.isEqual(transport.getEncoded(), forwarded.getEncoded()) + || MessageDigest.isEqual(transport.getPublicKey().getEncoded(), + forwarded.getPublicKey().getEncoded()); + } + + private static void validateUsage(X509Certificate certificate) throws CertificateException { + boolean[] usage = certificate.getKeyUsage(); + if (usage != null && (usage.length == 0 || !usage[0])) { + throw new CertificateException("Administrative client key usage is invalid"); + } + List extended = certificate.getExtendedKeyUsage(); + if (extended != null && !extended.contains(CLIENT_AUTH_EKU) && !extended.contains(ANY_EKU)) { + throw new CertificateException("Administrative client extended key usage is invalid"); + } + } + + private static PkiServerAuthenticationResult rejected(AdministrativeAuthenticationMode mode, + Optional transport, PkiServerAuthenticationResult.Code code) { + return new PkiServerAuthenticationResult.Rejected(mode, transport, Optional.empty(), code); + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/ClientTrustProviders.java b/pki-server/src/main/java/zeroecho/pki/server/http/ClientTrustProviders.java new file mode 100644 index 0000000..3dff5bc --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/http/ClientTrustProviders.java @@ -0,0 +1,75 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.server.http; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.ServiceLoader; + +import javax.net.ssl.X509TrustManager; + +import zeroecho.pki.server.spi.PkiServerClientTrustProvider; +import zeroecho.pki.spi.ProviderConfig; + +/** Deterministic explicit provider resolver for forwarded-client trust. */ +@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" }) +final class ClientTrustProviders { + private ClientTrustProviders() { + } + + /* default */ static X509TrustManager create(ProviderConfig configuration, ClassLoader loader) { + ProviderConfig exact = Objects.requireNonNull(configuration, "configuration"); + List providers = new ArrayList<>(); + ServiceLoader.load(PkiServerClientTrustProvider.class, Objects.requireNonNull(loader, "loader")) + .forEach(providers::add); + providers.sort(Comparator.comparing(PkiServerClientTrustProvider::id)); + if (providers.stream().map(PkiServerClientTrustProvider::id).distinct().count() != providers.size()) { + throw new IllegalStateException("Administrative-client trust provider identities are ambiguous"); + } + PkiServerClientTrustProvider selected = providers.stream() + .filter(provider -> provider.id().equals(exact.backendId())).findFirst() + .orElseThrow(() -> new IllegalArgumentException( + "Administrative-client trust provider is unavailable")); + try { + return Objects.requireNonNull(selected.create(exact), + "Administrative-client trust provider returned no trust manager"); + } catch (RuntimeException failure) { + throw failure; + } catch (Exception failure) { + throw new IllegalStateException("Administrative-client trust initialization failed"); + } + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/ForwardedClientCertificateParser.java b/pki-server/src/main/java/zeroecho/pki/server/http/ForwardedClientCertificateParser.java new file mode 100644 index 0000000..a44e038 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/http/ForwardedClientCertificateParser.java @@ -0,0 +1,230 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.server.http; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import zeroecho.pki.server.ForwardedCertificateFormat; +import zeroecho.pki.server.PkiServerConfiguration; + +/** Narrow strict parser for the two explicitly supported forwarded-certificate formats. */ +@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.AvoidLiteralsInIfCondition", "PMD.CyclomaticComplexity", + "PMD.AvoidDeeplyNestedIfStmts", "PMD.ExceptionAsFlowControl", + "PMD.AvoidThrowingNewInstanceOfSameException", "PMD.PreserveStackTrace", + "PMD.AvoidReassigningLoopVariables" }) +final class ForwardedClientCertificateParser { + /* default */ static final String RFC_CERTIFICATE_HEADER = "Client-Cert"; + /* default */ static final String RFC_CHAIN_HEADER = "Client-Cert-Chain"; + /* default */ static final String DIRECT_REJECTED_NGINX_HEADER = "X-ZeroEcho-Client-Cert"; + private static final String PEM_BEGIN = "-----BEGIN CERTIFICATE-----\n"; + private static final String PEM_END = "\n-----END CERTIFICATE-----\n"; + + private ForwardedClientCertificateParser() { + } + + /* default */ static List parse(PkiServerConfiguration.Authentication configuration, + Map> headers) { + Objects.requireNonNull(configuration, "configuration"); + Objects.requireNonNull(headers, "headers"); + ForwardedCertificateFormat format = configuration.forwardedCertificateFormat().orElseThrow(); + if (format == ForwardedCertificateFormat.RFC9440 + && present(headers, DIRECT_REJECTED_NGINX_HEADER) + || format == ForwardedCertificateFormat.NGINX_ESCAPED_PEM_V1 + && (present(headers, RFC_CERTIFICATE_HEADER) || present(headers, RFC_CHAIN_HEADER) + || !DIRECT_REJECTED_NGINX_HEADER.equalsIgnoreCase( + configuration.forwardedCertificateHeaderName().orElseThrow()) + && present(headers, DIRECT_REJECTED_NGINX_HEADER))) { + throw new IllegalArgumentException("Conflicting forwarded certificate representation"); + } + String leafValue = singleton(headers, configuration.forwardedCertificateHeaderName().orElseThrow(), true); + List encoded = new ArrayList<>(); + if (format == ForwardedCertificateFormat.RFC9440) { + encoded.add(byteSequence(leafValue, configuration.maximumForwardedCertificateBytes())); + String chainValue = singleton(headers, + configuration.forwardedCertificateChainHeaderName().orElseThrow(), false); + if (chainValue != null) { + int total = 0; + for (String item : chainValue.split(",", -1)) { + byte[] certificate = byteSequence(item.strip(), configuration.maximumForwardedChainBytes()); + total = Math.addExact(total, certificate.length); + if (total > configuration.maximumForwardedChainBytes() || encoded.size() >= 64) { + throw new IllegalArgumentException("Forwarded certificate chain is oversized"); + } + encoded.add(certificate); + } + } + } else { + encoded.add(escapedPem(leafValue, configuration.maximumForwardedCertificateBytes())); + } + List result = new ArrayList<>(encoded.size()); + for (byte[] certificate : encoded) result.add(certificate(certificate)); + return List.copyOf(result); + } + + /* default */ static boolean containsForwardedIdentity(Map> headers) { + return present(headers, RFC_CERTIFICATE_HEADER) || present(headers, RFC_CHAIN_HEADER) + || present(headers, DIRECT_REJECTED_NGINX_HEADER); + } + + private static String singleton(Map> headers, String name, boolean required) { + List matches = new ArrayList<>(); + int matchingNames = 0; + for (Map.Entry> entry : headers.entrySet()) { + if (entry.getKey().equalsIgnoreCase(name)) { + matchingNames++; + matches.addAll(entry.getValue()); + } + } + if (matchingNames > 1 || matches.size() > 1 || required && matches.isEmpty()) { + throw new IllegalArgumentException("Forwarded certificate header cardinality is invalid"); + } + return matches.isEmpty() ? null : matches.getFirst(); + } + + private static boolean present(Map> headers, String name) { + return headers.keySet().stream().anyMatch(candidate -> candidate.equalsIgnoreCase(name)); + } + + private static byte[] byteSequence(String source, int maximum) { + if (source.length() < 3 || source.charAt(0) != ':' || source.charAt(source.length() - 1) != ':' + || source.indexOf(';') >= 0 || source.indexOf(' ') >= 0 || source.indexOf('\t') >= 0) { + throw new IllegalArgumentException("RFC 9440 certificate field is malformed"); + } + String encoded = source.substring(1, source.length() - 1); + if (encoded.isEmpty() || encoded.length() > Math.multiplyExact(maximum, 2)) { + throw new IllegalArgumentException("RFC 9440 certificate field is oversized"); + } + try { + byte[] result = Base64.getDecoder().decode(encoded); + if (result.length == 0 || result.length > maximum + || !Base64.getEncoder().encodeToString(result).equals(encoded)) { + throw new IllegalArgumentException("RFC 9440 certificate byte sequence is not canonical"); + } + return result; + } catch (IllegalArgumentException failure) { + throw new IllegalArgumentException("RFC 9440 certificate byte sequence is invalid"); + } + } + + private static byte[] escapedPem(String source, int maximum) { + if (source.length() > Math.multiplyExact(maximum, 4) || source.indexOf('+') >= 0) { + throw new IllegalArgumentException("NGINX forwarded certificate is invalid"); + } + byte[] decoded = percentDecode(source); + String pem = new String(decoded, StandardCharsets.US_ASCII); + if (!Arrays.equals(decoded, pem.getBytes(StandardCharsets.US_ASCII)) || !pem.startsWith(PEM_BEGIN) + || !pem.endsWith(PEM_END) || pem.indexOf(PEM_BEGIN, 1) >= 0 + || pem.indexOf(PEM_END) != pem.lastIndexOf(PEM_END)) { + throw new IllegalArgumentException("NGINX forwarded PEM framing is invalid"); + } + String base64 = pem.substring(PEM_BEGIN.length(), pem.length() - PEM_END.length()); + String[] lines = base64.split("\\n", -1); + StringBuilder canonical = new StringBuilder(base64.length()); + for (int index = 0; index < lines.length; index++) { + String line = lines[index]; + if (line.isEmpty() || line.length() > 64 || index + 1 < lines.length && line.length() != 64) { + throw new IllegalArgumentException("NGINX forwarded PEM is not canonical"); + } + canonical.append(line); + } + try { + byte[] result = Base64.getDecoder().decode(canonical.toString()); + if (result.length == 0 || result.length > maximum + || !Base64.getEncoder().encodeToString(result).equals(canonical.toString())) { + throw new IllegalArgumentException("NGINX forwarded PEM payload is not canonical"); + } + return result; + } catch (IllegalArgumentException failure) { + throw new IllegalArgumentException("NGINX forwarded PEM payload is invalid"); + } + } + + private static byte[] percentDecode(String source) { + byte[] output = new byte[source.length()]; + int length = 0; + for (int index = 0; index < source.length(); index++) { + char current = source.charAt(index); + int value; + if (current == '%') { + if (index + 2 >= source.length()) throw new IllegalArgumentException("Percent escape is truncated"); + value = hexadecimal(source.charAt(++index)) * 16 + hexadecimal(source.charAt(++index)); + } else { + if (current > 0x7e || !unreserved(current)) { + throw new IllegalArgumentException("Escaped PEM contains unescaped data"); + } + value = current; + } + if (value == 0 || value < 0x20 && value != '\n' || value == 0x7f) { + throw new IllegalArgumentException("Escaped PEM contains a control character"); + } + output[length++] = (byte) value; + } + return Arrays.copyOf(output, length); + } + + private static int hexadecimal(char value) { + int digit = Character.digit(value, 16); + if (digit < 0) throw new IllegalArgumentException("Percent escape is malformed"); + return digit; + } + + private static boolean unreserved(char value) { + return value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' + || value >= '0' && value <= '9' || value == '-' || value == '.' || value == '_' + || value == '~'; + } + + private static X509Certificate certificate(byte[] encoded) { + try { + ByteArrayInputStream input = new ByteArrayInputStream(encoded); + X509Certificate result = (X509Certificate) CertificateFactory.getInstance("X.509") + .generateCertificate(input); + if (input.available() != 0 || !Arrays.equals(encoded, result.getEncoded())) { + throw new IllegalArgumentException("Forwarded certificate is not canonical DER"); + } + return result; + } catch (java.security.cert.CertificateException failure) { + throw new IllegalArgumentException("Forwarded certificate is malformed"); + } + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/MutualTlsAuthenticator.java b/pki-server/src/main/java/zeroecho/pki/server/http/MutualTlsAuthenticator.java index 04cc195..7ee7b29 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/http/MutualTlsAuthenticator.java +++ b/pki-server/src/main/java/zeroecho/pki/server/http/MutualTlsAuthenticator.java @@ -46,8 +46,10 @@ import java.util.Date; import java.util.HexFormat; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.function.Function; +import zeroecho.pki.server.AdministrativeAuthenticationMode; import zeroecho.pki.server.PkiServerConfiguration.ClientCertificateMapping; import zeroecho.pki.server.SecurityPrincipal; import zeroecho.pki.server.spi.PkiServerAuthenticationContext; @@ -108,6 +110,8 @@ public final class MutualTlsAuthenticator implements PkiServerAuthenticator { SecurityPrincipal principal = principalResolver.apply(matches.getFirst().principalId()); if (!principal.enabled()) { return new PkiServerAuthenticationResult.Rejected( + AdministrativeAuthenticationMode.DIRECT_MTLS, Optional.empty(), + Optional.of(principal.principalId()), PkiServerAuthenticationResult.Code.PRINCIPAL_DISABLED); } return new PkiServerAuthenticationResult.Authenticated(principal.principalId()); diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/Pkcs12ClientTrustProvider.java b/pki-server/src/main/java/zeroecho/pki/server/http/Pkcs12ClientTrustProvider.java new file mode 100644 index 0000000..e3b898d --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/http/Pkcs12ClientTrustProvider.java @@ -0,0 +1,103 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.server.http; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.util.Arrays; +import java.util.Objects; +import java.util.Set; + +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +import zeroecho.pki.server.spi.PkiServerClientTrustProvider; +import zeroecho.pki.spi.ProviderConfig; + +/** External PKCS#12 trust-store provider for forwarded administrative clients. */ +@SuppressWarnings("PMD") +public final class Pkcs12ClientTrustProvider implements PkiServerClientTrustProvider { + /** Stable provider identity. */ + public static final String ID = "jsse-pkcs12-client-trust"; + private static final Set KEYS = Set.of("trustStore", "trustStorePasswordEnvironment"); + + @Override + public String id() { + return ID; + } + + @Override + public X509TrustManager create(ProviderConfig configuration) throws Exception { + ProviderConfig exact = Objects.requireNonNull(configuration, "configuration"); + if (!ID.equals(exact.backendId()) || !exact.properties().keySet().equals(KEYS)) { + throw new IllegalArgumentException("Administrative-client trust configuration is invalid"); + } + Path path = Path.of(exact.require("trustStore")).toAbsolutePath().normalize(); + if (path.getParent() == null || Files.isSymbolicLink(path)) { + throw new IllegalArgumentException("Administrative-client trust source is invalid"); + } + String environment = exact.require("trustStorePasswordEnvironment"); + if (!environment.matches("[A-Z][A-Z0-9_]{0,127}")) { + throw new IllegalArgumentException("Administrative-client trust secret reference is invalid"); + } + String secret = System.getenv(environment); + if (secret == null || secret.isEmpty()) { + throw new IllegalStateException("Administrative-client trust material is unavailable"); + } + char[] password = secret.toCharArray(); + try { + KeyStore store = KeyStore.getInstance("PKCS12"); + try (InputStream input = Files.newInputStream(path)) { + store.load(input, password); + } + TrustManagerFactory factory = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm()); + factory.init(store); + X509TrustManager result = null; + for (TrustManager manager : factory.getTrustManagers()) { + if (manager instanceof X509TrustManager candidate) { + if (result != null) throw new IllegalStateException("Administrative-client trust is ambiguous"); + result = candidate; + } + } + if (result == null) throw new IllegalStateException("Administrative-client trust is unavailable"); + return result; + } finally { + Arrays.fill(password, '\0'); + } + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/spi/PkiServerAuthenticationContext.java b/pki-server/src/main/java/zeroecho/pki/server/spi/PkiServerAuthenticationContext.java index a452730..1246ce6 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/spi/PkiServerAuthenticationContext.java +++ b/pki-server/src/main/java/zeroecho/pki/server/spi/PkiServerAuthenticationContext.java @@ -35,6 +35,8 @@ package zeroecho.pki.server.spi; import java.security.cert.X509Certificate; import java.util.List; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.Objects; import zeroecho.pki.server.RealmId; @@ -48,9 +50,10 @@ import zeroecho.pki.server.RealmId; * @param cipherSuite negotiated cipher suite * @param requestId safe request correlation identity * @param realmId exact server realm + * @param requestHeaders bounded immutable request headers used only by an explicitly configured proxy mode */ public record PkiServerAuthenticationContext(List peerCertificates, String protocol, - String cipherSuite, String requestId, RealmId realmId) { + String cipherSuite, String requestId, RealmId realmId, Map> requestHeaders) { /** Validates and snapshots the finite authentication context. */ public PkiServerAuthenticationContext { peerCertificates = List.copyOf(Objects.requireNonNull(peerCertificates, "peerCertificates")); @@ -65,5 +68,28 @@ public record PkiServerAuthenticationContext(List peerCertifica throw new IllegalArgumentException("Request correlation identity is invalid"); } Objects.requireNonNull(realmId, "realmId"); + Map> copied = new LinkedHashMap<>(); + for (Map.Entry> entry : Objects.requireNonNull(requestHeaders, + "requestHeaders").entrySet()) { + if (entry.getKey() == null || entry.getKey().length() > 256 || copied.size() >= 256) { + throw new IllegalArgumentException("Authentication header context is invalid"); + } + copied.put(entry.getKey(), List.copyOf(entry.getValue())); + } + requestHeaders = Map.copyOf(copied); + } + + /** Creates a direct-mTLS context without forwarded identity headers. */ + public PkiServerAuthenticationContext(List peerCertificates, String protocol, + String cipherSuite, String requestId, RealmId realmId) { + this(peerCertificates, protocol, cipherSuite, requestId, realmId, Map.of()); + } + + /** Returns safe metadata only; certificate and header values are never rendered. */ + @Override + public String toString() { + return "PkiServerAuthenticationContext[peerCertificates=" + peerCertificates.size() + + ", protocol=redacted, cipherSuite=redacted, requestId=" + requestId + + ", realmId=" + realmId + ", forwardedHeaderCount=" + requestHeaders.size() + "]"; } } diff --git a/pki-server/src/main/java/zeroecho/pki/server/spi/PkiServerAuthenticationResult.java b/pki-server/src/main/java/zeroecho/pki/server/spi/PkiServerAuthenticationResult.java index 4a08083..8a746e4 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/spi/PkiServerAuthenticationResult.java +++ b/pki-server/src/main/java/zeroecho/pki/server/spi/PkiServerAuthenticationResult.java @@ -36,13 +36,33 @@ package zeroecho.pki.server.spi; import java.util.Objects; import java.util.Optional; +import zeroecho.pki.server.AdministrativeAuthenticationMode; + /** Safe closed mutual-TLS authentication result. */ public sealed interface PkiServerAuthenticationResult permits PkiServerAuthenticationResult.Authenticated, PkiServerAuthenticationResult.Rejected { /** Successfully resolved persisted principal identity. */ - record Authenticated(String principalId) implements PkiServerAuthenticationResult { + record Authenticated(AdministrativeAuthenticationMode mode, Optional transportPrincipalId, + String endClientPrincipalId) implements PkiServerAuthenticationResult { /** Validates the finite principal identity. */ public Authenticated { + Objects.requireNonNull(mode, "mode"); + transportPrincipalId = Objects.requireNonNull(transportPrincipalId, "transportPrincipalId"); + transportPrincipalId.ifPresent(Authenticated::requirePrincipal); + requirePrincipal(endClientPrincipalId); + if (mode == AdministrativeAuthenticationMode.DIRECT_MTLS && transportPrincipalId.isPresent() + || mode == AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY + && transportPrincipalId.isEmpty()) { + throw new IllegalArgumentException("Authentication principal separation is invalid"); + } + } + + /** Creates a direct-mTLS result for an existing certificate mapper. */ + public Authenticated(String principalId) { + this(AdministrativeAuthenticationMode.DIRECT_MTLS, Optional.empty(), principalId); + } + + private static void requirePrincipal(String principalId) { if (principalId == null || !principalId.matches("[a-zA-Z0-9][a-zA-Z0-9._:-]{0,255}")) { throw new IllegalArgumentException("Principal identity is invalid"); } @@ -50,18 +70,35 @@ public sealed interface PkiServerAuthenticationResult permits PkiServerAuthentic } /** Authentication rejection containing only a stable safe code. */ - record Rejected(Code code) implements PkiServerAuthenticationResult { - /** Validates the rejection. */ public Rejected { Objects.requireNonNull(code, "code"); } + record Rejected(AdministrativeAuthenticationMode mode, Optional transportPrincipalId, + Optional endClientPrincipalId, Code code) implements PkiServerAuthenticationResult { + /** Validates the rejection. */ + public Rejected { + Objects.requireNonNull(mode, "mode"); + transportPrincipalId = Objects.requireNonNull(transportPrincipalId, "transportPrincipalId"); + endClientPrincipalId = Objects.requireNonNull(endClientPrincipalId, "endClientPrincipalId"); + transportPrincipalId.ifPresent(Authenticated::requirePrincipal); + endClientPrincipalId.ifPresent(Authenticated::requirePrincipal); + Objects.requireNonNull(code, "code"); + } + + /** Creates a direct-mode rejection without resolved identity metadata. */ + public Rejected(Code code) { + this(AdministrativeAuthenticationMode.DIRECT_MTLS, Optional.empty(), Optional.empty(), code); + } } /** Safe non-disclosing rejection codes. */ enum Code { - CERTIFICATE_INVALID, MAPPING_UNAVAILABLE, MAPPING_AMBIGUOUS, PRINCIPAL_DISABLED, INTERNAL_FAILURE + CERTIFICATE_INVALID, MAPPING_UNAVAILABLE, MAPPING_AMBIGUOUS, PRINCIPAL_DISABLED, + FORWARDED_IDENTITY_FORBIDDEN, PROXY_NOT_TRUSTED, FORWARDING_NOT_AUTHORIZED, + FORWARDED_IDENTITY_MISSING, FORWARDED_IDENTITY_INVALID, CLIENT_TRUST_REJECTED, + CLIENT_USAGE_REJECTED, IDENTITY_COLLISION, INTERNAL_FAILURE } /** @return principal identity when authentication succeeded */ default Optional resolvedPrincipalId() { return this instanceof Authenticated authenticated - ? Optional.of(authenticated.principalId()) : Optional.empty(); + ? Optional.of(authenticated.endClientPrincipalId()) : Optional.empty(); } } diff --git a/pki-server/src/main/java/zeroecho/pki/server/spi/PkiServerClientTrustProvider.java b/pki-server/src/main/java/zeroecho/pki/server/spi/PkiServerClientTrustProvider.java new file mode 100644 index 0000000..7bfa72b --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/spi/PkiServerClientTrustProvider.java @@ -0,0 +1,57 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.server.spi; + +import javax.net.ssl.X509TrustManager; + +import zeroecho.pki.spi.ProviderConfig; + +/** + * Explicit provider for the external administrative-client trust policy used + * after a trusted proxy forwards a certificate chain. + */ +@SuppressWarnings("PMD.SignatureDeclareThrowsException") +public interface PkiServerClientTrustProvider { + /** @return stable explicitly configured provider identity */ + String id(); + + /** + * Creates an independent client trust manager without exposing trust-store secrets. + * + * @param configuration strict provider configuration + * @return initialized client trust manager + * @throws Exception when configured trust material is unavailable or invalid + */ + X509TrustManager create(ProviderConfig configuration) throws Exception; +} diff --git a/pki-server/src/main/resources/META-INF/services/zeroecho.pki.server.spi.PkiServerClientTrustProvider b/pki-server/src/main/resources/META-INF/services/zeroecho.pki.server.spi.PkiServerClientTrustProvider new file mode 100644 index 0000000..e3e2ac5 --- /dev/null +++ b/pki-server/src/main/resources/META-INF/services/zeroecho.pki.server.spi.PkiServerClientTrustProvider @@ -0,0 +1 @@ +zeroecho.pki.server.http.Pkcs12ClientTrustProvider diff --git a/pki-server/src/test/java/zeroecho/pki/server/AuthorizationAndRoleTest.java b/pki-server/src/test/java/zeroecho/pki/server/AuthorizationAndRoleTest.java index 30edd66..b05b024 100644 --- a/pki-server/src/test/java/zeroecho/pki/server/AuthorizationAndRoleTest.java +++ b/pki-server/src/test/java/zeroecho/pki/server/AuthorizationAndRoleTest.java @@ -111,6 +111,8 @@ class AuthorizationAndRoleTest { assertEquals(15, catalog.templates().size()); assertTrue(catalog.templates().stream().anyMatch(item -> item.templateId().equals("platform-operator"))); assertFalse(catalog.templates().stream().anyMatch(item -> item.templateId().contains("owner"))); + assertFalse(catalog.templates().stream().anyMatch(item -> item.actions() + .contains(Permission.Action.FORWARD_AUTHENTICATED_CLIENT_IDENTITY))); RoleTemplateCatalog.Assignment requester = new RoleTemplateCatalog.Assignment("requester-role", "requester", "requester", 1, ServerTestSupport.scope(), true); assertTrue(catalog.instantiate(requester).stream() diff --git a/pki-server/src/test/java/zeroecho/pki/server/HttpServerTestSupport.java b/pki-server/src/test/java/zeroecho/pki/server/HttpServerTestSupport.java index a00f73a..69d3e0f 100644 --- a/pki-server/src/test/java/zeroecho/pki/server/HttpServerTestSupport.java +++ b/pki-server/src/test/java/zeroecho/pki/server/HttpServerTestSupport.java @@ -53,6 +53,8 @@ import java.util.Set; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.asn1.x509.BasicConstraints; @@ -67,34 +69,75 @@ import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import zeroecho.pki.application.PkiSessionConfiguration; import zeroecho.pki.server.http.TestTlsProvider; +import zeroecho.pki.server.http.TestClientTrustProvider; import zeroecho.pki.spi.ProviderConfig; import zeroecho.pki.spi.store.MetadataStoreId; /** Shared deterministic TLS, realm and server configuration fixtures. */ -final class HttpServerTestSupport { +public final class HttpServerTestSupport { static final char[] PASSWORD = "test-password".toCharArray(); private HttpServerTestSupport() { } - static Fixture tls() throws Exception { - KeyPair rootKey = keyPair(); + public static Fixture tls() throws Exception { + KeyPair rootKey = keyPair((byte) 1); X509Certificate root = certificate(rootKey, rootKey, "CN=ZeroEcho Test Root", "CN=ZeroEcho Test Root", BigInteger.ONE, true, false); - KeyPair serverKey = keyPair(); + KeyPair serverKey = keyPair((byte) 2); X509Certificate server = certificate(serverKey, rootKey, "CN=localhost", "CN=ZeroEcho Test Root", BigInteger.TWO, false, true); - KeyPair clientKey = keyPair(); + KeyPair clientKey = keyPair((byte) 3); X509Certificate client = certificate(clientKey, rootKey, "CN=client", "CN=ZeroEcho Test Root", BigInteger.valueOf(3), false, false); SSLContext serverContext = context(serverKey, server, root, true); SSLContext clientContext = context(clientKey, client, root, true); SSLContext anonymousContext = context(clientKey, client, root, false); + KeyPair proxyKey = keyPair((byte) 4); + X509Certificate proxy = certificate(proxyKey, rootKey, "CN=proxy", "CN=ZeroEcho Test Root", + BigInteger.valueOf(4), false, false); + SSLContext proxyContext = context(proxyKey, proxy, root, true); + KeyPair forwardedKey = keyPair((byte) 5); + X509Certificate forwarded = certificate(forwardedKey, rootKey, "CN=forwarded-client", + "CN=ZeroEcho Test Root", BigInteger.valueOf(5), false, false); TestTlsProvider.install(serverContext); - return new Fixture(client, clientContext, anonymousContext); + TestClientTrustProvider.install(trustManager(root)); + return new Fixture(client, clientContext, anonymousContext, proxy, proxyContext, forwarded, root); } - static PkiServerConfiguration configuration(java.nio.file.Path directory, X509Certificate client) + public static PkiServerConfiguration configuration(java.nio.file.Path directory, X509Certificate client) throws Exception { + return configuration(directory, directAuthentication(client)); + } + + public static PkiServerConfiguration proxyConfiguration(java.nio.file.Path directory, Fixture fixture, + ForwardedCertificateFormat format) throws Exception { + PkiServerConfiguration.ClientCertificateMapping proxy = mapping("proxy-map", "trusted-proxy", + fixture.proxyCertificate()); + PkiServerConfiguration.ClientCertificateMapping forwarded = mapping("forwarded-map", "administrator", + fixture.forwardedCertificate()); + String header = format == ForwardedCertificateFormat.RFC9440 + ? "Client-Cert" : "X-ZeroEcho-Client-Cert"; + Optional chainHeader = format == ForwardedCertificateFormat.RFC9440 + ? Optional.of("Client-Cert-Chain") : Optional.empty(); + PkiServerConfiguration.Authentication authentication = new PkiServerConfiguration.Authentication( + AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY, List.of(), List.of(proxy), + List.of(forwarded), Set.of("trusted-proxy"), Optional.of(format), Optional.of(header), chainHeader, + Optional.of(new ProviderConfig(TestClientTrustProvider.ID, Map.of())), 16_384, 65_536); + return configuration(directory, authentication); + } + + private static PkiServerConfiguration.Authentication directAuthentication(X509Certificate client) + throws Exception { + PkiServerConfiguration.ClientCertificateMapping mapping = mapping("administrator-map", "administrator", + client); + return new PkiServerConfiguration.Authentication(AdministrativeAuthenticationMode.DIRECT_MTLS, + List.of(mapping), List.of(), List.of(), Set.of(), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty(), 0, 0); + } + + private static PkiServerConfiguration configuration(java.nio.file.Path directory, + PkiServerConfiguration.Authentication authentication) throws Exception { + java.nio.file.Files.createDirectories(directory); ApprovalService.Policy approval = new ApprovalService.Policy("high-risk", 1, Set.of("approver"), Set.of(), true, Duration.ofHours(1), true); PkiSessionConfiguration session = new PkiSessionConfiguration(1, @@ -106,29 +149,34 @@ final class HttpServerTestSupport { DisclosureService.Defaults.recommended(), directory.resolve("control.log"), new MetadataStoreId("fedcba9876543210fedcba9876543210"), Map.of(OperationSecurityDescriptors.ApprovalCategory.HIGH_RISK, approval)); - String certificateDigest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") - .digest(client.getEncoded())); - PkiServerConfiguration.ClientCertificateMapping mapping = - new PkiServerConfiguration.ClientCertificateMapping("administrator-map", "administrator", - Optional.of(certificateDigest), Optional.empty(), Optional.empty()); - return new PkiServerConfiguration(1, "test-server", realm, + return new PkiServerConfiguration(PkiServerConfiguration.CURRENT_VERSION, "test-server", realm, new PkiServerConfiguration.Listener(InetAddress.getByName("127.0.0.1"), 0, new ProviderConfig("test-tls", Map.of()), true, 16_384, 65_536), - new PkiServerConfiguration.Authentication(List.of(mapping)), + authentication, new PkiServerConfiguration.Execution(2, 4, 2, 4, 8, Duration.ofSeconds(5), Duration.ofSeconds(10), Duration.ofSeconds(1), Duration.ofSeconds(1)), new PkiServerConfiguration.RuntimeCapabilities(Optional.empty())); } - static SecureRandom random() throws Exception { + private static PkiServerConfiguration.ClientCertificateMapping mapping(String mappingId, + String principalId, X509Certificate certificate) throws Exception { + String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(certificate.getEncoded())); + return new PkiServerConfiguration.ClientCertificateMapping(mappingId, principalId, Optional.of(digest), + Optional.empty(), Optional.empty()); + } + + public static SecureRandom random() throws Exception { SecureRandom random = SecureRandom.getInstance("SHA1PRNG"); random.setSeed(new byte[] { 1, 3, 5, 7, 9 }); return random; } - private static KeyPair keyPair() throws Exception { + private static KeyPair keyPair(byte identity) throws Exception { KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); - generator.initialize(2048, random()); + SecureRandom deterministic = SecureRandom.getInstance("SHA1PRNG"); + deterministic.setSeed(new byte[] { 1, 3, 5, 7, 9, identity }); + generator.initialize(2048, deterministic); return generator.generateKeyPair(); } @@ -172,5 +220,19 @@ final class HttpServerTestSupport { return context; } - record Fixture(X509Certificate clientCertificate, SSLContext clientContext, SSLContext anonymousContext) { } + private static X509TrustManager trustManager(X509Certificate root) throws Exception { + KeyStore trust = KeyStore.getInstance("PKCS12"); + trust.load(null, PASSWORD); + trust.setCertificateEntry("root", root); + TrustManagerFactory factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + factory.init(trust); + for (TrustManager manager : factory.getTrustManagers()) { + if (manager instanceof X509TrustManager result) return result; + } + throw new IllegalStateException("Test trust manager unavailable"); + } + + public record Fixture(X509Certificate clientCertificate, SSLContext clientContext, SSLContext anonymousContext, + X509Certificate proxyCertificate, SSLContext proxyContext, X509Certificate forwardedCertificate, + X509Certificate rootCertificate) { } } diff --git a/pki-server/src/test/java/zeroecho/pki/server/PkiHttpsServerTest.java b/pki-server/src/test/java/zeroecho/pki/server/PkiHttpsServerTest.java index 4586789..c79b013 100644 --- a/pki-server/src/test/java/zeroecho/pki/server/PkiHttpsServerTest.java +++ b/pki-server/src/test/java/zeroecho/pki/server/PkiHttpsServerTest.java @@ -49,6 +49,8 @@ import java.util.HexFormat; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Base64; +import java.util.Set; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -193,10 +195,145 @@ class PkiHttpsServerTest { System.out.println("...ok"); } + @Test + void authenticatesTrustedProxyWithRfc9440ForwardedClient() throws Exception { + System.out.println("authenticatesTrustedProxyWithRfc9440ForwardedClient"); + HttpServerTestSupport.Fixture tls = HttpServerTestSupport.tls(); + PkiServerConfiguration configuration = HttpServerTestSupport.proxyConfiguration( + temporaryDirectory.resolve("rfc9440"), tls, ForwardedCertificateFormat.RFC9440); + seed(configuration); + try (PkiHttpsServer server = PkiHttpsServer.start(configuration, PkiSessionRuntimeDependencies.none(), + ServerTestSupport.CLOCK, HttpServerTestSupport.random(), TestTlsProvider.class.getClassLoader())) { + HttpClient proxy = HttpClient.newBuilder().sslContext(tls.proxyContext()).build(); + URI uri = URI.create("https://localhost:" + server.address().getPort() + "/admin/v1/realm"); + String leaf = ":" + Base64.getEncoder().encodeToString(tls.forwardedCertificate().getEncoded()) + ":"; + String root = ":" + Base64.getEncoder().encodeToString(tls.rootCertificate().getEncoded()) + ":"; + HttpResponse response = proxy.send(HttpRequest.newBuilder(uri) + .header("Client-Cert", leaf).header("Client-Cert-Chain", root).GET().build(), + HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("production")); + System.out.println("...transport=trusted-proxy,end=administrator"); + } + System.out.println("...ok"); + } + + @Test + void rejectsForwardedHeadersInDirectModeAndDuplicateProxyHeaders() throws Exception { + System.out.println("rejectsForwardedHeadersInDirectModeAndDuplicateProxyHeaders"); + HttpServerTestSupport.Fixture tls = HttpServerTestSupport.tls(); + PkiServerConfiguration direct = HttpServerTestSupport.configuration(temporaryDirectory.resolve("direct"), + tls.clientCertificate()); + seed(direct); + String leaf = ":" + Base64.getEncoder().encodeToString(tls.forwardedCertificate().getEncoded()) + ":"; + try (PkiHttpsServer server = PkiHttpsServer.start(direct, PkiSessionRuntimeDependencies.none(), + ServerTestSupport.CLOCK, HttpServerTestSupport.random(), TestTlsProvider.class.getClassLoader())) { + HttpClient client = HttpClient.newBuilder().sslContext(tls.clientContext()).build(); + URI uri = URI.create("https://localhost:" + server.address().getPort() + "/admin/v1/realm"); + assertEquals(400, client.send(HttpRequest.newBuilder(uri).header("Client-Cert", leaf).GET().build(), + HttpResponse.BodyHandlers.ofString()).statusCode()); + } + PkiServerConfiguration proxyConfiguration = HttpServerTestSupport.proxyConfiguration( + temporaryDirectory.resolve("proxy"), tls, ForwardedCertificateFormat.RFC9440); + seed(proxyConfiguration); + try (PkiHttpsServer server = PkiHttpsServer.start(proxyConfiguration, + PkiSessionRuntimeDependencies.none(), ServerTestSupport.CLOCK, HttpServerTestSupport.random(), + TestTlsProvider.class.getClassLoader())) { + HttpClient proxy = HttpClient.newBuilder().sslContext(tls.proxyContext()).build(); + URI uri = URI.create("https://localhost:" + server.address().getPort() + "/admin/v1/realm"); + HttpRequest duplicate = HttpRequest.newBuilder(uri).header("Client-Cert", leaf) + .header("Client-Cert", leaf).GET().build(); + assertEquals(401, proxy.send(duplicate, HttpResponse.BodyHandlers.ofString()).statusCode()); + } + System.out.println("...duplicate-forwarded-identity=rejected"); + System.out.println("...ok"); + } + + @Test + void acceptsOnlyStrictNginxEscapedPem() throws Exception { + System.out.println("acceptsOnlyStrictNginxEscapedPem"); + HttpServerTestSupport.Fixture tls = HttpServerTestSupport.tls(); + PkiServerConfiguration configuration = HttpServerTestSupport.proxyConfiguration( + temporaryDirectory.resolve("nginx"), tls, ForwardedCertificateFormat.NGINX_ESCAPED_PEM_V1); + seed(configuration); + String pem = pem(tls.forwardedCertificate()); + String escaped = escape(pem); + try (PkiHttpsServer server = PkiHttpsServer.start(configuration, PkiSessionRuntimeDependencies.none(), + ServerTestSupport.CLOCK, HttpServerTestSupport.random(), TestTlsProvider.class.getClassLoader())) { + HttpClient proxy = HttpClient.newBuilder().sslContext(tls.proxyContext()).build(); + URI uri = URI.create("https://localhost:" + server.address().getPort() + "/admin/v1/realm"); + assertEquals(200, proxy.send(HttpRequest.newBuilder(uri) + .header("X-ZeroEcho-Client-Cert", escaped).GET().build(), + HttpResponse.BodyHandlers.ofString()).statusCode()); + assertEquals(401, proxy.send(HttpRequest.newBuilder(uri) + .header("X-ZeroEcho-Client-Cert", pem.replace("\n", "" )).GET().build(), + HttpResponse.BodyHandlers.ofString()).statusCode()); + } + System.out.println("...format-autodetection=false"); + System.out.println("...ok"); + } + + @Test + void rejectsProxyWithoutForwardingPermission() throws Exception { + System.out.println("rejectsProxyWithoutForwardingPermission"); + HttpServerTestSupport.Fixture tls = HttpServerTestSupport.tls(); + PkiServerConfiguration configuration = HttpServerTestSupport.proxyConfiguration( + temporaryDirectory.resolve("no-forwarding"), tls, ForwardedCertificateFormat.RFC9440); + seed(configuration, false); + try (PkiHttpsServer server = PkiHttpsServer.start(configuration, PkiSessionRuntimeDependencies.none(), + ServerTestSupport.CLOCK, HttpServerTestSupport.random(), TestTlsProvider.class.getClassLoader())) { + HttpClient proxy = HttpClient.newBuilder().sslContext(tls.proxyContext()).build(); + URI uri = URI.create("https://localhost:" + server.address().getPort() + "/admin/v1/realm"); + String leaf = ":" + Base64.getEncoder().encodeToString(tls.forwardedCertificate().getEncoded()) + ":"; + assertEquals(401, proxy.send(HttpRequest.newBuilder(uri).header("Client-Cert", leaf).GET().build(), + HttpResponse.BodyHandlers.ofString()).statusCode()); + } + System.out.println("...gateway-invoked=false"); + System.out.println("...ok"); + } + + @Test + void rejectsContradictoryAuthenticationModeConfiguration() throws Exception { + System.out.println("rejectsContradictoryAuthenticationModeConfiguration"); + HttpServerTestSupport.Fixture tls = HttpServerTestSupport.tls(); + PkiServerConfiguration direct = HttpServerTestSupport.configuration(temporaryDirectory.resolve("config"), + tls.clientCertificate()); + PkiServerConfiguration.Authentication directAuthentication = direct.authentication(); + assertThrows(IllegalArgumentException.class, () -> new PkiServerConfiguration.Authentication( + AdministrativeAuthenticationMode.DIRECT_MTLS, directAuthentication.directClientMappings(), + List.of(), List.of(), Set.of(), Optional.of(ForwardedCertificateFormat.RFC9440), + Optional.empty(), Optional.empty(), Optional.empty(), 0, 0)); + PkiServerConfiguration.Authentication proxy = HttpServerTestSupport.proxyConfiguration( + temporaryDirectory.resolve("proxy-config"), tls, ForwardedCertificateFormat.RFC9440) + .authentication(); + assertThrows(IllegalArgumentException.class, () -> new PkiServerConfiguration.Authentication( + AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY, List.of(), + proxy.proxyTransportMappings(), proxy.forwardedClientMappings(), + proxy.trustedProxyPrincipalIds(), proxy.forwardedCertificateFormat(), + proxy.forwardedCertificateHeaderName(), proxy.forwardedCertificateChainHeaderName(), + Optional.empty(), proxy.maximumForwardedCertificateBytes(), proxy.maximumForwardedChainBytes())); + assertThrows(IllegalArgumentException.class, () -> new PkiServerConfiguration.Authentication( + AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY, List.of(), + proxy.proxyTransportMappings(), proxy.forwardedClientMappings(), + proxy.trustedProxyPrincipalIds(), Optional.of(ForwardedCertificateFormat.NGINX_ESCAPED_PEM_V1), + Optional.of("Client-Cert"), Optional.empty(), proxy.administrativeClientTrust(), 16_384, 65_536)); + System.out.println("...implicit-mode=false"); + System.out.println("...ok"); + } + private void seed(PkiServerConfiguration configuration) throws Exception { + seed(configuration, true); + } + + private void seed(PkiServerConfiguration configuration, boolean grantForwarding) throws Exception { try (ServerRealmContext context = ServerRealmContext.open(configuration.realm(), PkiSessionRuntimeDependencies.none(), ServerTestSupport.CLOCK, HttpServerTestSupport.random())) { context.createPrincipal(ServerTestSupport.principal("administrator"), "system"); + if (configuration.authentication().mode() + == AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY) { + context.createPrincipal(new SecurityPrincipal("trusted-proxy", SecurityPrincipal.Type.SERVICE, + "Trusted reverse proxy", Optional.empty(), Map.of(), true), "system"); + } Permission.Scope realmScope = new Permission.Scope(ServerTestSupport.REALM, Optional.empty(), Optional.empty(), Optional.empty()); context.grant(ServerTestSupport.grant("realm-read", "administrator", Permission.Effect.ALLOW, @@ -211,9 +348,36 @@ class PkiHttpsServerTest { context.grant(ServerTestSupport.grant("principal-manage", "administrator", Permission.Effect.ALLOW, Permission.Action.PRINCIPAL_MANAGE, Permission.ResourceType.PRINCIPAL, realmScope, Permission.Relationship.ANY, Permission.DataView.METADATA_REDACTED), "system"); + if (grantForwarding && configuration.authentication().mode() + == AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY) { + context.grant(ServerTestSupport.grant("proxy-forward", "trusted-proxy", Permission.Effect.ALLOW, + Permission.Action.FORWARD_AUTHENTICATED_CLIENT_IDENTITY, Permission.ResourceType.REALM, + realmScope, Permission.Relationship.ANY, Permission.DataView.METADATA_REDACTED), "system"); + } } } + private static String pem(java.security.cert.X509Certificate certificate) throws Exception { + String encoded = Base64.getMimeEncoder(64, new byte[] { '\n' }).encodeToString(certificate.getEncoded()); + return "-----BEGIN CERTIFICATE-----\n" + encoded + "\n-----END CERTIFICATE-----\n"; + } + + private static String escape(String value) { + StringBuilder result = new StringBuilder(value.length() * 3); + for (byte item : value.getBytes(java.nio.charset.StandardCharsets.US_ASCII)) { + int unsigned = item & 0xff; + char character = (char) unsigned; + if (character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' + || character >= '0' && character <= '9' || character == '-' || character == '.' + || character == '_' || character == '~') { + result.append(character); + } else { + result.append('%').append(HexFormat.of().withUpperCase().toHexDigits(item)); + } + } + return result.toString(); + } + private static PkiServerConfiguration.ClientCertificateMapping mapping(String id, String principal, String digest) { return new PkiServerConfiguration.ClientCertificateMapping(id, principal, Optional.of(digest), diff --git a/pki-server/src/test/java/zeroecho/pki/server/http/ForwardedClientCertificateParserTest.java b/pki-server/src/test/java/zeroecho/pki/server/http/ForwardedClientCertificateParserTest.java new file mode 100644 index 0000000..3bcee01 --- /dev/null +++ b/pki-server/src/test/java/zeroecho/pki/server/http/ForwardedClientCertificateParserTest.java @@ -0,0 +1,146 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.server.http; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; +import java.security.cert.X509Certificate; +import java.util.Base64; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import zeroecho.pki.server.ForwardedCertificateFormat; +import zeroecho.pki.server.HttpServerTestSupport; +import zeroecho.pki.server.PkiServerConfiguration; +import zeroecho.pki.server.PkiServerConfigurationCodec; + +/** Hostile-input coverage for both explicit forwarded certificate formats. */ +class ForwardedClientCertificateParserTest { + @TempDir java.nio.file.Path temporaryDirectory; + + @Test + void parsesStrictRfc9440LeafAndChain() throws Exception { + System.out.println("parsesStrictRfc9440LeafAndChain"); + HttpServerTestSupport.Fixture fixture = HttpServerTestSupport.tls(); + PkiServerConfiguration.Authentication configuration = HttpServerTestSupport.proxyConfiguration( + temporaryDirectory, fixture, ForwardedCertificateFormat.RFC9440).authentication(); + String leaf = item(fixture.forwardedCertificate()); + String root = item(fixture.rootCertificate()); + List parsed = ForwardedClientCertificateParser.parse(configuration, + Map.of("Client-Cert", List.of(leaf), "Client-Cert-Chain", List.of(root))); + assertEquals(2, parsed.size()); + assertEquals(fixture.forwardedCertificate(), parsed.getFirst()); + assertThrows(IllegalArgumentException.class, () -> ForwardedClientCertificateParser.parse(configuration, + Map.of("Client-Cert", List.of(leaf, leaf)))); + assertThrows(IllegalArgumentException.class, () -> ForwardedClientCertificateParser.parse(configuration, + Map.of("Client-Cert", List.of(leaf), "X-ZeroEcho-Client-Cert", List.of("ignored")))); + assertThrows(IllegalArgumentException.class, () -> ForwardedClientCertificateParser.parse(configuration, + Map.of("Client-Cert", List.of(leaf + ";foo=bar")))); + System.out.println("...chain-count=" + parsed.size()); + System.out.println("...ok"); + } + + @Test + void rejectsMalformedRfc9440ByteSequences() throws Exception { + System.out.println("rejectsMalformedRfc9440ByteSequences"); + HttpServerTestSupport.Fixture fixture = HttpServerTestSupport.tls(); + PkiServerConfiguration.Authentication configuration = HttpServerTestSupport.proxyConfiguration( + temporaryDirectory, fixture, ForwardedCertificateFormat.RFC9440).authentication(); + for (String malformed : List.of("", ":*:", ":Zg=:", ":Zm9v:trailing", "::")) { + assertThrows(IllegalArgumentException.class, () -> ForwardedClientCertificateParser.parse(configuration, + Map.of("Client-Cert", List.of(malformed)))); + } + System.out.println("...malformed-count=5"); + System.out.println("...ok"); + } + + @Test + void parsesOnlyCanonicalNginxEscapedPem() throws Exception { + System.out.println("parsesOnlyCanonicalNginxEscapedPem"); + HttpServerTestSupport.Fixture fixture = HttpServerTestSupport.tls(); + PkiServerConfiguration.Authentication configuration = HttpServerTestSupport.proxyConfiguration( + temporaryDirectory, fixture, ForwardedCertificateFormat.NGINX_ESCAPED_PEM_V1).authentication(); + String pem = pem(fixture.forwardedCertificate()); + String escaped = escape(pem); + assertEquals(fixture.forwardedCertificate(), ForwardedClientCertificateParser.parse(configuration, + Map.of("X-ZeroEcho-Client-Cert", List.of(escaped))).getFirst()); + for (String malformed : List.of(pem, escaped + "%", escaped.replaceFirst("%0A", "+"), + escaped + escaped, escape(pem.replace("CERTIFICATE", "PUBLIC KEY")))) { + assertThrows(IllegalArgumentException.class, () -> ForwardedClientCertificateParser.parse(configuration, + Map.of("X-ZeroEcho-Client-Cert", List.of(malformed)))); + } + System.out.println("...raw-pem-autodetection=false"); + System.out.println("...ok"); + } + + @Test + void rejectsObsoleteConfigurationSchemaWithoutFallback() { + System.out.println("rejectsObsoleteConfigurationSchemaWithoutFallback"); + byte[] obsolete = "{\"version\":1}".getBytes(StandardCharsets.UTF_8); + assertThrows(IllegalArgumentException.class, () -> PkiServerConfigurationCodec.decode(obsolete)); + System.out.println("...current-schema=" + PkiServerConfiguration.CURRENT_VERSION); + System.out.println("...ok"); + } + + private static String item(X509Certificate certificate) throws Exception { + return ":" + Base64.getEncoder().encodeToString(certificate.getEncoded()) + ":"; + } + + private static String pem(X509Certificate certificate) throws Exception { + String encoded = Base64.getMimeEncoder(64, new byte[] { '\n' }).encodeToString(certificate.getEncoded()); + return "-----BEGIN CERTIFICATE-----\n" + encoded + "\n-----END CERTIFICATE-----\n"; + } + + private static String escape(String value) { + StringBuilder result = new StringBuilder(value.length() * 3); + for (byte item : value.getBytes(StandardCharsets.US_ASCII)) { + int unsigned = item & 0xff; + char character = (char) unsigned; + if (character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' + || character >= '0' && character <= '9' || character == '-' || character == '.' + || character == '_' || character == '~') { + result.append(character); + } else { + result.append('%').append(HexFormat.of().withUpperCase().toHexDigits(item)); + } + } + return result.toString(); + } +} diff --git a/pki-server/src/test/java/zeroecho/pki/server/http/TestClientTrustProvider.java b/pki-server/src/test/java/zeroecho/pki/server/http/TestClientTrustProvider.java new file mode 100644 index 0000000..7c2de0e --- /dev/null +++ b/pki-server/src/test/java/zeroecho/pki/server/http/TestClientTrustProvider.java @@ -0,0 +1,66 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.server.http; + +import java.util.Objects; + +import javax.net.ssl.X509TrustManager; + +import zeroecho.pki.server.spi.PkiServerClientTrustProvider; +import zeroecho.pki.spi.ProviderConfig; + +/** Deterministic process-confined forwarded-client trust provider for tests. */ +public final class TestClientTrustProvider implements PkiServerClientTrustProvider { + /** Stable test provider identity. */ + public static final String ID = "test-client-trust"; + private static volatile X509TrustManager installed; + + /** Installs the deterministic test trust manager. */ + public static void install(X509TrustManager manager) { + installed = Objects.requireNonNull(manager, "manager"); + } + + @Override + public String id() { + return ID; + } + + @Override + public X509TrustManager create(ProviderConfig configuration) { + if (!ID.equals(configuration.backendId()) || !configuration.properties().isEmpty()) { + throw new IllegalArgumentException("Test client trust configuration is invalid"); + } + return Objects.requireNonNull(installed, "Test client trust is not installed"); + } +} diff --git a/pki-server/src/test/resources/META-INF/services/zeroecho.pki.server.spi.PkiServerClientTrustProvider b/pki-server/src/test/resources/META-INF/services/zeroecho.pki.server.spi.PkiServerClientTrustProvider new file mode 100644 index 0000000..eb1aa7f --- /dev/null +++ b/pki-server/src/test/resources/META-INF/services/zeroecho.pki.server.spi.PkiServerClientTrustProvider @@ -0,0 +1 @@ +zeroecho.pki.server.http.TestClientTrustProvider