From d7793e5c49d2bcbb93e1056ae87222f3ac9e3062 Mon Sep 17 00:00:00 2001 From: Leo Galambos Date: Tue, 4 Aug 2026 20:16:34 +0200 Subject: [PATCH] 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. --- docs/pki-server-admin-https.md | 47 +++ docs/pki-server-loopback-example.json | 77 ++++ docs/pki-server-production-example.json | 77 ++++ pki-server/build.gradle | 17 + .../zeroecho/pki/server/PkiHttpsServer.java | 208 +++++++++ .../pki/server/PkiServerConfiguration.java | 226 ++++++++++ .../server/PkiServerConfigurationCodec.java | 321 ++++++++++++++ .../zeroecho/pki/server/PkiServerMain.java | 131 ++++++ .../pki/server/ServerOperationGateway.java | 88 +++- .../pki/server/ServerRealmContext.java | 26 ++ .../pki/server/http/AdminHttpHandler.java | 396 ++++++++++++++++++ .../pki/server/http/HttpOperationCodec.java | 260 ++++++++++++ .../pki/server/http/HttpResponses.java | 156 +++++++ .../server/http/MutualTlsAuthenticator.java | 165 ++++++++ .../pki/server/http/Pkcs12TlsProvider.java | 124 ++++++ .../pki/server/http/PkiHttpsTransport.java | 220 ++++++++++ .../zeroecho/pki/server/http/RequestIds.java | 62 +++ .../pki/server/http/ServerRuntime.java | 202 +++++++++ .../zeroecho/pki/server/http/StrictJson.java | 173 ++++++++ .../pki/server/http/TlsProviders.java | 77 ++++ .../spi/PkiServerAuthenticationContext.java | 69 +++ .../spi/PkiServerAuthenticationResult.java | 67 +++ .../server/spi/PkiServerAuthenticator.java | 53 +++ .../pki/server/spi/PkiServerTlsProvider.java | 62 +++ ...roecho.pki.server.spi.PkiServerTlsProvider | 1 + .../pki/server/HttpServerTestSupport.java | 176 ++++++++ .../pki/server/PkiHttpsServerTest.java | 220 ++++++++++ .../pki/server/http/ServerRuntimeTest.java | 104 +++++ .../pki/server/http/TestTlsProvider.java | 61 +++ ...roecho.pki.server.spi.PkiServerTlsProvider | 1 + 30 files changed, 3866 insertions(+), 1 deletion(-) create mode 100644 docs/pki-server-admin-https.md create mode 100644 docs/pki-server-loopback-example.json create mode 100644 docs/pki-server-production-example.json create mode 100644 pki-server/src/main/java/zeroecho/pki/server/PkiHttpsServer.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/PkiServerConfiguration.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/PkiServerConfigurationCodec.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/PkiServerMain.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/http/AdminHttpHandler.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/http/HttpOperationCodec.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/http/HttpResponses.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/http/MutualTlsAuthenticator.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/http/Pkcs12TlsProvider.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/http/PkiHttpsTransport.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/http/RequestIds.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/http/ServerRuntime.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/http/StrictJson.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/http/TlsProviders.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/spi/PkiServerAuthenticationContext.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/spi/PkiServerAuthenticationResult.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/spi/PkiServerAuthenticator.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/spi/PkiServerTlsProvider.java create mode 100644 pki-server/src/main/resources/META-INF/services/zeroecho.pki.server.spi.PkiServerTlsProvider create mode 100644 pki-server/src/test/java/zeroecho/pki/server/HttpServerTestSupport.java create mode 100644 pki-server/src/test/java/zeroecho/pki/server/PkiHttpsServerTest.java create mode 100644 pki-server/src/test/java/zeroecho/pki/server/http/ServerRuntimeTest.java create mode 100644 pki-server/src/test/java/zeroecho/pki/server/http/TestTlsProvider.java create mode 100644 pki-server/src/test/resources/META-INF/services/zeroecho.pki.server.spi.PkiServerTlsProvider diff --git a/docs/pki-server-admin-https.md b/docs/pki-server-admin-https.md new file mode 100644 index 0000000..6d2a554 --- /dev/null +++ b/docs/pki-server-admin-https.md @@ -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 16–128 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. diff --git a/docs/pki-server-loopback-example.json b/docs/pki-server-loopback-example.json new file mode 100644 index 0000000..ccbef4b --- /dev/null +++ b/docs/pki-server-loopback-example.json @@ -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": {} +} diff --git a/docs/pki-server-production-example.json b/docs/pki-server-production-example.json new file mode 100644 index 0000000..1061bc4 --- /dev/null +++ b/docs/pki-server-production-example.json @@ -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": {} +} diff --git a/pki-server/build.gradle b/pki-server/build.gradle index 874e872..cde2645 100644 --- a/pki-server/build.gradle +++ b/pki-server/build.gradle @@ -1,5 +1,6 @@ plugins { id 'buildlogic.java-library-conventions' + id 'application' id 'com.palantir.git-version' } @@ -10,6 +11,22 @@ dependencies { implementation project(':lib') implementation platform('tools.jackson:jackson-bom:3.1.5') implementation 'tools.jackson.core:jackson-core' + testImplementation 'org.bouncycastle:bcpkix-jdk18on:1.84' +} + +application { + mainClass = 'zeroecho.pki.server.PkiServerMain' + applicationName = 'zeroecho-pki-server' +} + +jar { + manifest { + attributes( + 'Main-Class': application.mainClass, + 'Implementation-Title': 'ZeroEcho PKI Server', + 'Implementation-Version': "${version}" + ) + } } javadoc { diff --git a/pki-server/src/main/java/zeroecho/pki/server/PkiHttpsServer.java b/pki-server/src/main/java/zeroecho/pki/server/PkiHttpsServer.java new file mode 100644 index 0000000..c076221 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/PkiHttpsServer.java @@ -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. + * + *

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

+ */ +@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; + + private PkiHttpsServer(PkiServerConfiguration configuration, ServerRealmContext realm, + PkiServerAuthenticator authenticator, PkiHttpsTransport transport, + AtomicReference 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 = 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, 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"); + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfiguration.java b/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfiguration.java new file mode 100644 index 0000000..6afb2f3 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfiguration.java @@ -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. + * + *

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

+ * + * @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 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 certificateSha256, Optional subjectPublicKeyInfoSha256, + Optional 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 digest(Optional 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 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"); + } + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfigurationCodec.java b/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfigurationCodec.java new file mode 100644 index 0000000..96d3ab2 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfigurationCodec.java @@ -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 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 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 signing = value.optionalObject("signing") + .map(PkiServerConfigurationCodec::signing); + List publishers = new ArrayList<>(); + for (PkiOperationValue item : value.list("publishers")) publishers.add(provider(Fields.of(item))); + List 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 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 result = new LinkedHashMap<>(); + for (Map.Entry 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 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 strings(List values) { + Set 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 fields; + private final Set consumed = new LinkedHashSet<>(); + + private Fields(Map 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 names) { if (!names.containsAll(fields.keySet())) + throw new IllegalArgumentException("Unknown configuration field"); } + String text(String name) { consumed.add(name); return text(require(name)); } + Optional 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 optionalObject(String name) { if (!fields.containsKey(name)) return Optional.empty(); + return Optional.of(object(name)); } + List list(String name) { consumed.add(name); PkiOperationValue value = require(name); + if (!(value instanceof PkiOperationValue.ListValue list)) throw type(); return list.values(); } + Map 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"); + } + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/PkiServerMain.java b/pki-server/src/main/java/zeroecho/pki/server/PkiServerMain.java new file mode 100644 index 0000000..3b00945 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/PkiServerMain.java @@ -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 + zeroecho-pki-server --validate-config --config + 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 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); + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerOperationGateway.java b/pki-server/src/main/java/zeroecho/pki/server/ServerOperationGateway.java index dd095a3..bfa0a68 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/ServerOperationGateway.java +++ b/pki-server/src/main/java/zeroecho/pki/server/ServerOperationGateway.java @@ -58,7 +58,7 @@ import zeroecho.pki.application.PkiResourceScopeResolver; * exactly once to the session executor, and preserves the returned outcome.

*/ @SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.FieldDeclarationsShouldBeAtStartOfClass", - "PMD.ExcessiveParameterList", "PMD.CyclomaticComplexity" }) + "PMD.ExcessiveParameterList", "PMD.CyclomaticComplexity", "PMD.AvoidInstantiatingObjectsInLoops" }) public final class ServerOperationGateway { /** * Complete transport-neutral request admission input. @@ -218,6 +218,92 @@ public final class ServerOperationGateway { return new Outcome.Executed(backend); } + /** + * Authorizes one non-PKI administrative control resource through the same + * default-deny engine used for typed operations. + * + * @param principalId authenticated persisted principal + * @param action exact control action + * @param resource exact realm control resource + * @param context safe closed-condition context + * @param correlationId bounded request correlation identity + * @return safe authorization decision + */ + public AuthorizationEngine.Decision authorizeControl(String principalId, Permission.Action action, + Permission.Resource resource, Permission.Context context, String correlationId) { + openCheck.run(); + Permission.requirePrincipal(principalId); + Permission.requireBounded(correlationId, 256, "correlation ID"); + SecurityPrincipal principal = control.requirePrincipal(principalId); + List 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 discoverableOperations(String principalId) { + openCheck.run(); + SecurityPrincipal principal = control.requirePrincipal(principalId); + List 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 grants(SecurityPrincipal principal) { + List 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 grants, + java.util.Set breakGlassIds) { + for (Permission.Grant grant : grants) { + if (grant.action() != descriptor.action() || grant.resourceType() != descriptor.resourceType()) { + continue; + } + Permission.Resource resource = new Permission.Resource(descriptor.resourceType(), grant.scope(), + Optional.empty(), Optional.empty()); + AuthorizationEngine.Decision decision = authorization.authorize(new AuthorizationEngine.Request( + realmId, exposure, principal, descriptor.action(), resource, Permission.Relationship.ANY, + descriptor.dataView(), Permission.Context.empty(), grants, breakGlassIds)); + if (decision.allowed()) return true; + } + return false; + } + private PkiOperationOutcome filterAuthorities(PkiOperationOutcome outcome, SecurityPrincipal principal, List grants, java.util.Set breakGlassIds, Permission.Context context) { if (!(outcome instanceof PkiOperationOutcome.Success success)) return outcome; diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerRealmContext.java b/pki-server/src/main/java/zeroecho/pki/server/ServerRealmContext.java index c7fc8d3..24fc041 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/ServerRealmContext.java +++ b/pki-server/src/main/java/zeroecho/pki/server/ServerRealmContext.java @@ -171,6 +171,32 @@ public final class ServerRealmContext implements AutoCloseable { public AuditorViews auditorViews() { requireOpen(); return auditorViews; } /** @return authorized typed-operation gateway */ public ServerOperationGateway gateway() { requireOpen(); return gateway; } + /** + * Resolves one persisted principal for transport authentication. + * + * @param principalId canonical principal identity + * @return persisted identity metadata without credentials or grants + * @throws IllegalArgumentException when the principal is unavailable + * @throws IllegalStateException when this realm is not open + */ + public SecurityPrincipal principal(String principalId) { + requireOpen(); + return control.requirePrincipal(principalId); + } + /** + * Records one transport-safe lifecycle or request classification through the + * shared realm audit authority. + * + * @param action stable transport action + * @param principalId authenticated principal or {@code system} + * @param safeDetails finite pre-redacted details + */ + public void auditTransport(String action, String principalId, Map safeDetails) { + requireOpen(); + Permission.requireBounded(action, 128, "audit action"); + Permission.requirePrincipal(principalId); + audit.record(action, principalId, Optional.empty(), Map.copyOf(safeDetails)); + } /** @return current lifecycle state */ public State state() { return state.get(); } diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/AdminHttpHandler.java b/pki-server/src/main/java/zeroecho/pki/server/http/AdminHttpHandler.java new file mode 100644 index 0000000..ff6f9a3 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/http/AdminHttpHandler.java @@ -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 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 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 values = realm.gateway().discoverableOperations(principalId).stream() + .map(AdminHttpHandler::descriptorValue).map(PkiOperationValue.class::cast).toList(); + Map 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 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 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> 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 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; + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/HttpOperationCodec.java b/pki-server/src/main/java/zeroecho/pki/server/http/HttpOperationCodec.java new file mode 100644 index 0000000..01e14d7 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/http/HttpOperationCodec.java @@ -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 approvalId, Optional requestedDeadline) { } + + private HttpOperationCodec() { + } + + static Decoded decode(String operationId, byte[] document, int maximumBytes, RealmId realmId, + java.util.function.Function 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 authority = root.optionalText("authorityId").map(PkiId::new); + Optional approval = root.optionalText("approvalId"); + approval.ifPresent(value -> requireId(value, "approval ID")); + Optional 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 objectId = objectId(operation); + Permission.Resource resource = new Permission.Resource(descriptor.resourceType(), scope, objectId, + Optional.empty()); + Optional 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 authority) { + Optional 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 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 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 fields; + private final Set consumed = new LinkedHashSet<>(); + private Fields(Map 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 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 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"); + } + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/HttpResponses.java b/pki-server/src/main/java/zeroecho/pki/server/http/HttpResponses.java new file mode 100644 index 0000000..16c599e --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/http/HttpResponses.java @@ -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) { } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/MutualTlsAuthenticator.java b/pki-server/src/main/java/zeroecho/pki/server/http/MutualTlsAuthenticator.java new file mode 100644 index 0000000..04cc195 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/http/MutualTlsAuthenticator.java @@ -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. + * + *

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.

+ */ +@SuppressWarnings("PMD") +public final class MutualTlsAuthenticator implements PkiServerAuthenticator { + private final List mappings; + private final Function 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 mappings, + Function 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 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) { } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/Pkcs12TlsProvider.java b/pki-server/src/main/java/zeroecho/pki/server/http/Pkcs12TlsProvider.java new file mode 100644 index 0000000..7379e8e --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/http/Pkcs12TlsProvider.java @@ -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. + * + *

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.

+ */ +@SuppressWarnings("PMD") +public final class Pkcs12TlsProvider implements PkiServerTlsProvider { + /** Stable explicit provider identity. */ + public static final String ID = "jsse-pkcs12"; + private static final Set 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(); + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/PkiHttpsTransport.java b/pki-server/src/main/java/zeroecho/pki/server/http/PkiHttpsTransport.java new file mode 100644 index 0000000..9879559 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/http/PkiHttpsTransport.java @@ -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. + * + *

The public server lifecycle uses this type only as an internal module boundary; + * request handlers, queues, permits and provider discovery remain package-private.

+ */ +@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(); + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/RequestIds.java b/pki-server/src/main/java/zeroecho/pki/server/http/RequestIds.java new file mode 100644 index 0000000..6abc007 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/http/RequestIds.java @@ -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 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(); + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/ServerRuntime.java b/pki-server/src/main/java/zeroecho/pki/server/http/ServerRuntime.java new file mode 100644 index 0000000..81bf117 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/http/ServerRuntime.java @@ -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 cancellations = ConcurrentHashMap.newKeySet(); + private final Set> 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(); + } + + Submitted submit(CancellableCallable task) { + if (!accepting.get()) throw new RejectedExecutionException("Server is not accepting operations"); + CancellationToken cancellation = new CancellationToken(); + cancellations.add(cancellation); + Future 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(Future 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 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; + } + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/StrictJson.java b/pki-server/src/main/java/zeroecho/pki/server/http/StrictJson.java new file mode 100644 index 0000000..c5bb556 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/http/StrictJson.java @@ -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 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 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 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); + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/TlsProviders.java b/pki-server/src/main/java/zeroecho/pki/server/http/TlsProviders.java new file mode 100644 index 0000000..a3abb4f --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/http/TlsProviders.java @@ -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 providers = new ArrayList<>(); + ServiceLoader.load(PkiServerTlsProvider.class, Objects.requireNonNull(loader, "loader")) + .forEach(providers::add); + providers.sort(Comparator.comparing(PkiServerTlsProvider::id)); + Set 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"); + } + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/spi/PkiServerAuthenticationContext.java b/pki-server/src/main/java/zeroecho/pki/server/spi/PkiServerAuthenticationContext.java new file mode 100644 index 0000000..a452730 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/spi/PkiServerAuthenticationContext.java @@ -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 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"); + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/spi/PkiServerAuthenticationResult.java b/pki-server/src/main/java/zeroecho/pki/server/spi/PkiServerAuthenticationResult.java new file mode 100644 index 0000000..4a08083 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/spi/PkiServerAuthenticationResult.java @@ -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 resolvedPrincipalId() { + return this instanceof Authenticated authenticated + ? Optional.of(authenticated.principalId()) : Optional.empty(); + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/spi/PkiServerAuthenticator.java b/pki-server/src/main/java/zeroecho/pki/server/spi/PkiServerAuthenticator.java new file mode 100644 index 0000000..98fb999 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/spi/PkiServerAuthenticator.java @@ -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. + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/spi/PkiServerTlsProvider.java b/pki-server/src/main/java/zeroecho/pki/server/spi/PkiServerTlsProvider.java new file mode 100644 index 0000000..db5516d --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/spi/PkiServerTlsProvider.java @@ -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. + * + *

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()}.

+ */ +@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; +} diff --git a/pki-server/src/main/resources/META-INF/services/zeroecho.pki.server.spi.PkiServerTlsProvider b/pki-server/src/main/resources/META-INF/services/zeroecho.pki.server.spi.PkiServerTlsProvider new file mode 100644 index 0000000..a27d05b --- /dev/null +++ b/pki-server/src/main/resources/META-INF/services/zeroecho.pki.server.spi.PkiServerTlsProvider @@ -0,0 +1 @@ +zeroecho.pki.server.http.Pkcs12TlsProvider diff --git a/pki-server/src/test/java/zeroecho/pki/server/HttpServerTestSupport.java b/pki-server/src/test/java/zeroecho/pki/server/HttpServerTestSupport.java new file mode 100644 index 0000000..a00f73a --- /dev/null +++ b/pki-server/src/test/java/zeroecho/pki/server/HttpServerTestSupport.java @@ -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) { } +} diff --git a/pki-server/src/test/java/zeroecho/pki/server/PkiHttpsServerTest.java b/pki-server/src/test/java/zeroecho/pki/server/PkiHttpsServerTest.java new file mode 100644 index 0000000..069aef3 --- /dev/null +++ b/pki-server/src/test/java/zeroecho/pki/server/PkiHttpsServerTest.java @@ -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 live = get(client, base.resolve("/health/live")); + assertEquals(200, live.statusCode()); + assertEquals(live.headers().firstValue("X-ZeroEcho-Request-Id").orElseThrow().length() >= 16, true); + HttpResponse ready = get(client, base.resolve("/health/ready")); + assertEquals(200, ready.statusCode()); + HttpResponse realm = get(client, base.resolve("/admin/v1/realm")); + assertEquals(200, realm.statusCode()); + assertTrue(realm.body().contains("production")); + HttpResponse operations = get(client, base.resolve("/admin/v1/operations")); + assertEquals(200, operations.statusCode()); + assertTrue(operations.body().contains("algorithm.binding.list")); + HttpResponse 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 hidden = get(client, base.resolve("/admin/v1/operations/credential.issue")); + assertEquals(404, hidden.statusCode()); + HttpResponse 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 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 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()); + } +} diff --git a/pki-server/src/test/java/zeroecho/pki/server/http/ServerRuntimeTest.java b/pki-server/src/test/java/zeroecho/pki/server/http/ServerRuntimeTest.java new file mode 100644 index 0000000..9f2733b --- /dev/null +++ b/pki-server/src/test/java/zeroecho/pki/server/http/ServerRuntimeTest.java @@ -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 first = runtime.submit(cancellation -> { + started.countDown(); + release.await(); + return Thread.currentThread().getName(); + }); + assertTrue(started.await(2, TimeUnit.SECONDS)); + ServerRuntime.Submitted 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 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)); + } +} diff --git a/pki-server/src/test/java/zeroecho/pki/server/http/TestTlsProvider.java b/pki-server/src/test/java/zeroecho/pki/server/http/TestTlsProvider.java new file mode 100644 index 0000000..50da30e --- /dev/null +++ b/pki-server/src/test/java/zeroecho/pki/server/http/TestTlsProvider.java @@ -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 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"); + } +} diff --git a/pki-server/src/test/resources/META-INF/services/zeroecho.pki.server.spi.PkiServerTlsProvider b/pki-server/src/test/resources/META-INF/services/zeroecho.pki.server.spi.PkiServerTlsProvider new file mode 100644 index 0000000..1e533fc --- /dev/null +++ b/pki-server/src/test/resources/META-INF/services/zeroecho.pki.server.spi.PkiServerTlsProvider @@ -0,0 +1 @@ +zeroecho.pki.server.http.TestTlsProvider