feat(pki-server): add secure administrative HTTPS API

Add the mutually authenticated administrative HTTPS server with strict
typed JSON, bounded request execution, multi-authority authorization,
approval enforcement, safe auditing and finite shutdown.

Reuse one long-lived realm and PKI session without duplicating backend
authority or operation semantics.
This commit is contained in:
2026-08-04 20:16:34 +02:00
parent 8a5cbb61b3
commit d7793e5c49
30 changed files with 3866 additions and 1 deletions

View File

@@ -1,5 +1,6 @@
plugins {
id 'buildlogic.java-library-conventions'
id 'application'
id 'com.palantir.git-version'
}
@@ -10,6 +11,22 @@ dependencies {
implementation project(':lib')
implementation platform('tools.jackson:jackson-bom:3.1.5')
implementation 'tools.jackson.core:jackson-core'
testImplementation 'org.bouncycastle:bcpkix-jdk18on:1.84'
}
application {
mainClass = 'zeroecho.pki.server.PkiServerMain'
applicationName = 'zeroecho-pki-server'
}
jar {
manifest {
attributes(
'Main-Class': application.mainClass,
'Implementation-Title': 'ZeroEcho PKI Server',
'Implementation-Version': "${version}"
)
}
}
javadoc {

View File

@@ -0,0 +1,208 @@
/*******************************************************************************
* 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;
import java.net.InetSocketAddress;
import java.security.SecureRandom;
import java.time.Clock;
import java.util.Map;
import java.util.Objects;
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.PkiHttpsTransport;
import zeroecho.pki.server.spi.PkiServerAuthenticator;
/**
* Lifecycle owner for one bounded administrative mutual-TLS HTTPS server.
*
* <p>One instance owns one long-lived {@link ServerRealmContext} and therefore
* one long-lived PKI session. It is thread-safe for lifecycle inspection and
* idempotent close. HTTP handlers never construct sessions or execute operations
* outside {@link ServerOperationGateway}.</p>
*/
@SuppressWarnings("PMD")
public final class PkiHttpsServer implements AutoCloseable {
/** Closed process lifecycle. */
public enum State { NEW, STARTING, READY, QUIESCING, TERMINATED }
private final PkiServerConfiguration configuration;
private final ServerRealmContext realm;
private final PkiServerAuthenticator authenticator;
private final PkiHttpsTransport transport;
private final AtomicReference<State> state;
private PkiHttpsServer(PkiServerConfiguration configuration, ServerRealmContext realm,
PkiServerAuthenticator authenticator, PkiHttpsTransport transport,
AtomicReference<State> state) {
this.configuration = configuration;
this.realm = realm;
this.authenticator = authenticator;
this.transport = transport;
this.state = state;
}
/**
* Starts one production HTTPS server using explicitly configured capabilities.
*
* @param configuration validated server configuration
* @param runtimeDependencies process-local PKI capabilities
* @return fully ready lifecycle owner
*/
public static PkiHttpsServer start(PkiServerConfiguration configuration,
PkiSessionRuntimeDependencies runtimeDependencies) {
return start(configuration, runtimeDependencies, Clock.systemUTC(), new SecureRandom(),
Thread.currentThread().getContextClassLoader());
}
static PkiHttpsServer start(PkiServerConfiguration configuration,
PkiSessionRuntimeDependencies runtimeDependencies, Clock clock, SecureRandom random,
ClassLoader loader) {
PkiServerConfiguration exact = Objects.requireNonNull(configuration, "configuration");
Objects.requireNonNull(runtimeDependencies, "runtimeDependencies");
Objects.requireNonNull(clock, "clock");
Objects.requireNonNull(random, "random");
AtomicReference<State> state = new AtomicReference<>(State.NEW);
state.set(State.STARTING);
ServerRealmContext realm = null;
PkiServerAuthenticator authenticator = null;
PkiHttpsTransport transport = null;
try {
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);
ServerRealmContext sharedRealm = realm;
transport = PkiHttpsTransport.startResolved(exact, sharedRealm, authenticator, clock, random, tls,
() -> state.get() == State.READY && sharedRealm.state() == ServerRealmContext.State.OPEN);
state.set(State.READY);
realm.auditTransport("SERVER_READY", "system", Map.of("state", "READY"));
return new PkiHttpsServer(exact, realm, authenticator, transport, state);
} catch (RuntimeException | Error primary) {
closePartial(transport, realm, authenticator, state, primary);
throw primary;
}
}
/** @return current lifecycle state */
public State state() {
return state.get();
}
/** @return actual bound address, including an allocated ephemeral port */
public InetSocketAddress address() {
if (state.get() == State.TERMINATED) throw new IllegalStateException("HTTPS server is terminated");
return transport.address();
}
/** @return the one lifecycle-owned realm context */
public ServerRealmContext realm() {
if (state.get() != State.READY) throw new IllegalStateException("HTTPS server is not ready");
return realm;
}
/**
* Installs the sole finite process-shutdown bridge for the packaged launcher.
* Tests and embedded integrations may instead close the server directly.
*/
public void installShutdownHook() {
Runtime.getRuntime().addShutdownHook(transport.shutdownHook(() -> {
try {
close();
} catch (Exception ignored) {
// A shutdown hook has no safe caller to receive close failure details.
}
}));
}
/**
* Quiesces admission, terminates the listener and bounded executors, then
* closes realm and authentication resources. Repeated calls are harmless.
*/
@Override
public void close() throws Exception {
if (!state.compareAndSet(State.READY, State.QUIESCING)
&& !state.compareAndSet(State.STARTING, State.QUIESCING)) return;
Throwable primary = null;
try {
realm.auditTransport("SERVER_SHUTDOWN", "system", Map.of("state", "QUIESCING"));
} catch (Throwable failure) {
primary = failure;
}
transport.quiesce();
try {
transport.shutdown(configuration.execution().gracefulShutdown());
} catch (Throwable failure) {
primary = suppress(primary, failure);
}
primary = close(realm, primary);
primary = close(authenticator, primary);
state.set(State.TERMINATED);
rethrow(primary);
}
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);
state.set(State.TERMINATED);
}
private static Throwable close(AutoCloseable resource, Throwable primary) {
if (resource == null) return primary;
try {
resource.close();
} catch (Throwable failure) {
return suppress(primary, failure);
}
return primary;
}
private static Throwable suppress(Throwable primary, Throwable failure) {
if (primary == null) return failure;
if (primary != failure) primary.addSuppressed(failure);
return primary;
}
private static void rethrow(Throwable failure) throws Exception {
if (failure == null) return;
if (failure instanceof Exception exception) throw exception;
if (failure instanceof Error error) throw error;
throw new IllegalStateException("Server close failed");
}
}

View File

@@ -0,0 +1,226 @@
/*******************************************************************************
* 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;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import zeroecho.pki.spi.ProviderConfig;
/**
* Strict immutable configuration for one administrative HTTPS server instance.
*
* <p>The record owns no secret values. Provider properties may contain paths and
* names of external environment-variable references and therefore must never be
* rendered or logged. One instance belongs to exactly one realm and creates one
* long-lived {@link ServerRealmContext}.</p>
*
* @param version server schema version
* @param serverName safe bounded operational name
* @param realm exact realm configuration
* @param listener HTTPS listener policy
* @param authentication strict client-certificate mappings
* @param execution bounded execution and shutdown policy
* @param runtime process-local capability references
*/
@SuppressWarnings("PMD")
public record PkiServerConfiguration(int version, String serverName, ServerRealmConfiguration realm,
Listener listener, Authentication authentication, Execution execution, RuntimeCapabilities runtime) {
/** Current server configuration schema. */
public static final int CURRENT_VERSION = 1;
/** Validates all security-sensitive fields before resource allocation. */
public PkiServerConfiguration {
if (version != CURRENT_VERSION) throw new IllegalArgumentException("Unsupported server schema version");
Permission.requireBounded(serverName, 128, "server name");
Objects.requireNonNull(realm, "realm");
Objects.requireNonNull(listener, "listener");
Objects.requireNonNull(authentication, "authentication");
Objects.requireNonNull(execution, "execution");
Objects.requireNonNull(runtime, "runtime");
if (!listener.clientCertificateRequired()) {
throw new IllegalArgumentException("Administrative HTTPS requires client certificates");
}
if (authentication.mappings().isEmpty()) {
throw new IllegalArgumentException("Administrative HTTPS requires principal mappings");
}
}
/**
* HTTPS listener configuration.
*
* @param address parsed canonical literal bind address
* @param port TCP port, where zero requests an ephemeral test port
* @param tlsProvider explicitly selected TLS material provider
* @param clientCertificateRequired must be {@code true}
* @param maximumHeaderBytes bounded aggregate decoded header bytes
* @param maximumBodyBytes bounded request body bytes
*/
public record Listener(InetAddress address, int port, ProviderConfig tlsProvider,
boolean clientCertificateRequired, int maximumHeaderBytes, int maximumBodyBytes) {
/** Validates listener bounds and explicit TLS selection. */
public Listener {
Objects.requireNonNull(address, "address");
if (port < 0 || port > 65_535) throw new IllegalArgumentException("Listener port is invalid");
Objects.requireNonNull(tlsProvider, "tlsProvider");
if (maximumHeaderBytes < 1_024 || maximumHeaderBytes > 1_048_576) {
throw new IllegalArgumentException("Header bound is invalid");
}
if (maximumBodyBytes < 1_024 || maximumBodyBytes > 16_777_216) {
throw new IllegalArgumentException("Body bound is invalid");
}
}
/** @return exact socket address without DNS resolution */
public InetSocketAddress socketAddress() {
return new InetSocketAddress(address, port);
}
}
/**
* Client-certificate authentication mappings.
*
* @param mappings finite explicit immutable mappings
*/
public record Authentication(List<ClientCertificateMapping> mappings) {
/** Validates mapping uniqueness and snapshots input order. */
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");
}
}
}
/**
* One exact client-certificate mapping. All present commitments must match.
*
* @param mappingId stable mapping identity
* @param principalId persisted server principal identity
* @param certificateSha256 optional canonical certificate commitment
* @param subjectPublicKeyInfoSha256 optional canonical SPKI commitment
* @param issuerSerialSha256 optional issuer-DER and positive-serial commitment
*/
public record ClientCertificateMapping(String mappingId, String principalId,
Optional<String> certificateSha256, Optional<String> subjectPublicKeyInfoSha256,
Optional<String> issuerSerialSha256) {
/** Validates stable identities and fixed-size lowercase commitments. */
public ClientCertificateMapping {
Permission.requireId(mappingId, "mapping");
Permission.requirePrincipal(principalId);
certificateSha256 = digest(certificateSha256, "certificateSha256");
subjectPublicKeyInfoSha256 = digest(subjectPublicKeyInfoSha256, "subjectPublicKeyInfoSha256");
issuerSerialSha256 = digest(issuerSerialSha256, "issuerSerialSha256");
if (certificateSha256.isEmpty() && subjectPublicKeyInfoSha256.isEmpty()
&& issuerSerialSha256.isEmpty()) {
throw new IllegalArgumentException("Certificate mapping requires a cryptographic commitment");
}
}
private static Optional<String> digest(Optional<String> source, String name) {
return Objects.requireNonNull(source, name).map(value -> {
if (!value.matches("[0-9a-f]{64}")) throw new IllegalArgumentException(name + " is invalid");
return value;
});
}
}
/**
* Bounded worker, queue, admission, deadline and shutdown policy.
*
* @param transportWorkers fixed HTTPS handler worker count
* @param transportQueueCapacity bounded handler backlog
* @param operationWorkers maximum concurrently executing gateway calls
* @param operationQueueCapacity bounded waiting gateway calls
* @param maximumAdmittedRequests maximum requests admitted across parsing and execution
* @param defaultDeadline default request deadline
* @param maximumDeadline hard caller deadline ceiling
* @param gracefulShutdown graceful drain duration
* @param forcedShutdown forced cancellation and termination duration
*/
public record Execution(int transportWorkers, int transportQueueCapacity, int operationWorkers,
int operationQueueCapacity, int maximumAdmittedRequests, Duration defaultDeadline,
Duration maximumDeadline, Duration gracefulShutdown, Duration forcedShutdown) {
/** Validates finite positive bounds and duration ordering. */
public Execution {
bounded(transportWorkers, 1, 256, "transport worker count");
bounded(transportQueueCapacity, 1, 65_536, "transport queue capacity");
bounded(operationWorkers, 1, 256, "operation worker count");
bounded(operationQueueCapacity, 1, 65_536, "operation queue capacity");
bounded(maximumAdmittedRequests, 1, 131_072, "admitted request count");
positive(defaultDeadline, Duration.ofHours(1), "default deadline");
positive(maximumDeadline, Duration.ofHours(1), "maximum deadline");
positive(gracefulShutdown, Duration.ofMinutes(5), "graceful shutdown");
positive(forcedShutdown, Duration.ofMinutes(5), "forced shutdown");
if (defaultDeadline.compareTo(maximumDeadline) > 0) {
throw new IllegalArgumentException("Default deadline exceeds the maximum");
}
}
}
/**
* Process-local capability references.
*
* @param keyUnlockEnvironmentVariable optional external unlock reference
*/
public record RuntimeCapabilities(Optional<String> keyUnlockEnvironmentVariable) {
/** Validates the external reference without resolving its secret value. */
public RuntimeCapabilities {
keyUnlockEnvironmentVariable = Objects.requireNonNull(keyUnlockEnvironmentVariable,
"keyUnlockEnvironmentVariable").map(value -> {
if (!value.matches("[A-Z][A-Z0-9_]{0,127}")) {
throw new IllegalArgumentException("Key-unlock environment reference is invalid");
}
return value;
});
}
}
private static void bounded(int value, int minimum, int maximum, String name) {
if (value < minimum || value > maximum) throw new IllegalArgumentException(name + " is invalid");
}
private static void positive(Duration value, Duration maximum, String name) {
Objects.requireNonNull(value, name);
if (value.isZero() || value.isNegative() || value.compareTo(maximum) > 0) {
throw new IllegalArgumentException(name + " is invalid");
}
}
}

View File

@@ -0,0 +1,321 @@
/*******************************************************************************
* 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;
import java.io.IOException;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.application.PkiOperationValue;
import zeroecho.pki.application.PkiSessionConfiguration;
import zeroecho.pki.server.OperationSecurityDescriptors.ApprovalCategory;
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. */
@SuppressWarnings("PMD")
public final class PkiServerConfigurationCodec {
/** Maximum server configuration document size. */
public static final int MAXIMUM_CONFIGURATION_BYTES = 1_048_576;
private PkiServerConfigurationCodec() {
}
/**
* Reads and validates one exact configuration without opening providers,
* stores, listeners, audit sinks, or sessions.
*
* @param path configuration path
* @return immutable validated configuration
* @throws IOException when the bounded file cannot be read
* @throws IllegalArgumentException when framing or semantics are invalid
*/
public static PkiServerConfiguration read(Path path) throws IOException {
Path exact = path.toAbsolutePath().normalize();
long size = Files.size(exact);
if (size < 1 || size > MAXIMUM_CONFIGURATION_BYTES || Files.isSymbolicLink(exact)) {
throw new IllegalArgumentException("Server configuration source is invalid");
}
return decode(Files.readAllBytes(exact));
}
/** Decodes one bounded strict configuration document. */
public static PkiServerConfiguration decode(byte[] document) {
Fields root = Fields.of(StrictJson.parse(document, MAXIMUM_CONFIGURATION_BYTES));
root.exact("version", "serverName", "realm", "listener", "authentication", "execution", "runtime");
PkiServerConfiguration configuration = new PkiServerConfiguration(root.integer("version"),
root.text("serverName"), realm(root.object("realm")), listener(root.object("listener")),
authentication(root.object("authentication")), execution(root.object("execution")),
runtime(root.object("runtime")));
root.complete();
return configuration;
}
private static ServerRealmConfiguration realm(Fields value) {
value.exact("realmId", "displayName", "authorityExposure", "authorizationCommitment",
"approvalCommitment", "disclosureCommitment", "disclosureDefaults", "controlLog",
"controlStoreId", "approvalPolicy", "pkiSession");
AuthorityExposurePolicy exposure = exposure(value.object("authorityExposure"));
ApprovalService.Policy approval = approval(value.object("approvalPolicy"));
String approvalCommitment = value.text("approvalCommitment");
if (!approval.commitment().equals(approvalCommitment)) {
throw new IllegalArgumentException("Approval policy commitment does not match");
}
ServerRealmConfiguration result = new ServerRealmConfiguration(new RealmId(value.text("realmId")),
value.text("displayName"), session(value.object("pkiSession")), exposure,
value.text("authorizationCommitment"), approvalCommitment, value.text("disclosureCommitment"),
disclosure(value.object("disclosureDefaults")), Path.of(value.text("controlLog")),
new MetadataStoreId(value.text("controlStoreId")), Map.of(ApprovalCategory.HIGH_RISK, approval));
value.complete();
return result;
}
private static AuthorityExposurePolicy exposure(Fields value) {
value.exact("mode", "authorityIds", "creationPermitted");
Set<PkiId> authorities = new LinkedHashSet<>();
for (PkiOperationValue item : value.list("authorityIds")) authorities.add(new PkiId(Fields.text(item)));
AuthorityExposurePolicy result = new AuthorityExposurePolicy(
AuthorityExposurePolicy.Mode.valueOf(value.text("mode")), authorities,
value.bool("creationPermitted"));
value.complete();
return result;
}
private static ApprovalService.Policy approval(Fields value) {
value.exact("policyId", "threshold", "eligibleApprovers", "requiredRoleTemplateIds",
"requesterSeparation", "lifetimeMillis", "justificationRequired");
ApprovalService.Policy result = new ApprovalService.Policy(value.text("policyId"),
value.integer("threshold"), strings(value.list("eligibleApprovers")),
strings(value.list("requiredRoleTemplateIds")), value.bool("requesterSeparation"),
Duration.ofMillis(value.longValue("lifetimeMillis")), value.bool("justificationRequired"));
value.complete();
return result;
}
private static DisclosureService.Defaults disclosure(Fields value) {
value.exact("rootCa", "intermediateCa", "caChain", "crl", "leaf", "sensitiveLeaf");
DisclosureService.Defaults result = new DisclosureService.Defaults(policy(value.text("rootCa")),
policy(value.text("intermediateCa")), policy(value.text("caChain")), policy(value.text("crl")),
policy(value.text("leaf")), policy(value.text("sensitiveLeaf")));
value.complete();
return result;
}
private static DisclosureService.Policy policy(String value) {
return DisclosureService.Policy.valueOf(value);
}
private static PkiSessionConfiguration session(Fields value) {
Set<String> allowed = Set.of("version", "store", "audit", "signing", "publishers", "bindingProviders");
value.allowed(allowed);
int version = value.integer("version");
ProviderConfig store = provider(value.object("store"));
ProviderConfig audit = provider(value.object("audit"));
Optional<PkiSessionConfiguration.SigningConfiguration> signing = value.optionalObject("signing")
.map(PkiServerConfigurationCodec::signing);
List<ProviderConfig> publishers = new ArrayList<>();
for (PkiOperationValue item : value.list("publishers")) publishers.add(provider(Fields.of(item)));
List<PkiSessionConfiguration.BindingProviderConfiguration> bindings = new ArrayList<>();
for (PkiOperationValue item : value.list("bindingProviders")) bindings.add(binding(Fields.of(item)));
value.complete();
return new PkiSessionConfiguration(version, store, audit, signing, publishers, bindings);
}
private static PkiSessionConfiguration.SigningConfiguration signing(Fields value) {
Set<String> allowed = Set.of("workflow", "framework", "busPath", "signatureAlgorithm",
"signingTtlMillis", "unlockEnvironmentVariable", "certificateSignatureBinding",
"crlSignatureBinding", "subjectPublicKeyBinding");
value.allowed(allowed);
PkiSessionConfiguration.SigningConfiguration result = new PkiSessionConfiguration.SigningConfiguration(
provider(value.object("workflow")), provider(value.object("framework")), value.text("busPath"),
value.text("signatureAlgorithm"), Duration.ofMillis(value.longValue("signingTtlMillis")),
value.optionalText("unlockEnvironmentVariable"), value.optionalText("certificateSignatureBinding"),
value.optionalText("crlSignatureBinding"), value.optionalText("subjectPublicKeyBinding"));
value.complete();
return result;
}
private static PkiSessionConfiguration.BindingProviderConfiguration binding(Fields value) {
value.allowed(Set.of("providerId", "authorizedOidRoots", "expectedBindingSetVersion"));
PkiSessionConfiguration.BindingProviderConfiguration result =
new PkiSessionConfiguration.BindingProviderConfiguration(value.text("providerId"),
List.copyOf(strings(value.list("authorizedOidRoots"))),
value.optionalText("expectedBindingSetVersion"));
value.complete();
return result;
}
private static ProviderConfig provider(Fields value) {
value.exact("id", "properties");
Fields properties = value.object("properties");
Map<String, String> result = new LinkedHashMap<>();
for (Map.Entry<String, PkiOperationValue> item : properties.remaining().entrySet()) {
result.put(item.getKey(), Fields.text(item.getValue()));
}
properties.consumeAll();
ProviderConfig provider = new ProviderConfig(value.text("id"), result);
value.complete();
return provider;
}
private static PkiServerConfiguration.Listener listener(Fields value) {
value.exact("address", "port", "tlsProvider", "clientCertificateRequired", "maximumHeaderBytes",
"maximumBodyBytes");
PkiServerConfiguration.Listener result = new PkiServerConfiguration.Listener(
address(value.text("address")), value.integer("port"), provider(value.object("tlsProvider")),
value.bool("clientCertificateRequired"), value.integer("maximumHeaderBytes"),
value.integer("maximumBodyBytes"));
value.complete();
return result;
}
private static PkiServerConfiguration.Authentication authentication(Fields value) {
value.exact("mappings");
List<PkiServerConfiguration.ClientCertificateMapping> mappings = new ArrayList<>();
for (PkiOperationValue item : value.list("mappings")) {
Fields mapping = Fields.of(item);
mapping.allowed(Set.of("mappingId", "principalId", "certificateSha256",
"subjectPublicKeyInfoSha256", "issuerSerialSha256"));
mappings.add(new PkiServerConfiguration.ClientCertificateMapping(mapping.text("mappingId"),
mapping.text("principalId"), mapping.optionalText("certificateSha256"),
mapping.optionalText("subjectPublicKeyInfoSha256"),
mapping.optionalText("issuerSerialSha256")));
mapping.complete();
}
value.complete();
return new PkiServerConfiguration.Authentication(mappings);
}
private static PkiServerConfiguration.Execution execution(Fields value) {
value.exact("transportWorkers", "transportQueueCapacity", "operationWorkers",
"operationQueueCapacity", "maximumAdmittedRequests", "defaultDeadlineMillis",
"maximumDeadlineMillis", "gracefulShutdownMillis", "forcedShutdownMillis");
PkiServerConfiguration.Execution result = new PkiServerConfiguration.Execution(
value.integer("transportWorkers"), value.integer("transportQueueCapacity"),
value.integer("operationWorkers"), value.integer("operationQueueCapacity"),
value.integer("maximumAdmittedRequests"), Duration.ofMillis(value.longValue("defaultDeadlineMillis")),
Duration.ofMillis(value.longValue("maximumDeadlineMillis")),
Duration.ofMillis(value.longValue("gracefulShutdownMillis")),
Duration.ofMillis(value.longValue("forcedShutdownMillis")));
value.complete();
return result;
}
private static PkiServerConfiguration.RuntimeCapabilities runtime(Fields value) {
value.allowed(Set.of("keyUnlockEnvironmentVariable"));
PkiServerConfiguration.RuntimeCapabilities result = new PkiServerConfiguration.RuntimeCapabilities(
value.optionalText("keyUnlockEnvironmentVariable"));
value.complete();
return result;
}
private static InetAddress address(String value) {
if (!value.matches("[0-9A-Fa-f:.]{2,64}") || value.contains("..")) {
throw new IllegalArgumentException("Listener address must be a canonical IP literal");
}
try {
InetAddress result = InetAddress.getByName(value);
if (!result.getHostAddress().equalsIgnoreCase(value)
&& !(result.isLoopbackAddress() && "::1".equals(value))) {
throw new IllegalArgumentException("Listener address is not canonical");
}
return result;
} catch (UnknownHostException failure) {
throw new IllegalArgumentException("Listener address is invalid");
}
}
private static Set<String> strings(List<PkiOperationValue> values) {
Set<String> result = new LinkedHashSet<>();
for (PkiOperationValue value : values) {
if (!result.add(Fields.text(value))) throw new IllegalArgumentException("Duplicate string value");
}
return Set.copyOf(result);
}
/** Strict consumed-field view over one JSON object. */
private static final class Fields {
private final Map<String, PkiOperationValue> fields;
private final Set<String> consumed = new LinkedHashSet<>();
private Fields(Map<String, PkiOperationValue> fields) { this.fields = fields; }
static Fields of(PkiOperationValue value) {
if (!(value instanceof PkiOperationValue.ObjectValue object)) {
throw new IllegalArgumentException("Configuration value must be an object");
}
return new Fields(object.fields());
}
void exact(String... names) { allowed(Set.of(names)); if (fields.size() != names.length)
throw new IllegalArgumentException("Configuration fields are incomplete"); }
void allowed(Set<String> names) { if (!names.containsAll(fields.keySet()))
throw new IllegalArgumentException("Unknown configuration field"); }
String text(String name) { consumed.add(name); return text(require(name)); }
Optional<String> optionalText(String name) { if (!fields.containsKey(name)) return Optional.empty();
return Optional.of(text(name)); }
int integer(String name) { long value = longValue(name); return Math.toIntExact(value); }
long longValue(String name) { consumed.add(name); PkiOperationValue value = require(name);
if (!(value instanceof PkiOperationValue.IntegerValue integer)) throw type(); return integer.value(); }
boolean bool(String name) { consumed.add(name); PkiOperationValue value = require(name);
if (!(value instanceof PkiOperationValue.BooleanValue bool)) throw type(); return bool.value(); }
Fields object(String name) { consumed.add(name); return of(require(name)); }
Optional<Fields> optionalObject(String name) { if (!fields.containsKey(name)) return Optional.empty();
return Optional.of(object(name)); }
List<PkiOperationValue> list(String name) { consumed.add(name); PkiOperationValue value = require(name);
if (!(value instanceof PkiOperationValue.ListValue list)) throw type(); return list.values(); }
Map<String, PkiOperationValue> remaining() { return fields; }
void consumeAll() { consumed.addAll(fields.keySet()); }
void complete() { if (!consumed.equals(fields.keySet())) throw new IllegalArgumentException(
"Configuration fields were not consumed"); }
private PkiOperationValue require(String name) { PkiOperationValue value = fields.get(name);
if (value == null) throw new IllegalArgumentException("Required configuration field is missing");
return value; }
static String text(PkiOperationValue value) { if (!(value instanceof PkiOperationValue.Text text))
throw type(); return text.value(); }
private static IllegalArgumentException type() {
return new IllegalArgumentException("Configuration field type is invalid");
}
}
}

View File

@@ -0,0 +1,131 @@
/*******************************************************************************
* 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;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import zeroecho.core.spi.KeyringUnlockProvider;
import zeroecho.core.storage.KeyringPassword;
import zeroecho.pki.application.PkiSessionRuntimeDependencies;
/** Production command-line entry point for the administrative HTTPS server. */
@SuppressWarnings("PMD")
public final class PkiServerMain {
private static final String HELP = """
Usage: zeroecho-pki-server --config <server-config.json>
zeroecho-pki-server --validate-config --config <server-config.json>
zeroecho-pki-server --help
zeroecho-pki-server --version
""";
private PkiServerMain() {
}
/**
* Runs configuration validation or starts the long-lived server process.
*
* @param arguments exact process arguments
*/
public static void main(String[] arguments) {
int result = run(arguments);
if (result != 0) System.exit(result);
}
static int run(String[] arguments) {
try {
if (arguments.length == 1 && "--help".equals(arguments[0])) {
System.out.print(HELP);
return 0;
}
if (arguments.length == 1 && "--version".equals(arguments[0])) {
String version = Optional.ofNullable(PkiServerMain.class.getPackage().getImplementationVersion())
.orElse("development");
System.out.println("zeroecho-pki-server " + version);
return 0;
}
boolean validate = Arrays.asList(arguments).contains("--validate-config");
Path path = configurationPath(arguments, validate);
PkiServerConfiguration configuration = PkiServerConfigurationCodec.read(path);
if (validate) {
System.out.println("configuration valid");
return 0;
}
PkiSessionRuntimeDependencies dependencies = runtimeDependencies(configuration);
PkiHttpsServer server = PkiHttpsServer.start(configuration, dependencies);
server.installShutdownHook();
new CountDownLatch(1).await();
return 0;
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return 0;
} catch (IOException | IllegalArgumentException failure) {
System.err.println("Server configuration is invalid");
return 2;
} catch (RuntimeException failure) {
System.err.println("Server startup failed");
return 3;
}
}
private static Path configurationPath(String[] arguments, boolean validate) {
int expected = validate ? 3 : 2;
if (arguments.length != expected) throw new IllegalArgumentException("Invalid invocation");
int configIndex = validate && "--validate-config".equals(arguments[0]) ? 1 : 0;
if (!"--config".equals(arguments[configIndex])) throw new IllegalArgumentException("Invalid invocation");
if (validate && !"--validate-config".equals(arguments[2])) {
if (!"--validate-config".equals(arguments[0])) throw new IllegalArgumentException("Invalid invocation");
}
return Path.of(arguments[configIndex + 1]);
}
private static PkiSessionRuntimeDependencies runtimeDependencies(PkiServerConfiguration configuration) {
Optional<String> environment = configuration.runtime().keyUnlockEnvironmentVariable();
if (environment.isEmpty()) return PkiSessionRuntimeDependencies.none();
KeyringUnlockProvider provider = () -> {
String value = System.getenv(environment.orElseThrow());
if (value == null || value.isEmpty()) throw new IOException("Configured unlock source is unavailable");
char[] secret = value.toCharArray();
try {
return new KeyringPassword(secret);
} finally {
Arrays.fill(secret, '\0');
}
};
return PkiSessionRuntimeDependencies.withKeyringUnlockProvider(provider);
}
}

View File

@@ -58,7 +58,7 @@ import zeroecho.pki.application.PkiResourceScopeResolver;
* exactly once to the session executor, and preserves the returned outcome.</p>
*/
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.FieldDeclarationsShouldBeAtStartOfClass",
"PMD.ExcessiveParameterList", "PMD.CyclomaticComplexity" })
"PMD.ExcessiveParameterList", "PMD.CyclomaticComplexity", "PMD.AvoidInstantiatingObjectsInLoops" })
public final class ServerOperationGateway {
/**
* Complete transport-neutral request admission input.
@@ -218,6 +218,92 @@ public final class ServerOperationGateway {
return new Outcome.Executed(backend);
}
/**
* Authorizes one non-PKI administrative control resource through the same
* default-deny engine used for typed operations.
*
* @param principalId authenticated persisted principal
* @param action exact control action
* @param resource exact realm control resource
* @param context safe closed-condition context
* @param correlationId bounded request correlation identity
* @return safe authorization decision
*/
public AuthorizationEngine.Decision authorizeControl(String principalId, Permission.Action action,
Permission.Resource resource, Permission.Context context, String correlationId) {
openCheck.run();
Permission.requirePrincipal(principalId);
Permission.requireBounded(correlationId, 256, "correlation ID");
SecurityPrincipal principal = control.requirePrincipal(principalId);
List<Permission.Grant> grants = grants(principal);
BreakGlassService.ActiveGrants emergency = breakGlass.activeFor(principalId);
grants.addAll(emergency.grants());
AuthorizationEngine.Decision decision = authorization.authorize(new AuthorizationEngine.Request(realmId,
exposure, principal, action, resource, Permission.Relationship.ANY,
Permission.DataView.METADATA_REDACTED, context, grants, emergency.grantIds()));
audit.record(decision.allowed() ? "AUTHORIZATION_ALLOW" : "AUTHORIZATION_DENY", principalId,
Optional.empty(), Map.of("action", action.name(), "code", decision.code().name(),
"correlation", correlationId));
if (decision.usedBreakGlass()) breakGlass.auditUse(principalId);
return decision;
}
/**
* Returns operation descriptors discoverable by one principal without
* returning grant scopes or protected resource identities.
*
* @param principalId authenticated persisted principal
* @return descriptors ordered by stable operation identity
*/
public List<OperationSecurityDescriptors.Descriptor> discoverableOperations(String principalId) {
openCheck.run();
SecurityPrincipal principal = control.requirePrincipal(principalId);
List<Permission.Grant> grants = grants(principal);
BreakGlassService.ActiveGrants emergency = breakGlass.activeFor(principalId);
grants.addAll(emergency.grants());
return descriptors.descriptors().values().stream()
.filter(descriptor -> discoverable(descriptor, principal, grants, emergency.grantIds()))
.sorted(java.util.Comparator.comparing(OperationSecurityDescriptors.Descriptor::operationId))
.toList();
}
/**
* Resolves the existing security descriptor for a decoded typed operation.
*
* @param operation decoded transport-neutral operation
* @return immutable descriptor from the sole gateway descriptor authority
* @throws SecurityException when the operation is not remotely exposed
*/
public OperationSecurityDescriptors.Descriptor descriptor(PkiOperation operation) {
openCheck.run();
return descriptors.require(operation);
}
private List<Permission.Grant> grants(SecurityPrincipal principal) {
List<Permission.Grant> grants = new ArrayList<>(control.grantsFor(principal.principalId()));
for (RoleTemplateCatalog.Assignment assignment : control.assignmentsFor(principal.principalId())) {
grants.addAll(roles.instantiate(assignment));
}
return grants;
}
private boolean discoverable(OperationSecurityDescriptors.Descriptor descriptor,
SecurityPrincipal principal, List<Permission.Grant> grants,
java.util.Set<String> breakGlassIds) {
for (Permission.Grant grant : grants) {
if (grant.action() != descriptor.action() || grant.resourceType() != descriptor.resourceType()) {
continue;
}
Permission.Resource resource = new Permission.Resource(descriptor.resourceType(), grant.scope(),
Optional.empty(), Optional.empty());
AuthorizationEngine.Decision decision = authorization.authorize(new AuthorizationEngine.Request(
realmId, exposure, principal, descriptor.action(), resource, Permission.Relationship.ANY,
descriptor.dataView(), Permission.Context.empty(), grants, breakGlassIds));
if (decision.allowed()) return true;
}
return false;
}
private PkiOperationOutcome filterAuthorities(PkiOperationOutcome outcome, SecurityPrincipal principal,
List<Permission.Grant> grants, java.util.Set<String> breakGlassIds, Permission.Context context) {
if (!(outcome instanceof PkiOperationOutcome.Success success)) return outcome;

View File

@@ -171,6 +171,32 @@ public final class ServerRealmContext implements AutoCloseable {
public AuditorViews auditorViews() { requireOpen(); return auditorViews; }
/** @return authorized typed-operation gateway */
public ServerOperationGateway gateway() { requireOpen(); return gateway; }
/**
* Resolves one persisted principal for transport authentication.
*
* @param principalId canonical principal identity
* @return persisted identity metadata without credentials or grants
* @throws IllegalArgumentException when the principal is unavailable
* @throws IllegalStateException when this realm is not open
*/
public SecurityPrincipal principal(String principalId) {
requireOpen();
return control.requirePrincipal(principalId);
}
/**
* Records one transport-safe lifecycle or request classification through the
* shared realm audit authority.
*
* @param action stable transport action
* @param principalId authenticated principal or {@code system}
* @param safeDetails finite pre-redacted details
*/
public void auditTransport(String action, String principalId, Map<String, String> safeDetails) {
requireOpen();
Permission.requireBounded(action, 128, "audit action");
Permission.requirePrincipal(principalId);
audit.record(action, principalId, Optional.empty(), Map.copyOf(safeDetails));
}
/** @return current lifecycle state */
public State state() { return state.get(); }

View File

@@ -0,0 +1,396 @@
/*******************************************************************************
* 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.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.BooleanSupplier;
import javax.net.ssl.SSLPeerUnverifiedException;
import com.sun.net.httpserver.Headers;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpsExchange;
import zeroecho.pki.application.PkiOperationValue;
import zeroecho.pki.server.AuthorizationEngine;
import zeroecho.pki.server.OperationSecurityDescriptors;
import zeroecho.pki.server.Permission;
import zeroecho.pki.server.PkiServerConfiguration;
import zeroecho.pki.server.ServerOperationGateway;
import zeroecho.pki.server.ServerRealmContext;
import zeroecho.pki.server.spi.PkiServerAuthenticationContext;
import zeroecho.pki.server.spi.PkiServerAuthenticationResult;
import zeroecho.pki.server.spi.PkiServerAuthenticator;
/** Single exact-path HTTPS adapter; all PKI execution delegates to the gateway. */
@SuppressWarnings("PMD")
final class AdminHttpHandler implements HttpHandler {
private static final String JSON = "application/json; charset=utf-8";
private final PkiServerConfiguration configuration;
private final ServerRealmContext realm;
private final PkiServerAuthenticator authenticator;
private final ServerRuntime runtime;
private final Clock clock;
private final RequestIds requestIds;
private final BooleanSupplier ready;
AdminHttpHandler(PkiServerConfiguration configuration, ServerRealmContext realm,
PkiServerAuthenticator authenticator, ServerRuntime runtime, Clock clock,
RequestIds requestIds, BooleanSupplier ready) {
this.configuration = configuration;
this.realm = realm;
this.authenticator = authenticator;
this.runtime = runtime;
this.clock = clock;
this.requestIds = requestIds;
this.ready = ready;
}
@Override
public void handle(HttpExchange exchange) throws IOException {
String requestId = "unavailable-request";
HttpResponses.Response response;
boolean admitted = false;
try {
requireHeadersBounded(exchange.getRequestHeaders());
requestId = requestIds.resolve(exchange.getRequestHeaders().get(RequestIds.HEADER));
URI uri = exchange.getRequestURI();
if (uri.getRawQuery() != null || uri.getRawFragment() != null) throw malformed(requestId, "route");
String path = uri.getPath();
if ("/health/live".equals(path)) {
response = requireMethod(exchange, "GET")
? HttpResponses.minimal(200, requestId, "health.live", "LIVE")
: method(requestId, "health.live");
} else if ("/health/ready".equals(path)) {
boolean isReady = ready.getAsBoolean();
response = requireMethod(exchange, "GET")
? HttpResponses.minimal(isReady ? 200 : 503, requestId,
"health.ready", isReady ? "READY" : "NOT_READY")
: method(requestId, "health.ready");
} else {
if (!runtime.tryAdmit()) {
realm.auditTransport("REQUEST_REJECTED", "system", Map.of("request", requestId,
"classification", "ADMISSION_REJECTED"));
response = HttpResponses.failure(429, requestId, "request", "OVERLOADED",
"ADMISSION_REJECTED", "ADMISSION_REJECTED", false, false);
} else {
admitted = true;
realm.auditTransport("REQUEST_ADMITTED", "system", Map.of("request", requestId));
response = admin((HttpsExchange) exchange, path, requestId);
}
}
} catch (TransportFailure failure) {
response = failure.response();
} catch (IllegalArgumentException invalid) {
response = HttpResponses.failure(400, requestId, "request", "VALIDATION_FAILURE",
"MALFORMED_REQUEST", "MALFORMED_REQUEST", false, false);
} catch (RuntimeException failure) {
response = HttpResponses.failure(500, requestId, "request", "INTERNAL_FAILURE",
"SAFE_INTERNAL_FAILURE", "SAFE_INTERNAL_FAILURE", false, false);
} finally {
if (admitted) runtime.releaseAdmission();
}
send(exchange, requestId, response);
}
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));
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));
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/";
if (!path.startsWith(prefix) || path.length() == prefix.length()) return notFound(requestId);
String operationId = path.substring(prefix.length());
if (!operationId.matches("[a-z][a-z0-9.]{2,127}")) return notFound(requestId);
Optional<OperationSecurityDescriptors.Descriptor> descriptor = realm.gateway()
.discoverableOperations(principalId).stream()
.filter(item -> item.operationId().equals(operationId)).findFirst();
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);
}
private HttpResponses.Response realm(HttpsExchange exchange, String requestId, String principalId) {
if (!requireMethod(exchange, "GET")) return method(requestId, "realm.inspect");
Permission.Resource resource = new Permission.Resource(Permission.ResourceType.REALM,
new Permission.Scope(configuration.realm().realmId(), Optional.empty(), Optional.empty(),
Optional.empty()), Optional.empty(), Optional.empty());
AuthorizationEngine.Decision decision = realm.gateway().authorizeControl(principalId,
Permission.Action.REALM_READ, resource, Permission.Context.empty(), requestId);
if (!decision.allowed()) return denied(requestId, "realm.inspect");
Map<String, PkiOperationValue> fields = new LinkedHashMap<>();
fields.put("realmId", new PkiOperationValue.Text(configuration.realm().realmId().value()));
fields.put("displayName", new PkiOperationValue.Text(configuration.realm().displayName()));
fields.put("authorityExposure", new PkiOperationValue.Text(
configuration.realm().authorityExposure().mode().name()));
fields.put("authorityCreationPermitted", new PkiOperationValue.BooleanValue(
configuration.realm().authorityExposure().authorityCreationPermitted()));
return HttpResponses.success(requestId, "realm.inspect", new PkiOperationValue.ObjectValue(fields));
}
private HttpResponses.Response operations(HttpsExchange exchange, String requestId, String principalId) {
if (!requireMethod(exchange, "GET")) return method(requestId, "operation.list");
List<PkiOperationValue> values = realm.gateway().discoverableOperations(principalId).stream()
.map(AdminHttpHandler::descriptorValue).map(PkiOperationValue.class::cast).toList();
Map<String, PkiOperationValue> fields = new LinkedHashMap<>();
fields.put("count", new PkiOperationValue.IntegerValue(values.size()));
fields.put("operations", new PkiOperationValue.ListValue(values));
return HttpResponses.success(requestId, "operation.list", new PkiOperationValue.ObjectValue(fields));
}
private HttpResponses.Response descriptor(String requestId, OperationSecurityDescriptors.Descriptor value) {
return HttpResponses.success(requestId, "operation.inspect", descriptorValue(value));
}
private HttpResponses.Response execute(HttpsExchange exchange, String requestId,
String principalId, String operationId) throws IOException {
String contentType = exchange.getRequestHeaders().getFirst("Content-Type");
if (contentType == null || !(contentType.equalsIgnoreCase("application/json")
|| contentType.equalsIgnoreCase(JSON))) {
return HttpResponses.failure(415, requestId, operationId, "VALIDATION_FAILURE",
"UNSUPPORTED_MEDIA_TYPE", "UNSUPPORTED_MEDIA_TYPE", false, false);
}
byte[] body;
try {
body = body(exchange.getRequestBody(), exchange.getRequestHeaders().getFirst("Content-Length"));
} catch (IllegalArgumentException invalid) {
return HttpResponses.failure(413, requestId, operationId, "VALIDATION_FAILURE",
"REQUEST_BODY_REJECTED", "REQUEST_BODY_REJECTED", false, false);
}
HttpOperationCodec.Decoded decoded;
try {
decoded = HttpOperationCodec.decode(operationId, body, configuration.listener().maximumBodyBytes(),
configuration.realm().realmId(), realm.gateway()::descriptor);
} catch (SecurityException unavailable) {
return notFound(requestId);
} catch (IllegalArgumentException invalid) {
return HttpResponses.failure(400, requestId, operationId, "VALIDATION_FAILURE",
"INVALID_OPERATION_REQUEST", "INVALID_OPERATION_REQUEST", false, false);
}
Duration effective = decoded.requestedDeadline().orElse(configuration.execution().defaultDeadline());
if (effective.compareTo(configuration.execution().maximumDeadline()) > 0) {
effective = configuration.execution().maximumDeadline();
}
Instant deadline;
try {
deadline = clock.instant().plus(effective);
} catch (RuntimeException overflow) {
return HttpResponses.failure(400, requestId, operationId, "VALIDATION_FAILURE",
"INVALID_DEADLINE", "INVALID_DEADLINE", false, false);
}
if (!clock.instant().isBefore(deadline)) return deadline(requestId, operationId);
ServerRuntime.Submitted<ServerOperationGateway.Outcome> submitted;
try {
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(),
Permission.Relationship.ANY, decoded.context(), decoded.approvalId(), requestId);
return realm.gateway().execute(gateway, cancellation);
});
} catch (RejectedExecutionException overloaded) {
realm.auditTransport("REQUEST_OVERLOAD", principalId, Map.of("request", requestId,
"operation", operationId));
return HttpResponses.failure(429, requestId, operationId, "OVERLOADED", "OPERATION_QUEUE_FULL",
"OPERATION_QUEUE_FULL", false, false);
}
try {
long remaining = Math.max(1L, Duration.between(clock.instant(), deadline).toMillis());
ServerOperationGateway.Outcome outcome = submitted.future().get(remaining, TimeUnit.MILLISECONDS);
realm.auditTransport("REQUEST_COMPLETED", principalId,
Map.of("request", requestId, "operation", operationId));
return HttpResponses.gateway(requestId, operationId, outcome);
} catch (TimeoutException timeout) {
submitted.cancellation().cancel();
submitted.future().cancel(true);
realm.auditTransport("REQUEST_DEADLINE", principalId,
Map.of("request", requestId, "operation", operationId));
return deadline(requestId, operationId);
} catch (InterruptedException interrupted) {
submitted.cancellation().cancel();
submitted.future().cancel(true);
Thread.currentThread().interrupt();
return deadline(requestId, operationId);
} catch (ExecutionException failure) {
if (failure.getCause() instanceof DeadlineExceeded) return deadline(requestId, operationId);
return HttpResponses.failure(500, requestId, operationId, "INTERNAL_FAILURE",
"SAFE_INTERNAL_FAILURE", "SAFE_INTERNAL_FAILURE", false, false);
} finally {
submitted.finish();
}
}
private PkiServerAuthenticationResult authenticate(HttpsExchange exchange, String requestId) {
try {
Certificate[] peer = exchange.getSSLSession().getPeerCertificates();
List<X509Certificate> chain = new ArrayList<>();
for (Certificate certificate : peer) {
if (!(certificate instanceof X509Certificate x509)) {
return new PkiServerAuthenticationResult.Rejected(
PkiServerAuthenticationResult.Code.CERTIFICATE_INVALID);
}
chain.add(x509);
}
return authenticator.authenticate(new PkiServerAuthenticationContext(chain,
exchange.getSSLSession().getProtocol(), exchange.getSSLSession().getCipherSuite(),
requestId, configuration.realm().realmId()));
} catch (SSLPeerUnverifiedException failure) {
return new PkiServerAuthenticationResult.Rejected(
PkiServerAuthenticationResult.Code.CERTIFICATE_INVALID);
}
}
private byte[] body(InputStream input, String contentLength) throws IOException {
int maximum = configuration.listener().maximumBodyBytes();
if (contentLength != null) {
try {
long length = Long.parseLong(contentLength);
if (length < 0 || length > maximum) throw new IllegalArgumentException("Body is oversized");
} catch (NumberFormatException failure) {
throw new IllegalArgumentException("Content length is invalid");
}
}
ByteArrayOutputStream output = new ByteArrayOutputStream(Math.min(maximum, 8_192));
byte[] buffer = new byte[8_192];
int total = 0;
int read;
while ((read = input.read(buffer)) >= 0) {
total = Math.addExact(total, read);
if (total > maximum) throw new IllegalArgumentException("Body is oversized");
output.write(buffer, 0, read);
}
return output.toByteArray();
}
private void requireHeadersBounded(Headers headers) {
int bytes = 0;
for (Map.Entry<String, List<String>> header : headers.entrySet()) {
bytes = Math.addExact(bytes, header.getKey().getBytes(StandardCharsets.UTF_8).length);
for (String value : header.getValue()) {
bytes = Math.addExact(bytes, value.getBytes(StandardCharsets.UTF_8).length);
if (bytes > configuration.listener().maximumHeaderBytes()) {
throw new IllegalArgumentException("Request headers are oversized");
}
}
}
}
private static PkiOperationValue.ObjectValue descriptorValue(OperationSecurityDescriptors.Descriptor value) {
Map<String, PkiOperationValue> fields = new LinkedHashMap<>();
fields.put("operation", new PkiOperationValue.Text(value.operationId()));
fields.put("mutating", new PkiOperationValue.BooleanValue(value.mutating()));
fields.put("resourceType", new PkiOperationValue.Text(value.resourceType().name()));
fields.put("approval", new PkiOperationValue.Text(value.approvalCategory().name()));
return new PkiOperationValue.ObjectValue(fields);
}
private static boolean requireMethod(HttpExchange exchange, String expected) {
return expected.equals(exchange.getRequestMethod());
}
private static HttpResponses.Response method(String requestId, String operation) {
return HttpResponses.failure(405, requestId, operation, "VALIDATION_FAILURE", "METHOD_NOT_ALLOWED",
"METHOD_NOT_ALLOWED", false, false);
}
private static HttpResponses.Response notFound(String requestId) {
return HttpResponses.failure(404, requestId, "operation", "NOT_FOUND", "OPERATION_NOT_FOUND",
"OPERATION_NOT_FOUND", false, false);
}
private static HttpResponses.Response denied(String requestId, String operation) {
return HttpResponses.failure(403, requestId, operation, "DENIED", "AUTHORIZATION_DENIED",
"AUTHORIZATION_DENIED", false, false);
}
private static HttpResponses.Response deadline(String requestId, String operation) {
return HttpResponses.failure(504, requestId, operation, "CANCELLED", "DEADLINE_EXCEEDED",
"DEADLINE_EXCEEDED", false, false);
}
private static TransportFailure malformed(String requestId, String operation) {
return new TransportFailure(HttpResponses.failure(400, requestId, operation, "VALIDATION_FAILURE",
"MALFORMED_REQUEST", "MALFORMED_REQUEST", false, false));
}
private static void send(HttpExchange exchange, String requestId, HttpResponses.Response response)
throws IOException {
byte[] body = response.body();
exchange.getResponseHeaders().set("Content-Type", JSON);
exchange.getResponseHeaders().set("Cache-Control", "no-store");
exchange.getResponseHeaders().set(RequestIds.HEADER, requestId);
exchange.sendResponseHeaders(response.statusCode(), body.length);
try (java.io.OutputStream output = exchange.getResponseBody()) {
output.write(body);
} finally {
exchange.close();
}
}
private static final class TransportFailure extends RuntimeException {
private static final long serialVersionUID = 1L;
private final transient HttpResponses.Response response;
private TransportFailure(HttpResponses.Response response) { this.response = response; }
private HttpResponses.Response response() { return response; }
}
private static final class DeadlineExceeded extends RuntimeException {
private static final long serialVersionUID = 1L;
}
}

View File

@@ -0,0 +1,260 @@
/*******************************************************************************
* 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.time.Duration;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.SubjectRef;
import zeroecho.pki.api.ca.CaState;
import zeroecho.pki.api.revocation.RevocationReason;
import zeroecho.pki.api.status.StatusObjectType;
import zeroecho.pki.application.PkiOperation;
import zeroecho.pki.application.PkiOperationValue;
import zeroecho.pki.server.OperationSecurityDescriptors;
import zeroecho.pki.server.Permission;
import zeroecho.pki.server.RealmId;
/** Closed strict HTTP decoding for operations exposed by the existing gateway. */
@SuppressWarnings("PMD")
final class HttpOperationCodec {
/** Fully decoded gateway input excluding authenticated principal and request ID. */
record Decoded(PkiOperation operation, Permission.Resource resource, Permission.Context context,
Optional<String> approvalId, Optional<Duration> requestedDeadline) { }
private HttpOperationCodec() {
}
static Decoded decode(String operationId, byte[] document, int maximumBytes, RealmId realmId,
java.util.function.Function<PkiOperation, OperationSecurityDescriptors.Descriptor> descriptors) {
Fields root = Fields.of(StrictJson.parse(document, maximumBytes));
root.allowed(Set.of("version", "authorityId", "approvalId", "deadline", "arguments"));
if (root.integer("version") != 1) throw new IllegalArgumentException("Request version is unsupported");
Optional<PkiId> authority = root.optionalText("authorityId").map(PkiId::new);
Optional<String> approval = root.optionalText("approvalId");
approval.ifPresent(value -> requireId(value, "approval ID"));
Optional<Duration> deadline = root.optionalText("deadline").map(HttpOperationCodec::duration);
Fields arguments = root.object("arguments");
PkiOperation operation = operation(operationId, arguments);
arguments.complete();
root.complete();
OperationSecurityDescriptors.Descriptor descriptor = descriptors.apply(operation);
boolean realmWide = realmWide(operation);
if (realmWide == authority.isPresent()) {
throw new IllegalArgumentException(realmWide
? "Realm-wide operation must not specify authorityId"
: "Authority-scoped operation requires authorityId");
}
validateAuthority(operation, authority);
Permission.Scope scope = new Permission.Scope(realmId, authority, Optional.empty(), profile(operation));
Optional<PkiId> objectId = objectId(operation);
Permission.Resource resource = new Permission.Resource(descriptor.resourceType(), scope, objectId,
Optional.empty());
Optional<String> reason = operation instanceof PkiOperation.TransitionAuthority transition
? Optional.of(transition.reason()) : Optional.empty();
Permission.Context context = new Permission.Context(reason, approval.isPresent(), false, Map.of());
return new Decoded(operation, resource, context, approval, deadline);
}
private static PkiOperation operation(String id, Fields fields) {
return switch (id) {
case PkiOperation.ListAuthorities.NAME -> {
fields.exact("limit"); yield new PkiOperation.ListAuthorities(fields.integer("limit"));
}
case PkiOperation.InspectAuthority.NAME -> {
fields.exact("caId"); yield new PkiOperation.InspectAuthority(fields.pkiId("caId"));
}
case PkiOperation.CreateAuthority.NAME -> {
fields.exact("formatId", "subjectRef", "profileId", "keyRef");
yield new PkiOperation.CreateAuthority(new FormatId(fields.text("formatId")),
new SubjectRef(fields.text("subjectRef")), fields.text("profileId"),
new KeyRef(fields.text("keyRef")));
}
case PkiOperation.TransitionAuthority.NAME -> {
fields.exact("caId", "state", "reason");
yield new PkiOperation.TransitionAuthority(fields.pkiId("caId"),
CaState.valueOf(fields.text("state")), fields.text("reason"));
}
case PkiOperation.ListProfileVersions.NAME -> {
fields.exact("profileId"); yield new PkiOperation.ListProfileVersions(fields.text("profileId"));
}
case PkiOperation.InspectProfile.NAME -> {
fields.exact("profileId", "profileVersion");
yield new PkiOperation.InspectProfile(fields.text("profileId"), fields.longValue("profileVersion"));
}
case PkiOperation.ActivateProfile.NAME -> {
fields.exact("profileId", "profileVersion");
yield new PkiOperation.ActivateProfile(fields.text("profileId"), fields.longValue("profileVersion"));
}
case PkiOperation.InspectCredential.NAME -> {
fields.exact("credentialId"); yield new PkiOperation.InspectCredential(fields.pkiId("credentialId"));
}
case PkiOperation.IssueCredential.NAME -> {
fields.exact("issuerCaId", "requestId", "profileId");
yield new PkiOperation.IssueCredential(fields.pkiId("issuerCaId"), fields.pkiId("requestId"),
fields.text("profileId"));
}
case PkiOperation.RevokeCredential.NAME -> {
fields.exact("credentialId", "reason");
yield new PkiOperation.RevokeCredential(fields.pkiId("credentialId"),
RevocationReason.valueOf(fields.text("reason")));
}
case PkiOperation.ReadRevocationHistory.NAME -> {
fields.exact("credentialId", "limit");
yield new PkiOperation.ReadRevocationHistory(fields.pkiId("credentialId"), fields.integer("limit"));
}
case PkiOperation.GenerateStatus.NAME -> {
fields.exact("issuerCaId", "type", "formatId");
yield new PkiOperation.GenerateStatus(fields.pkiId("issuerCaId"),
StatusObjectType.valueOf(fields.text("type")), new FormatId(fields.text("formatId")));
}
case PkiOperation.InspectPublication.NAME -> {
fields.exact("publicationId");
yield new PkiOperation.InspectPublication(fields.pkiId("publicationId"));
}
case PkiOperation.ProcessPublication.NAME -> {
fields.exact("publicationId");
yield new PkiOperation.ProcessPublication(fields.pkiId("publicationId"));
}
case PkiOperation.ListAlgorithmBindings.NAME -> {
fields.allowed(Set.of("origin", "role", "limit"));
yield new PkiOperation.ListAlgorithmBindings(fields.optionalText("origin"),
fields.optionalText("role"), fields.integer("limit"));
}
case PkiOperation.InspectAlgorithmBinding.NAME -> {
fields.exact("bindingId");
yield new PkiOperation.InspectAlgorithmBinding(fields.text("bindingId"));
}
default -> throw new SecurityException("Operation is not exposed");
};
}
private static boolean realmWide(PkiOperation operation) {
return operation instanceof PkiOperation.ListAuthorities
|| operation instanceof PkiOperation.CreateAuthority
|| operation instanceof PkiOperation.ListAlgorithmBindings
|| operation instanceof PkiOperation.InspectAlgorithmBinding;
}
private static void validateAuthority(PkiOperation operation, Optional<PkiId> authority) {
Optional<PkiId> embedded = switch (operation) {
case PkiOperation.InspectAuthority value -> Optional.of(value.caId());
case PkiOperation.TransitionAuthority value -> Optional.of(value.caId());
case PkiOperation.IssueCredential value -> Optional.of(value.issuerCaId());
case PkiOperation.GenerateStatus value -> Optional.of(value.issuerCaId());
default -> Optional.empty();
};
if (embedded.isPresent() && !embedded.equals(authority)) {
throw new IllegalArgumentException("Operation and authority scope differ");
}
}
private static Optional<String> profile(PkiOperation operation) {
return switch (operation) {
case PkiOperation.ListProfileVersions value -> Optional.of(value.profileId());
case PkiOperation.InspectProfile value -> Optional.of(value.profileId());
case PkiOperation.ActivateProfile value -> Optional.of(value.profileId());
case PkiOperation.IssueCredential value -> Optional.of(value.profileId());
default -> Optional.empty();
};
}
private static Optional<PkiId> objectId(PkiOperation operation) {
return switch (operation) {
case PkiOperation.InspectAuthority value -> Optional.of(value.caId());
case PkiOperation.TransitionAuthority value -> Optional.of(value.caId());
case PkiOperation.InspectCredential value -> Optional.of(value.credentialId());
case PkiOperation.RevokeCredential value -> Optional.of(value.credentialId());
case PkiOperation.ReadRevocationHistory value -> Optional.of(value.credentialId());
case PkiOperation.InspectPublication value -> Optional.of(value.publicationId());
case PkiOperation.ProcessPublication value -> Optional.of(value.publicationId());
default -> Optional.empty();
};
}
private static Duration duration(String value) {
try {
Duration result = Duration.parse(value);
if (result.isZero() || result.isNegative()) throw new IllegalArgumentException("Deadline is invalid");
return result;
} catch (java.time.format.DateTimeParseException failure) {
throw new IllegalArgumentException("Deadline is invalid");
}
}
private static void requireId(String value, String name) {
if (value == null || !value.matches("[a-zA-Z0-9][a-zA-Z0-9._:-]{0,255}")) {
throw new IllegalArgumentException(name + " is invalid");
}
}
private static final class Fields {
private final Map<String, PkiOperationValue> fields;
private final Set<String> consumed = new LinkedHashSet<>();
private Fields(Map<String, PkiOperationValue> fields) { this.fields = fields; }
static Fields of(PkiOperationValue value) {
if (!(value instanceof PkiOperationValue.ObjectValue object)) {
throw new IllegalArgumentException("Request value must be an object");
}
return new Fields(object.fields());
}
void exact(String... names) { allowed(Set.of(names)); if (fields.size() != names.length)
throw new IllegalArgumentException("Request fields are incomplete"); }
void allowed(Set<String> names) { if (!names.containsAll(fields.keySet()))
throw new IllegalArgumentException("Unknown request field"); }
String text(String name) { consumed.add(name); PkiOperationValue value = require(name);
if (!(value instanceof PkiOperationValue.Text text)) throw type(); return text.value(); }
Optional<String> optionalText(String name) { return fields.containsKey(name)
? Optional.of(text(name)) : Optional.empty(); }
long longValue(String name) { consumed.add(name); PkiOperationValue value = require(name);
if (!(value instanceof PkiOperationValue.IntegerValue integer)) throw type(); return integer.value(); }
int integer(String name) { return Math.toIntExact(longValue(name)); }
PkiId pkiId(String name) { return new PkiId(text(name)); }
Fields object(String name) { consumed.add(name); return of(require(name)); }
void complete() { if (!Objects.equals(consumed, fields.keySet()))
throw new IllegalArgumentException("Request fields were not consumed"); }
private PkiOperationValue require(String name) { PkiOperationValue value = fields.get(name);
if (value == null) throw new IllegalArgumentException("Required request field is missing"); return value; }
private static IllegalArgumentException type() {
return new IllegalArgumentException("Request field type is invalid");
}
}
}

View File

@@ -0,0 +1,156 @@
/*******************************************************************************
* 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.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Objects;
import tools.jackson.core.JsonGenerator;
import tools.jackson.core.json.JsonFactory;
import zeroecho.pki.application.PkiOperationFailure;
import zeroecho.pki.application.PkiOperationOutcome;
import zeroecho.pki.application.PkiOperationValue;
import zeroecho.pki.server.ServerOperationGateway;
/** Deterministic versioned transport response and status mapping. */
@SuppressWarnings("PMD")
final class HttpResponses {
record Response(int statusCode, byte[] body) {
Response { body = Objects.requireNonNull(body, "body").clone(); }
@Override public byte[] body() { return body.clone(); }
}
private static final JsonFactory JSON = JsonFactory.builder().build();
private HttpResponses() {
}
static Response gateway(String requestId, String operation, ServerOperationGateway.Outcome outcome) {
return switch (outcome) {
case ServerOperationGateway.Outcome.Executed executed -> backend(requestId, operation,
executed.outcome());
case ServerOperationGateway.Outcome.Denied denied -> failure(403, requestId, operation,
"DENIED", "AUTHORIZATION_DENIED", denied.code().name(), false, false);
case ServerOperationGateway.Outcome.ApprovalRequired required -> successLikeFailure(409, requestId,
operation, "APPROVAL_REQUIRED", "APPROVAL_REQUIRED", required.operationCommitment());
};
}
static Response failure(int status, String requestId, String operation, String classification,
String code, String messageKey, boolean recoveryRequired, boolean reconciliationRequired) {
return encode(status, requestId, operation, "FAILED", null,
new Failure(classification, code, messageKey, recoveryRequired, reconciliationRequired));
}
static Response minimal(int status, String requestId, String operation, String state) {
return encode(status, requestId, operation, "SUCCEEDED",
new PkiOperationValue.ObjectValue(java.util.Map.of("state", new PkiOperationValue.Text(state))),
null);
}
static Response success(String requestId, String operation, PkiOperationValue.ObjectValue result) {
return encode(200, requestId, operation, "SUCCEEDED", result, null);
}
private static Response backend(String requestId, String operation, PkiOperationOutcome outcome) {
return switch (outcome) {
case PkiOperationOutcome.Success success -> encode(200, requestId, operation, "SUCCEEDED",
new PkiOperationValue.ObjectValue(success.result().fields()), null);
case PkiOperationOutcome.Failure failure -> {
int status = status(failure.classification());
boolean recovery = failure.classification() == PkiOperationFailure.RECOVERY_REQUIRED;
boolean reconciliation = failure.classification() == PkiOperationFailure.EXTERNAL_OUTCOME_UNKNOWN;
yield failure(status, requestId, operation, failure.classification().name(), failure.code(),
failure.code(), recovery, reconciliation);
}
};
}
private static Response successLikeFailure(int status, String requestId, String operation,
String classification, String code, String commitment) {
PkiOperationValue result = new PkiOperationValue.ObjectValue(java.util.Map.of(
"operationCommitment", new PkiOperationValue.Text(commitment)));
return encode(status, requestId, operation, classification, result,
new Failure(classification, code, code, false, false));
}
private static int status(PkiOperationFailure failure) {
return switch (failure) {
case VALIDATION_FAILURE -> 400;
case POLICY_REJECTION -> 422;
case NOT_FOUND -> 404;
case CONFLICT -> 409;
case RECOVERY_REQUIRED, RESOURCE_FAILURE -> 503;
case EXTERNAL_OUTCOME_UNKNOWN -> 409;
case CANCELLED -> 504;
case INTERNAL_FAILURE -> 500;
};
}
private static Response encode(int status, String requestId, String operation, String outcome,
PkiOperationValue result, Failure failure) {
try {
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (JsonGenerator generator = JSON.createGenerator(output)) {
generator.writeStartObject();
generator.writeNumberProperty("version", 1);
generator.writeStringProperty("requestId", requestId);
generator.writeStringProperty("operation", operation);
generator.writeStringProperty("status", outcome);
generator.writeName("result");
if (result == null) generator.writeNull(); else StrictJson.write(generator, result);
generator.writeName("failure");
if (failure == null) generator.writeNull(); else writeFailure(generator, failure);
generator.writeEndObject();
}
return new Response(status, output.toByteArray());
} catch (IOException impossible) {
throw new IllegalStateException("Safe HTTP response cannot be encoded");
}
}
private static void writeFailure(JsonGenerator generator, Failure failure) throws IOException {
generator.writeStartObject();
generator.writeStringProperty("classification", failure.classification());
generator.writeStringProperty("code", failure.code());
generator.writeStringProperty("messageKey", failure.messageKey());
generator.writeBooleanProperty("recoveryRequired", failure.recoveryRequired());
generator.writeBooleanProperty("reconciliationRequired", failure.reconciliationRequired());
generator.writeEndObject();
}
private record Failure(String classification, String code, String messageKey,
boolean recoveryRequired, boolean reconciliationRequired) { }
}

View File

@@ -0,0 +1,165 @@
/*******************************************************************************
* 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.math.BigInteger;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.time.Clock;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import zeroecho.pki.server.PkiServerConfiguration.ClientCertificateMapping;
import zeroecho.pki.server.SecurityPrincipal;
import zeroecho.pki.server.spi.PkiServerAuthenticationContext;
import zeroecho.pki.server.spi.PkiServerAuthenticationResult;
import zeroecho.pki.server.spi.PkiServerAuthenticator;
/**
* Production mutual-TLS authenticator using immutable cryptographic certificate
* commitments rather than mutable subject or SAN strings.
*
* <p>The HTTPS handshake has already validated the chain through the configured
* JSSE trust manager. This authenticator additionally validates validity time,
* canonical single-certificate encoding, mapping uniqueness, and the enabled
* state of the persisted principal.</p>
*/
@SuppressWarnings("PMD")
public final class MutualTlsAuthenticator implements PkiServerAuthenticator {
private final List<ClientCertificateMapping> mappings;
private final Function<String, SecurityPrincipal> principalResolver;
private final Clock clock;
/**
* Creates one immutable principal mapper.
*
* @param mappings exact cryptographic mappings
* @param principalResolver persisted principal resolver
* @param clock injected validity clock
*/
public MutualTlsAuthenticator(List<ClientCertificateMapping> mappings,
Function<String, SecurityPrincipal> principalResolver, Clock clock) {
this.mappings = List.copyOf(Objects.requireNonNull(mappings, "mappings"));
this.principalResolver = Objects.requireNonNull(principalResolver, "principalResolver");
this.clock = Objects.requireNonNull(clock, "clock");
}
@Override
public PkiServerAuthenticationResult authenticate(PkiServerAuthenticationContext context) {
Objects.requireNonNull(context, "context");
try {
for (X509Certificate certificate : context.peerCertificates()) {
certificate.checkValidity(Date.from(clock.instant()));
requireCanonical(certificate);
}
X509Certificate leaf = context.peerCertificates().getFirst();
Commitments commitments = commitments(leaf);
List<ClientCertificateMapping> matches = new ArrayList<>();
for (ClientCertificateMapping mapping : mappings) {
if (matches(mapping, commitments)) matches.add(mapping);
}
if (matches.isEmpty()) {
return new PkiServerAuthenticationResult.Rejected(
PkiServerAuthenticationResult.Code.MAPPING_UNAVAILABLE);
}
if (matches.size() != 1) {
return new PkiServerAuthenticationResult.Rejected(
PkiServerAuthenticationResult.Code.MAPPING_AMBIGUOUS);
}
SecurityPrincipal principal = principalResolver.apply(matches.getFirst().principalId());
if (!principal.enabled()) {
return new PkiServerAuthenticationResult.Rejected(
PkiServerAuthenticationResult.Code.PRINCIPAL_DISABLED);
}
return new PkiServerAuthenticationResult.Authenticated(principal.principalId());
} catch (java.security.cert.CertificateException | IllegalArgumentException failure) {
return new PkiServerAuthenticationResult.Rejected(
PkiServerAuthenticationResult.Code.CERTIFICATE_INVALID);
} catch (RuntimeException failure) {
return new PkiServerAuthenticationResult.Rejected(
PkiServerAuthenticationResult.Code.INTERNAL_FAILURE);
}
}
private static boolean matches(ClientCertificateMapping mapping, Commitments commitments) {
return mapping.certificateSha256().map(commitments.certificate()::equals).orElse(true)
&& mapping.subjectPublicKeyInfoSha256().map(commitments.spki()::equals).orElse(true)
&& mapping.issuerSerialSha256().map(commitments.issuerSerial()::equals).orElse(true);
}
private static Commitments commitments(X509Certificate certificate)
throws java.security.cert.CertificateEncodingException {
BigInteger serial = certificate.getSerialNumber();
if (serial.signum() <= 0) throw new IllegalArgumentException("Certificate serial is invalid");
MessageDigest issuerSerial = sha256();
issuerSerial.update(certificate.getIssuerX500Principal().getEncoded());
issuerSerial.update((byte) 0);
issuerSerial.update(serial.toByteArray());
return new Commitments(digest(certificate.getEncoded()), digest(certificate.getPublicKey().getEncoded()),
HexFormat.of().formatHex(issuerSerial.digest()));
}
private static void requireCanonical(X509Certificate certificate)
throws java.security.cert.CertificateException {
byte[] encoded = certificate.getEncoded();
ByteArrayInputStream input = new ByteArrayInputStream(encoded);
X509Certificate parsed = (X509Certificate) CertificateFactory.getInstance("X.509")
.generateCertificate(input);
if (input.available() != 0 || !Arrays.equals(encoded, parsed.getEncoded())) {
throw new java.security.cert.CertificateException("Certificate encoding is not canonical");
}
}
private static String digest(byte[] value) {
return HexFormat.of().formatHex(sha256().digest(value));
}
private static MessageDigest sha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 is unavailable", impossible);
}
}
private record Commitments(String certificate, String spki, String issuerSerial) { }
}

View File

@@ -0,0 +1,124 @@
/*******************************************************************************
* 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.security.SecureRandom;
import java.util.Arrays;
import java.util.Objects;
import java.util.Set;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;
import zeroecho.pki.server.spi.PkiServerTlsProvider;
import zeroecho.pki.spi.ProviderConfig;
/**
* Production JSSE provider using externally protected PKCS#12 identity and trust
* stores.
*
* <p>Configuration contains only store paths and names of environment variables.
* Passwords are resolved into short-lived character arrays, cleared after JSSE
* initialization, and never returned or logged.</p>
*/
@SuppressWarnings("PMD")
public final class Pkcs12TlsProvider implements PkiServerTlsProvider {
/** Stable explicit provider identity. */
public static final String ID = "jsse-pkcs12";
private static final Set<String> KEYS = Set.of("keyStore", "keyStorePasswordEnvironment",
"trustStore", "trustStorePasswordEnvironment");
@Override
public String id() {
return ID;
}
@Override
public SSLContext create(ProviderConfig configuration) throws Exception {
ProviderConfig exact = Objects.requireNonNull(configuration, "configuration");
if (!ID.equals(exact.backendId()) || !exact.properties().keySet().equals(KEYS)) {
throw new IllegalArgumentException("TLS provider configuration is invalid");
}
Path keyStorePath = path(exact.require("keyStore"));
Path trustStorePath = path(exact.require("trustStore"));
char[] keyPassword = secret(exact.require("keyStorePasswordEnvironment"));
char[] trustPassword = secret(exact.require("trustStorePasswordEnvironment"));
try {
KeyStore keyStore = load(keyStorePath, keyPassword);
KeyStore trustStore = load(trustStorePath, trustPassword);
KeyManagerFactory keys = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
keys.init(keyStore, keyPassword);
TrustManagerFactory trust = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
trust.init(trustStore);
SSLContext context = SSLContext.getInstance("TLS");
context.init(keys.getKeyManagers(), trust.getTrustManagers(), new SecureRandom());
return context;
} finally {
Arrays.fill(keyPassword, '\0');
Arrays.fill(trustPassword, '\0');
}
}
private static KeyStore load(Path path, char[] password) throws Exception {
KeyStore store = KeyStore.getInstance("PKCS12");
try (InputStream input = Files.newInputStream(path)) {
store.load(input, password);
}
return store;
}
private static Path path(String value) {
Path result = Path.of(value).toAbsolutePath().normalize();
if (result.getParent() == null || Files.isSymbolicLink(result)) {
throw new IllegalArgumentException("TLS material path is invalid");
}
return result;
}
private static char[] secret(String environmentName) {
if (!environmentName.matches("[A-Z][A-Z0-9_]{0,127}")) {
throw new IllegalArgumentException("TLS secret reference is invalid");
}
String value = System.getenv(environmentName);
if (value == null || value.isEmpty()) {
throw new IllegalStateException("TLS material is unavailable");
}
return value.toCharArray();
}
}

View File

@@ -0,0 +1,220 @@
/*******************************************************************************
* 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.IOException;
import java.net.InetSocketAddress;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.util.Objects;
import java.util.function.BooleanSupplier;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import com.sun.net.httpserver.HttpsConfigurator;
import com.sun.net.httpserver.HttpsParameters;
import com.sun.net.httpserver.HttpsServer;
import zeroecho.pki.server.PkiServerConfiguration;
import zeroecho.pki.server.ServerRealmContext;
import zeroecho.pki.server.spi.PkiServerAuthenticator;
/**
* Narrow lifecycle facade hiding the JDK HTTPS listener and bounded execution runtime.
*
* <p>The public server lifecycle uses this type only as an internal module boundary;
* request handlers, queues, permits and provider discovery remain package-private.</p>
*/
@SuppressWarnings("PMD")
public final class PkiHttpsTransport implements AutoCloseable {
private final HttpsServer listener;
private final ServerRuntime runtime;
private PkiHttpsTransport(HttpsServer listener, ServerRuntime runtime) {
this.listener = listener;
this.runtime = runtime;
}
/**
* Opens and starts one strictly mutual-TLS listener.
*
* @param configuration validated server configuration
* @param realm shared long-lived realm
* @param authenticator initialized mTLS authenticator
* @param clock request-time source
* @param random correlation identifier source
* @param loader explicitly scoped TLS-provider loader
* @param ready readiness predicate owned by the server lifecycle
* @return started bounded transport
*/
public static PkiHttpsTransport start(PkiServerConfiguration configuration, ServerRealmContext realm,
PkiServerAuthenticator authenticator, Clock clock, SecureRandom random, ClassLoader loader,
BooleanSupplier ready) {
Objects.requireNonNull(configuration, "configuration");
Objects.requireNonNull(realm, "realm");
Objects.requireNonNull(authenticator, "authenticator");
Objects.requireNonNull(ready, "ready");
ServerRuntime runtime = null;
HttpsServer listener = null;
try {
SSLContext tls = resolveTls(configuration, loader);
runtime = new ServerRuntime(configuration.execution());
listener = HttpsServer.create(configuration.listener().socketAddress(),
configuration.execution().transportQueueCapacity());
listener.setHttpsConfigurator(configurator(tls));
listener.setExecutor(runtime.transportExecutor());
listener.createContext("/", new AdminHttpHandler(configuration, realm, authenticator, runtime,
clock, new RequestIds(random), ready));
listener.start();
return new PkiHttpsTransport(listener, runtime);
} catch (IOException failure) {
closePartial(listener, runtime);
throw new IllegalStateException("HTTPS listener initialization failed");
} catch (RuntimeException | Error failure) {
closePartial(listener, runtime);
throw failure;
}
}
/**
* Resolves the explicitly selected TLS provider before realm resources are opened.
*
* @param configuration validated server configuration
* @param loader explicitly scoped provider loader
* @return initialized JSSE context without exposing private-key material
*/
public static SSLContext resolveTls(PkiServerConfiguration configuration, ClassLoader loader) {
Objects.requireNonNull(configuration, "configuration");
return TlsProviders.create(configuration.listener().tlsProvider(), loader);
}
/**
* Starts a listener using a TLS context resolved before realm construction.
*
* @param configuration validated server configuration
* @param realm shared long-lived realm
* @param authenticator initialized mTLS authenticator
* @param clock request-time source
* @param random correlation identifier source
* @param tls initialized TLS context
* @param ready readiness predicate
* @return started bounded transport
*/
public static PkiHttpsTransport startResolved(PkiServerConfiguration configuration,
ServerRealmContext realm, PkiServerAuthenticator authenticator, Clock clock, SecureRandom random,
SSLContext tls, BooleanSupplier ready) {
Objects.requireNonNull(configuration, "configuration");
Objects.requireNonNull(tls, "tls");
ServerRuntime runtime = null;
HttpsServer listener = null;
try {
runtime = new ServerRuntime(configuration.execution());
listener = HttpsServer.create(configuration.listener().socketAddress(),
configuration.execution().transportQueueCapacity());
listener.setHttpsConfigurator(configurator(tls));
listener.setExecutor(runtime.transportExecutor());
listener.createContext("/", new AdminHttpHandler(configuration, realm, authenticator, runtime,
clock, new RequestIds(random), ready));
listener.start();
return new PkiHttpsTransport(listener, runtime);
} catch (IOException failure) {
closePartial(listener, runtime);
throw new IllegalStateException("HTTPS listener initialization failed");
} catch (RuntimeException | Error failure) {
closePartial(listener, runtime);
throw failure;
}
}
/** @return actual bound listener address, including an allocated ephemeral port */
public InetSocketAddress address() {
return listener.getAddress();
}
/** @return whether operation admission is accepting new requests */
public boolean accepting() {
return runtime.accepting();
}
/** Prevents new operation admission without closing in-flight work. */
public void quiesce() {
runtime.quiesce();
}
/**
* Creates the sole process-shutdown thread through the centralized runtime.
*
* @param task finite server close action
* @return unstarted JVM shutdown hook
*/
public Thread shutdownHook(Runnable task) {
return ServerRuntime.shutdownHook(Objects.requireNonNull(task, "task"));
}
@Override
public void close() {
shutdown(Duration.ZERO);
}
/**
* Stops new exchanges, grants admitted exchanges a finite drain period and
* then closes all bounded executors.
*
* @param graceful finite listener drain period
*/
public void shutdown(Duration graceful) {
runtime.quiesce();
int seconds = Math.toIntExact(Math.min(Integer.MAX_VALUE,
Objects.requireNonNull(graceful, "graceful").toSeconds()));
listener.stop(seconds);
runtime.close();
}
private static HttpsConfigurator configurator(SSLContext context) {
return new HttpsConfigurator(context) {
@Override public void configure(HttpsParameters parameters) {
SSLParameters secure = context.getDefaultSSLParameters();
secure.setNeedClientAuth(true);
parameters.setSSLParameters(secure);
}
};
}
private static void closePartial(HttpsServer listener, ServerRuntime runtime) {
if (listener != null) listener.stop(0);
if (runtime != null) runtime.close();
}
}

View File

@@ -0,0 +1,62 @@
/*******************************************************************************
* 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.SecureRandom;
import java.util.Base64;
import java.util.List;
import java.util.Objects;
/** Strict opaque request-correlation identity policy. */
@SuppressWarnings("PMD")
final class RequestIds {
static final String HEADER = "X-ZeroEcho-Request-Id";
private final SecureRandom random;
RequestIds(SecureRandom random) {
this.random = Objects.requireNonNull(random, "random");
}
String resolve(List<String> supplied) {
if (supplied == null || supplied.isEmpty()) {
byte[] value = new byte[18];
random.nextBytes(value);
return Base64.getUrlEncoder().withoutPadding().encodeToString(value);
}
if (supplied.size() != 1 || !supplied.getFirst().matches("[A-Za-z0-9_-]{16,128}")) {
throw new IllegalArgumentException("Request correlation header is invalid");
}
return supplied.getFirst();
}
}

View File

@@ -0,0 +1,202 @@
/*******************************************************************************
* 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.time.Duration;
import java.util.Set;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Future;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.Semaphore;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import zeroecho.core.io.CancellationSignal;
import zeroecho.pki.server.PkiServerConfiguration;
/** Centralized owner of all bounded server-created threads, queues and permits. */
@SuppressWarnings("PMD")
final class ServerRuntime implements AutoCloseable {
static final String TRANSPORT_PREFIX = "zeroecho-pki-https-";
static final String OPERATION_PREFIX = "zeroecho-pki-operation-";
static final String SHUTDOWN_NAME = "zeroecho-pki-shutdown";
private final ThreadPoolExecutor transport;
private final ThreadPoolExecutor operations;
private final Semaphore admitted;
private final Set<CancellationToken> cancellations = ConcurrentHashMap.newKeySet();
private final Set<Future<?>> futures = ConcurrentHashMap.newKeySet();
private final PkiServerConfiguration.Execution configuration;
private final AtomicBoolean accepting = new AtomicBoolean(true);
ServerRuntime(PkiServerConfiguration.Execution configuration) {
this.configuration = configuration;
transport = pool(configuration.transportWorkers(), configuration.transportQueueCapacity(),
new NamedThreadFactory(TRANSPORT_PREFIX));
operations = pool(configuration.operationWorkers(), configuration.operationQueueCapacity(),
new NamedThreadFactory(OPERATION_PREFIX));
admitted = new Semaphore(configuration.maximumAdmittedRequests(), true);
}
ThreadPoolExecutor transportExecutor() {
return transport;
}
boolean tryAdmit() {
return accepting.get() && admitted.tryAcquire();
}
void releaseAdmission() {
admitted.release();
}
<T> Submitted<T> submit(CancellableCallable<T> task) {
if (!accepting.get()) throw new RejectedExecutionException("Server is not accepting operations");
CancellationToken cancellation = new CancellationToken();
cancellations.add(cancellation);
Future<T> future;
try {
future = operations.submit(() -> {
try {
cancellation.throwIfCancelled();
return task.call(cancellation);
} finally {
cancellations.remove(cancellation);
}
});
} catch (RejectedExecutionException failure) {
cancellations.remove(cancellation);
throw failure;
}
futures.add(future);
return new Submitted<>(future, cancellation, () -> futures.remove(future));
}
void quiesce() {
accepting.set(false);
}
boolean accepting() {
return accepting.get();
}
int operationQueueSize() {
return operations.getQueue().size();
}
int activeOperations() {
return operations.getActiveCount();
}
static Thread shutdownHook(Runnable task) {
return new Thread(task, SHUTDOWN_NAME);
}
@Override
public void close() {
quiesce();
transport.shutdown();
operations.shutdown();
boolean transportWorker = Thread.currentThread().getName().startsWith(TRANSPORT_PREFIX);
boolean operationWorker = Thread.currentThread().getName().startsWith(OPERATION_PREFIX);
if (!operationWorker) {
await(operations, configuration.gracefulShutdown());
}
if (!operations.isTerminated()) {
for (CancellationToken cancellation : cancellations) cancellation.cancel();
if (!operationWorker) {
for (Future<?> future : futures) future.cancel(true);
}
operations.shutdownNow();
if (!operationWorker) {
await(operations, configuration.forcedShutdown());
}
}
if (!transportWorker) await(transport, configuration.gracefulShutdown());
if (!transport.isTerminated()) {
transport.shutdownNow();
if (!transportWorker) await(transport, configuration.forcedShutdown());
}
}
private static ThreadPoolExecutor pool(int workers, int queueCapacity, ThreadFactory factory) {
ThreadPoolExecutor result = new ThreadPoolExecutor(workers, workers, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(queueCapacity), factory, new ThreadPoolExecutor.AbortPolicy());
result.prestartAllCoreThreads();
return result;
}
private static void await(ThreadPoolExecutor executor, Duration duration) {
try {
if (!executor.awaitTermination(duration.toMillis(), TimeUnit.MILLISECONDS)) return;
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
}
}
record Submitted<T>(Future<T> future, CancellationToken cancellation, Runnable completed) {
Submitted {
if (future == null || cancellation == null || completed == null) {
throw new NullPointerException("Submitted operation fields must not be null");
}
}
void finish() { completed.run(); }
}
@FunctionalInterface
interface CancellableCallable<T> {
T call(CancellationToken cancellation) throws Exception;
}
static final class CancellationToken implements CancellationSignal {
private final AtomicBoolean cancelled = new AtomicBoolean();
@Override public boolean isCancelled() { return cancelled.get() || Thread.currentThread().isInterrupted(); }
void cancel() { cancelled.set(true); }
}
private static final class NamedThreadFactory implements ThreadFactory {
private final String prefix;
private final AtomicInteger sequence = new AtomicInteger();
private NamedThreadFactory(String prefix) { this.prefix = prefix; }
@Override public Thread newThread(Runnable task) {
Thread thread = new Thread(task, prefix + sequence.incrementAndGet());
thread.setDaemon(false);
return thread;
}
}
}

View File

@@ -0,0 +1,173 @@
/*******************************************************************************
* 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.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import tools.jackson.core.JacksonException;
import tools.jackson.core.JsonGenerator;
import tools.jackson.core.JsonParser;
import tools.jackson.core.JsonToken;
import tools.jackson.core.ObjectReadContext;
import tools.jackson.core.StreamReadConstraints;
import tools.jackson.core.StreamReadFeature;
import tools.jackson.core.json.JsonFactory;
import tools.jackson.core.json.JsonFactoryBuilder;
import tools.jackson.core.json.JsonReadFeature;
import zeroecho.pki.application.PkiOperationValue;
/** Strict bounded UTF-8 JSON framing shared by server configuration and HTTP. */
@SuppressWarnings("PMD")
public final class StrictJson {
/** Absolute technical JSON document ceiling. */
public static final int MAXIMUM_DOCUMENT_BYTES = 16_777_216;
private static final int MAXIMUM_CONTAINER_ENTRIES = 4_096;
private static final JsonFactory FACTORY = createFactory();
private StrictJson() {
}
/** Parses exactly one complete strict JSON value with duplicate detection. */
public static PkiOperationValue parse(byte[] document, int maximumBytes) {
if (document == null || document.length == 0 || maximumBytes < 1
|| maximumBytes > MAXIMUM_DOCUMENT_BYTES || document.length > maximumBytes || hasBom(document)) {
throw invalid();
}
try (JsonParser parser = FACTORY.createParser(ObjectReadContext.empty(), document, 0, document.length)) {
JsonToken first = parser.nextToken();
if (first == null) throw invalid();
PkiOperationValue result = read(parser, first);
if (parser.nextToken() != null) throw invalid();
return result;
} catch (JacksonException failure) {
throw invalid(failure);
}
}
/** Encodes one safe operation value deterministically as UTF-8 JSON. */
public static byte[] encode(PkiOperationValue value) {
try {
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (JsonGenerator generator = FACTORY.createGenerator(output)) {
write(generator, value);
}
return output.toByteArray();
} catch (IOException failure) {
throw new IllegalStateException("Safe JSON response cannot be encoded");
}
}
private static PkiOperationValue read(JsonParser parser, JsonToken token) {
return switch (token) {
case START_OBJECT -> readObject(parser);
case START_ARRAY -> readArray(parser);
case VALUE_STRING -> new PkiOperationValue.Text(parser.getString());
case VALUE_NUMBER_INT -> new PkiOperationValue.IntegerValue(parser.getLongValue());
case VALUE_TRUE -> new PkiOperationValue.BooleanValue(true);
case VALUE_FALSE -> new PkiOperationValue.BooleanValue(false);
default -> throw invalid();
};
}
private static PkiOperationValue.ObjectValue readObject(JsonParser parser) {
Map<String, PkiOperationValue> fields = new LinkedHashMap<>();
while (parser.nextToken() != JsonToken.END_OBJECT) {
if (parser.currentToken() != JsonToken.PROPERTY_NAME
|| fields.size() >= MAXIMUM_CONTAINER_ENTRIES) throw invalid();
String name = parser.currentName();
JsonToken token = parser.nextToken();
if (token == null || fields.putIfAbsent(name, read(parser, token)) != null) throw invalid();
}
return new PkiOperationValue.ObjectValue(fields);
}
private static PkiOperationValue.ListValue readArray(JsonParser parser) {
List<PkiOperationValue> values = new ArrayList<>();
JsonToken token;
while ((token = parser.nextToken()) != JsonToken.END_ARRAY) {
if (token == null || values.size() >= MAXIMUM_CONTAINER_ENTRIES) throw invalid();
values.add(read(parser, token));
}
return new PkiOperationValue.ListValue(values);
}
static void write(JsonGenerator generator, PkiOperationValue value) throws IOException {
switch (value) {
case PkiOperationValue.Text text -> generator.writeString(text.value());
case PkiOperationValue.IntegerValue integer -> generator.writeNumber(integer.value());
case PkiOperationValue.BooleanValue bool -> generator.writeBoolean(bool.value());
case PkiOperationValue.ObjectValue object -> {
generator.writeStartObject();
for (Map.Entry<String, PkiOperationValue> field : object.fields().entrySet()) {
generator.writeName(field.getKey());
write(generator, field.getValue());
}
generator.writeEndObject();
}
case PkiOperationValue.ListValue list -> {
generator.writeStartArray();
for (PkiOperationValue item : list.values()) write(generator, item);
generator.writeEndArray();
}
}
}
private static JsonFactory createFactory() {
StreamReadConstraints constraints = StreamReadConstraints.builder().maxNestingDepth(16)
.maxDocumentLength(MAXIMUM_DOCUMENT_BYTES).maxTokenCount(65_536).maxNumberLength(20)
.maxStringLength(16_384).maxNameLength(128).build();
JsonFactoryBuilder builder = JsonFactory.builder().streamReadConstraints(constraints)
.enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION).disable(StreamReadFeature.AUTO_CLOSE_SOURCE);
for (JsonReadFeature feature : JsonReadFeature.values()) builder.disable(feature);
return builder.build();
}
private static boolean hasBom(byte[] value) {
return value.length >= 3 && value[0] == (byte) 0xef && value[1] == (byte) 0xbb
&& value[2] == (byte) 0xbf;
}
private static IllegalArgumentException invalid() {
return new IllegalArgumentException("Server JSON document is invalid");
}
private static IllegalArgumentException invalid(JacksonException failure) {
return new IllegalArgumentException("Server JSON document is invalid", failure);
}
}

View File

@@ -0,0 +1,77 @@
/*******************************************************************************
* 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.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.ServiceLoader;
import java.util.Set;
import javax.net.ssl.SSLContext;
import zeroecho.pki.server.spi.PkiServerTlsProvider;
import zeroecho.pki.spi.ProviderConfig;
/** Deterministic explicit TLS-provider selection. */
@SuppressWarnings("PMD")
final class TlsProviders {
private TlsProviders() {
}
static SSLContext create(ProviderConfig configuration, ClassLoader loader) {
Objects.requireNonNull(configuration, "configuration");
List<PkiServerTlsProvider> providers = new ArrayList<>();
ServiceLoader.load(PkiServerTlsProvider.class, Objects.requireNonNull(loader, "loader"))
.forEach(providers::add);
providers.sort(Comparator.comparing(PkiServerTlsProvider::id));
Set<String> ids = new HashSet<>();
if (providers.stream().anyMatch(provider -> provider.id() == null || provider.id().isBlank()
|| !ids.add(provider.id()))) {
throw new IllegalStateException("TLS provider identities are ambiguous");
}
PkiServerTlsProvider selected = providers.stream()
.filter(provider -> provider.id().equals(configuration.backendId()))
.findFirst().orElseThrow(() -> new IllegalStateException("Configured TLS provider is unavailable"));
try {
return selected.create(configuration);
} catch (RuntimeException failure) {
throw failure;
} catch (Exception failure) {
throw new IllegalStateException("TLS provider initialization failed");
}
}
}

View File

@@ -0,0 +1,69 @@
/*******************************************************************************
* 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 java.security.cert.X509Certificate;
import java.util.List;
import java.util.Objects;
import zeroecho.pki.server.RealmId;
/**
* Bounded read-only mutual-TLS authentication input, independent of operation
* arguments.
*
* @param peerCertificates trusted JSSE peer chain, leaf first
* @param protocol negotiated TLS protocol
* @param cipherSuite negotiated cipher suite
* @param requestId safe request correlation identity
* @param realmId exact server realm
*/
public record PkiServerAuthenticationContext(List<X509Certificate> peerCertificates, String protocol,
String cipherSuite, String requestId, RealmId realmId) {
/** Validates and snapshots the finite authentication context. */
public PkiServerAuthenticationContext {
peerCertificates = List.copyOf(Objects.requireNonNull(peerCertificates, "peerCertificates"));
if (peerCertificates.isEmpty() || peerCertificates.size() > 64) {
throw new IllegalArgumentException("Peer certificate chain length is invalid");
}
if (protocol == null || protocol.isBlank() || protocol.length() > 64
|| cipherSuite == null || cipherSuite.isBlank() || cipherSuite.length() > 128) {
throw new IllegalArgumentException("TLS session metadata is invalid");
}
if (requestId == null || !requestId.matches("[A-Za-z0-9_-]{16,128}")) {
throw new IllegalArgumentException("Request correlation identity is invalid");
}
Objects.requireNonNull(realmId, "realmId");
}
}

View File

@@ -0,0 +1,67 @@
/*******************************************************************************
* 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 java.util.Objects;
import java.util.Optional;
/** 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 {
/** Validates the finite principal identity. */
public Authenticated {
if (principalId == null || !principalId.matches("[a-zA-Z0-9][a-zA-Z0-9._:-]{0,255}")) {
throw new IllegalArgumentException("Principal identity is invalid");
}
}
}
/** Authentication rejection containing only a stable safe code. */
record Rejected(Code code) implements PkiServerAuthenticationResult {
/** Validates the rejection. */ public Rejected { Objects.requireNonNull(code, "code"); }
}
/** Safe non-disclosing rejection codes. */
enum Code {
CERTIFICATE_INVALID, MAPPING_UNAVAILABLE, MAPPING_AMBIGUOUS, PRINCIPAL_DISABLED, INTERNAL_FAILURE
}
/** @return principal identity when authentication succeeded */
default Optional<String> resolvedPrincipalId() {
return this instanceof Authenticated authenticated
? Optional.of(authenticated.principalId()) : Optional.empty();
}
}

View File

@@ -0,0 +1,53 @@
/*******************************************************************************
* 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;
/** Transport-neutral server authentication boundary. */
@FunctionalInterface
public interface PkiServerAuthenticator extends AutoCloseable {
/**
* Authenticates one already TLS-validated peer without inspecting operation
* arguments.
*
* @param context bounded trusted TLS context
* @return safe authentication result
*/
PkiServerAuthenticationResult authenticate(PkiServerAuthenticationContext context);
/** Releases optional provider resources. Default authenticators own none. */
@Override
default void close() throws Exception {
// No resource by default.
}
}

View File

@@ -0,0 +1,62 @@
/*******************************************************************************
* 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.SSLContext;
import zeroecho.pki.spi.ProviderConfig;
/**
* Explicit server-local boundary for lifecycle-confined JSSE key and trust
* material.
*
* <p>Implementations may load a private key only inside JSSE key-manager
* construction. They must not return key stores, keys, passwords, aliases, or
* provider exception details. Providers are discovered deterministically but are
* activated only by an exact configured {@link #id()}.</p>
*/
@SuppressWarnings("PMD")
public interface PkiServerTlsProvider {
/** @return stable explicit provider identity */
String id();
/**
* Creates one fully initialized mutual-TLS context.
*
* @param configuration opaque provider configuration that must be validated strictly
* @return initialized JSSE context containing server identity and client trust
* @throws Exception when material is unavailable or invalid
*/
SSLContext create(ProviderConfig configuration) throws Exception;
}

View File

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

View File

@@ -0,0 +1,176 @@
/*******************************************************************************
* 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;
import java.math.BigInteger;
import java.net.InetAddress;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.BasicConstraints;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.asn1.x509.GeneralNames;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import zeroecho.pki.application.PkiSessionConfiguration;
import zeroecho.pki.server.http.TestTlsProvider;
import zeroecho.pki.spi.ProviderConfig;
import zeroecho.pki.spi.store.MetadataStoreId;
/** Shared deterministic TLS, realm and server configuration fixtures. */
final class HttpServerTestSupport {
static final char[] PASSWORD = "test-password".toCharArray();
private HttpServerTestSupport() { }
static Fixture tls() throws Exception {
KeyPair rootKey = keyPair();
X509Certificate root = certificate(rootKey, rootKey, "CN=ZeroEcho Test Root", "CN=ZeroEcho Test Root",
BigInteger.ONE, true, false);
KeyPair serverKey = keyPair();
X509Certificate server = certificate(serverKey, rootKey, "CN=localhost", "CN=ZeroEcho Test Root",
BigInteger.TWO, false, true);
KeyPair clientKey = keyPair();
X509Certificate client = certificate(clientKey, rootKey, "CN=client", "CN=ZeroEcho Test Root",
BigInteger.valueOf(3), false, false);
SSLContext serverContext = context(serverKey, server, root, true);
SSLContext clientContext = context(clientKey, client, root, true);
SSLContext anonymousContext = context(clientKey, client, root, false);
TestTlsProvider.install(serverContext);
return new Fixture(client, clientContext, anonymousContext);
}
static PkiServerConfiguration configuration(java.nio.file.Path directory, X509Certificate client)
throws Exception {
ApprovalService.Policy approval = new ApprovalService.Policy("high-risk", 1, Set.of("approver"),
Set.of(), true, Duration.ofHours(1), true);
PkiSessionConfiguration session = new PkiSessionConfiguration(1,
new ProviderConfig("fs", Map.of("root", directory.resolve("pki-store").toString())),
new ProviderConfig("memory", Map.of("size", "128")), Optional.empty(), List.of(), List.of());
ServerRealmConfiguration realm = new ServerRealmConfiguration(ServerTestSupport.REALM, "Production",
session, new AuthorityExposurePolicy(AuthorityExposurePolicy.Mode.ALL_REALM_AUTHORITIES,
Set.of(), true), ServerTestSupport.DIGEST, approval.commitment(), ServerTestSupport.DIGEST,
DisclosureService.Defaults.recommended(), directory.resolve("control.log"),
new MetadataStoreId("fedcba9876543210fedcba9876543210"),
Map.of(OperationSecurityDescriptors.ApprovalCategory.HIGH_RISK, approval));
String certificateDigest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(client.getEncoded()));
PkiServerConfiguration.ClientCertificateMapping mapping =
new PkiServerConfiguration.ClientCertificateMapping("administrator-map", "administrator",
Optional.of(certificateDigest), Optional.empty(), Optional.empty());
return new PkiServerConfiguration(1, "test-server", realm,
new PkiServerConfiguration.Listener(InetAddress.getByName("127.0.0.1"), 0,
new ProviderConfig("test-tls", Map.of()), true, 16_384, 65_536),
new PkiServerConfiguration.Authentication(List.of(mapping)),
new PkiServerConfiguration.Execution(2, 4, 2, 4, 8, Duration.ofSeconds(5),
Duration.ofSeconds(10), Duration.ofSeconds(1), Duration.ofSeconds(1)),
new PkiServerConfiguration.RuntimeCapabilities(Optional.empty()));
}
static SecureRandom random() throws Exception {
SecureRandom random = SecureRandom.getInstance("SHA1PRNG");
random.setSeed(new byte[] { 1, 3, 5, 7, 9 });
return random;
}
private static KeyPair keyPair() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048, random());
return generator.generateKeyPair();
}
private static X509Certificate certificate(KeyPair subjectKey, KeyPair issuerKey, String subject,
String issuer, BigInteger serial, boolean ca, boolean server) throws Exception {
Instant before = Instant.parse("2026-01-01T00:00:00Z");
Instant after = Instant.parse("2027-01-01T00:00:00Z");
JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(new X500Name(issuer), serial,
Date.from(before), Date.from(after), new X500Name(subject), subjectKey.getPublic());
builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(ca));
if (server) {
builder.addExtension(Extension.subjectAlternativeName, false,
new GeneralNames(new GeneralName[] { new GeneralName(GeneralName.dNSName, "localhost"),
new GeneralName(GeneralName.iPAddress, "127.0.0.1") }));
}
ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(issuerKey.getPrivate());
X509CertificateHolder holder = builder.build(signer);
X509Certificate result = new JcaX509CertificateConverter().getCertificate(holder);
result.verify(issuerKey.getPublic());
return result;
}
private static SSLContext context(KeyPair identity, X509Certificate certificate, X509Certificate root,
boolean includeIdentity) throws Exception {
KeyStore keys = KeyStore.getInstance("PKCS12");
keys.load(null, PASSWORD);
if (includeIdentity) {
keys.setKeyEntry("identity", identity.getPrivate(), PASSWORD,
new java.security.cert.Certificate[] { certificate, root });
}
KeyManagerFactory keyManagers = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
keyManagers.init(keys, PASSWORD);
KeyStore trust = KeyStore.getInstance("PKCS12");
trust.load(null, PASSWORD);
trust.setCertificateEntry("root", root);
TrustManagerFactory trustManagers = TrustManagerFactory.getInstance(
TrustManagerFactory.getDefaultAlgorithm());
trustManagers.init(trust);
SSLContext context = SSLContext.getInstance("TLSv1.3");
context.init(keyManagers.getKeyManagers(), trustManagers.getTrustManagers(), random());
return context;
}
record Fixture(X509Certificate clientCertificate, SSLContext clientContext, SSLContext anonymousContext) { }
}

View File

@@ -0,0 +1,220 @@
/*******************************************************************************
* 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;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.security.MessageDigest;
import java.time.Duration;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import zeroecho.pki.application.PkiSessionRuntimeDependencies;
import zeroecho.pki.server.http.MutualTlsAuthenticator;
import zeroecho.pki.server.http.TestTlsProvider;
import zeroecho.pki.server.spi.PkiServerAuthenticationContext;
import zeroecho.pki.server.spi.PkiServerAuthenticationResult;
/** Mutual-TLS authentication, HTTPS framing, gateway and lifecycle integration tests. */
class PkiHttpsServerTest {
@TempDir java.nio.file.Path temporaryDirectory;
@Test
void rejectsInsecureConfigurationAndStrictJsonFraming() throws Exception {
System.out.println("rejectsInsecureConfigurationAndStrictJsonFraming");
HttpServerTestSupport.Fixture tls = HttpServerTestSupport.tls();
PkiServerConfiguration valid = HttpServerTestSupport.configuration(temporaryDirectory,
tls.clientCertificate());
assertThrows(IllegalArgumentException.class, () -> new PkiServerConfiguration(valid.version(),
valid.serverName(), valid.realm(), new PkiServerConfiguration.Listener(valid.listener().address(),
valid.listener().port(), valid.listener().tlsProvider(), false, 16_384, 65_536),
valid.authentication(), valid.execution(), valid.runtime()));
assertThrows(IllegalArgumentException.class, () -> PkiServerConfigurationCodec.decode(
"{\"version\":1,\"version\":1}".getBytes(java.nio.charset.StandardCharsets.UTF_8)));
assertThrows(IllegalArgumentException.class, () -> new PkiServerConfiguration.Execution(1, 1, 1, 1,
1, Duration.ofSeconds(2), Duration.ofSeconds(1), Duration.ofSeconds(1), Duration.ofSeconds(1)));
System.out.println("...strict-security-fields=true");
System.out.println("...ok");
}
@Test
void authenticatesCryptographicCommitmentAndRejectsAmbiguityAndDisabledPrincipal() throws Exception {
System.out.println("authenticatesCryptographicCommitmentAndRejectsAmbiguityAndDisabledPrincipal");
HttpServerTestSupport.Fixture tls = HttpServerTestSupport.tls();
String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(tls.clientCertificate().getEncoded()));
PkiServerConfiguration.ClientCertificateMapping first = mapping("first", "enabled", digest);
PkiServerAuthenticationContext context = new PkiServerAuthenticationContext(
List.of(tls.clientCertificate()), "TLSv1.3", "TLS_AES_128_GCM_SHA256", "request-identifier1",
ServerTestSupport.REALM);
MutualTlsAuthenticator valid = new MutualTlsAuthenticator(List.of(first),
ignored -> ServerTestSupport.principal("enabled"), ServerTestSupport.CLOCK);
assertInstanceOf(PkiServerAuthenticationResult.Authenticated.class, valid.authenticate(context));
MutualTlsAuthenticator ambiguous = new MutualTlsAuthenticator(
List.of(first, mapping("second", "enabled", digest)),
ignored -> ServerTestSupport.principal("enabled"), ServerTestSupport.CLOCK);
PkiServerAuthenticationResult rejected = ambiguous.authenticate(context);
assertEquals(PkiServerAuthenticationResult.Code.MAPPING_AMBIGUOUS,
((PkiServerAuthenticationResult.Rejected) rejected).code());
MutualTlsAuthenticator disabled = new MutualTlsAuthenticator(List.of(first), ignored ->
new SecurityPrincipal("enabled", SecurityPrincipal.Type.USER, "disabled", Optional.empty(),
Map.of(), false), ServerTestSupport.CLOCK);
assertEquals(PkiServerAuthenticationResult.Code.PRINCIPAL_DISABLED,
((PkiServerAuthenticationResult.Rejected) disabled.authenticate(context)).code());
System.out.println("...subject-string-authority=false");
System.out.println("...ok");
}
@Test
void servesAuthenticatedHealthRealmDiscoveryAndGatewayOperation() throws Exception {
System.out.println("servesAuthenticatedHealthRealmDiscoveryAndGatewayOperation");
HttpServerTestSupport.Fixture tls = HttpServerTestSupport.tls();
PkiServerConfiguration configuration = HttpServerTestSupport.configuration(temporaryDirectory,
tls.clientCertificate());
seed(configuration);
PkiHttpsServer server = PkiHttpsServer.start(configuration, PkiSessionRuntimeDependencies.none(),
ServerTestSupport.CLOCK, HttpServerTestSupport.random(), TestTlsProvider.class.getClassLoader());
try {
HttpClient client = HttpClient.newBuilder().sslContext(tls.clientContext()).build();
URI base = URI.create("https://localhost:" + server.address().getPort());
HttpResponse<String> live = get(client, base.resolve("/health/live"));
assertEquals(200, live.statusCode());
assertEquals(live.headers().firstValue("X-ZeroEcho-Request-Id").orElseThrow().length() >= 16, true);
HttpResponse<String> ready = get(client, base.resolve("/health/ready"));
assertEquals(200, ready.statusCode());
HttpResponse<String> realm = get(client, base.resolve("/admin/v1/realm"));
assertEquals(200, realm.statusCode());
assertTrue(realm.body().contains("production"));
HttpResponse<String> operations = get(client, base.resolve("/admin/v1/operations"));
assertEquals(200, operations.statusCode());
assertTrue(operations.body().contains("algorithm.binding.list"));
HttpResponse<String> executed = post(client,
base.resolve("/admin/v1/operations/algorithm.binding.list"),
"{\"version\":1,\"arguments\":{\"limit\":10}}");
assertEquals(200, executed.statusCode());
assertTrue(executed.body().contains("\"status\":\"SUCCEEDED\""));
HttpResponse<String> hidden = get(client, base.resolve("/admin/v1/operations/credential.issue"));
assertEquals(404, hidden.statusCode());
HttpResponse<String> malformed = post(client,
base.resolve("/admin/v1/operations/algorithm.binding.list"),
"{\"version\":1,\"version\":1,\"arguments\":{\"limit\":10}}");
assertEquals(400, malformed.statusCode());
System.out.println("...gateway-operation-status=" + executed.statusCode());
} finally {
server.close();
}
assertEquals(PkiHttpsServer.State.TERMINATED, server.state());
assertFalse(Thread.getAllStackTraces().keySet().stream().anyMatch(thread -> thread.isAlive()
&& (thread.getName().startsWith("zeroecho-pki-https-")
|| thread.getName().startsWith("zeroecho-pki-operation-"))));
System.out.println("...ok");
}
@Test
void rejectsClientWithoutCertificateBeforeGatewayInvocation() throws Exception {
System.out.println("rejectsClientWithoutCertificateBeforeGatewayInvocation");
HttpServerTestSupport.Fixture tls = HttpServerTestSupport.tls();
PkiServerConfiguration configuration = HttpServerTestSupport.configuration(temporaryDirectory,
tls.clientCertificate());
seed(configuration);
try (PkiHttpsServer server = PkiHttpsServer.start(configuration, PkiSessionRuntimeDependencies.none(),
ServerTestSupport.CLOCK, HttpServerTestSupport.random(), TestTlsProvider.class.getClassLoader())) {
HttpClient client = HttpClient.newBuilder().sslContext(tls.anonymousContext()).build();
URI uri = URI.create("https://localhost:" + server.address().getPort() + "/admin/v1/realm");
assertThrows(java.io.IOException.class,
() -> client.send(HttpRequest.newBuilder(uri).GET().build(), HttpResponse.BodyHandlers.ofString()));
System.out.println("...tls-client-certificate=required");
}
System.out.println("...ok");
}
@Test
void packagedEntryPointProvidesHelpAndSafeValidationFailure() {
System.out.println("packagedEntryPointProvidesHelpAndSafeValidationFailure");
assertEquals(0, PkiServerMain.run(new String[] { "--help" }));
assertEquals(0, PkiServerMain.run(new String[] { "--version" }));
assertEquals(2, PkiServerMain.run(new String[] { "--config", temporaryDirectory.resolve("missing.json").toString(),
"--validate-config" }));
System.out.println("...listener-opened-in-validation=false");
System.out.println("...ok");
}
private void seed(PkiServerConfiguration configuration) throws Exception {
try (ServerRealmContext context = ServerRealmContext.open(configuration.realm(),
PkiSessionRuntimeDependencies.none(), ServerTestSupport.CLOCK, HttpServerTestSupport.random())) {
context.createPrincipal(ServerTestSupport.principal("administrator"), "system");
Permission.Scope realmScope = new Permission.Scope(ServerTestSupport.REALM, Optional.empty(),
Optional.empty(), Optional.empty());
context.grant(ServerTestSupport.grant("realm-read", "administrator", Permission.Effect.ALLOW,
Permission.Action.REALM_READ, Permission.ResourceType.REALM, realmScope,
Permission.Relationship.ANY, Permission.DataView.METADATA_REDACTED), "system");
context.grant(ServerTestSupport.grant("authority-list", "administrator", Permission.Effect.ALLOW,
Permission.Action.AUTHORITY_LIST, Permission.ResourceType.AUTHORITY, realmScope,
Permission.Relationship.ANY, Permission.DataView.METADATA_REDACTED), "system");
context.grant(ServerTestSupport.grant("binding-read", "administrator", Permission.Effect.ALLOW,
Permission.Action.X509_BINDING_READ, Permission.ResourceType.X509_BINDING, realmScope,
Permission.Relationship.ANY, Permission.DataView.METADATA_REDACTED), "system");
}
}
private static PkiServerConfiguration.ClientCertificateMapping mapping(String id, String principal,
String digest) {
return new PkiServerConfiguration.ClientCertificateMapping(id, principal, Optional.of(digest),
Optional.empty(), Optional.empty());
}
private static HttpResponse<String> get(HttpClient client, URI uri) throws Exception {
return client.send(HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(5)).GET().build(),
HttpResponse.BodyHandlers.ofString());
}
private static HttpResponse<String> post(HttpClient client, URI uri, String body) throws Exception {
return client.send(HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(5))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
}
}

View File

@@ -0,0 +1,104 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.server.http;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import zeroecho.pki.server.PkiServerConfiguration;
/** Deterministic bounded-admission, queue, cancellation and worker-close tests. */
class ServerRuntimeTest {
@Test
void enforcesFixedWorkersBoundedQueueAndPermitRelease() throws Exception {
System.out.println("enforcesFixedWorkersBoundedQueueAndPermitRelease");
ServerRuntime runtime = new ServerRuntime(configuration());
CountDownLatch started = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
try {
assertTrue(runtime.tryAdmit());
assertEquals(false, runtime.tryAdmit());
runtime.releaseAdmission();
ServerRuntime.Submitted<String> first = runtime.submit(cancellation -> {
started.countDown();
release.await();
return Thread.currentThread().getName();
});
assertTrue(started.await(2, TimeUnit.SECONDS));
ServerRuntime.Submitted<String> waiting = runtime.submit(cancellation -> "queued");
assertEquals(1, runtime.activeOperations());
assertEquals(1, runtime.operationQueueSize());
assertThrows(RejectedExecutionException.class, () -> runtime.submit(cancellation -> "rejected"));
release.countDown();
assertTrue(first.future().get(2, TimeUnit.SECONDS).startsWith(ServerRuntime.OPERATION_PREFIX));
assertEquals("queued", waiting.future().get(2, TimeUnit.SECONDS));
first.finish();
waiting.finish();
System.out.println("...maximum-concurrent=1");
} finally {
release.countDown();
runtime.close();
}
System.out.println("...ok");
}
@Test
void closesFromOperationWorkerWithoutWaitingForItself() throws Exception {
System.out.println("closesFromOperationWorkerWithoutWaitingForItself");
ServerRuntime runtime = new ServerRuntime(configuration());
ServerRuntime.Submitted<Boolean> submitted = runtime.submit(cancellation -> {
runtime.close();
return Thread.currentThread().isInterrupted();
});
submitted.future().get(2, TimeUnit.SECONDS);
submitted.finish();
assertEquals(false, runtime.accepting());
System.out.println("...self-wait=false");
System.out.println("...ok");
}
private static PkiServerConfiguration.Execution configuration() {
return new PkiServerConfiguration.Execution(1, 1, 1, 1, 1,
Duration.ofSeconds(1), Duration.ofSeconds(1), Duration.ofMillis(100),
Duration.ofMillis(100));
}
}

View File

@@ -0,0 +1,61 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.server.http;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
import javax.net.ssl.SSLContext;
import zeroecho.pki.server.spi.PkiServerTlsProvider;
import zeroecho.pki.spi.ProviderConfig;
/** Deterministic test-only TLS capability selected explicitly by provider ID. */
public final class TestTlsProvider implements PkiServerTlsProvider {
private static final AtomicReference<SSLContext> CONTEXT = new AtomicReference<>();
/** Installs one test fixture before server construction. */
public static void install(SSLContext context) {
CONTEXT.set(Objects.requireNonNull(context, "context"));
}
@Override public String id() { return "test-tls"; }
@Override public SSLContext create(ProviderConfig configuration) {
if (!configuration.properties().isEmpty()) {
throw new IllegalArgumentException("Test TLS configuration must be empty");
}
return Objects.requireNonNull(CONTEXT.get(), "Test TLS fixture is unavailable");
}
}

View File

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