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

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