feat(pki-server): add secure administrative HTTPS API
Add the mutually authenticated administrative HTTPS server with strict typed JSON, bounded request execution, multi-authority authorization, approval enforcement, safe auditing and finite shutdown. Reuse one long-lived realm and PKI session without duplicating backend authority or operation semantics.
This commit is contained in:
208
pki-server/src/main/java/zeroecho/pki/server/PkiHttpsServer.java
Normal file
208
pki-server/src/main/java/zeroecho/pki/server/PkiHttpsServer.java
Normal file
@@ -0,0 +1,208 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.server;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import javax.net.ssl.SSLContext;
|
||||
|
||||
import zeroecho.pki.application.PkiSessionRuntimeDependencies;
|
||||
import zeroecho.pki.server.http.MutualTlsAuthenticator;
|
||||
import zeroecho.pki.server.http.PkiHttpsTransport;
|
||||
import zeroecho.pki.server.spi.PkiServerAuthenticator;
|
||||
|
||||
/**
|
||||
* Lifecycle owner for one bounded administrative mutual-TLS HTTPS server.
|
||||
*
|
||||
* <p>One instance owns one long-lived {@link ServerRealmContext} and therefore
|
||||
* one long-lived PKI session. It is thread-safe for lifecycle inspection and
|
||||
* idempotent close. HTTP handlers never construct sessions or execute operations
|
||||
* outside {@link ServerOperationGateway}.</p>
|
||||
*/
|
||||
@SuppressWarnings("PMD")
|
||||
public final class PkiHttpsServer implements AutoCloseable {
|
||||
/** Closed process lifecycle. */
|
||||
public enum State { NEW, STARTING, READY, QUIESCING, TERMINATED }
|
||||
|
||||
private final PkiServerConfiguration configuration;
|
||||
private final ServerRealmContext realm;
|
||||
private final PkiServerAuthenticator authenticator;
|
||||
private final PkiHttpsTransport transport;
|
||||
private final AtomicReference<State> state;
|
||||
|
||||
private PkiHttpsServer(PkiServerConfiguration configuration, ServerRealmContext realm,
|
||||
PkiServerAuthenticator authenticator, PkiHttpsTransport transport,
|
||||
AtomicReference<State> state) {
|
||||
this.configuration = configuration;
|
||||
this.realm = realm;
|
||||
this.authenticator = authenticator;
|
||||
this.transport = transport;
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts one production HTTPS server using explicitly configured capabilities.
|
||||
*
|
||||
* @param configuration validated server configuration
|
||||
* @param runtimeDependencies process-local PKI capabilities
|
||||
* @return fully ready lifecycle owner
|
||||
*/
|
||||
public static PkiHttpsServer start(PkiServerConfiguration configuration,
|
||||
PkiSessionRuntimeDependencies runtimeDependencies) {
|
||||
return start(configuration, runtimeDependencies, Clock.systemUTC(), new SecureRandom(),
|
||||
Thread.currentThread().getContextClassLoader());
|
||||
}
|
||||
|
||||
static PkiHttpsServer start(PkiServerConfiguration configuration,
|
||||
PkiSessionRuntimeDependencies runtimeDependencies, Clock clock, SecureRandom random,
|
||||
ClassLoader loader) {
|
||||
PkiServerConfiguration exact = Objects.requireNonNull(configuration, "configuration");
|
||||
Objects.requireNonNull(runtimeDependencies, "runtimeDependencies");
|
||||
Objects.requireNonNull(clock, "clock");
|
||||
Objects.requireNonNull(random, "random");
|
||||
AtomicReference<State> state = new AtomicReference<>(State.NEW);
|
||||
state.set(State.STARTING);
|
||||
ServerRealmContext realm = null;
|
||||
PkiServerAuthenticator authenticator = null;
|
||||
PkiHttpsTransport transport = null;
|
||||
try {
|
||||
SSLContext tls = PkiHttpsTransport.resolveTls(exact, loader);
|
||||
realm = ServerRealmContext.open(exact.realm(), runtimeDependencies, clock, random);
|
||||
realm.auditTransport("SERVER_STARTUP", "system", Map.of("state", "STARTING"));
|
||||
authenticator = new MutualTlsAuthenticator(exact.authentication().mappings(), realm::principal, clock);
|
||||
ServerRealmContext sharedRealm = realm;
|
||||
transport = PkiHttpsTransport.startResolved(exact, sharedRealm, authenticator, clock, random, tls,
|
||||
() -> state.get() == State.READY && sharedRealm.state() == ServerRealmContext.State.OPEN);
|
||||
state.set(State.READY);
|
||||
realm.auditTransport("SERVER_READY", "system", Map.of("state", "READY"));
|
||||
return new PkiHttpsServer(exact, realm, authenticator, transport, state);
|
||||
} catch (RuntimeException | Error primary) {
|
||||
closePartial(transport, realm, authenticator, state, primary);
|
||||
throw primary;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return current lifecycle state */
|
||||
public State state() {
|
||||
return state.get();
|
||||
}
|
||||
|
||||
/** @return actual bound address, including an allocated ephemeral port */
|
||||
public InetSocketAddress address() {
|
||||
if (state.get() == State.TERMINATED) throw new IllegalStateException("HTTPS server is terminated");
|
||||
return transport.address();
|
||||
}
|
||||
|
||||
/** @return the one lifecycle-owned realm context */
|
||||
public ServerRealmContext realm() {
|
||||
if (state.get() != State.READY) throw new IllegalStateException("HTTPS server is not ready");
|
||||
return realm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the sole finite process-shutdown bridge for the packaged launcher.
|
||||
* Tests and embedded integrations may instead close the server directly.
|
||||
*/
|
||||
public void installShutdownHook() {
|
||||
Runtime.getRuntime().addShutdownHook(transport.shutdownHook(() -> {
|
||||
try {
|
||||
close();
|
||||
} catch (Exception ignored) {
|
||||
// A shutdown hook has no safe caller to receive close failure details.
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Quiesces admission, terminates the listener and bounded executors, then
|
||||
* closes realm and authentication resources. Repeated calls are harmless.
|
||||
*/
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
if (!state.compareAndSet(State.READY, State.QUIESCING)
|
||||
&& !state.compareAndSet(State.STARTING, State.QUIESCING)) return;
|
||||
Throwable primary = null;
|
||||
try {
|
||||
realm.auditTransport("SERVER_SHUTDOWN", "system", Map.of("state", "QUIESCING"));
|
||||
} catch (Throwable failure) {
|
||||
primary = failure;
|
||||
}
|
||||
transport.quiesce();
|
||||
try {
|
||||
transport.shutdown(configuration.execution().gracefulShutdown());
|
||||
} catch (Throwable failure) {
|
||||
primary = suppress(primary, failure);
|
||||
}
|
||||
primary = close(realm, primary);
|
||||
primary = close(authenticator, primary);
|
||||
state.set(State.TERMINATED);
|
||||
rethrow(primary);
|
||||
}
|
||||
|
||||
private static void closePartial(PkiHttpsTransport transport, ServerRealmContext realm,
|
||||
PkiServerAuthenticator authenticator, AtomicReference<State> state, Throwable primary) {
|
||||
primary = close(transport, primary);
|
||||
primary = close(realm, primary);
|
||||
close(authenticator, primary);
|
||||
state.set(State.TERMINATED);
|
||||
}
|
||||
|
||||
private static Throwable close(AutoCloseable resource, Throwable primary) {
|
||||
if (resource == null) return primary;
|
||||
try {
|
||||
resource.close();
|
||||
} catch (Throwable failure) {
|
||||
return suppress(primary, failure);
|
||||
}
|
||||
return primary;
|
||||
}
|
||||
|
||||
private static Throwable suppress(Throwable primary, Throwable failure) {
|
||||
if (primary == null) return failure;
|
||||
if (primary != failure) primary.addSuppressed(failure);
|
||||
return primary;
|
||||
}
|
||||
|
||||
private static void rethrow(Throwable failure) throws Exception {
|
||||
if (failure == null) return;
|
||||
if (failure instanceof Exception exception) throw exception;
|
||||
if (failure instanceof Error error) throw error;
|
||||
throw new IllegalStateException("Server close failed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.server;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import zeroecho.pki.spi.ProviderConfig;
|
||||
|
||||
/**
|
||||
* Strict immutable configuration for one administrative HTTPS server instance.
|
||||
*
|
||||
* <p>The record owns no secret values. Provider properties may contain paths and
|
||||
* names of external environment-variable references and therefore must never be
|
||||
* rendered or logged. One instance belongs to exactly one realm and creates one
|
||||
* long-lived {@link ServerRealmContext}.</p>
|
||||
*
|
||||
* @param version server schema version
|
||||
* @param serverName safe bounded operational name
|
||||
* @param realm exact realm configuration
|
||||
* @param listener HTTPS listener policy
|
||||
* @param authentication strict client-certificate mappings
|
||||
* @param execution bounded execution and shutdown policy
|
||||
* @param runtime process-local capability references
|
||||
*/
|
||||
@SuppressWarnings("PMD")
|
||||
public record PkiServerConfiguration(int version, String serverName, ServerRealmConfiguration realm,
|
||||
Listener listener, Authentication authentication, Execution execution, RuntimeCapabilities runtime) {
|
||||
|
||||
/** Current server configuration schema. */
|
||||
public static final int CURRENT_VERSION = 1;
|
||||
|
||||
/** Validates all security-sensitive fields before resource allocation. */
|
||||
public PkiServerConfiguration {
|
||||
if (version != CURRENT_VERSION) throw new IllegalArgumentException("Unsupported server schema version");
|
||||
Permission.requireBounded(serverName, 128, "server name");
|
||||
Objects.requireNonNull(realm, "realm");
|
||||
Objects.requireNonNull(listener, "listener");
|
||||
Objects.requireNonNull(authentication, "authentication");
|
||||
Objects.requireNonNull(execution, "execution");
|
||||
Objects.requireNonNull(runtime, "runtime");
|
||||
if (!listener.clientCertificateRequired()) {
|
||||
throw new IllegalArgumentException("Administrative HTTPS requires client certificates");
|
||||
}
|
||||
if (authentication.mappings().isEmpty()) {
|
||||
throw new IllegalArgumentException("Administrative HTTPS requires principal mappings");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTPS listener configuration.
|
||||
*
|
||||
* @param address parsed canonical literal bind address
|
||||
* @param port TCP port, where zero requests an ephemeral test port
|
||||
* @param tlsProvider explicitly selected TLS material provider
|
||||
* @param clientCertificateRequired must be {@code true}
|
||||
* @param maximumHeaderBytes bounded aggregate decoded header bytes
|
||||
* @param maximumBodyBytes bounded request body bytes
|
||||
*/
|
||||
public record Listener(InetAddress address, int port, ProviderConfig tlsProvider,
|
||||
boolean clientCertificateRequired, int maximumHeaderBytes, int maximumBodyBytes) {
|
||||
/** Validates listener bounds and explicit TLS selection. */
|
||||
public Listener {
|
||||
Objects.requireNonNull(address, "address");
|
||||
if (port < 0 || port > 65_535) throw new IllegalArgumentException("Listener port is invalid");
|
||||
Objects.requireNonNull(tlsProvider, "tlsProvider");
|
||||
if (maximumHeaderBytes < 1_024 || maximumHeaderBytes > 1_048_576) {
|
||||
throw new IllegalArgumentException("Header bound is invalid");
|
||||
}
|
||||
if (maximumBodyBytes < 1_024 || maximumBodyBytes > 16_777_216) {
|
||||
throw new IllegalArgumentException("Body bound is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
/** @return exact socket address without DNS resolution */
|
||||
public InetSocketAddress socketAddress() {
|
||||
return new InetSocketAddress(address, port);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-certificate authentication mappings.
|
||||
*
|
||||
* @param mappings finite explicit immutable mappings
|
||||
*/
|
||||
public record Authentication(List<ClientCertificateMapping> mappings) {
|
||||
/** Validates mapping uniqueness and snapshots input order. */
|
||||
public Authentication {
|
||||
mappings = List.copyOf(Objects.requireNonNull(mappings, "mappings"));
|
||||
if (mappings.size() > 10_000) throw new IllegalArgumentException("Too many principal mappings");
|
||||
if (mappings.stream().map(ClientCertificateMapping::mappingId).distinct().count() != mappings.size()) {
|
||||
throw new IllegalArgumentException("Duplicate principal mapping identity");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One exact client-certificate mapping. All present commitments must match.
|
||||
*
|
||||
* @param mappingId stable mapping identity
|
||||
* @param principalId persisted server principal identity
|
||||
* @param certificateSha256 optional canonical certificate commitment
|
||||
* @param subjectPublicKeyInfoSha256 optional canonical SPKI commitment
|
||||
* @param issuerSerialSha256 optional issuer-DER and positive-serial commitment
|
||||
*/
|
||||
public record ClientCertificateMapping(String mappingId, String principalId,
|
||||
Optional<String> certificateSha256, Optional<String> subjectPublicKeyInfoSha256,
|
||||
Optional<String> issuerSerialSha256) {
|
||||
/** Validates stable identities and fixed-size lowercase commitments. */
|
||||
public ClientCertificateMapping {
|
||||
Permission.requireId(mappingId, "mapping");
|
||||
Permission.requirePrincipal(principalId);
|
||||
certificateSha256 = digest(certificateSha256, "certificateSha256");
|
||||
subjectPublicKeyInfoSha256 = digest(subjectPublicKeyInfoSha256, "subjectPublicKeyInfoSha256");
|
||||
issuerSerialSha256 = digest(issuerSerialSha256, "issuerSerialSha256");
|
||||
if (certificateSha256.isEmpty() && subjectPublicKeyInfoSha256.isEmpty()
|
||||
&& issuerSerialSha256.isEmpty()) {
|
||||
throw new IllegalArgumentException("Certificate mapping requires a cryptographic commitment");
|
||||
}
|
||||
}
|
||||
|
||||
private static Optional<String> digest(Optional<String> source, String name) {
|
||||
return Objects.requireNonNull(source, name).map(value -> {
|
||||
if (!value.matches("[0-9a-f]{64}")) throw new IllegalArgumentException(name + " is invalid");
|
||||
return value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded worker, queue, admission, deadline and shutdown policy.
|
||||
*
|
||||
* @param transportWorkers fixed HTTPS handler worker count
|
||||
* @param transportQueueCapacity bounded handler backlog
|
||||
* @param operationWorkers maximum concurrently executing gateway calls
|
||||
* @param operationQueueCapacity bounded waiting gateway calls
|
||||
* @param maximumAdmittedRequests maximum requests admitted across parsing and execution
|
||||
* @param defaultDeadline default request deadline
|
||||
* @param maximumDeadline hard caller deadline ceiling
|
||||
* @param gracefulShutdown graceful drain duration
|
||||
* @param forcedShutdown forced cancellation and termination duration
|
||||
*/
|
||||
public record Execution(int transportWorkers, int transportQueueCapacity, int operationWorkers,
|
||||
int operationQueueCapacity, int maximumAdmittedRequests, Duration defaultDeadline,
|
||||
Duration maximumDeadline, Duration gracefulShutdown, Duration forcedShutdown) {
|
||||
/** Validates finite positive bounds and duration ordering. */
|
||||
public Execution {
|
||||
bounded(transportWorkers, 1, 256, "transport worker count");
|
||||
bounded(transportQueueCapacity, 1, 65_536, "transport queue capacity");
|
||||
bounded(operationWorkers, 1, 256, "operation worker count");
|
||||
bounded(operationQueueCapacity, 1, 65_536, "operation queue capacity");
|
||||
bounded(maximumAdmittedRequests, 1, 131_072, "admitted request count");
|
||||
positive(defaultDeadline, Duration.ofHours(1), "default deadline");
|
||||
positive(maximumDeadline, Duration.ofHours(1), "maximum deadline");
|
||||
positive(gracefulShutdown, Duration.ofMinutes(5), "graceful shutdown");
|
||||
positive(forcedShutdown, Duration.ofMinutes(5), "forced shutdown");
|
||||
if (defaultDeadline.compareTo(maximumDeadline) > 0) {
|
||||
throw new IllegalArgumentException("Default deadline exceeds the maximum");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process-local capability references.
|
||||
*
|
||||
* @param keyUnlockEnvironmentVariable optional external unlock reference
|
||||
*/
|
||||
public record RuntimeCapabilities(Optional<String> keyUnlockEnvironmentVariable) {
|
||||
/** Validates the external reference without resolving its secret value. */
|
||||
public RuntimeCapabilities {
|
||||
keyUnlockEnvironmentVariable = Objects.requireNonNull(keyUnlockEnvironmentVariable,
|
||||
"keyUnlockEnvironmentVariable").map(value -> {
|
||||
if (!value.matches("[A-Z][A-Z0-9_]{0,127}")) {
|
||||
throw new IllegalArgumentException("Key-unlock environment reference is invalid");
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void bounded(int value, int minimum, int maximum, String name) {
|
||||
if (value < minimum || value > maximum) throw new IllegalArgumentException(name + " is invalid");
|
||||
}
|
||||
|
||||
private static void positive(Duration value, Duration maximum, String name) {
|
||||
Objects.requireNonNull(value, name);
|
||||
if (value.isZero() || value.isNegative() || value.compareTo(maximum) > 0) {
|
||||
throw new IllegalArgumentException(name + " is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.server;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.UnknownHostException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.application.PkiOperationValue;
|
||||
import zeroecho.pki.application.PkiSessionConfiguration;
|
||||
import zeroecho.pki.server.OperationSecurityDescriptors.ApprovalCategory;
|
||||
import zeroecho.pki.server.http.StrictJson;
|
||||
import zeroecho.pki.spi.ProviderConfig;
|
||||
import zeroecho.pki.spi.store.MetadataStoreId;
|
||||
|
||||
/** Strict version-one server configuration decoder with closed field sets. */
|
||||
@SuppressWarnings("PMD")
|
||||
public final class PkiServerConfigurationCodec {
|
||||
/** Maximum server configuration document size. */
|
||||
public static final int MAXIMUM_CONFIGURATION_BYTES = 1_048_576;
|
||||
|
||||
private PkiServerConfigurationCodec() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and validates one exact configuration without opening providers,
|
||||
* stores, listeners, audit sinks, or sessions.
|
||||
*
|
||||
* @param path configuration path
|
||||
* @return immutable validated configuration
|
||||
* @throws IOException when the bounded file cannot be read
|
||||
* @throws IllegalArgumentException when framing or semantics are invalid
|
||||
*/
|
||||
public static PkiServerConfiguration read(Path path) throws IOException {
|
||||
Path exact = path.toAbsolutePath().normalize();
|
||||
long size = Files.size(exact);
|
||||
if (size < 1 || size > MAXIMUM_CONFIGURATION_BYTES || Files.isSymbolicLink(exact)) {
|
||||
throw new IllegalArgumentException("Server configuration source is invalid");
|
||||
}
|
||||
return decode(Files.readAllBytes(exact));
|
||||
}
|
||||
|
||||
/** Decodes one bounded strict configuration document. */
|
||||
public static PkiServerConfiguration decode(byte[] document) {
|
||||
Fields root = Fields.of(StrictJson.parse(document, MAXIMUM_CONFIGURATION_BYTES));
|
||||
root.exact("version", "serverName", "realm", "listener", "authentication", "execution", "runtime");
|
||||
PkiServerConfiguration configuration = new PkiServerConfiguration(root.integer("version"),
|
||||
root.text("serverName"), realm(root.object("realm")), listener(root.object("listener")),
|
||||
authentication(root.object("authentication")), execution(root.object("execution")),
|
||||
runtime(root.object("runtime")));
|
||||
root.complete();
|
||||
return configuration;
|
||||
}
|
||||
|
||||
private static ServerRealmConfiguration realm(Fields value) {
|
||||
value.exact("realmId", "displayName", "authorityExposure", "authorizationCommitment",
|
||||
"approvalCommitment", "disclosureCommitment", "disclosureDefaults", "controlLog",
|
||||
"controlStoreId", "approvalPolicy", "pkiSession");
|
||||
AuthorityExposurePolicy exposure = exposure(value.object("authorityExposure"));
|
||||
ApprovalService.Policy approval = approval(value.object("approvalPolicy"));
|
||||
String approvalCommitment = value.text("approvalCommitment");
|
||||
if (!approval.commitment().equals(approvalCommitment)) {
|
||||
throw new IllegalArgumentException("Approval policy commitment does not match");
|
||||
}
|
||||
ServerRealmConfiguration result = new ServerRealmConfiguration(new RealmId(value.text("realmId")),
|
||||
value.text("displayName"), session(value.object("pkiSession")), exposure,
|
||||
value.text("authorizationCommitment"), approvalCommitment, value.text("disclosureCommitment"),
|
||||
disclosure(value.object("disclosureDefaults")), Path.of(value.text("controlLog")),
|
||||
new MetadataStoreId(value.text("controlStoreId")), Map.of(ApprovalCategory.HIGH_RISK, approval));
|
||||
value.complete();
|
||||
return result;
|
||||
}
|
||||
|
||||
private static AuthorityExposurePolicy exposure(Fields value) {
|
||||
value.exact("mode", "authorityIds", "creationPermitted");
|
||||
Set<PkiId> authorities = new LinkedHashSet<>();
|
||||
for (PkiOperationValue item : value.list("authorityIds")) authorities.add(new PkiId(Fields.text(item)));
|
||||
AuthorityExposurePolicy result = new AuthorityExposurePolicy(
|
||||
AuthorityExposurePolicy.Mode.valueOf(value.text("mode")), authorities,
|
||||
value.bool("creationPermitted"));
|
||||
value.complete();
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ApprovalService.Policy approval(Fields value) {
|
||||
value.exact("policyId", "threshold", "eligibleApprovers", "requiredRoleTemplateIds",
|
||||
"requesterSeparation", "lifetimeMillis", "justificationRequired");
|
||||
ApprovalService.Policy result = new ApprovalService.Policy(value.text("policyId"),
|
||||
value.integer("threshold"), strings(value.list("eligibleApprovers")),
|
||||
strings(value.list("requiredRoleTemplateIds")), value.bool("requesterSeparation"),
|
||||
Duration.ofMillis(value.longValue("lifetimeMillis")), value.bool("justificationRequired"));
|
||||
value.complete();
|
||||
return result;
|
||||
}
|
||||
|
||||
private static DisclosureService.Defaults disclosure(Fields value) {
|
||||
value.exact("rootCa", "intermediateCa", "caChain", "crl", "leaf", "sensitiveLeaf");
|
||||
DisclosureService.Defaults result = new DisclosureService.Defaults(policy(value.text("rootCa")),
|
||||
policy(value.text("intermediateCa")), policy(value.text("caChain")), policy(value.text("crl")),
|
||||
policy(value.text("leaf")), policy(value.text("sensitiveLeaf")));
|
||||
value.complete();
|
||||
return result;
|
||||
}
|
||||
|
||||
private static DisclosureService.Policy policy(String value) {
|
||||
return DisclosureService.Policy.valueOf(value);
|
||||
}
|
||||
|
||||
private static PkiSessionConfiguration session(Fields value) {
|
||||
Set<String> allowed = Set.of("version", "store", "audit", "signing", "publishers", "bindingProviders");
|
||||
value.allowed(allowed);
|
||||
int version = value.integer("version");
|
||||
ProviderConfig store = provider(value.object("store"));
|
||||
ProviderConfig audit = provider(value.object("audit"));
|
||||
Optional<PkiSessionConfiguration.SigningConfiguration> signing = value.optionalObject("signing")
|
||||
.map(PkiServerConfigurationCodec::signing);
|
||||
List<ProviderConfig> publishers = new ArrayList<>();
|
||||
for (PkiOperationValue item : value.list("publishers")) publishers.add(provider(Fields.of(item)));
|
||||
List<PkiSessionConfiguration.BindingProviderConfiguration> bindings = new ArrayList<>();
|
||||
for (PkiOperationValue item : value.list("bindingProviders")) bindings.add(binding(Fields.of(item)));
|
||||
value.complete();
|
||||
return new PkiSessionConfiguration(version, store, audit, signing, publishers, bindings);
|
||||
}
|
||||
|
||||
private static PkiSessionConfiguration.SigningConfiguration signing(Fields value) {
|
||||
Set<String> allowed = Set.of("workflow", "framework", "busPath", "signatureAlgorithm",
|
||||
"signingTtlMillis", "unlockEnvironmentVariable", "certificateSignatureBinding",
|
||||
"crlSignatureBinding", "subjectPublicKeyBinding");
|
||||
value.allowed(allowed);
|
||||
PkiSessionConfiguration.SigningConfiguration result = new PkiSessionConfiguration.SigningConfiguration(
|
||||
provider(value.object("workflow")), provider(value.object("framework")), value.text("busPath"),
|
||||
value.text("signatureAlgorithm"), Duration.ofMillis(value.longValue("signingTtlMillis")),
|
||||
value.optionalText("unlockEnvironmentVariable"), value.optionalText("certificateSignatureBinding"),
|
||||
value.optionalText("crlSignatureBinding"), value.optionalText("subjectPublicKeyBinding"));
|
||||
value.complete();
|
||||
return result;
|
||||
}
|
||||
|
||||
private static PkiSessionConfiguration.BindingProviderConfiguration binding(Fields value) {
|
||||
value.allowed(Set.of("providerId", "authorizedOidRoots", "expectedBindingSetVersion"));
|
||||
PkiSessionConfiguration.BindingProviderConfiguration result =
|
||||
new PkiSessionConfiguration.BindingProviderConfiguration(value.text("providerId"),
|
||||
List.copyOf(strings(value.list("authorizedOidRoots"))),
|
||||
value.optionalText("expectedBindingSetVersion"));
|
||||
value.complete();
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ProviderConfig provider(Fields value) {
|
||||
value.exact("id", "properties");
|
||||
Fields properties = value.object("properties");
|
||||
Map<String, String> result = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, PkiOperationValue> item : properties.remaining().entrySet()) {
|
||||
result.put(item.getKey(), Fields.text(item.getValue()));
|
||||
}
|
||||
properties.consumeAll();
|
||||
ProviderConfig provider = new ProviderConfig(value.text("id"), result);
|
||||
value.complete();
|
||||
return provider;
|
||||
}
|
||||
|
||||
private static PkiServerConfiguration.Listener listener(Fields value) {
|
||||
value.exact("address", "port", "tlsProvider", "clientCertificateRequired", "maximumHeaderBytes",
|
||||
"maximumBodyBytes");
|
||||
PkiServerConfiguration.Listener result = new PkiServerConfiguration.Listener(
|
||||
address(value.text("address")), value.integer("port"), provider(value.object("tlsProvider")),
|
||||
value.bool("clientCertificateRequired"), value.integer("maximumHeaderBytes"),
|
||||
value.integer("maximumBodyBytes"));
|
||||
value.complete();
|
||||
return result;
|
||||
}
|
||||
|
||||
private static PkiServerConfiguration.Authentication authentication(Fields value) {
|
||||
value.exact("mappings");
|
||||
List<PkiServerConfiguration.ClientCertificateMapping> mappings = new ArrayList<>();
|
||||
for (PkiOperationValue item : value.list("mappings")) {
|
||||
Fields mapping = Fields.of(item);
|
||||
mapping.allowed(Set.of("mappingId", "principalId", "certificateSha256",
|
||||
"subjectPublicKeyInfoSha256", "issuerSerialSha256"));
|
||||
mappings.add(new PkiServerConfiguration.ClientCertificateMapping(mapping.text("mappingId"),
|
||||
mapping.text("principalId"), mapping.optionalText("certificateSha256"),
|
||||
mapping.optionalText("subjectPublicKeyInfoSha256"),
|
||||
mapping.optionalText("issuerSerialSha256")));
|
||||
mapping.complete();
|
||||
}
|
||||
value.complete();
|
||||
return new PkiServerConfiguration.Authentication(mappings);
|
||||
}
|
||||
|
||||
private static PkiServerConfiguration.Execution execution(Fields value) {
|
||||
value.exact("transportWorkers", "transportQueueCapacity", "operationWorkers",
|
||||
"operationQueueCapacity", "maximumAdmittedRequests", "defaultDeadlineMillis",
|
||||
"maximumDeadlineMillis", "gracefulShutdownMillis", "forcedShutdownMillis");
|
||||
PkiServerConfiguration.Execution result = new PkiServerConfiguration.Execution(
|
||||
value.integer("transportWorkers"), value.integer("transportQueueCapacity"),
|
||||
value.integer("operationWorkers"), value.integer("operationQueueCapacity"),
|
||||
value.integer("maximumAdmittedRequests"), Duration.ofMillis(value.longValue("defaultDeadlineMillis")),
|
||||
Duration.ofMillis(value.longValue("maximumDeadlineMillis")),
|
||||
Duration.ofMillis(value.longValue("gracefulShutdownMillis")),
|
||||
Duration.ofMillis(value.longValue("forcedShutdownMillis")));
|
||||
value.complete();
|
||||
return result;
|
||||
}
|
||||
|
||||
private static PkiServerConfiguration.RuntimeCapabilities runtime(Fields value) {
|
||||
value.allowed(Set.of("keyUnlockEnvironmentVariable"));
|
||||
PkiServerConfiguration.RuntimeCapabilities result = new PkiServerConfiguration.RuntimeCapabilities(
|
||||
value.optionalText("keyUnlockEnvironmentVariable"));
|
||||
value.complete();
|
||||
return result;
|
||||
}
|
||||
|
||||
private static InetAddress address(String value) {
|
||||
if (!value.matches("[0-9A-Fa-f:.]{2,64}") || value.contains("..")) {
|
||||
throw new IllegalArgumentException("Listener address must be a canonical IP literal");
|
||||
}
|
||||
try {
|
||||
InetAddress result = InetAddress.getByName(value);
|
||||
if (!result.getHostAddress().equalsIgnoreCase(value)
|
||||
&& !(result.isLoopbackAddress() && "::1".equals(value))) {
|
||||
throw new IllegalArgumentException("Listener address is not canonical");
|
||||
}
|
||||
return result;
|
||||
} catch (UnknownHostException failure) {
|
||||
throw new IllegalArgumentException("Listener address is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private static Set<String> strings(List<PkiOperationValue> values) {
|
||||
Set<String> result = new LinkedHashSet<>();
|
||||
for (PkiOperationValue value : values) {
|
||||
if (!result.add(Fields.text(value))) throw new IllegalArgumentException("Duplicate string value");
|
||||
}
|
||||
return Set.copyOf(result);
|
||||
}
|
||||
|
||||
/** Strict consumed-field view over one JSON object. */
|
||||
private static final class Fields {
|
||||
private final Map<String, PkiOperationValue> fields;
|
||||
private final Set<String> consumed = new LinkedHashSet<>();
|
||||
|
||||
private Fields(Map<String, PkiOperationValue> fields) { this.fields = fields; }
|
||||
static Fields of(PkiOperationValue value) {
|
||||
if (!(value instanceof PkiOperationValue.ObjectValue object)) {
|
||||
throw new IllegalArgumentException("Configuration value must be an object");
|
||||
}
|
||||
return new Fields(object.fields());
|
||||
}
|
||||
void exact(String... names) { allowed(Set.of(names)); if (fields.size() != names.length)
|
||||
throw new IllegalArgumentException("Configuration fields are incomplete"); }
|
||||
void allowed(Set<String> names) { if (!names.containsAll(fields.keySet()))
|
||||
throw new IllegalArgumentException("Unknown configuration field"); }
|
||||
String text(String name) { consumed.add(name); return text(require(name)); }
|
||||
Optional<String> optionalText(String name) { if (!fields.containsKey(name)) return Optional.empty();
|
||||
return Optional.of(text(name)); }
|
||||
int integer(String name) { long value = longValue(name); return Math.toIntExact(value); }
|
||||
long longValue(String name) { consumed.add(name); PkiOperationValue value = require(name);
|
||||
if (!(value instanceof PkiOperationValue.IntegerValue integer)) throw type(); return integer.value(); }
|
||||
boolean bool(String name) { consumed.add(name); PkiOperationValue value = require(name);
|
||||
if (!(value instanceof PkiOperationValue.BooleanValue bool)) throw type(); return bool.value(); }
|
||||
Fields object(String name) { consumed.add(name); return of(require(name)); }
|
||||
Optional<Fields> optionalObject(String name) { if (!fields.containsKey(name)) return Optional.empty();
|
||||
return Optional.of(object(name)); }
|
||||
List<PkiOperationValue> list(String name) { consumed.add(name); PkiOperationValue value = require(name);
|
||||
if (!(value instanceof PkiOperationValue.ListValue list)) throw type(); return list.values(); }
|
||||
Map<String, PkiOperationValue> remaining() { return fields; }
|
||||
void consumeAll() { consumed.addAll(fields.keySet()); }
|
||||
void complete() { if (!consumed.equals(fields.keySet())) throw new IllegalArgumentException(
|
||||
"Configuration fields were not consumed"); }
|
||||
private PkiOperationValue require(String name) { PkiOperationValue value = fields.get(name);
|
||||
if (value == null) throw new IllegalArgumentException("Required configuration field is missing");
|
||||
return value; }
|
||||
static String text(PkiOperationValue value) { if (!(value instanceof PkiOperationValue.Text text))
|
||||
throw type(); return text.value(); }
|
||||
private static IllegalArgumentException type() {
|
||||
return new IllegalArgumentException("Configuration field type is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
131
pki-server/src/main/java/zeroecho/pki/server/PkiServerMain.java
Normal file
131
pki-server/src/main/java/zeroecho/pki/server/PkiServerMain.java
Normal file
@@ -0,0 +1,131 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.server;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
|
||||
import zeroecho.core.spi.KeyringUnlockProvider;
|
||||
import zeroecho.core.storage.KeyringPassword;
|
||||
import zeroecho.pki.application.PkiSessionRuntimeDependencies;
|
||||
|
||||
/** Production command-line entry point for the administrative HTTPS server. */
|
||||
@SuppressWarnings("PMD")
|
||||
public final class PkiServerMain {
|
||||
private static final String HELP = """
|
||||
Usage: zeroecho-pki-server --config <server-config.json>
|
||||
zeroecho-pki-server --validate-config --config <server-config.json>
|
||||
zeroecho-pki-server --help
|
||||
zeroecho-pki-server --version
|
||||
""";
|
||||
|
||||
private PkiServerMain() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs configuration validation or starts the long-lived server process.
|
||||
*
|
||||
* @param arguments exact process arguments
|
||||
*/
|
||||
public static void main(String[] arguments) {
|
||||
int result = run(arguments);
|
||||
if (result != 0) System.exit(result);
|
||||
}
|
||||
|
||||
static int run(String[] arguments) {
|
||||
try {
|
||||
if (arguments.length == 1 && "--help".equals(arguments[0])) {
|
||||
System.out.print(HELP);
|
||||
return 0;
|
||||
}
|
||||
if (arguments.length == 1 && "--version".equals(arguments[0])) {
|
||||
String version = Optional.ofNullable(PkiServerMain.class.getPackage().getImplementationVersion())
|
||||
.orElse("development");
|
||||
System.out.println("zeroecho-pki-server " + version);
|
||||
return 0;
|
||||
}
|
||||
boolean validate = Arrays.asList(arguments).contains("--validate-config");
|
||||
Path path = configurationPath(arguments, validate);
|
||||
PkiServerConfiguration configuration = PkiServerConfigurationCodec.read(path);
|
||||
if (validate) {
|
||||
System.out.println("configuration valid");
|
||||
return 0;
|
||||
}
|
||||
PkiSessionRuntimeDependencies dependencies = runtimeDependencies(configuration);
|
||||
PkiHttpsServer server = PkiHttpsServer.start(configuration, dependencies);
|
||||
server.installShutdownHook();
|
||||
new CountDownLatch(1).await();
|
||||
return 0;
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
return 0;
|
||||
} catch (IOException | IllegalArgumentException failure) {
|
||||
System.err.println("Server configuration is invalid");
|
||||
return 2;
|
||||
} catch (RuntimeException failure) {
|
||||
System.err.println("Server startup failed");
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
private static Path configurationPath(String[] arguments, boolean validate) {
|
||||
int expected = validate ? 3 : 2;
|
||||
if (arguments.length != expected) throw new IllegalArgumentException("Invalid invocation");
|
||||
int configIndex = validate && "--validate-config".equals(arguments[0]) ? 1 : 0;
|
||||
if (!"--config".equals(arguments[configIndex])) throw new IllegalArgumentException("Invalid invocation");
|
||||
if (validate && !"--validate-config".equals(arguments[2])) {
|
||||
if (!"--validate-config".equals(arguments[0])) throw new IllegalArgumentException("Invalid invocation");
|
||||
}
|
||||
return Path.of(arguments[configIndex + 1]);
|
||||
}
|
||||
|
||||
private static PkiSessionRuntimeDependencies runtimeDependencies(PkiServerConfiguration configuration) {
|
||||
Optional<String> environment = configuration.runtime().keyUnlockEnvironmentVariable();
|
||||
if (environment.isEmpty()) return PkiSessionRuntimeDependencies.none();
|
||||
KeyringUnlockProvider provider = () -> {
|
||||
String value = System.getenv(environment.orElseThrow());
|
||||
if (value == null || value.isEmpty()) throw new IOException("Configured unlock source is unavailable");
|
||||
char[] secret = value.toCharArray();
|
||||
try {
|
||||
return new KeyringPassword(secret);
|
||||
} finally {
|
||||
Arrays.fill(secret, '\0');
|
||||
}
|
||||
};
|
||||
return PkiSessionRuntimeDependencies.withKeyringUnlockProvider(provider);
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ import zeroecho.pki.application.PkiResourceScopeResolver;
|
||||
* exactly once to the session executor, and preserves the returned outcome.</p>
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.FieldDeclarationsShouldBeAtStartOfClass",
|
||||
"PMD.ExcessiveParameterList", "PMD.CyclomaticComplexity" })
|
||||
"PMD.ExcessiveParameterList", "PMD.CyclomaticComplexity", "PMD.AvoidInstantiatingObjectsInLoops" })
|
||||
public final class ServerOperationGateway {
|
||||
/**
|
||||
* Complete transport-neutral request admission input.
|
||||
@@ -218,6 +218,92 @@ public final class ServerOperationGateway {
|
||||
return new Outcome.Executed(backend);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorizes one non-PKI administrative control resource through the same
|
||||
* default-deny engine used for typed operations.
|
||||
*
|
||||
* @param principalId authenticated persisted principal
|
||||
* @param action exact control action
|
||||
* @param resource exact realm control resource
|
||||
* @param context safe closed-condition context
|
||||
* @param correlationId bounded request correlation identity
|
||||
* @return safe authorization decision
|
||||
*/
|
||||
public AuthorizationEngine.Decision authorizeControl(String principalId, Permission.Action action,
|
||||
Permission.Resource resource, Permission.Context context, String correlationId) {
|
||||
openCheck.run();
|
||||
Permission.requirePrincipal(principalId);
|
||||
Permission.requireBounded(correlationId, 256, "correlation ID");
|
||||
SecurityPrincipal principal = control.requirePrincipal(principalId);
|
||||
List<Permission.Grant> grants = grants(principal);
|
||||
BreakGlassService.ActiveGrants emergency = breakGlass.activeFor(principalId);
|
||||
grants.addAll(emergency.grants());
|
||||
AuthorizationEngine.Decision decision = authorization.authorize(new AuthorizationEngine.Request(realmId,
|
||||
exposure, principal, action, resource, Permission.Relationship.ANY,
|
||||
Permission.DataView.METADATA_REDACTED, context, grants, emergency.grantIds()));
|
||||
audit.record(decision.allowed() ? "AUTHORIZATION_ALLOW" : "AUTHORIZATION_DENY", principalId,
|
||||
Optional.empty(), Map.of("action", action.name(), "code", decision.code().name(),
|
||||
"correlation", correlationId));
|
||||
if (decision.usedBreakGlass()) breakGlass.auditUse(principalId);
|
||||
return decision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns operation descriptors discoverable by one principal without
|
||||
* returning grant scopes or protected resource identities.
|
||||
*
|
||||
* @param principalId authenticated persisted principal
|
||||
* @return descriptors ordered by stable operation identity
|
||||
*/
|
||||
public List<OperationSecurityDescriptors.Descriptor> discoverableOperations(String principalId) {
|
||||
openCheck.run();
|
||||
SecurityPrincipal principal = control.requirePrincipal(principalId);
|
||||
List<Permission.Grant> grants = grants(principal);
|
||||
BreakGlassService.ActiveGrants emergency = breakGlass.activeFor(principalId);
|
||||
grants.addAll(emergency.grants());
|
||||
return descriptors.descriptors().values().stream()
|
||||
.filter(descriptor -> discoverable(descriptor, principal, grants, emergency.grantIds()))
|
||||
.sorted(java.util.Comparator.comparing(OperationSecurityDescriptors.Descriptor::operationId))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the existing security descriptor for a decoded typed operation.
|
||||
*
|
||||
* @param operation decoded transport-neutral operation
|
||||
* @return immutable descriptor from the sole gateway descriptor authority
|
||||
* @throws SecurityException when the operation is not remotely exposed
|
||||
*/
|
||||
public OperationSecurityDescriptors.Descriptor descriptor(PkiOperation operation) {
|
||||
openCheck.run();
|
||||
return descriptors.require(operation);
|
||||
}
|
||||
|
||||
private List<Permission.Grant> grants(SecurityPrincipal principal) {
|
||||
List<Permission.Grant> grants = new ArrayList<>(control.grantsFor(principal.principalId()));
|
||||
for (RoleTemplateCatalog.Assignment assignment : control.assignmentsFor(principal.principalId())) {
|
||||
grants.addAll(roles.instantiate(assignment));
|
||||
}
|
||||
return grants;
|
||||
}
|
||||
|
||||
private boolean discoverable(OperationSecurityDescriptors.Descriptor descriptor,
|
||||
SecurityPrincipal principal, List<Permission.Grant> grants,
|
||||
java.util.Set<String> breakGlassIds) {
|
||||
for (Permission.Grant grant : grants) {
|
||||
if (grant.action() != descriptor.action() || grant.resourceType() != descriptor.resourceType()) {
|
||||
continue;
|
||||
}
|
||||
Permission.Resource resource = new Permission.Resource(descriptor.resourceType(), grant.scope(),
|
||||
Optional.empty(), Optional.empty());
|
||||
AuthorizationEngine.Decision decision = authorization.authorize(new AuthorizationEngine.Request(
|
||||
realmId, exposure, principal, descriptor.action(), resource, Permission.Relationship.ANY,
|
||||
descriptor.dataView(), Permission.Context.empty(), grants, breakGlassIds));
|
||||
if (decision.allowed()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private PkiOperationOutcome filterAuthorities(PkiOperationOutcome outcome, SecurityPrincipal principal,
|
||||
List<Permission.Grant> grants, java.util.Set<String> breakGlassIds, Permission.Context context) {
|
||||
if (!(outcome instanceof PkiOperationOutcome.Success success)) return outcome;
|
||||
|
||||
@@ -171,6 +171,32 @@ public final class ServerRealmContext implements AutoCloseable {
|
||||
public AuditorViews auditorViews() { requireOpen(); return auditorViews; }
|
||||
/** @return authorized typed-operation gateway */
|
||||
public ServerOperationGateway gateway() { requireOpen(); return gateway; }
|
||||
/**
|
||||
* Resolves one persisted principal for transport authentication.
|
||||
*
|
||||
* @param principalId canonical principal identity
|
||||
* @return persisted identity metadata without credentials or grants
|
||||
* @throws IllegalArgumentException when the principal is unavailable
|
||||
* @throws IllegalStateException when this realm is not open
|
||||
*/
|
||||
public SecurityPrincipal principal(String principalId) {
|
||||
requireOpen();
|
||||
return control.requirePrincipal(principalId);
|
||||
}
|
||||
/**
|
||||
* Records one transport-safe lifecycle or request classification through the
|
||||
* shared realm audit authority.
|
||||
*
|
||||
* @param action stable transport action
|
||||
* @param principalId authenticated principal or {@code system}
|
||||
* @param safeDetails finite pre-redacted details
|
||||
*/
|
||||
public void auditTransport(String action, String principalId, Map<String, String> safeDetails) {
|
||||
requireOpen();
|
||||
Permission.requireBounded(action, 128, "audit action");
|
||||
Permission.requirePrincipal(principalId);
|
||||
audit.record(action, principalId, Optional.empty(), Map.copyOf(safeDetails));
|
||||
}
|
||||
/** @return current lifecycle state */
|
||||
public State state() { return state.get(); }
|
||||
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.server.http;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
import javax.net.ssl.SSLPeerUnverifiedException;
|
||||
|
||||
import com.sun.net.httpserver.Headers;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
import com.sun.net.httpserver.HttpsExchange;
|
||||
|
||||
import zeroecho.pki.application.PkiOperationValue;
|
||||
import zeroecho.pki.server.AuthorizationEngine;
|
||||
import zeroecho.pki.server.OperationSecurityDescriptors;
|
||||
import zeroecho.pki.server.Permission;
|
||||
import zeroecho.pki.server.PkiServerConfiguration;
|
||||
import zeroecho.pki.server.ServerOperationGateway;
|
||||
import zeroecho.pki.server.ServerRealmContext;
|
||||
import zeroecho.pki.server.spi.PkiServerAuthenticationContext;
|
||||
import zeroecho.pki.server.spi.PkiServerAuthenticationResult;
|
||||
import zeroecho.pki.server.spi.PkiServerAuthenticator;
|
||||
|
||||
/** Single exact-path HTTPS adapter; all PKI execution delegates to the gateway. */
|
||||
@SuppressWarnings("PMD")
|
||||
final class AdminHttpHandler implements HttpHandler {
|
||||
private static final String JSON = "application/json; charset=utf-8";
|
||||
private final PkiServerConfiguration configuration;
|
||||
private final ServerRealmContext realm;
|
||||
private final PkiServerAuthenticator authenticator;
|
||||
private final ServerRuntime runtime;
|
||||
private final Clock clock;
|
||||
private final RequestIds requestIds;
|
||||
private final BooleanSupplier ready;
|
||||
|
||||
AdminHttpHandler(PkiServerConfiguration configuration, ServerRealmContext realm,
|
||||
PkiServerAuthenticator authenticator, ServerRuntime runtime, Clock clock,
|
||||
RequestIds requestIds, BooleanSupplier ready) {
|
||||
this.configuration = configuration;
|
||||
this.realm = realm;
|
||||
this.authenticator = authenticator;
|
||||
this.runtime = runtime;
|
||||
this.clock = clock;
|
||||
this.requestIds = requestIds;
|
||||
this.ready = ready;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(HttpExchange exchange) throws IOException {
|
||||
String requestId = "unavailable-request";
|
||||
HttpResponses.Response response;
|
||||
boolean admitted = false;
|
||||
try {
|
||||
requireHeadersBounded(exchange.getRequestHeaders());
|
||||
requestId = requestIds.resolve(exchange.getRequestHeaders().get(RequestIds.HEADER));
|
||||
URI uri = exchange.getRequestURI();
|
||||
if (uri.getRawQuery() != null || uri.getRawFragment() != null) throw malformed(requestId, "route");
|
||||
String path = uri.getPath();
|
||||
if ("/health/live".equals(path)) {
|
||||
response = requireMethod(exchange, "GET")
|
||||
? HttpResponses.minimal(200, requestId, "health.live", "LIVE")
|
||||
: method(requestId, "health.live");
|
||||
} else if ("/health/ready".equals(path)) {
|
||||
boolean isReady = ready.getAsBoolean();
|
||||
response = requireMethod(exchange, "GET")
|
||||
? HttpResponses.minimal(isReady ? 200 : 503, requestId,
|
||||
"health.ready", isReady ? "READY" : "NOT_READY")
|
||||
: method(requestId, "health.ready");
|
||||
} else {
|
||||
if (!runtime.tryAdmit()) {
|
||||
realm.auditTransport("REQUEST_REJECTED", "system", Map.of("request", requestId,
|
||||
"classification", "ADMISSION_REJECTED"));
|
||||
response = HttpResponses.failure(429, requestId, "request", "OVERLOADED",
|
||||
"ADMISSION_REJECTED", "ADMISSION_REJECTED", false, false);
|
||||
} else {
|
||||
admitted = true;
|
||||
realm.auditTransport("REQUEST_ADMITTED", "system", Map.of("request", requestId));
|
||||
response = admin((HttpsExchange) exchange, path, requestId);
|
||||
}
|
||||
}
|
||||
} catch (TransportFailure failure) {
|
||||
response = failure.response();
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
response = HttpResponses.failure(400, requestId, "request", "VALIDATION_FAILURE",
|
||||
"MALFORMED_REQUEST", "MALFORMED_REQUEST", false, false);
|
||||
} catch (RuntimeException failure) {
|
||||
response = HttpResponses.failure(500, requestId, "request", "INTERNAL_FAILURE",
|
||||
"SAFE_INTERNAL_FAILURE", "SAFE_INTERNAL_FAILURE", false, false);
|
||||
} finally {
|
||||
if (admitted) runtime.releaseAdmission();
|
||||
}
|
||||
send(exchange, requestId, response);
|
||||
}
|
||||
|
||||
private HttpResponses.Response admin(HttpsExchange exchange, String path, String requestId) throws IOException {
|
||||
PkiServerAuthenticationResult authentication = authenticate(exchange, requestId);
|
||||
if (!(authentication instanceof PkiServerAuthenticationResult.Authenticated authenticated)) {
|
||||
realm.auditTransport("TLS_AUTHENTICATION_FAILURE", "system", Map.of("request", requestId));
|
||||
return HttpResponses.failure(401, requestId, "authentication", "AUTHENTICATION_FAILURE",
|
||||
"CLIENT_AUTHENTICATION_FAILED", "CLIENT_AUTHENTICATION_FAILED", false, false);
|
||||
}
|
||||
String principalId = authenticated.principalId();
|
||||
realm.auditTransport("TLS_AUTHENTICATION_SUCCESS", principalId, Map.of("request", requestId));
|
||||
if ("/admin/v1/realm".equals(path)) return realm(exchange, requestId, principalId);
|
||||
if ("/admin/v1/operations".equals(path)) return operations(exchange, requestId, principalId);
|
||||
String prefix = "/admin/v1/operations/";
|
||||
if (!path.startsWith(prefix) || path.length() == prefix.length()) return notFound(requestId);
|
||||
String operationId = path.substring(prefix.length());
|
||||
if (!operationId.matches("[a-z][a-z0-9.]{2,127}")) return notFound(requestId);
|
||||
Optional<OperationSecurityDescriptors.Descriptor> descriptor = realm.gateway()
|
||||
.discoverableOperations(principalId).stream()
|
||||
.filter(item -> item.operationId().equals(operationId)).findFirst();
|
||||
if (descriptor.isEmpty()) return notFound(requestId);
|
||||
if (requireMethod(exchange, "GET")) return descriptor(requestId, descriptor.orElseThrow());
|
||||
if (!requireMethod(exchange, "POST")) return method(requestId, operationId);
|
||||
return execute(exchange, requestId, principalId, operationId);
|
||||
}
|
||||
|
||||
private HttpResponses.Response realm(HttpsExchange exchange, String requestId, String principalId) {
|
||||
if (!requireMethod(exchange, "GET")) return method(requestId, "realm.inspect");
|
||||
Permission.Resource resource = new Permission.Resource(Permission.ResourceType.REALM,
|
||||
new Permission.Scope(configuration.realm().realmId(), Optional.empty(), Optional.empty(),
|
||||
Optional.empty()), Optional.empty(), Optional.empty());
|
||||
AuthorizationEngine.Decision decision = realm.gateway().authorizeControl(principalId,
|
||||
Permission.Action.REALM_READ, resource, Permission.Context.empty(), requestId);
|
||||
if (!decision.allowed()) return denied(requestId, "realm.inspect");
|
||||
Map<String, PkiOperationValue> fields = new LinkedHashMap<>();
|
||||
fields.put("realmId", new PkiOperationValue.Text(configuration.realm().realmId().value()));
|
||||
fields.put("displayName", new PkiOperationValue.Text(configuration.realm().displayName()));
|
||||
fields.put("authorityExposure", new PkiOperationValue.Text(
|
||||
configuration.realm().authorityExposure().mode().name()));
|
||||
fields.put("authorityCreationPermitted", new PkiOperationValue.BooleanValue(
|
||||
configuration.realm().authorityExposure().authorityCreationPermitted()));
|
||||
return HttpResponses.success(requestId, "realm.inspect", new PkiOperationValue.ObjectValue(fields));
|
||||
}
|
||||
|
||||
private HttpResponses.Response operations(HttpsExchange exchange, String requestId, String principalId) {
|
||||
if (!requireMethod(exchange, "GET")) return method(requestId, "operation.list");
|
||||
List<PkiOperationValue> values = realm.gateway().discoverableOperations(principalId).stream()
|
||||
.map(AdminHttpHandler::descriptorValue).map(PkiOperationValue.class::cast).toList();
|
||||
Map<String, PkiOperationValue> fields = new LinkedHashMap<>();
|
||||
fields.put("count", new PkiOperationValue.IntegerValue(values.size()));
|
||||
fields.put("operations", new PkiOperationValue.ListValue(values));
|
||||
return HttpResponses.success(requestId, "operation.list", new PkiOperationValue.ObjectValue(fields));
|
||||
}
|
||||
|
||||
private HttpResponses.Response descriptor(String requestId, OperationSecurityDescriptors.Descriptor value) {
|
||||
return HttpResponses.success(requestId, "operation.inspect", descriptorValue(value));
|
||||
}
|
||||
|
||||
private HttpResponses.Response execute(HttpsExchange exchange, String requestId,
|
||||
String principalId, String operationId) throws IOException {
|
||||
String contentType = exchange.getRequestHeaders().getFirst("Content-Type");
|
||||
if (contentType == null || !(contentType.equalsIgnoreCase("application/json")
|
||||
|| contentType.equalsIgnoreCase(JSON))) {
|
||||
return HttpResponses.failure(415, requestId, operationId, "VALIDATION_FAILURE",
|
||||
"UNSUPPORTED_MEDIA_TYPE", "UNSUPPORTED_MEDIA_TYPE", false, false);
|
||||
}
|
||||
byte[] body;
|
||||
try {
|
||||
body = body(exchange.getRequestBody(), exchange.getRequestHeaders().getFirst("Content-Length"));
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
return HttpResponses.failure(413, requestId, operationId, "VALIDATION_FAILURE",
|
||||
"REQUEST_BODY_REJECTED", "REQUEST_BODY_REJECTED", false, false);
|
||||
}
|
||||
HttpOperationCodec.Decoded decoded;
|
||||
try {
|
||||
decoded = HttpOperationCodec.decode(operationId, body, configuration.listener().maximumBodyBytes(),
|
||||
configuration.realm().realmId(), realm.gateway()::descriptor);
|
||||
} catch (SecurityException unavailable) {
|
||||
return notFound(requestId);
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
return HttpResponses.failure(400, requestId, operationId, "VALIDATION_FAILURE",
|
||||
"INVALID_OPERATION_REQUEST", "INVALID_OPERATION_REQUEST", false, false);
|
||||
}
|
||||
Duration effective = decoded.requestedDeadline().orElse(configuration.execution().defaultDeadline());
|
||||
if (effective.compareTo(configuration.execution().maximumDeadline()) > 0) {
|
||||
effective = configuration.execution().maximumDeadline();
|
||||
}
|
||||
Instant deadline;
|
||||
try {
|
||||
deadline = clock.instant().plus(effective);
|
||||
} catch (RuntimeException overflow) {
|
||||
return HttpResponses.failure(400, requestId, operationId, "VALIDATION_FAILURE",
|
||||
"INVALID_DEADLINE", "INVALID_DEADLINE", false, false);
|
||||
}
|
||||
if (!clock.instant().isBefore(deadline)) return deadline(requestId, operationId);
|
||||
ServerRuntime.Submitted<ServerOperationGateway.Outcome> submitted;
|
||||
try {
|
||||
submitted = runtime.submit(cancellation -> {
|
||||
if (!clock.instant().isBefore(deadline)) throw new DeadlineExceeded();
|
||||
ServerOperationGateway.Request gateway = new ServerOperationGateway.Request(
|
||||
configuration.realm().realmId(), principalId, decoded.operation(), decoded.resource(),
|
||||
Permission.Relationship.ANY, decoded.context(), decoded.approvalId(), requestId);
|
||||
return realm.gateway().execute(gateway, cancellation);
|
||||
});
|
||||
} catch (RejectedExecutionException overloaded) {
|
||||
realm.auditTransport("REQUEST_OVERLOAD", principalId, Map.of("request", requestId,
|
||||
"operation", operationId));
|
||||
return HttpResponses.failure(429, requestId, operationId, "OVERLOADED", "OPERATION_QUEUE_FULL",
|
||||
"OPERATION_QUEUE_FULL", false, false);
|
||||
}
|
||||
try {
|
||||
long remaining = Math.max(1L, Duration.between(clock.instant(), deadline).toMillis());
|
||||
ServerOperationGateway.Outcome outcome = submitted.future().get(remaining, TimeUnit.MILLISECONDS);
|
||||
realm.auditTransport("REQUEST_COMPLETED", principalId,
|
||||
Map.of("request", requestId, "operation", operationId));
|
||||
return HttpResponses.gateway(requestId, operationId, outcome);
|
||||
} catch (TimeoutException timeout) {
|
||||
submitted.cancellation().cancel();
|
||||
submitted.future().cancel(true);
|
||||
realm.auditTransport("REQUEST_DEADLINE", principalId,
|
||||
Map.of("request", requestId, "operation", operationId));
|
||||
return deadline(requestId, operationId);
|
||||
} catch (InterruptedException interrupted) {
|
||||
submitted.cancellation().cancel();
|
||||
submitted.future().cancel(true);
|
||||
Thread.currentThread().interrupt();
|
||||
return deadline(requestId, operationId);
|
||||
} catch (ExecutionException failure) {
|
||||
if (failure.getCause() instanceof DeadlineExceeded) return deadline(requestId, operationId);
|
||||
return HttpResponses.failure(500, requestId, operationId, "INTERNAL_FAILURE",
|
||||
"SAFE_INTERNAL_FAILURE", "SAFE_INTERNAL_FAILURE", false, false);
|
||||
} finally {
|
||||
submitted.finish();
|
||||
}
|
||||
}
|
||||
|
||||
private PkiServerAuthenticationResult authenticate(HttpsExchange exchange, String requestId) {
|
||||
try {
|
||||
Certificate[] peer = exchange.getSSLSession().getPeerCertificates();
|
||||
List<X509Certificate> chain = new ArrayList<>();
|
||||
for (Certificate certificate : peer) {
|
||||
if (!(certificate instanceof X509Certificate x509)) {
|
||||
return new PkiServerAuthenticationResult.Rejected(
|
||||
PkiServerAuthenticationResult.Code.CERTIFICATE_INVALID);
|
||||
}
|
||||
chain.add(x509);
|
||||
}
|
||||
return authenticator.authenticate(new PkiServerAuthenticationContext(chain,
|
||||
exchange.getSSLSession().getProtocol(), exchange.getSSLSession().getCipherSuite(),
|
||||
requestId, configuration.realm().realmId()));
|
||||
} catch (SSLPeerUnverifiedException failure) {
|
||||
return new PkiServerAuthenticationResult.Rejected(
|
||||
PkiServerAuthenticationResult.Code.CERTIFICATE_INVALID);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] body(InputStream input, String contentLength) throws IOException {
|
||||
int maximum = configuration.listener().maximumBodyBytes();
|
||||
if (contentLength != null) {
|
||||
try {
|
||||
long length = Long.parseLong(contentLength);
|
||||
if (length < 0 || length > maximum) throw new IllegalArgumentException("Body is oversized");
|
||||
} catch (NumberFormatException failure) {
|
||||
throw new IllegalArgumentException("Content length is invalid");
|
||||
}
|
||||
}
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream(Math.min(maximum, 8_192));
|
||||
byte[] buffer = new byte[8_192];
|
||||
int total = 0;
|
||||
int read;
|
||||
while ((read = input.read(buffer)) >= 0) {
|
||||
total = Math.addExact(total, read);
|
||||
if (total > maximum) throw new IllegalArgumentException("Body is oversized");
|
||||
output.write(buffer, 0, read);
|
||||
}
|
||||
return output.toByteArray();
|
||||
}
|
||||
|
||||
private void requireHeadersBounded(Headers headers) {
|
||||
int bytes = 0;
|
||||
for (Map.Entry<String, List<String>> header : headers.entrySet()) {
|
||||
bytes = Math.addExact(bytes, header.getKey().getBytes(StandardCharsets.UTF_8).length);
|
||||
for (String value : header.getValue()) {
|
||||
bytes = Math.addExact(bytes, value.getBytes(StandardCharsets.UTF_8).length);
|
||||
if (bytes > configuration.listener().maximumHeaderBytes()) {
|
||||
throw new IllegalArgumentException("Request headers are oversized");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static PkiOperationValue.ObjectValue descriptorValue(OperationSecurityDescriptors.Descriptor value) {
|
||||
Map<String, PkiOperationValue> fields = new LinkedHashMap<>();
|
||||
fields.put("operation", new PkiOperationValue.Text(value.operationId()));
|
||||
fields.put("mutating", new PkiOperationValue.BooleanValue(value.mutating()));
|
||||
fields.put("resourceType", new PkiOperationValue.Text(value.resourceType().name()));
|
||||
fields.put("approval", new PkiOperationValue.Text(value.approvalCategory().name()));
|
||||
return new PkiOperationValue.ObjectValue(fields);
|
||||
}
|
||||
|
||||
private static boolean requireMethod(HttpExchange exchange, String expected) {
|
||||
return expected.equals(exchange.getRequestMethod());
|
||||
}
|
||||
|
||||
private static HttpResponses.Response method(String requestId, String operation) {
|
||||
return HttpResponses.failure(405, requestId, operation, "VALIDATION_FAILURE", "METHOD_NOT_ALLOWED",
|
||||
"METHOD_NOT_ALLOWED", false, false);
|
||||
}
|
||||
|
||||
private static HttpResponses.Response notFound(String requestId) {
|
||||
return HttpResponses.failure(404, requestId, "operation", "NOT_FOUND", "OPERATION_NOT_FOUND",
|
||||
"OPERATION_NOT_FOUND", false, false);
|
||||
}
|
||||
|
||||
private static HttpResponses.Response denied(String requestId, String operation) {
|
||||
return HttpResponses.failure(403, requestId, operation, "DENIED", "AUTHORIZATION_DENIED",
|
||||
"AUTHORIZATION_DENIED", false, false);
|
||||
}
|
||||
|
||||
private static HttpResponses.Response deadline(String requestId, String operation) {
|
||||
return HttpResponses.failure(504, requestId, operation, "CANCELLED", "DEADLINE_EXCEEDED",
|
||||
"DEADLINE_EXCEEDED", false, false);
|
||||
}
|
||||
|
||||
private static TransportFailure malformed(String requestId, String operation) {
|
||||
return new TransportFailure(HttpResponses.failure(400, requestId, operation, "VALIDATION_FAILURE",
|
||||
"MALFORMED_REQUEST", "MALFORMED_REQUEST", false, false));
|
||||
}
|
||||
|
||||
private static void send(HttpExchange exchange, String requestId, HttpResponses.Response response)
|
||||
throws IOException {
|
||||
byte[] body = response.body();
|
||||
exchange.getResponseHeaders().set("Content-Type", JSON);
|
||||
exchange.getResponseHeaders().set("Cache-Control", "no-store");
|
||||
exchange.getResponseHeaders().set(RequestIds.HEADER, requestId);
|
||||
exchange.sendResponseHeaders(response.statusCode(), body.length);
|
||||
try (java.io.OutputStream output = exchange.getResponseBody()) {
|
||||
output.write(body);
|
||||
} finally {
|
||||
exchange.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class TransportFailure extends RuntimeException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
private final transient HttpResponses.Response response;
|
||||
private TransportFailure(HttpResponses.Response response) { this.response = response; }
|
||||
private HttpResponses.Response response() { return response; }
|
||||
}
|
||||
private static final class DeadlineExceeded extends RuntimeException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.server.http;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import zeroecho.pki.api.FormatId;
|
||||
import zeroecho.pki.api.KeyRef;
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.api.SubjectRef;
|
||||
import zeroecho.pki.api.ca.CaState;
|
||||
import zeroecho.pki.api.revocation.RevocationReason;
|
||||
import zeroecho.pki.api.status.StatusObjectType;
|
||||
import zeroecho.pki.application.PkiOperation;
|
||||
import zeroecho.pki.application.PkiOperationValue;
|
||||
import zeroecho.pki.server.OperationSecurityDescriptors;
|
||||
import zeroecho.pki.server.Permission;
|
||||
import zeroecho.pki.server.RealmId;
|
||||
|
||||
/** Closed strict HTTP decoding for operations exposed by the existing gateway. */
|
||||
@SuppressWarnings("PMD")
|
||||
final class HttpOperationCodec {
|
||||
/** Fully decoded gateway input excluding authenticated principal and request ID. */
|
||||
record Decoded(PkiOperation operation, Permission.Resource resource, Permission.Context context,
|
||||
Optional<String> approvalId, Optional<Duration> requestedDeadline) { }
|
||||
|
||||
private HttpOperationCodec() {
|
||||
}
|
||||
|
||||
static Decoded decode(String operationId, byte[] document, int maximumBytes, RealmId realmId,
|
||||
java.util.function.Function<PkiOperation, OperationSecurityDescriptors.Descriptor> descriptors) {
|
||||
Fields root = Fields.of(StrictJson.parse(document, maximumBytes));
|
||||
root.allowed(Set.of("version", "authorityId", "approvalId", "deadline", "arguments"));
|
||||
if (root.integer("version") != 1) throw new IllegalArgumentException("Request version is unsupported");
|
||||
Optional<PkiId> authority = root.optionalText("authorityId").map(PkiId::new);
|
||||
Optional<String> approval = root.optionalText("approvalId");
|
||||
approval.ifPresent(value -> requireId(value, "approval ID"));
|
||||
Optional<Duration> deadline = root.optionalText("deadline").map(HttpOperationCodec::duration);
|
||||
Fields arguments = root.object("arguments");
|
||||
PkiOperation operation = operation(operationId, arguments);
|
||||
arguments.complete();
|
||||
root.complete();
|
||||
OperationSecurityDescriptors.Descriptor descriptor = descriptors.apply(operation);
|
||||
boolean realmWide = realmWide(operation);
|
||||
if (realmWide == authority.isPresent()) {
|
||||
throw new IllegalArgumentException(realmWide
|
||||
? "Realm-wide operation must not specify authorityId"
|
||||
: "Authority-scoped operation requires authorityId");
|
||||
}
|
||||
validateAuthority(operation, authority);
|
||||
Permission.Scope scope = new Permission.Scope(realmId, authority, Optional.empty(), profile(operation));
|
||||
Optional<PkiId> objectId = objectId(operation);
|
||||
Permission.Resource resource = new Permission.Resource(descriptor.resourceType(), scope, objectId,
|
||||
Optional.empty());
|
||||
Optional<String> reason = operation instanceof PkiOperation.TransitionAuthority transition
|
||||
? Optional.of(transition.reason()) : Optional.empty();
|
||||
Permission.Context context = new Permission.Context(reason, approval.isPresent(), false, Map.of());
|
||||
return new Decoded(operation, resource, context, approval, deadline);
|
||||
}
|
||||
|
||||
private static PkiOperation operation(String id, Fields fields) {
|
||||
return switch (id) {
|
||||
case PkiOperation.ListAuthorities.NAME -> {
|
||||
fields.exact("limit"); yield new PkiOperation.ListAuthorities(fields.integer("limit"));
|
||||
}
|
||||
case PkiOperation.InspectAuthority.NAME -> {
|
||||
fields.exact("caId"); yield new PkiOperation.InspectAuthority(fields.pkiId("caId"));
|
||||
}
|
||||
case PkiOperation.CreateAuthority.NAME -> {
|
||||
fields.exact("formatId", "subjectRef", "profileId", "keyRef");
|
||||
yield new PkiOperation.CreateAuthority(new FormatId(fields.text("formatId")),
|
||||
new SubjectRef(fields.text("subjectRef")), fields.text("profileId"),
|
||||
new KeyRef(fields.text("keyRef")));
|
||||
}
|
||||
case PkiOperation.TransitionAuthority.NAME -> {
|
||||
fields.exact("caId", "state", "reason");
|
||||
yield new PkiOperation.TransitionAuthority(fields.pkiId("caId"),
|
||||
CaState.valueOf(fields.text("state")), fields.text("reason"));
|
||||
}
|
||||
case PkiOperation.ListProfileVersions.NAME -> {
|
||||
fields.exact("profileId"); yield new PkiOperation.ListProfileVersions(fields.text("profileId"));
|
||||
}
|
||||
case PkiOperation.InspectProfile.NAME -> {
|
||||
fields.exact("profileId", "profileVersion");
|
||||
yield new PkiOperation.InspectProfile(fields.text("profileId"), fields.longValue("profileVersion"));
|
||||
}
|
||||
case PkiOperation.ActivateProfile.NAME -> {
|
||||
fields.exact("profileId", "profileVersion");
|
||||
yield new PkiOperation.ActivateProfile(fields.text("profileId"), fields.longValue("profileVersion"));
|
||||
}
|
||||
case PkiOperation.InspectCredential.NAME -> {
|
||||
fields.exact("credentialId"); yield new PkiOperation.InspectCredential(fields.pkiId("credentialId"));
|
||||
}
|
||||
case PkiOperation.IssueCredential.NAME -> {
|
||||
fields.exact("issuerCaId", "requestId", "profileId");
|
||||
yield new PkiOperation.IssueCredential(fields.pkiId("issuerCaId"), fields.pkiId("requestId"),
|
||||
fields.text("profileId"));
|
||||
}
|
||||
case PkiOperation.RevokeCredential.NAME -> {
|
||||
fields.exact("credentialId", "reason");
|
||||
yield new PkiOperation.RevokeCredential(fields.pkiId("credentialId"),
|
||||
RevocationReason.valueOf(fields.text("reason")));
|
||||
}
|
||||
case PkiOperation.ReadRevocationHistory.NAME -> {
|
||||
fields.exact("credentialId", "limit");
|
||||
yield new PkiOperation.ReadRevocationHistory(fields.pkiId("credentialId"), fields.integer("limit"));
|
||||
}
|
||||
case PkiOperation.GenerateStatus.NAME -> {
|
||||
fields.exact("issuerCaId", "type", "formatId");
|
||||
yield new PkiOperation.GenerateStatus(fields.pkiId("issuerCaId"),
|
||||
StatusObjectType.valueOf(fields.text("type")), new FormatId(fields.text("formatId")));
|
||||
}
|
||||
case PkiOperation.InspectPublication.NAME -> {
|
||||
fields.exact("publicationId");
|
||||
yield new PkiOperation.InspectPublication(fields.pkiId("publicationId"));
|
||||
}
|
||||
case PkiOperation.ProcessPublication.NAME -> {
|
||||
fields.exact("publicationId");
|
||||
yield new PkiOperation.ProcessPublication(fields.pkiId("publicationId"));
|
||||
}
|
||||
case PkiOperation.ListAlgorithmBindings.NAME -> {
|
||||
fields.allowed(Set.of("origin", "role", "limit"));
|
||||
yield new PkiOperation.ListAlgorithmBindings(fields.optionalText("origin"),
|
||||
fields.optionalText("role"), fields.integer("limit"));
|
||||
}
|
||||
case PkiOperation.InspectAlgorithmBinding.NAME -> {
|
||||
fields.exact("bindingId");
|
||||
yield new PkiOperation.InspectAlgorithmBinding(fields.text("bindingId"));
|
||||
}
|
||||
default -> throw new SecurityException("Operation is not exposed");
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean realmWide(PkiOperation operation) {
|
||||
return operation instanceof PkiOperation.ListAuthorities
|
||||
|| operation instanceof PkiOperation.CreateAuthority
|
||||
|| operation instanceof PkiOperation.ListAlgorithmBindings
|
||||
|| operation instanceof PkiOperation.InspectAlgorithmBinding;
|
||||
}
|
||||
|
||||
private static void validateAuthority(PkiOperation operation, Optional<PkiId> authority) {
|
||||
Optional<PkiId> embedded = switch (operation) {
|
||||
case PkiOperation.InspectAuthority value -> Optional.of(value.caId());
|
||||
case PkiOperation.TransitionAuthority value -> Optional.of(value.caId());
|
||||
case PkiOperation.IssueCredential value -> Optional.of(value.issuerCaId());
|
||||
case PkiOperation.GenerateStatus value -> Optional.of(value.issuerCaId());
|
||||
default -> Optional.empty();
|
||||
};
|
||||
if (embedded.isPresent() && !embedded.equals(authority)) {
|
||||
throw new IllegalArgumentException("Operation and authority scope differ");
|
||||
}
|
||||
}
|
||||
|
||||
private static Optional<String> profile(PkiOperation operation) {
|
||||
return switch (operation) {
|
||||
case PkiOperation.ListProfileVersions value -> Optional.of(value.profileId());
|
||||
case PkiOperation.InspectProfile value -> Optional.of(value.profileId());
|
||||
case PkiOperation.ActivateProfile value -> Optional.of(value.profileId());
|
||||
case PkiOperation.IssueCredential value -> Optional.of(value.profileId());
|
||||
default -> Optional.empty();
|
||||
};
|
||||
}
|
||||
|
||||
private static Optional<PkiId> objectId(PkiOperation operation) {
|
||||
return switch (operation) {
|
||||
case PkiOperation.InspectAuthority value -> Optional.of(value.caId());
|
||||
case PkiOperation.TransitionAuthority value -> Optional.of(value.caId());
|
||||
case PkiOperation.InspectCredential value -> Optional.of(value.credentialId());
|
||||
case PkiOperation.RevokeCredential value -> Optional.of(value.credentialId());
|
||||
case PkiOperation.ReadRevocationHistory value -> Optional.of(value.credentialId());
|
||||
case PkiOperation.InspectPublication value -> Optional.of(value.publicationId());
|
||||
case PkiOperation.ProcessPublication value -> Optional.of(value.publicationId());
|
||||
default -> Optional.empty();
|
||||
};
|
||||
}
|
||||
|
||||
private static Duration duration(String value) {
|
||||
try {
|
||||
Duration result = Duration.parse(value);
|
||||
if (result.isZero() || result.isNegative()) throw new IllegalArgumentException("Deadline is invalid");
|
||||
return result;
|
||||
} catch (java.time.format.DateTimeParseException failure) {
|
||||
throw new IllegalArgumentException("Deadline is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireId(String value, String name) {
|
||||
if (value == null || !value.matches("[a-zA-Z0-9][a-zA-Z0-9._:-]{0,255}")) {
|
||||
throw new IllegalArgumentException(name + " is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Fields {
|
||||
private final Map<String, PkiOperationValue> fields;
|
||||
private final Set<String> consumed = new LinkedHashSet<>();
|
||||
private Fields(Map<String, PkiOperationValue> fields) { this.fields = fields; }
|
||||
static Fields of(PkiOperationValue value) {
|
||||
if (!(value instanceof PkiOperationValue.ObjectValue object)) {
|
||||
throw new IllegalArgumentException("Request value must be an object");
|
||||
}
|
||||
return new Fields(object.fields());
|
||||
}
|
||||
void exact(String... names) { allowed(Set.of(names)); if (fields.size() != names.length)
|
||||
throw new IllegalArgumentException("Request fields are incomplete"); }
|
||||
void allowed(Set<String> names) { if (!names.containsAll(fields.keySet()))
|
||||
throw new IllegalArgumentException("Unknown request field"); }
|
||||
String text(String name) { consumed.add(name); PkiOperationValue value = require(name);
|
||||
if (!(value instanceof PkiOperationValue.Text text)) throw type(); return text.value(); }
|
||||
Optional<String> optionalText(String name) { return fields.containsKey(name)
|
||||
? Optional.of(text(name)) : Optional.empty(); }
|
||||
long longValue(String name) { consumed.add(name); PkiOperationValue value = require(name);
|
||||
if (!(value instanceof PkiOperationValue.IntegerValue integer)) throw type(); return integer.value(); }
|
||||
int integer(String name) { return Math.toIntExact(longValue(name)); }
|
||||
PkiId pkiId(String name) { return new PkiId(text(name)); }
|
||||
Fields object(String name) { consumed.add(name); return of(require(name)); }
|
||||
void complete() { if (!Objects.equals(consumed, fields.keySet()))
|
||||
throw new IllegalArgumentException("Request fields were not consumed"); }
|
||||
private PkiOperationValue require(String name) { PkiOperationValue value = fields.get(name);
|
||||
if (value == null) throw new IllegalArgumentException("Required request field is missing"); return value; }
|
||||
private static IllegalArgumentException type() {
|
||||
return new IllegalArgumentException("Request field type is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) { }
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.server.http;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.Clock;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
|
||||
import zeroecho.pki.server.PkiServerConfiguration.ClientCertificateMapping;
|
||||
import zeroecho.pki.server.SecurityPrincipal;
|
||||
import zeroecho.pki.server.spi.PkiServerAuthenticationContext;
|
||||
import zeroecho.pki.server.spi.PkiServerAuthenticationResult;
|
||||
import zeroecho.pki.server.spi.PkiServerAuthenticator;
|
||||
|
||||
/**
|
||||
* Production mutual-TLS authenticator using immutable cryptographic certificate
|
||||
* commitments rather than mutable subject or SAN strings.
|
||||
*
|
||||
* <p>The HTTPS handshake has already validated the chain through the configured
|
||||
* JSSE trust manager. This authenticator additionally validates validity time,
|
||||
* canonical single-certificate encoding, mapping uniqueness, and the enabled
|
||||
* state of the persisted principal.</p>
|
||||
*/
|
||||
@SuppressWarnings("PMD")
|
||||
public final class MutualTlsAuthenticator implements PkiServerAuthenticator {
|
||||
private final List<ClientCertificateMapping> mappings;
|
||||
private final Function<String, SecurityPrincipal> principalResolver;
|
||||
private final Clock clock;
|
||||
|
||||
/**
|
||||
* Creates one immutable principal mapper.
|
||||
*
|
||||
* @param mappings exact cryptographic mappings
|
||||
* @param principalResolver persisted principal resolver
|
||||
* @param clock injected validity clock
|
||||
*/
|
||||
public MutualTlsAuthenticator(List<ClientCertificateMapping> mappings,
|
||||
Function<String, SecurityPrincipal> principalResolver, Clock clock) {
|
||||
this.mappings = List.copyOf(Objects.requireNonNull(mappings, "mappings"));
|
||||
this.principalResolver = Objects.requireNonNull(principalResolver, "principalResolver");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
|
||||
@Override
|
||||
public PkiServerAuthenticationResult authenticate(PkiServerAuthenticationContext context) {
|
||||
Objects.requireNonNull(context, "context");
|
||||
try {
|
||||
for (X509Certificate certificate : context.peerCertificates()) {
|
||||
certificate.checkValidity(Date.from(clock.instant()));
|
||||
requireCanonical(certificate);
|
||||
}
|
||||
X509Certificate leaf = context.peerCertificates().getFirst();
|
||||
Commitments commitments = commitments(leaf);
|
||||
List<ClientCertificateMapping> matches = new ArrayList<>();
|
||||
for (ClientCertificateMapping mapping : mappings) {
|
||||
if (matches(mapping, commitments)) matches.add(mapping);
|
||||
}
|
||||
if (matches.isEmpty()) {
|
||||
return new PkiServerAuthenticationResult.Rejected(
|
||||
PkiServerAuthenticationResult.Code.MAPPING_UNAVAILABLE);
|
||||
}
|
||||
if (matches.size() != 1) {
|
||||
return new PkiServerAuthenticationResult.Rejected(
|
||||
PkiServerAuthenticationResult.Code.MAPPING_AMBIGUOUS);
|
||||
}
|
||||
SecurityPrincipal principal = principalResolver.apply(matches.getFirst().principalId());
|
||||
if (!principal.enabled()) {
|
||||
return new PkiServerAuthenticationResult.Rejected(
|
||||
PkiServerAuthenticationResult.Code.PRINCIPAL_DISABLED);
|
||||
}
|
||||
return new PkiServerAuthenticationResult.Authenticated(principal.principalId());
|
||||
} catch (java.security.cert.CertificateException | IllegalArgumentException failure) {
|
||||
return new PkiServerAuthenticationResult.Rejected(
|
||||
PkiServerAuthenticationResult.Code.CERTIFICATE_INVALID);
|
||||
} catch (RuntimeException failure) {
|
||||
return new PkiServerAuthenticationResult.Rejected(
|
||||
PkiServerAuthenticationResult.Code.INTERNAL_FAILURE);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean matches(ClientCertificateMapping mapping, Commitments commitments) {
|
||||
return mapping.certificateSha256().map(commitments.certificate()::equals).orElse(true)
|
||||
&& mapping.subjectPublicKeyInfoSha256().map(commitments.spki()::equals).orElse(true)
|
||||
&& mapping.issuerSerialSha256().map(commitments.issuerSerial()::equals).orElse(true);
|
||||
}
|
||||
|
||||
private static Commitments commitments(X509Certificate certificate)
|
||||
throws java.security.cert.CertificateEncodingException {
|
||||
BigInteger serial = certificate.getSerialNumber();
|
||||
if (serial.signum() <= 0) throw new IllegalArgumentException("Certificate serial is invalid");
|
||||
MessageDigest issuerSerial = sha256();
|
||||
issuerSerial.update(certificate.getIssuerX500Principal().getEncoded());
|
||||
issuerSerial.update((byte) 0);
|
||||
issuerSerial.update(serial.toByteArray());
|
||||
return new Commitments(digest(certificate.getEncoded()), digest(certificate.getPublicKey().getEncoded()),
|
||||
HexFormat.of().formatHex(issuerSerial.digest()));
|
||||
}
|
||||
|
||||
private static void requireCanonical(X509Certificate certificate)
|
||||
throws java.security.cert.CertificateException {
|
||||
byte[] encoded = certificate.getEncoded();
|
||||
ByteArrayInputStream input = new ByteArrayInputStream(encoded);
|
||||
X509Certificate parsed = (X509Certificate) CertificateFactory.getInstance("X.509")
|
||||
.generateCertificate(input);
|
||||
if (input.available() != 0 || !Arrays.equals(encoded, parsed.getEncoded())) {
|
||||
throw new java.security.cert.CertificateException("Certificate encoding is not canonical");
|
||||
}
|
||||
}
|
||||
|
||||
private static String digest(byte[] value) {
|
||||
return HexFormat.of().formatHex(sha256().digest(value));
|
||||
}
|
||||
|
||||
private static MessageDigest sha256() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException impossible) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", impossible);
|
||||
}
|
||||
}
|
||||
|
||||
private record Commitments(String certificate, String spki, String issuerSerial) { }
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.server.http;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyStore;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import javax.net.ssl.KeyManagerFactory;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
|
||||
import zeroecho.pki.server.spi.PkiServerTlsProvider;
|
||||
import zeroecho.pki.spi.ProviderConfig;
|
||||
|
||||
/**
|
||||
* Production JSSE provider using externally protected PKCS#12 identity and trust
|
||||
* stores.
|
||||
*
|
||||
* <p>Configuration contains only store paths and names of environment variables.
|
||||
* Passwords are resolved into short-lived character arrays, cleared after JSSE
|
||||
* initialization, and never returned or logged.</p>
|
||||
*/
|
||||
@SuppressWarnings("PMD")
|
||||
public final class Pkcs12TlsProvider implements PkiServerTlsProvider {
|
||||
/** Stable explicit provider identity. */
|
||||
public static final String ID = "jsse-pkcs12";
|
||||
private static final Set<String> KEYS = Set.of("keyStore", "keyStorePasswordEnvironment",
|
||||
"trustStore", "trustStorePasswordEnvironment");
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SSLContext create(ProviderConfig configuration) throws Exception {
|
||||
ProviderConfig exact = Objects.requireNonNull(configuration, "configuration");
|
||||
if (!ID.equals(exact.backendId()) || !exact.properties().keySet().equals(KEYS)) {
|
||||
throw new IllegalArgumentException("TLS provider configuration is invalid");
|
||||
}
|
||||
Path keyStorePath = path(exact.require("keyStore"));
|
||||
Path trustStorePath = path(exact.require("trustStore"));
|
||||
char[] keyPassword = secret(exact.require("keyStorePasswordEnvironment"));
|
||||
char[] trustPassword = secret(exact.require("trustStorePasswordEnvironment"));
|
||||
try {
|
||||
KeyStore keyStore = load(keyStorePath, keyPassword);
|
||||
KeyStore trustStore = load(trustStorePath, trustPassword);
|
||||
KeyManagerFactory keys = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
|
||||
keys.init(keyStore, keyPassword);
|
||||
TrustManagerFactory trust = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
|
||||
trust.init(trustStore);
|
||||
SSLContext context = SSLContext.getInstance("TLS");
|
||||
context.init(keys.getKeyManagers(), trust.getTrustManagers(), new SecureRandom());
|
||||
return context;
|
||||
} finally {
|
||||
Arrays.fill(keyPassword, '\0');
|
||||
Arrays.fill(trustPassword, '\0');
|
||||
}
|
||||
}
|
||||
|
||||
private static KeyStore load(Path path, char[] password) throws Exception {
|
||||
KeyStore store = KeyStore.getInstance("PKCS12");
|
||||
try (InputStream input = Files.newInputStream(path)) {
|
||||
store.load(input, password);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
private static Path path(String value) {
|
||||
Path result = Path.of(value).toAbsolutePath().normalize();
|
||||
if (result.getParent() == null || Files.isSymbolicLink(result)) {
|
||||
throw new IllegalArgumentException("TLS material path is invalid");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static char[] secret(String environmentName) {
|
||||
if (!environmentName.matches("[A-Z][A-Z0-9_]{0,127}")) {
|
||||
throw new IllegalArgumentException("TLS secret reference is invalid");
|
||||
}
|
||||
String value = System.getenv(environmentName);
|
||||
if (value == null || value.isEmpty()) {
|
||||
throw new IllegalStateException("TLS material is unavailable");
|
||||
}
|
||||
return value.toCharArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.server.http;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLParameters;
|
||||
|
||||
import com.sun.net.httpserver.HttpsConfigurator;
|
||||
import com.sun.net.httpserver.HttpsParameters;
|
||||
import com.sun.net.httpserver.HttpsServer;
|
||||
|
||||
import zeroecho.pki.server.PkiServerConfiguration;
|
||||
import zeroecho.pki.server.ServerRealmContext;
|
||||
import zeroecho.pki.server.spi.PkiServerAuthenticator;
|
||||
|
||||
/**
|
||||
* Narrow lifecycle facade hiding the JDK HTTPS listener and bounded execution runtime.
|
||||
*
|
||||
* <p>The public server lifecycle uses this type only as an internal module boundary;
|
||||
* request handlers, queues, permits and provider discovery remain package-private.</p>
|
||||
*/
|
||||
@SuppressWarnings("PMD")
|
||||
public final class PkiHttpsTransport implements AutoCloseable {
|
||||
private final HttpsServer listener;
|
||||
private final ServerRuntime runtime;
|
||||
|
||||
private PkiHttpsTransport(HttpsServer listener, ServerRuntime runtime) {
|
||||
this.listener = listener;
|
||||
this.runtime = runtime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens and starts one strictly mutual-TLS listener.
|
||||
*
|
||||
* @param configuration validated server configuration
|
||||
* @param realm shared long-lived realm
|
||||
* @param authenticator initialized mTLS authenticator
|
||||
* @param clock request-time source
|
||||
* @param random correlation identifier source
|
||||
* @param loader explicitly scoped TLS-provider loader
|
||||
* @param ready readiness predicate owned by the server lifecycle
|
||||
* @return started bounded transport
|
||||
*/
|
||||
public static PkiHttpsTransport start(PkiServerConfiguration configuration, ServerRealmContext realm,
|
||||
PkiServerAuthenticator authenticator, Clock clock, SecureRandom random, ClassLoader loader,
|
||||
BooleanSupplier ready) {
|
||||
Objects.requireNonNull(configuration, "configuration");
|
||||
Objects.requireNonNull(realm, "realm");
|
||||
Objects.requireNonNull(authenticator, "authenticator");
|
||||
Objects.requireNonNull(ready, "ready");
|
||||
ServerRuntime runtime = null;
|
||||
HttpsServer listener = null;
|
||||
try {
|
||||
SSLContext tls = resolveTls(configuration, loader);
|
||||
runtime = new ServerRuntime(configuration.execution());
|
||||
listener = HttpsServer.create(configuration.listener().socketAddress(),
|
||||
configuration.execution().transportQueueCapacity());
|
||||
listener.setHttpsConfigurator(configurator(tls));
|
||||
listener.setExecutor(runtime.transportExecutor());
|
||||
listener.createContext("/", new AdminHttpHandler(configuration, realm, authenticator, runtime,
|
||||
clock, new RequestIds(random), ready));
|
||||
listener.start();
|
||||
return new PkiHttpsTransport(listener, runtime);
|
||||
} catch (IOException failure) {
|
||||
closePartial(listener, runtime);
|
||||
throw new IllegalStateException("HTTPS listener initialization failed");
|
||||
} catch (RuntimeException | Error failure) {
|
||||
closePartial(listener, runtime);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the explicitly selected TLS provider before realm resources are opened.
|
||||
*
|
||||
* @param configuration validated server configuration
|
||||
* @param loader explicitly scoped provider loader
|
||||
* @return initialized JSSE context without exposing private-key material
|
||||
*/
|
||||
public static SSLContext resolveTls(PkiServerConfiguration configuration, ClassLoader loader) {
|
||||
Objects.requireNonNull(configuration, "configuration");
|
||||
return TlsProviders.create(configuration.listener().tlsProvider(), loader);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a listener using a TLS context resolved before realm construction.
|
||||
*
|
||||
* @param configuration validated server configuration
|
||||
* @param realm shared long-lived realm
|
||||
* @param authenticator initialized mTLS authenticator
|
||||
* @param clock request-time source
|
||||
* @param random correlation identifier source
|
||||
* @param tls initialized TLS context
|
||||
* @param ready readiness predicate
|
||||
* @return started bounded transport
|
||||
*/
|
||||
public static PkiHttpsTransport startResolved(PkiServerConfiguration configuration,
|
||||
ServerRealmContext realm, PkiServerAuthenticator authenticator, Clock clock, SecureRandom random,
|
||||
SSLContext tls, BooleanSupplier ready) {
|
||||
Objects.requireNonNull(configuration, "configuration");
|
||||
Objects.requireNonNull(tls, "tls");
|
||||
ServerRuntime runtime = null;
|
||||
HttpsServer listener = null;
|
||||
try {
|
||||
runtime = new ServerRuntime(configuration.execution());
|
||||
listener = HttpsServer.create(configuration.listener().socketAddress(),
|
||||
configuration.execution().transportQueueCapacity());
|
||||
listener.setHttpsConfigurator(configurator(tls));
|
||||
listener.setExecutor(runtime.transportExecutor());
|
||||
listener.createContext("/", new AdminHttpHandler(configuration, realm, authenticator, runtime,
|
||||
clock, new RequestIds(random), ready));
|
||||
listener.start();
|
||||
return new PkiHttpsTransport(listener, runtime);
|
||||
} catch (IOException failure) {
|
||||
closePartial(listener, runtime);
|
||||
throw new IllegalStateException("HTTPS listener initialization failed");
|
||||
} catch (RuntimeException | Error failure) {
|
||||
closePartial(listener, runtime);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return actual bound listener address, including an allocated ephemeral port */
|
||||
public InetSocketAddress address() {
|
||||
return listener.getAddress();
|
||||
}
|
||||
|
||||
/** @return whether operation admission is accepting new requests */
|
||||
public boolean accepting() {
|
||||
return runtime.accepting();
|
||||
}
|
||||
|
||||
/** Prevents new operation admission without closing in-flight work. */
|
||||
public void quiesce() {
|
||||
runtime.quiesce();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the sole process-shutdown thread through the centralized runtime.
|
||||
*
|
||||
* @param task finite server close action
|
||||
* @return unstarted JVM shutdown hook
|
||||
*/
|
||||
public Thread shutdownHook(Runnable task) {
|
||||
return ServerRuntime.shutdownHook(Objects.requireNonNull(task, "task"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
shutdown(Duration.ZERO);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops new exchanges, grants admitted exchanges a finite drain period and
|
||||
* then closes all bounded executors.
|
||||
*
|
||||
* @param graceful finite listener drain period
|
||||
*/
|
||||
public void shutdown(Duration graceful) {
|
||||
runtime.quiesce();
|
||||
int seconds = Math.toIntExact(Math.min(Integer.MAX_VALUE,
|
||||
Objects.requireNonNull(graceful, "graceful").toSeconds()));
|
||||
listener.stop(seconds);
|
||||
runtime.close();
|
||||
}
|
||||
|
||||
private static HttpsConfigurator configurator(SSLContext context) {
|
||||
return new HttpsConfigurator(context) {
|
||||
@Override public void configure(HttpsParameters parameters) {
|
||||
SSLParameters secure = context.getDefaultSSLParameters();
|
||||
secure.setNeedClientAuth(true);
|
||||
parameters.setSSLParameters(secure);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static void closePartial(HttpsServer listener, ServerRuntime runtime) {
|
||||
if (listener != null) listener.stop(0);
|
||||
if (runtime != null) runtime.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.server.http;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Strict opaque request-correlation identity policy. */
|
||||
@SuppressWarnings("PMD")
|
||||
final class RequestIds {
|
||||
static final String HEADER = "X-ZeroEcho-Request-Id";
|
||||
private final SecureRandom random;
|
||||
|
||||
RequestIds(SecureRandom random) {
|
||||
this.random = Objects.requireNonNull(random, "random");
|
||||
}
|
||||
|
||||
String resolve(List<String> supplied) {
|
||||
if (supplied == null || supplied.isEmpty()) {
|
||||
byte[] value = new byte[18];
|
||||
random.nextBytes(value);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(value);
|
||||
}
|
||||
if (supplied.size() != 1 || !supplied.getFirst().matches("[A-Za-z0-9_-]{16,128}")) {
|
||||
throw new IllegalArgumentException("Request correlation header is invalid");
|
||||
}
|
||||
return supplied.getFirst();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.server.http;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import zeroecho.core.io.CancellationSignal;
|
||||
import zeroecho.pki.server.PkiServerConfiguration;
|
||||
|
||||
/** Centralized owner of all bounded server-created threads, queues and permits. */
|
||||
@SuppressWarnings("PMD")
|
||||
final class ServerRuntime implements AutoCloseable {
|
||||
static final String TRANSPORT_PREFIX = "zeroecho-pki-https-";
|
||||
static final String OPERATION_PREFIX = "zeroecho-pki-operation-";
|
||||
static final String SHUTDOWN_NAME = "zeroecho-pki-shutdown";
|
||||
|
||||
private final ThreadPoolExecutor transport;
|
||||
private final ThreadPoolExecutor operations;
|
||||
private final Semaphore admitted;
|
||||
private final Set<CancellationToken> cancellations = ConcurrentHashMap.newKeySet();
|
||||
private final Set<Future<?>> futures = ConcurrentHashMap.newKeySet();
|
||||
private final PkiServerConfiguration.Execution configuration;
|
||||
private final AtomicBoolean accepting = new AtomicBoolean(true);
|
||||
|
||||
ServerRuntime(PkiServerConfiguration.Execution configuration) {
|
||||
this.configuration = configuration;
|
||||
transport = pool(configuration.transportWorkers(), configuration.transportQueueCapacity(),
|
||||
new NamedThreadFactory(TRANSPORT_PREFIX));
|
||||
operations = pool(configuration.operationWorkers(), configuration.operationQueueCapacity(),
|
||||
new NamedThreadFactory(OPERATION_PREFIX));
|
||||
admitted = new Semaphore(configuration.maximumAdmittedRequests(), true);
|
||||
}
|
||||
|
||||
ThreadPoolExecutor transportExecutor() {
|
||||
return transport;
|
||||
}
|
||||
|
||||
boolean tryAdmit() {
|
||||
return accepting.get() && admitted.tryAcquire();
|
||||
}
|
||||
|
||||
void releaseAdmission() {
|
||||
admitted.release();
|
||||
}
|
||||
|
||||
<T> Submitted<T> submit(CancellableCallable<T> task) {
|
||||
if (!accepting.get()) throw new RejectedExecutionException("Server is not accepting operations");
|
||||
CancellationToken cancellation = new CancellationToken();
|
||||
cancellations.add(cancellation);
|
||||
Future<T> future;
|
||||
try {
|
||||
future = operations.submit(() -> {
|
||||
try {
|
||||
cancellation.throwIfCancelled();
|
||||
return task.call(cancellation);
|
||||
} finally {
|
||||
cancellations.remove(cancellation);
|
||||
}
|
||||
});
|
||||
} catch (RejectedExecutionException failure) {
|
||||
cancellations.remove(cancellation);
|
||||
throw failure;
|
||||
}
|
||||
futures.add(future);
|
||||
return new Submitted<>(future, cancellation, () -> futures.remove(future));
|
||||
}
|
||||
|
||||
void quiesce() {
|
||||
accepting.set(false);
|
||||
}
|
||||
|
||||
boolean accepting() {
|
||||
return accepting.get();
|
||||
}
|
||||
|
||||
int operationQueueSize() {
|
||||
return operations.getQueue().size();
|
||||
}
|
||||
|
||||
int activeOperations() {
|
||||
return operations.getActiveCount();
|
||||
}
|
||||
|
||||
static Thread shutdownHook(Runnable task) {
|
||||
return new Thread(task, SHUTDOWN_NAME);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
quiesce();
|
||||
transport.shutdown();
|
||||
operations.shutdown();
|
||||
boolean transportWorker = Thread.currentThread().getName().startsWith(TRANSPORT_PREFIX);
|
||||
boolean operationWorker = Thread.currentThread().getName().startsWith(OPERATION_PREFIX);
|
||||
if (!operationWorker) {
|
||||
await(operations, configuration.gracefulShutdown());
|
||||
}
|
||||
if (!operations.isTerminated()) {
|
||||
for (CancellationToken cancellation : cancellations) cancellation.cancel();
|
||||
if (!operationWorker) {
|
||||
for (Future<?> future : futures) future.cancel(true);
|
||||
}
|
||||
operations.shutdownNow();
|
||||
if (!operationWorker) {
|
||||
await(operations, configuration.forcedShutdown());
|
||||
}
|
||||
}
|
||||
if (!transportWorker) await(transport, configuration.gracefulShutdown());
|
||||
if (!transport.isTerminated()) {
|
||||
transport.shutdownNow();
|
||||
if (!transportWorker) await(transport, configuration.forcedShutdown());
|
||||
}
|
||||
}
|
||||
|
||||
private static ThreadPoolExecutor pool(int workers, int queueCapacity, ThreadFactory factory) {
|
||||
ThreadPoolExecutor result = new ThreadPoolExecutor(workers, workers, 0L, TimeUnit.MILLISECONDS,
|
||||
new ArrayBlockingQueue<>(queueCapacity), factory, new ThreadPoolExecutor.AbortPolicy());
|
||||
result.prestartAllCoreThreads();
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void await(ThreadPoolExecutor executor, Duration duration) {
|
||||
try {
|
||||
if (!executor.awaitTermination(duration.toMillis(), TimeUnit.MILLISECONDS)) return;
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
record Submitted<T>(Future<T> future, CancellationToken cancellation, Runnable completed) {
|
||||
Submitted {
|
||||
if (future == null || cancellation == null || completed == null) {
|
||||
throw new NullPointerException("Submitted operation fields must not be null");
|
||||
}
|
||||
}
|
||||
void finish() { completed.run(); }
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface CancellableCallable<T> {
|
||||
T call(CancellationToken cancellation) throws Exception;
|
||||
}
|
||||
|
||||
static final class CancellationToken implements CancellationSignal {
|
||||
private final AtomicBoolean cancelled = new AtomicBoolean();
|
||||
@Override public boolean isCancelled() { return cancelled.get() || Thread.currentThread().isInterrupted(); }
|
||||
void cancel() { cancelled.set(true); }
|
||||
}
|
||||
|
||||
private static final class NamedThreadFactory implements ThreadFactory {
|
||||
private final String prefix;
|
||||
private final AtomicInteger sequence = new AtomicInteger();
|
||||
private NamedThreadFactory(String prefix) { this.prefix = prefix; }
|
||||
@Override public Thread newThread(Runnable task) {
|
||||
Thread thread = new Thread(task, prefix + sequence.incrementAndGet());
|
||||
thread.setDaemon(false);
|
||||
return thread;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.server.http;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.core.JsonGenerator;
|
||||
import tools.jackson.core.JsonParser;
|
||||
import tools.jackson.core.JsonToken;
|
||||
import tools.jackson.core.ObjectReadContext;
|
||||
import tools.jackson.core.StreamReadConstraints;
|
||||
import tools.jackson.core.StreamReadFeature;
|
||||
import tools.jackson.core.json.JsonFactory;
|
||||
import tools.jackson.core.json.JsonFactoryBuilder;
|
||||
import tools.jackson.core.json.JsonReadFeature;
|
||||
import zeroecho.pki.application.PkiOperationValue;
|
||||
|
||||
/** Strict bounded UTF-8 JSON framing shared by server configuration and HTTP. */
|
||||
@SuppressWarnings("PMD")
|
||||
public final class StrictJson {
|
||||
/** Absolute technical JSON document ceiling. */
|
||||
public static final int MAXIMUM_DOCUMENT_BYTES = 16_777_216;
|
||||
private static final int MAXIMUM_CONTAINER_ENTRIES = 4_096;
|
||||
private static final JsonFactory FACTORY = createFactory();
|
||||
|
||||
private StrictJson() {
|
||||
}
|
||||
|
||||
/** Parses exactly one complete strict JSON value with duplicate detection. */
|
||||
public static PkiOperationValue parse(byte[] document, int maximumBytes) {
|
||||
if (document == null || document.length == 0 || maximumBytes < 1
|
||||
|| maximumBytes > MAXIMUM_DOCUMENT_BYTES || document.length > maximumBytes || hasBom(document)) {
|
||||
throw invalid();
|
||||
}
|
||||
try (JsonParser parser = FACTORY.createParser(ObjectReadContext.empty(), document, 0, document.length)) {
|
||||
JsonToken first = parser.nextToken();
|
||||
if (first == null) throw invalid();
|
||||
PkiOperationValue result = read(parser, first);
|
||||
if (parser.nextToken() != null) throw invalid();
|
||||
return result;
|
||||
} catch (JacksonException failure) {
|
||||
throw invalid(failure);
|
||||
}
|
||||
}
|
||||
|
||||
/** Encodes one safe operation value deterministically as UTF-8 JSON. */
|
||||
public static byte[] encode(PkiOperationValue value) {
|
||||
try {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
try (JsonGenerator generator = FACTORY.createGenerator(output)) {
|
||||
write(generator, value);
|
||||
}
|
||||
return output.toByteArray();
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Safe JSON response cannot be encoded");
|
||||
}
|
||||
}
|
||||
|
||||
private static PkiOperationValue read(JsonParser parser, JsonToken token) {
|
||||
return switch (token) {
|
||||
case START_OBJECT -> readObject(parser);
|
||||
case START_ARRAY -> readArray(parser);
|
||||
case VALUE_STRING -> new PkiOperationValue.Text(parser.getString());
|
||||
case VALUE_NUMBER_INT -> new PkiOperationValue.IntegerValue(parser.getLongValue());
|
||||
case VALUE_TRUE -> new PkiOperationValue.BooleanValue(true);
|
||||
case VALUE_FALSE -> new PkiOperationValue.BooleanValue(false);
|
||||
default -> throw invalid();
|
||||
};
|
||||
}
|
||||
|
||||
private static PkiOperationValue.ObjectValue readObject(JsonParser parser) {
|
||||
Map<String, PkiOperationValue> fields = new LinkedHashMap<>();
|
||||
while (parser.nextToken() != JsonToken.END_OBJECT) {
|
||||
if (parser.currentToken() != JsonToken.PROPERTY_NAME
|
||||
|| fields.size() >= MAXIMUM_CONTAINER_ENTRIES) throw invalid();
|
||||
String name = parser.currentName();
|
||||
JsonToken token = parser.nextToken();
|
||||
if (token == null || fields.putIfAbsent(name, read(parser, token)) != null) throw invalid();
|
||||
}
|
||||
return new PkiOperationValue.ObjectValue(fields);
|
||||
}
|
||||
|
||||
private static PkiOperationValue.ListValue readArray(JsonParser parser) {
|
||||
List<PkiOperationValue> values = new ArrayList<>();
|
||||
JsonToken token;
|
||||
while ((token = parser.nextToken()) != JsonToken.END_ARRAY) {
|
||||
if (token == null || values.size() >= MAXIMUM_CONTAINER_ENTRIES) throw invalid();
|
||||
values.add(read(parser, token));
|
||||
}
|
||||
return new PkiOperationValue.ListValue(values);
|
||||
}
|
||||
|
||||
static void write(JsonGenerator generator, PkiOperationValue value) throws IOException {
|
||||
switch (value) {
|
||||
case PkiOperationValue.Text text -> generator.writeString(text.value());
|
||||
case PkiOperationValue.IntegerValue integer -> generator.writeNumber(integer.value());
|
||||
case PkiOperationValue.BooleanValue bool -> generator.writeBoolean(bool.value());
|
||||
case PkiOperationValue.ObjectValue object -> {
|
||||
generator.writeStartObject();
|
||||
for (Map.Entry<String, PkiOperationValue> field : object.fields().entrySet()) {
|
||||
generator.writeName(field.getKey());
|
||||
write(generator, field.getValue());
|
||||
}
|
||||
generator.writeEndObject();
|
||||
}
|
||||
case PkiOperationValue.ListValue list -> {
|
||||
generator.writeStartArray();
|
||||
for (PkiOperationValue item : list.values()) write(generator, item);
|
||||
generator.writeEndArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonFactory createFactory() {
|
||||
StreamReadConstraints constraints = StreamReadConstraints.builder().maxNestingDepth(16)
|
||||
.maxDocumentLength(MAXIMUM_DOCUMENT_BYTES).maxTokenCount(65_536).maxNumberLength(20)
|
||||
.maxStringLength(16_384).maxNameLength(128).build();
|
||||
JsonFactoryBuilder builder = JsonFactory.builder().streamReadConstraints(constraints)
|
||||
.enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION).disable(StreamReadFeature.AUTO_CLOSE_SOURCE);
|
||||
for (JsonReadFeature feature : JsonReadFeature.values()) builder.disable(feature);
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private static boolean hasBom(byte[] value) {
|
||||
return value.length >= 3 && value[0] == (byte) 0xef && value[1] == (byte) 0xbb
|
||||
&& value[2] == (byte) 0xbf;
|
||||
}
|
||||
|
||||
private static IllegalArgumentException invalid() {
|
||||
return new IllegalArgumentException("Server JSON document is invalid");
|
||||
}
|
||||
|
||||
private static IllegalArgumentException invalid(JacksonException failure) {
|
||||
return new IllegalArgumentException("Server JSON document is invalid", failure);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.server.http;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.ServiceLoader;
|
||||
import java.util.Set;
|
||||
|
||||
import javax.net.ssl.SSLContext;
|
||||
|
||||
import zeroecho.pki.server.spi.PkiServerTlsProvider;
|
||||
import zeroecho.pki.spi.ProviderConfig;
|
||||
|
||||
/** Deterministic explicit TLS-provider selection. */
|
||||
@SuppressWarnings("PMD")
|
||||
final class TlsProviders {
|
||||
private TlsProviders() {
|
||||
}
|
||||
|
||||
static SSLContext create(ProviderConfig configuration, ClassLoader loader) {
|
||||
Objects.requireNonNull(configuration, "configuration");
|
||||
List<PkiServerTlsProvider> providers = new ArrayList<>();
|
||||
ServiceLoader.load(PkiServerTlsProvider.class, Objects.requireNonNull(loader, "loader"))
|
||||
.forEach(providers::add);
|
||||
providers.sort(Comparator.comparing(PkiServerTlsProvider::id));
|
||||
Set<String> ids = new HashSet<>();
|
||||
if (providers.stream().anyMatch(provider -> provider.id() == null || provider.id().isBlank()
|
||||
|| !ids.add(provider.id()))) {
|
||||
throw new IllegalStateException("TLS provider identities are ambiguous");
|
||||
}
|
||||
PkiServerTlsProvider selected = providers.stream()
|
||||
.filter(provider -> provider.id().equals(configuration.backendId()))
|
||||
.findFirst().orElseThrow(() -> new IllegalStateException("Configured TLS provider is unavailable"));
|
||||
try {
|
||||
return selected.create(configuration);
|
||||
} catch (RuntimeException failure) {
|
||||
throw failure;
|
||||
} catch (Exception failure) {
|
||||
throw new IllegalStateException("TLS provider initialization failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.server.spi;
|
||||
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import zeroecho.pki.server.RealmId;
|
||||
|
||||
/**
|
||||
* Bounded read-only mutual-TLS authentication input, independent of operation
|
||||
* arguments.
|
||||
*
|
||||
* @param peerCertificates trusted JSSE peer chain, leaf first
|
||||
* @param protocol negotiated TLS protocol
|
||||
* @param cipherSuite negotiated cipher suite
|
||||
* @param requestId safe request correlation identity
|
||||
* @param realmId exact server realm
|
||||
*/
|
||||
public record PkiServerAuthenticationContext(List<X509Certificate> peerCertificates, String protocol,
|
||||
String cipherSuite, String requestId, RealmId realmId) {
|
||||
/** Validates and snapshots the finite authentication context. */
|
||||
public PkiServerAuthenticationContext {
|
||||
peerCertificates = List.copyOf(Objects.requireNonNull(peerCertificates, "peerCertificates"));
|
||||
if (peerCertificates.isEmpty() || peerCertificates.size() > 64) {
|
||||
throw new IllegalArgumentException("Peer certificate chain length is invalid");
|
||||
}
|
||||
if (protocol == null || protocol.isBlank() || protocol.length() > 64
|
||||
|| cipherSuite == null || cipherSuite.isBlank() || cipherSuite.length() > 128) {
|
||||
throw new IllegalArgumentException("TLS session metadata is invalid");
|
||||
}
|
||||
if (requestId == null || !requestId.matches("[A-Za-z0-9_-]{16,128}")) {
|
||||
throw new IllegalArgumentException("Request correlation identity is invalid");
|
||||
}
|
||||
Objects.requireNonNull(realmId, "realmId");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.server.spi;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Safe closed mutual-TLS authentication result. */
|
||||
public sealed interface PkiServerAuthenticationResult permits PkiServerAuthenticationResult.Authenticated,
|
||||
PkiServerAuthenticationResult.Rejected {
|
||||
/** Successfully resolved persisted principal identity. */
|
||||
record Authenticated(String principalId) implements PkiServerAuthenticationResult {
|
||||
/** Validates the finite principal identity. */
|
||||
public Authenticated {
|
||||
if (principalId == null || !principalId.matches("[a-zA-Z0-9][a-zA-Z0-9._:-]{0,255}")) {
|
||||
throw new IllegalArgumentException("Principal identity is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Authentication rejection containing only a stable safe code. */
|
||||
record Rejected(Code code) implements PkiServerAuthenticationResult {
|
||||
/** Validates the rejection. */ public Rejected { Objects.requireNonNull(code, "code"); }
|
||||
}
|
||||
|
||||
/** Safe non-disclosing rejection codes. */
|
||||
enum Code {
|
||||
CERTIFICATE_INVALID, MAPPING_UNAVAILABLE, MAPPING_AMBIGUOUS, PRINCIPAL_DISABLED, INTERNAL_FAILURE
|
||||
}
|
||||
|
||||
/** @return principal identity when authentication succeeded */
|
||||
default Optional<String> resolvedPrincipalId() {
|
||||
return this instanceof Authenticated authenticated
|
||||
? Optional.of(authenticated.principalId()) : Optional.empty();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.server.spi;
|
||||
|
||||
import javax.net.ssl.SSLContext;
|
||||
|
||||
import zeroecho.pki.spi.ProviderConfig;
|
||||
|
||||
/**
|
||||
* Explicit server-local boundary for lifecycle-confined JSSE key and trust
|
||||
* material.
|
||||
*
|
||||
* <p>Implementations may load a private key only inside JSSE key-manager
|
||||
* construction. They must not return key stores, keys, passwords, aliases, or
|
||||
* provider exception details. Providers are discovered deterministically but are
|
||||
* activated only by an exact configured {@link #id()}.</p>
|
||||
*/
|
||||
@SuppressWarnings("PMD")
|
||||
public interface PkiServerTlsProvider {
|
||||
/** @return stable explicit provider identity */
|
||||
String id();
|
||||
|
||||
/**
|
||||
* Creates one fully initialized mutual-TLS context.
|
||||
*
|
||||
* @param configuration opaque provider configuration that must be validated strictly
|
||||
* @return initialized JSSE context containing server identity and client trust
|
||||
* @throws Exception when material is unavailable or invalid
|
||||
*/
|
||||
SSLContext create(ProviderConfig configuration) throws Exception;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
zeroecho.pki.server.http.Pkcs12TlsProvider
|
||||
Reference in New Issue
Block a user