feat(pki-server): support trusted reverse-proxy authentication

Support explicit direct-mTLS and trusted-reverse-proxy authentication
modes with mutually authenticated backend transport.

Keep proxy and end-client principals separate, validate forwarded
certificates independently, and enforce narrowly scoped forwarding
authority for RFC 9440 and NGINX escaped-PEM profiles.
This commit is contained in:
2026-08-04 23:07:01 +02:00
parent 5b896ee2a2
commit 7328f075dd
29 changed files with 1954 additions and 72 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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"

View File

@@ -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"

View File

@@ -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": {}
}

View File

@@ -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": {}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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),

View File

@@ -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> 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);
}

View File

@@ -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<ClientCertificateMapping> mappings) {
/** Validates mapping uniqueness and snapshots input order. */
public record Authentication(AdministrativeAuthenticationMode mode,
List<ClientCertificateMapping> directClientMappings,
List<ClientCertificateMapping> proxyTransportMappings,
List<ClientCertificateMapping> forwardedClientMappings,
Set<String> trustedProxyPrincipalIds,
Optional<ForwardedCertificateFormat> forwardedCertificateFormat,
Optional<String> forwardedCertificateHeaderName,
Optional<String> forwardedCertificateChainHeaderName,
Optional<ProviderConfig> 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<ClientCertificateMapping> mappings(List<ClientCertificateMapping> source,
String name) {
List<ClientCertificateMapping> 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<String> header(Optional<String> 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<ClientCertificateMapping> direct,
List<ClientCertificateMapping> proxy, List<ClientCertificateMapping> forwarded,
Set<String> trusted, Optional<ForwardedCertificateFormat> format, Optional<String> header,
Optional<String> chainHeader, Optional<ProviderConfig> 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<ClientCertificateMapping> direct,
List<ClientCertificateMapping> proxy, List<ClientCertificateMapping> forwarded,
Set<String> trusted, Optional<ForwardedCertificateFormat> format, Optional<String> header,
Optional<String> chainHeader, Optional<ProviderConfig> 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<String> 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<String> 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<String> proxyCertificates = proxy.stream().flatMap(mapping -> mapping.certificateSha256().stream())
.collect(java.util.stream.Collectors.toUnmodifiableSet());
Set<String> 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");
}
}
}

View File

@@ -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<PkiServerConfiguration.ClientCertificateMapping> 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<PkiServerConfiguration.ClientCertificateMapping> proxy = mappings(value.list("proxyTransportMappings"));
List<PkiServerConfiguration.ClientCertificateMapping> 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<PkiServerConfiguration.ClientCertificateMapping> mappings(
List<PkiOperationValue> source) {
List<PkiServerConfiguration.ClientCertificateMapping> 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) {

View File

@@ -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<String> transportPrincipalId,
AdministrativeOperation operation,
Permission.Resource resource, Permission.Relationship relationship, Permission.Context context,
Optional<String> 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<String> 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<String> 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<Permission.Grant> 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<String, String> gatewayDetails(Request request, String resultName, String result) {
Map<String, String> 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()) {

View File

@@ -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<String, List<String>> authenticationHeaders(Headers headers) {
java.util.Set<String> 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<String, List<String>> result = new LinkedHashMap<>();
for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
if (accepted.contains(entry.getKey())) result.put(entry.getKey(), List.copyOf(entry.getValue()));
}
return Map.copyOf(result);
}
private static Map<String, String> authenticationDetails(String requestId,
zeroecho.pki.server.AdministrativeAuthenticationMode mode, Optional<String> transport,
Optional<String> endClient, String classification) {
Map<String, String> 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) {

View File

@@ -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.
*
* <p>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.</p>
*/
@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<String, String, AuthorizationEngine.Decision> forwardingAuthorization;
private AdministrativeAuthenticator(PkiServerConfiguration.Authentication configuration,
MutualTlsAuthenticator transportMapper, MutualTlsAuthenticator endClientMapper,
X509TrustManager forwardedTrust,
BiFunction<String, String, AuthorizationEngine.Decision> 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<String, SecurityPrincipal> principalResolver,
BiFunction<String, String, AuthorizationEngine.Decision> 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<String> 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<X509Certificate> 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<String> 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<String> transport, PkiServerAuthenticationResult.Code code) {
return new PkiServerAuthenticationResult.Rejected(mode, transport, Optional.empty(), code);
}
}

View File

@@ -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<PkiServerClientTrustProvider> 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");
}
}
}

View File

@@ -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<X509Certificate> parse(PkiServerConfiguration.Authentication configuration,
Map<String, List<String>> 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<byte[]> 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<X509Certificate> result = new ArrayList<>(encoded.size());
for (byte[] certificate : encoded) result.add(certificate(certificate));
return List.copyOf(result);
}
/* default */ static boolean containsForwardedIdentity(Map<String, List<String>> headers) {
return present(headers, RFC_CERTIFICATE_HEADER) || present(headers, RFC_CHAIN_HEADER)
|| present(headers, DIRECT_REJECTED_NGINX_HEADER);
}
private static String singleton(Map<String, List<String>> headers, String name, boolean required) {
List<String> matches = new ArrayList<>();
int matchingNames = 0;
for (Map.Entry<String, List<String>> 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<String, List<String>> 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");
}
}
}

View File

@@ -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());

View File

@@ -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<String> 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');
}
}
}

View File

@@ -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<X509Certificate> peerCertificates, String protocol,
String cipherSuite, String requestId, RealmId realmId) {
String cipherSuite, String requestId, RealmId realmId, Map<String, List<String>> 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<X509Certificate> peerCertifica
throw new IllegalArgumentException("Request correlation identity is invalid");
}
Objects.requireNonNull(realmId, "realmId");
Map<String, List<String>> copied = new LinkedHashMap<>();
for (Map.Entry<String, List<String>> 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<X509Certificate> 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() + "]";
}
}

View File

@@ -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<String> 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<String> transportPrincipalId,
Optional<String> 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<String> resolvedPrincipalId() {
return this instanceof Authenticated authenticated
? Optional.of(authenticated.principalId()) : Optional.empty();
? Optional.of(authenticated.endClientPrincipalId()) : Optional.empty();
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1 @@
zeroecho.pki.server.http.Pkcs12ClientTrustProvider

View File

@@ -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()

View File

@@ -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<String> 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) { }
}

View File

@@ -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<String> 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,8 +348,35 @@ 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) {

View File

@@ -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<X509Certificate> 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();
}
}

View File

@@ -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");
}
}

View File

@@ -0,0 +1 @@
zeroecho.pki.server.http.TestClientTrustProvider