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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() + "]";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user