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

@@ -0,0 +1,47 @@
# ZeroEcho administrative HTTPS server
The `pki-server` distribution exposes the transport-neutral realm gateway through mutual TLS. One process opens one realm and one long-lived PKI session; all administrative execution uses the existing typed operation catalog and `ServerOperationGateway`.
## Starting the server
```text
zeroecho-pki-server --validate-config --config server-config.json
zeroecho-pki-server --config server-config.json
```
Use `--help` and `--version` for launcher information. Validation parses the strict versioned JSON and checks finite security bounds without opening a listener, realm, store, provider, or audit sink.
The loopback and production examples are [pki-server-loopback-example.json](pki-server-loopback-example.json) and [pki-server-production-example.json](pki-server-production-example.json). They contain no credentials or key material. Replace every illustrative commitment and identity with provisioned values before use. The mapped principal must already exist in the durable server-control store and have explicit scoped grants; a certificate mapping establishes identity only and never grants authority.
## TLS and authentication
Administrative access has no anonymous mode. The listener always requires a client certificate. The built-in `jsse-pkcs12` TLS provider reads server identity and client trust stores from configured files, while passwords are resolved exclusively from named environment variables. Provider exceptions, aliases, references, certificate subjects, SANs, fingerprints, and paths are not returned to clients or written to request audit details.
Client certificates map to persisted principals through an exact SHA-256 certificate, canonical SPKI, or issuer-and-positive-serial commitment. Subject and SAN strings are not trust identities. An absent, ambiguous, expired, malformed, untrusted, or disabled mapping fails before typed operation decoding.
## API
The listener exposes only:
```text
GET /health/live
GET /health/ready
GET /admin/v1/realm
GET /admin/v1/operations
GET /admin/v1/operations/{operationId}
POST /admin/v1/operations/{operationId}
```
No ACME or public certificate repository routes are part of this server. Administrative POST bodies are strict UTF-8 JSON containing version, optional authority and approval identities, an optional shortening deadline, and the closed argument object for one typed operation. Batch execution, file paths, raw DER, polymorphic class metadata, unknown keys, duplicate keys, and trailing JSON are rejected.
Responses are deterministic version-one JSON. They carry only safe typed results or stable failure codes. `RECOVERY_REQUIRED` and `EXTERNAL_OUTCOME_UNKNOWN` remain distinct; deadlines never claim rollback and never trigger automatic retry.
## Capacity and shutdown
Transport workers, transport backlog, concurrent operation workers, waiting operation capacity, admitted requests, request body size, deadlines, and both shutdown periods are finite configuration values. Saturation never runs a request on the caller thread and a rejected request never invokes the gateway.
Shutdown first removes readiness and admission, stops listener acceptance, drains for the configured graceful period, cooperatively cancels remaining work, waits only for the forced period, then closes the one realm context. Already committed PKI changes remain committed.
## Request correlation
Clients may supply `X-ZeroEcho-Request-Id` using 16128 ASCII letters, digits, underscore, or hyphen. Otherwise the server creates a random opaque identifier. It is correlation metadata—not an idempotency key, object identity, approval, or capability token.

View File

@@ -0,0 +1,77 @@
{
"version": 1,
"serverName": "zeroecho-admin",
"realm": {
"realmId": "production",
"displayName": "ZeroEcho Production",
"authorityExposure": {
"mode": "ALL_REALM_AUTHORITIES",
"authorityIds": [],
"creationPermitted": false
},
"authorizationCommitment": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"approvalCommitment": "96f85d99cb556b16af9b963833a832f905010681f437a757d94aab4a22e4c29c",
"disclosureCommitment": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
"disclosureDefaults": {
"rootCa": "PUBLIC",
"intermediateCa": "PUBLIC",
"caChain": "PUBLIC",
"crl": "PUBLIC",
"leaf": "OWNER_ONLY",
"sensitiveLeaf": "RESTRICTED"
},
"controlLog": "state/server-control.log",
"controlStoreId": "0123456789abcdef0123456789abcdef",
"approvalPolicy": {
"policyId": "high-risk",
"threshold": 1,
"eligibleApprovers": ["bootstrap-approver"],
"requiredRoleTemplateIds": [],
"requesterSeparation": true,
"lifetimeMillis": 3600000,
"justificationRequired": true
},
"pkiSession": {
"version": 1,
"store": {"id": "fs", "properties": {"root": "state/pki"}},
"audit": {"id": "file", "properties": {"root": "state/audit"}},
"publishers": [],
"bindingProviders": []
}
},
"listener": {
"address": "127.0.0.1",
"port": 8443,
"tlsProvider": {
"id": "jsse-pkcs12",
"properties": {
"keyStore": "tls/server-identity.p12",
"keyStorePasswordEnvironment": "ZEROECHO_TLS_KEYSTORE_PASSWORD",
"trustStore": "tls/administrators-trust.p12",
"trustStorePasswordEnvironment": "ZEROECHO_TLS_TRUSTSTORE_PASSWORD"
}
},
"clientCertificateRequired": true,
"maximumHeaderBytes": 32768,
"maximumBodyBytes": 1048576
},
"authentication": {
"mappings": [{
"mappingId": "bootstrap-administrator",
"principalId": "bootstrap-admin",
"certificateSha256": "0000000000000000000000000000000000000000000000000000000000000000"
}]
},
"execution": {
"transportWorkers": 8,
"transportQueueCapacity": 64,
"operationWorkers": 4,
"operationQueueCapacity": 32,
"maximumAdmittedRequests": 96,
"defaultDeadlineMillis": 30000,
"maximumDeadlineMillis": 120000,
"gracefulShutdownMillis": 30000,
"forcedShutdownMillis": 10000
},
"runtime": {}
}

View File

@@ -0,0 +1,77 @@
{
"version": 1,
"serverName": "zeroecho-admin",
"realm": {
"realmId": "production",
"displayName": "ZeroEcho Production",
"authorityExposure": {
"mode": "ALL_REALM_AUTHORITIES",
"authorityIds": [],
"creationPermitted": true
},
"authorizationCommitment": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"approvalCommitment": "96f85d99cb556b16af9b963833a832f905010681f437a757d94aab4a22e4c29c",
"disclosureCommitment": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
"disclosureDefaults": {
"rootCa": "PUBLIC",
"intermediateCa": "PUBLIC",
"caChain": "PUBLIC",
"crl": "PUBLIC",
"leaf": "OWNER_ONLY",
"sensitiveLeaf": "RESTRICTED"
},
"controlLog": "state/server-control.log",
"controlStoreId": "0123456789abcdef0123456789abcdef",
"approvalPolicy": {
"policyId": "high-risk",
"threshold": 1,
"eligibleApprovers": ["bootstrap-approver"],
"requiredRoleTemplateIds": [],
"requesterSeparation": true,
"lifetimeMillis": 3600000,
"justificationRequired": true
},
"pkiSession": {
"version": 1,
"store": {"id": "fs", "properties": {"root": "state/pki"}},
"audit": {"id": "file", "properties": {"root": "state/audit"}},
"publishers": [],
"bindingProviders": []
}
},
"listener": {
"address": "0.0.0.0",
"port": 8443,
"tlsProvider": {
"id": "jsse-pkcs12",
"properties": {
"keyStore": "tls/server-identity.p12",
"keyStorePasswordEnvironment": "ZEROECHO_TLS_KEYSTORE_PASSWORD",
"trustStore": "tls/administrators-trust.p12",
"trustStorePasswordEnvironment": "ZEROECHO_TLS_TRUSTSTORE_PASSWORD"
}
},
"clientCertificateRequired": true,
"maximumHeaderBytes": 32768,
"maximumBodyBytes": 1048576
},
"authentication": {
"mappings": [{
"mappingId": "bootstrap-administrator",
"principalId": "bootstrap-admin",
"certificateSha256": "0000000000000000000000000000000000000000000000000000000000000000"
}]
},
"execution": {
"transportWorkers": 32,
"transportQueueCapacity": 256,
"operationWorkers": 16,
"operationQueueCapacity": 128,
"maximumAdmittedRequests": 384,
"defaultDeadlineMillis": 30000,
"maximumDeadlineMillis": 120000,
"gracefulShutdownMillis": 30000,
"forcedShutdownMillis": 10000
},
"runtime": {}
}

View File

@@ -1,5 +1,6 @@
plugins { plugins {
id 'buildlogic.java-library-conventions' id 'buildlogic.java-library-conventions'
id 'application'
id 'com.palantir.git-version' id 'com.palantir.git-version'
} }
@@ -10,6 +11,22 @@ dependencies {
implementation project(':lib') implementation project(':lib')
implementation platform('tools.jackson:jackson-bom:3.1.5') implementation platform('tools.jackson:jackson-bom:3.1.5')
implementation 'tools.jackson.core:jackson-core' 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 { 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> * exactly once to the session executor, and preserves the returned outcome.</p>
*/ */
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.FieldDeclarationsShouldBeAtStartOfClass", @SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.FieldDeclarationsShouldBeAtStartOfClass",
"PMD.ExcessiveParameterList", "PMD.CyclomaticComplexity" }) "PMD.ExcessiveParameterList", "PMD.CyclomaticComplexity", "PMD.AvoidInstantiatingObjectsInLoops" })
public final class ServerOperationGateway { public final class ServerOperationGateway {
/** /**
* Complete transport-neutral request admission input. * Complete transport-neutral request admission input.
@@ -218,6 +218,92 @@ public final class ServerOperationGateway {
return new Outcome.Executed(backend); 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, private PkiOperationOutcome filterAuthorities(PkiOperationOutcome outcome, SecurityPrincipal principal,
List<Permission.Grant> grants, java.util.Set<String> breakGlassIds, Permission.Context context) { List<Permission.Grant> grants, java.util.Set<String> breakGlassIds, Permission.Context context) {
if (!(outcome instanceof PkiOperationOutcome.Success success)) return outcome; 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; } public AuditorViews auditorViews() { requireOpen(); return auditorViews; }
/** @return authorized typed-operation gateway */ /** @return authorized typed-operation gateway */
public ServerOperationGateway gateway() { requireOpen(); return 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 */ /** @return current lifecycle state */
public State state() { return state.get(); } 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