feat(pki-server): add public PKI repository API
Add the disclosure-controlled public certificate, chain, CRL and status repository with capability-based unlisted access, bounded streaming, conditional caching and isolated public execution resources. Introduce authoritative issuer generations and explicit chain paths so issuance bundles and stable public chain routes never rely on inferred certificate ordering or runtime path guessing.
This commit is contained in:
@@ -49,7 +49,8 @@ import zeroecho.pki.api.PkiId;
|
||||
|
||||
/** Durable disclosure authority and capability-based retrieval decision service. */
|
||||
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.AvoidLiteralsInIfCondition",
|
||||
"PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.AvoidSynchronizedAtMethodLevel" })
|
||||
"PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.AvoidSynchronizedAtMethodLevel",
|
||||
"PMD.CyclomaticComplexity" })
|
||||
public final class DisclosureService {
|
||||
/** Durable one-time delivery classification without retaining the raw token. */
|
||||
public enum DeliveryState {
|
||||
@@ -267,18 +268,56 @@ public final class DisclosureService {
|
||||
return switch (record.policy()) {
|
||||
case PUBLIC -> Decision.ALLOWED;
|
||||
case PUBLIC_UNLISTED -> capabilityToken.filter(token -> validCapability(record.objectId(), token))
|
||||
.isPresent() ? Decision.ALLOWED : Decision.DENIED;
|
||||
.isPresent() || principal.isPresent() && (ownerRelationship || explicitAdministrativePermission)
|
||||
? Decision.ALLOWED : Decision.DENIED;
|
||||
case AUTHENTICATED -> principal.filter(SecurityPrincipal::enabled)
|
||||
.filter(value -> value.type() != SecurityPrincipal.Type.PUBLIC)
|
||||
.map(ignored -> Decision.ALLOWED).orElse(Decision.DENIED);
|
||||
case OWNER_ONLY -> ownerRelationship || explicitAdministrativePermission
|
||||
? Decision.ALLOWED : Decision.DENIED;
|
||||
case RESTRICTED -> explicitAdministrativePermission ? Decision.ALLOWED : Decision.DENIED;
|
||||
case NOT_PUBLISHED -> explicitAdministrativePermission || ownerRelationship
|
||||
? Decision.ALLOWED : Decision.DENIED;
|
||||
case NOT_PUBLISHED -> Decision.DENIED;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates public repository retrieval using a point-addressable bearer
|
||||
* credential. Capability and principal authority are not combined.
|
||||
*/
|
||||
public synchronized Decision decideRepository(PkiId objectId, Optional<SecurityPrincipal> principal,
|
||||
boolean ownerRelationship, boolean explicitAdministrativePermission, Optional<String> bearer) {
|
||||
Record record = store.requireDisclosure(objectId);
|
||||
if (bearer.isPresent()) {
|
||||
return record.policy() == Policy.PUBLIC_UNLISTED
|
||||
&& validBearer(objectId, bearer.orElseThrow()) ? Decision.ALLOWED : Decision.DENIED;
|
||||
}
|
||||
return switch (record.policy()) {
|
||||
case PUBLIC -> Decision.ALLOWED;
|
||||
case PUBLIC_UNLISTED -> principal.isPresent() && (ownerRelationship || explicitAdministrativePermission)
|
||||
? Decision.ALLOWED : Decision.DENIED;
|
||||
case AUTHENTICATED -> principal.filter(SecurityPrincipal::enabled)
|
||||
.filter(value -> value.type() != SecurityPrincipal.Type.PUBLIC)
|
||||
.map(ignored -> Decision.ALLOWED).orElse(Decision.DENIED);
|
||||
case OWNER_ONLY -> ownerRelationship || explicitAdministrativePermission
|
||||
? Decision.ALLOWED : Decision.DENIED;
|
||||
case RESTRICTED -> explicitAdministrativePermission ? Decision.ALLOWED : Decision.DENIED;
|
||||
case NOT_PUBLISHED -> Decision.DENIED;
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns the strict one-time bearer credential for an issued capability. */
|
||||
public static String bearerCredential(Capability capability, byte[] token) {
|
||||
Objects.requireNonNull(capability, "capability");
|
||||
return bearerCredential(capability.capabilityId(), token);
|
||||
}
|
||||
|
||||
/** Returns the strict bearer credential for one non-secret capability ID. */
|
||||
public static String bearerCredential(String capabilityId, byte[] token) {
|
||||
Permission.requireId(capabilityId, "capability");
|
||||
if (token == null || token.length != 32) throw new IllegalArgumentException("Capability token length");
|
||||
return capabilityId + '.' + java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(token);
|
||||
}
|
||||
|
||||
/** Issues 256 random capability bits and persists only their commitment. */
|
||||
public synchronized IssuedCapability issueCapability(PkiId objectId, Instant expiresAt,
|
||||
String actorPrincipalId) {
|
||||
@@ -335,6 +374,32 @@ public final class DisclosureService {
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean validBearer(PkiId objectId, String bearer) {
|
||||
if (bearer == null || !bearer.matches("cap-[0-9a-f]{32}\\.[A-Za-z0-9_-]{43}")) return false;
|
||||
int separator = bearer.indexOf('.');
|
||||
DisclosureService.Capability capability;
|
||||
try {
|
||||
capability = store.requireCapability(bearer.substring(0, separator));
|
||||
} catch (IllegalArgumentException unavailable) {
|
||||
return false;
|
||||
}
|
||||
byte[] token;
|
||||
try {
|
||||
token = java.util.Base64.getUrlDecoder().decode(bearer.substring(separator + 1));
|
||||
} catch (IllegalArgumentException malformed) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return token.length == 32 && !capability.revoked() && capability.realmId().equals(realmId)
|
||||
&& capability.objectId().equals(objectId) && "RETRIEVE".equals(capability.action())
|
||||
&& clock.instant().isBefore(capability.expiresAt())
|
||||
&& MessageDigest.isEqual(capability.tokenCommitment(),
|
||||
capabilityCommitment(objectId, capability.expiresAt(), token));
|
||||
} finally {
|
||||
java.util.Arrays.fill(token, (byte) 0);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] capabilityCommitment(PkiId objectId, Instant expiry, byte[] token) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
|
||||
@@ -140,7 +140,15 @@ public final class OperationSecurityDescriptors {
|
||||
control(ServerControlOperation.RevokeCapability.NAME, Permission.Action.DISCLOSURE_CAPABILITY_REVOKE, Permission.ResourceType.CAPABILITY, true, false),
|
||||
control(ServerControlOperation.InspectAuditView.REDACTED, Permission.Action.AUDIT_READ_REDACTED, Permission.ResourceType.AUDIT, false, false),
|
||||
control(ServerControlOperation.InspectAuditView.FULL, Permission.Action.AUDIT_READ_FULL, Permission.ResourceType.AUDIT, false, false),
|
||||
control(ServerControlOperation.InspectAuditView.PII, Permission.Action.AUDIT_READ_PII, Permission.ResourceType.AUDIT, false, false)));
|
||||
control(ServerControlOperation.InspectAuditView.PII, Permission.Action.AUDIT_READ_PII, Permission.ResourceType.AUDIT, false, false),
|
||||
control(ServerControlOperation.InspectRepositoryAlias.NAME, Permission.Action.REPOSITORY_ALIAS_READ,
|
||||
Permission.ResourceType.REPOSITORY_ALIAS, false, false),
|
||||
control(ServerControlOperation.ListRepositoryAliases.NAME, Permission.Action.REPOSITORY_ALIAS_READ,
|
||||
Permission.ResourceType.REPOSITORY_ALIAS, false, false),
|
||||
control(ServerControlOperation.SetRepositoryAlias.NAME, Permission.Action.REPOSITORY_ALIAS_MANAGE,
|
||||
Permission.ResourceType.REPOSITORY_ALIAS, true, true),
|
||||
control(ServerControlOperation.RemoveRepositoryAlias.NAME, Permission.Action.REPOSITORY_ALIAS_MANAGE,
|
||||
Permission.ResourceType.REPOSITORY_ALIAS, true, true)));
|
||||
}
|
||||
|
||||
/** Creates a registry and rejects duplicate operation identities. */
|
||||
@@ -360,6 +368,18 @@ public final class OperationSecurityDescriptors {
|
||||
case ServerControlOperation.RevokeCapability value -> "capability=" + atom(value.capabilityId());
|
||||
case ServerControlOperation.InspectAuditView value -> "object=" + atom(value.objectId().value())
|
||||
+ ";view=" + value.view().name();
|
||||
case ServerControlOperation.InspectRepositoryAlias value -> "authority="
|
||||
+ atom(value.authorityId().value()) + ";type=" + value.type().name();
|
||||
case ServerControlOperation.ListRepositoryAliases value -> "offset=" + value.offset()
|
||||
+ ";limit=" + value.limit();
|
||||
case ServerControlOperation.SetRepositoryAlias value -> "authority="
|
||||
+ atom(value.authorityId().value()) + ";type=" + value.type().name() + ";target="
|
||||
+ atom(value.targetObjectId().value()) + ";issuer="
|
||||
+ atom(value.issuerId().map(zeroecho.pki.api.PkiId::value).orElse("")) + ";expected="
|
||||
+ atom(value.expectedCurrentCommitment().orElse(""));
|
||||
case ServerControlOperation.RemoveRepositoryAlias value -> "authority="
|
||||
+ atom(value.authorityId().value()) + ";type=" + value.type().name() + ";expected="
|
||||
+ atom(value.expectedCurrentCommitment());
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ public final class Permission {
|
||||
AUDIT_READ_REDACTED(120), AUDIT_READ_FULL(121), AUDIT_READ_PII(122), AUDIT_EXPORT(123),
|
||||
AUDIT_INTEGRITY_VERIFY(124), BACKUP_EXPORT(140), BACKUP_VERIFY(141), RESTORE_EXECUTE(142),
|
||||
DISCLOSURE_READ(160), DISCLOSURE_CHANGE(161), DISCLOSURE_CAPABILITY_ISSUE(162),
|
||||
DISCLOSURE_CAPABILITY_REVOKE(163);
|
||||
DISCLOSURE_CAPABILITY_REVOKE(163), REPOSITORY_ALIAS_READ(164), REPOSITORY_ALIAS_MANAGE(165);
|
||||
|
||||
private final int code;
|
||||
|
||||
@@ -112,7 +112,7 @@ public final class Permission {
|
||||
REALM(1), SERVER_CONFIGURATION(2), PRINCIPAL(3), ROLE(4), GRANT(5), AUTHORITY(10),
|
||||
ISSUER(11), PROFILE(12), POLICY(13), X509_BINDING(14), REQUEST(20), CERTIFICATE(21),
|
||||
REVOCATION(22), STATUS_OBJECT(23), PUBLICATION(24), AUDIT(30), BACKUP(31), RESTORE(32),
|
||||
DISCLOSURE(33), CAPABILITY(34), APPROVAL(35), BREAK_GLASS(36);
|
||||
DISCLOSURE(33), CAPABILITY(34), APPROVAL(35), BREAK_GLASS(36), REPOSITORY_ALIAS(37);
|
||||
private final int code;
|
||||
ResourceType(int code) { this.code = code; }
|
||||
/** @return stable code */ public int code() { return code; }
|
||||
|
||||
@@ -38,6 +38,7 @@ import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import javax.net.ssl.SSLContext;
|
||||
@@ -45,6 +46,7 @@ import javax.net.ssl.SSLContext;
|
||||
import zeroecho.pki.application.PkiSessionRuntimeDependencies;
|
||||
import zeroecho.pki.server.http.AdministrativeAuthenticator;
|
||||
import zeroecho.pki.server.http.PkiHttpsTransport;
|
||||
import zeroecho.pki.server.http.PublicRepositoryTransport;
|
||||
import zeroecho.pki.server.spi.PkiServerAuthenticator;
|
||||
|
||||
/**
|
||||
@@ -64,15 +66,21 @@ public final class PkiHttpsServer implements AutoCloseable {
|
||||
private final ServerRealmContext realm;
|
||||
private final PkiServerAuthenticator authenticator;
|
||||
private final PkiHttpsTransport transport;
|
||||
private final Optional<PkiServerAuthenticator> publicAuthenticator;
|
||||
private final Optional<PublicRepositoryTransport> publicTransport;
|
||||
private final AtomicReference<State> state;
|
||||
|
||||
private PkiHttpsServer(PkiServerConfiguration configuration, ServerRealmContext realm,
|
||||
PkiServerAuthenticator authenticator, PkiHttpsTransport transport,
|
||||
Optional<PkiServerAuthenticator> publicAuthenticator,
|
||||
Optional<PublicRepositoryTransport> publicTransport,
|
||||
AtomicReference<State> state) {
|
||||
this.configuration = configuration;
|
||||
this.realm = realm;
|
||||
this.authenticator = authenticator;
|
||||
this.transport = transport;
|
||||
this.publicAuthenticator = publicAuthenticator;
|
||||
this.publicTransport = publicTransport;
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
@@ -101,6 +109,8 @@ public final class PkiHttpsServer implements AutoCloseable {
|
||||
ServerRealmContext realm = null;
|
||||
PkiServerAuthenticator authenticator = null;
|
||||
PkiHttpsTransport transport = null;
|
||||
PkiServerAuthenticator publicAuthenticator = null;
|
||||
PublicRepositoryTransport publicTransport = null;
|
||||
try {
|
||||
SSLContext tls = PkiHttpsTransport.resolveTls(exact, loader);
|
||||
realm = ServerRealmContext.open(exact.realm(), runtimeDependencies, clock, random);
|
||||
@@ -119,11 +129,23 @@ public final class PkiHttpsServer implements AutoCloseable {
|
||||
ServerRealmContext sharedRealm = realm;
|
||||
transport = PkiHttpsTransport.startResolved(exact, sharedRealm, authenticator, clock, random, tls,
|
||||
() -> state.get() == State.READY && sharedRealm.state() == ServerRealmContext.State.OPEN);
|
||||
if (exact.publicListener().isPresent()) {
|
||||
PkiServerConfiguration.PublicListener publicConfiguration = exact.publicListener().orElseThrow();
|
||||
validateTrustedProxies(publicConfiguration.authentication(), realm);
|
||||
AdministrativeAuthenticator resolvedPublicAuthenticator = AdministrativeAuthenticator.create(
|
||||
publicConfiguration.authentication(), realm::principal,
|
||||
realm.gateway()::authorizeForwardedIdentity, clock, loader);
|
||||
publicAuthenticator = resolvedPublicAuthenticator;
|
||||
publicTransport = PublicRepositoryTransport.start(publicConfiguration, sharedRealm,
|
||||
resolvedPublicAuthenticator, clock, random, loader,
|
||||
() -> 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);
|
||||
return new PkiHttpsServer(exact, realm, authenticator, transport,
|
||||
Optional.ofNullable(publicAuthenticator), Optional.ofNullable(publicTransport), state);
|
||||
} catch (RuntimeException | Error primary) {
|
||||
closePartial(transport, realm, authenticator, state, primary);
|
||||
closePartial(publicTransport, publicAuthenticator, transport, realm, authenticator, state, primary);
|
||||
throw primary;
|
||||
}
|
||||
}
|
||||
@@ -139,6 +161,12 @@ public final class PkiHttpsServer implements AutoCloseable {
|
||||
return transport.address();
|
||||
}
|
||||
|
||||
/** @return actual public listener address when the repository listener is enabled */
|
||||
public Optional<InetSocketAddress> publicAddress() {
|
||||
if (state.get() == State.TERMINATED) throw new IllegalStateException("HTTPS server is terminated");
|
||||
return publicTransport.map(PublicRepositoryTransport::address);
|
||||
}
|
||||
|
||||
/** @return the one lifecycle-owned realm context */
|
||||
public ServerRealmContext realm() {
|
||||
if (state.get() != State.READY) throw new IllegalStateException("HTTPS server is not ready");
|
||||
@@ -174,25 +202,50 @@ public final class PkiHttpsServer implements AutoCloseable {
|
||||
primary = failure;
|
||||
}
|
||||
transport.quiesce();
|
||||
publicTransport.ifPresent(PublicRepositoryTransport::quiesce);
|
||||
try {
|
||||
if (publicTransport.isPresent()) {
|
||||
publicTransport.orElseThrow().shutdown(configuration.publicListener().orElseThrow()
|
||||
.execution().gracefulShutdown());
|
||||
}
|
||||
} catch (Throwable failure) {
|
||||
primary = suppress(primary, failure);
|
||||
}
|
||||
try {
|
||||
transport.shutdown(configuration.execution().gracefulShutdown());
|
||||
} catch (Throwable failure) {
|
||||
primary = suppress(primary, failure);
|
||||
}
|
||||
if (publicAuthenticator.isPresent()) primary = close(publicAuthenticator.orElseThrow(), primary);
|
||||
primary = close(authenticator, primary);
|
||||
primary = close(realm, primary);
|
||||
state.set(State.TERMINATED);
|
||||
rethrow(primary);
|
||||
}
|
||||
|
||||
private static void closePartial(PkiHttpsTransport transport, ServerRealmContext realm,
|
||||
private static void closePartial(PublicRepositoryTransport publicTransport,
|
||||
PkiServerAuthenticator publicAuthenticator, PkiHttpsTransport transport, ServerRealmContext realm,
|
||||
PkiServerAuthenticator authenticator, AtomicReference<State> state, Throwable primary) {
|
||||
primary = close(publicTransport, primary);
|
||||
primary = close(publicAuthenticator, primary);
|
||||
primary = close(transport, primary);
|
||||
primary = close(authenticator, primary);
|
||||
close(realm, primary);
|
||||
state.set(State.TERMINATED);
|
||||
}
|
||||
|
||||
private static void validateTrustedProxies(PkiServerConfiguration.Authentication authentication,
|
||||
ServerRealmContext realm) {
|
||||
if (authentication.mode() != AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY) return;
|
||||
for (String principalId : authentication.trustedProxyPrincipalIds()) {
|
||||
SecurityPrincipal principal = realm.principal(principalId);
|
||||
if (!principal.enabled() || principal.type() != SecurityPrincipal.Type.SERVICE) {
|
||||
throw new IllegalArgumentException("Trusted proxy principal is unavailable");
|
||||
}
|
||||
realm.gateway().validateForwardingPrincipal(principalId);
|
||||
}
|
||||
}
|
||||
|
||||
private static Throwable close(AutoCloseable resource, Throwable primary) {
|
||||
if (resource == null) return primary;
|
||||
try {
|
||||
|
||||
@@ -58,13 +58,15 @@ import zeroecho.pki.spi.ProviderConfig;
|
||||
* @param authentication strict client-certificate mappings
|
||||
* @param execution bounded execution and shutdown policy
|
||||
* @param runtime process-local capability references
|
||||
* @param publicListener optional separately bounded public repository listener
|
||||
*/
|
||||
@SuppressWarnings("PMD")
|
||||
public record PkiServerConfiguration(int version, String serverName, ServerRealmConfiguration realm,
|
||||
Listener listener, Authentication authentication, Execution execution, RuntimeCapabilities runtime) {
|
||||
Listener listener, Authentication authentication, Execution execution, RuntimeCapabilities runtime,
|
||||
Optional<PublicListener> publicListener) {
|
||||
|
||||
/** Current server configuration schema. */
|
||||
public static final int CURRENT_VERSION = 2;
|
||||
public static final int CURRENT_VERSION = 3;
|
||||
|
||||
/** Validates all security-sensitive fields before resource allocation. */
|
||||
public PkiServerConfiguration {
|
||||
@@ -75,9 +77,27 @@ public record PkiServerConfiguration(int version, String serverName, ServerRealm
|
||||
Objects.requireNonNull(authentication, "authentication");
|
||||
Objects.requireNonNull(execution, "execution");
|
||||
Objects.requireNonNull(runtime, "runtime");
|
||||
publicListener = Objects.requireNonNull(publicListener, "publicListener");
|
||||
if (!listener.clientCertificateRequired()) {
|
||||
throw new IllegalArgumentException("Administrative HTTPS requires client certificates");
|
||||
}
|
||||
publicListener.ifPresent(publicConfiguration -> {
|
||||
if (listener.port() != 0
|
||||
&& listener.port() == publicConfiguration.port()) {
|
||||
boolean addressOverlap = listener.address().equals(publicConfiguration.address())
|
||||
|| listener.address().isAnyLocalAddress()
|
||||
|| publicConfiguration.address().isAnyLocalAddress();
|
||||
if (addressOverlap) {
|
||||
throw new IllegalArgumentException("Administrative and public listeners conflict");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Creates a configuration with the public repository listener disabled. */
|
||||
public PkiServerConfiguration(int version, String serverName, ServerRealmConfiguration realm,
|
||||
Listener listener, Authentication authentication, Execution execution, RuntimeCapabilities runtime) {
|
||||
this(version, serverName, realm, listener, authentication, execution, runtime, Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,6 +348,53 @@ public record PkiServerConfiguration(int version, String serverName, ServerRealm
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Separately bounded public repository listener configuration.
|
||||
*
|
||||
* @param address canonical bind address
|
||||
* @param port TCP port, including zero for an ephemeral test listener
|
||||
* @param tlsProvider optional explicit TLS provider; absent only for explicitly permitted loopback development
|
||||
* @param allowPlaintextLoopback whether the loopback-only development exception is selected
|
||||
* @param authentication direct or trusted-proxy optional-client authentication configuration
|
||||
* @param maximumHeaderBytes bounded public request-header bytes
|
||||
* @param maximumBodyBytes bounded public request-body bytes; repository methods still reject bodies
|
||||
* @param execution isolated public transport and stream execution bounds
|
||||
* @param maximumStreamDuration finite stream deadline
|
||||
* @param publicImmutableCache immutable-object public cache duration
|
||||
* @param publicAliasCache stable-alias public cache duration
|
||||
* @param authorityListExposed whether safe public authority metadata routes are enabled
|
||||
*/
|
||||
public record PublicListener(InetAddress address, int port, Optional<ProviderConfig> tlsProvider,
|
||||
boolean allowPlaintextLoopback, Authentication authentication, int maximumHeaderBytes,
|
||||
int maximumBodyBytes, Execution execution,
|
||||
Duration maximumStreamDuration, Duration publicImmutableCache, Duration publicAliasCache,
|
||||
boolean authorityListExposed) {
|
||||
/** Validates public-listener transport policy and independent finite bounds. */
|
||||
public PublicListener {
|
||||
Objects.requireNonNull(address, "address");
|
||||
if (port < 0 || port > 65_535) throw new IllegalArgumentException("Public listener port is invalid");
|
||||
tlsProvider = Objects.requireNonNull(tlsProvider, "tlsProvider");
|
||||
Objects.requireNonNull(authentication, "authentication");
|
||||
bounded(maximumHeaderBytes, 1_024, 1_048_576, "public header bound");
|
||||
bounded(maximumBodyBytes, 1, 65_536, "public body bound");
|
||||
Objects.requireNonNull(execution, "execution");
|
||||
positive(maximumStreamDuration, Duration.ofHours(1), "maximum stream duration");
|
||||
nonNegative(publicImmutableCache, Duration.ofDays(365), "immutable cache duration");
|
||||
nonNegative(publicAliasCache, Duration.ofDays(1), "alias cache duration");
|
||||
if (tlsProvider.isEmpty() && (!allowPlaintextLoopback || !address.isLoopbackAddress())) {
|
||||
throw new IllegalArgumentException("Public listener TLS is required");
|
||||
}
|
||||
if (tlsProvider.isPresent() && allowPlaintextLoopback) {
|
||||
throw new IllegalArgumentException("Public listener transport policy is contradictory");
|
||||
}
|
||||
}
|
||||
|
||||
/** @return exact public socket address without DNS resolution */
|
||||
public InetSocketAddress socketAddress() {
|
||||
return new InetSocketAddress(address, port);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process-local capability references.
|
||||
*
|
||||
@@ -356,4 +423,11 @@ public record PkiServerConfiguration(int version, String serverName, ServerRealm
|
||||
throw new IllegalArgumentException(name + " is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private static void nonNegative(Duration value, Duration maximum, String name) {
|
||||
Objects.requireNonNull(value, name);
|
||||
if (value.isNegative() || value.compareTo(maximum) > 0) {
|
||||
throw new IllegalArgumentException(name + " is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,11 +85,12 @@ public final class PkiServerConfigurationCodec {
|
||||
/** 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");
|
||||
root.allowed(Set.of("version", "serverName", "realm", "listener", "authentication", "execution",
|
||||
"runtime", "publicListener"));
|
||||
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")));
|
||||
runtime(root.object("runtime")), publicListener(root.object("publicListener")));
|
||||
root.complete();
|
||||
return configuration;
|
||||
}
|
||||
@@ -271,6 +272,31 @@ public final class PkiServerConfigurationCodec {
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Optional<PkiServerConfiguration.PublicListener> publicListener(Fields value) {
|
||||
value.allowed(Set.of("enabled", "address", "port", "tlsProvider", "allowPlaintextLoopback", "authentication",
|
||||
"maximumHeaderBytes", "maximumBodyBytes", "execution", "maximumStreamDurationMillis",
|
||||
"publicImmutableCacheMillis",
|
||||
"publicAliasCacheMillis", "authorityListExposed"));
|
||||
if (!value.bool("enabled")) {
|
||||
value.exact("enabled");
|
||||
value.complete();
|
||||
return Optional.empty();
|
||||
}
|
||||
Optional<ProviderConfig> tlsProvider = value.optionalObject("tlsProvider")
|
||||
.map(PkiServerConfigurationCodec::provider);
|
||||
PkiServerConfiguration.PublicListener result = new PkiServerConfiguration.PublicListener(
|
||||
address(value.text("address")), value.integer("port"), tlsProvider,
|
||||
value.bool("allowPlaintextLoopback"), authentication(value.object("authentication")),
|
||||
value.integer("maximumHeaderBytes"), value.integer("maximumBodyBytes"),
|
||||
execution(value.object("execution")),
|
||||
Duration.ofMillis(value.longValue("maximumStreamDurationMillis")),
|
||||
Duration.ofMillis(value.longValue("publicImmutableCacheMillis")),
|
||||
Duration.ofMillis(value.longValue("publicAliasCacheMillis")),
|
||||
value.bool("authorityListExposed"));
|
||||
value.complete();
|
||||
return Optional.of(result);
|
||||
}
|
||||
|
||||
private static PkiServerConfiguration.RuntimeCapabilities runtime(Fields value) {
|
||||
value.allowed(Set.of("keyUnlockEnvironmentVariable"));
|
||||
PkiServerConfiguration.RuntimeCapabilities result = new PkiServerConfiguration.RuntimeCapabilities(
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
/*******************************************************************************
|
||||
* 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.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.api.ca.CaRecord;
|
||||
import zeroecho.pki.api.ca.IssuerChainPath;
|
||||
import zeroecho.pki.api.ca.IssuerGeneration;
|
||||
import zeroecho.pki.api.credential.Credential;
|
||||
import zeroecho.pki.api.status.StatusObject;
|
||||
import zeroecho.pki.api.status.StatusObjectType;
|
||||
import zeroecho.pki.application.PkiRepository;
|
||||
import zeroecho.pki.application.PkiRepositoryContent;
|
||||
|
||||
/** Transport-neutral, read-only disclosed repository gateway. */
|
||||
@SuppressWarnings({ "PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.ExcessiveParameterList" })
|
||||
public final class PublicRepositoryGateway {
|
||||
/** Cache policy classes with distinct privacy semantics. */
|
||||
public enum CacheClass { PUBLIC_IMMUTABLE, PUBLIC_ALIAS, PRIVATE_AUTHENTICATED, NO_STORE_CAPABILITY }
|
||||
|
||||
/** Safe public authority metadata. */
|
||||
public record Authority(PkiId authorityId, zeroecho.pki.api.ca.CaKind kind,
|
||||
List<PkiId> issuerIds, PkiId currentIssuanceIssuerId) {
|
||||
/** Snapshots finite issuer identities. */
|
||||
public Authority { issuerIds = List.copyOf(issuerIds); }
|
||||
}
|
||||
|
||||
/** Exact explicit chain path represented as ordered immutable certificate links. */
|
||||
public record Chain(PkiId authorityId, PkiId issuerId, PkiId pathId,
|
||||
List<PkiId> orderedCredentialIds, String commitment, String etag, CacheClass cacheClass) {
|
||||
/** Snapshots exact order. */
|
||||
public Chain { orderedCredentialIds = List.copyOf(orderedCredentialIds); }
|
||||
}
|
||||
|
||||
/** Lifecycle-owned binary response descriptor. */
|
||||
public record Content(PkiRepositoryContent lease, String mediaType, String etag, CacheClass cacheClass)
|
||||
implements AutoCloseable {
|
||||
/** Validates response metadata. */
|
||||
public Content {
|
||||
Objects.requireNonNull(lease); Permission.requireBounded(mediaType, 128, "media type");
|
||||
Permission.requireBounded(etag, 256, "ETag"); Objects.requireNonNull(cacheClass);
|
||||
}
|
||||
/** Closes the underlying immutable-content lease. */
|
||||
@Override public void close() throws java.io.IOException { lease.close(); }
|
||||
}
|
||||
|
||||
/** Safe request identity, without certificate or bearer-token logging fields. */
|
||||
public record Access(Optional<SecurityPrincipal> principal, Optional<String> capabilityBearer,
|
||||
String correlationId) {
|
||||
/** Validates mutually exclusive principal and capability authority. */
|
||||
public Access {
|
||||
principal = Objects.requireNonNull(principal); capabilityBearer = Objects.requireNonNull(capabilityBearer);
|
||||
Permission.requireBounded(correlationId, 256, "correlation ID");
|
||||
if (principal.isPresent() && capabilityBearer.isPresent()) {
|
||||
throw new IllegalArgumentException("Principal and capability authority cannot be combined");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final RealmId realmId;
|
||||
private final AuthorityExposurePolicy exposure;
|
||||
private final PkiRepository repository;
|
||||
private final ServerControlStore control;
|
||||
private final RoleTemplateCatalog roles;
|
||||
private final AuthorizationEngine authorization;
|
||||
private final BreakGlassService breakGlass;
|
||||
private final DisclosureService disclosure;
|
||||
private final RepositoryAliasService aliases;
|
||||
private final Runnable openCheck;
|
||||
|
||||
/** Creates one gateway over the realm's existing authorities. */
|
||||
/* default */ PublicRepositoryGateway(RealmId realmId, AuthorityExposurePolicy exposure,
|
||||
PkiRepository repository,
|
||||
ServerControlStore control, RoleTemplateCatalog roles, AuthorizationEngine authorization,
|
||||
BreakGlassService breakGlass, DisclosureService disclosure, RepositoryAliasService aliases,
|
||||
Runnable openCheck) {
|
||||
this.realmId = Objects.requireNonNull(realmId); this.exposure = Objects.requireNonNull(exposure);
|
||||
this.repository = Objects.requireNonNull(repository); this.control = Objects.requireNonNull(control);
|
||||
this.roles = Objects.requireNonNull(roles); this.authorization = Objects.requireNonNull(authorization);
|
||||
this.breakGlass = Objects.requireNonNull(breakGlass); this.disclosure = Objects.requireNonNull(disclosure);
|
||||
this.aliases = Objects.requireNonNull(aliases); this.openCheck = Objects.requireNonNull(openCheck);
|
||||
}
|
||||
|
||||
/** Returns one bounded keyset page of exposed public authority metadata. */
|
||||
public List<Authority> authorities(Optional<PkiId> after, int limit) {
|
||||
openCheck.run();
|
||||
if (limit <= 0 || limit > 1_000) {
|
||||
throw new IllegalArgumentException("Public authority limit is invalid");
|
||||
}
|
||||
List<Authority> result = new ArrayList<>(limit);
|
||||
Optional<PkiId> cursor = Objects.requireNonNull(after, "after");
|
||||
while (result.size() < limit) {
|
||||
List<CaRecord> page = repository.authorities(cursor, Math.min(1_000, Math.max(64, limit)));
|
||||
if (page.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
for (CaRecord value : page) {
|
||||
if (exposure.allows(value.caId())) {
|
||||
result.add(authority(value));
|
||||
if (result.size() == limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor = Optional.of(page.getLast().caId());
|
||||
if (page.size() < Math.min(1_000, Math.max(64, limit))) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/** Returns one exact exposed authority. */
|
||||
public Authority authority(PkiId authorityId) {
|
||||
return authority(requireAuthority(authorityId));
|
||||
}
|
||||
|
||||
/** Opens one exact issuer-generation certificate after disclosure enforcement. */
|
||||
public Content issuerCertificate(PkiId authorityId, PkiId issuerId, Access access) {
|
||||
requireAuthority(authorityId);
|
||||
IssuerGeneration issuer = repository.issuer(issuerId).orElseThrow(PublicRepositoryGateway::unavailable);
|
||||
if (!authorityId.equals(issuer.authorityId())) {
|
||||
throw unavailable();
|
||||
}
|
||||
Credential credential = repository.credential(issuer.credentialId())
|
||||
.orElseThrow(PublicRepositoryGateway::unavailable);
|
||||
AccessDecision decision = decide(credential.credentialId(), authorityId,
|
||||
DisclosureService.ObjectType.CA_CERTIFICATE, true, Permission.Action.CERTIFICATE_DOWNLOAD, access);
|
||||
return content(repository.openCredential(credential.credentialId()), "application/pkix-cert", decision);
|
||||
}
|
||||
|
||||
/** Resolves one explicit chain path without constructing or preferring a path. */
|
||||
public Chain chainPath(PkiId authorityId, PkiId issuerId, PkiId pathId, Access access) {
|
||||
requireAuthority(authorityId);
|
||||
IssuerChainPath path = repository.chainPath(pathId).orElseThrow(PublicRepositoryGateway::unavailable);
|
||||
if (!authorityId.equals(path.authorityId()) || !issuerId.equals(path.issuerId())) {
|
||||
throw unavailable();
|
||||
}
|
||||
AccessDecision decision = decide(pathId, authorityId, DisclosureService.ObjectType.CA_CHAIN, true,
|
||||
Permission.Action.CA_CHAIN_DOWNLOAD, access);
|
||||
return chain(path, decision, path.pathCommitment(), false);
|
||||
}
|
||||
|
||||
/** Resolves the stable chain route only through CURRENT_CHAIN metadata. */
|
||||
public Chain currentChain(PkiId authorityId, Access access) {
|
||||
requireAuthority(authorityId);
|
||||
RepositoryAliasService.Record alias = aliases.require(authorityId, RepositoryAliasService.Type.CURRENT_CHAIN);
|
||||
if (alias.state() != RepositoryAliasService.State.ACTIVE) {
|
||||
throw unavailable();
|
||||
}
|
||||
IssuerChainPath path = repository.chainPath(alias.pathId().orElseThrow())
|
||||
.orElseThrow(PublicRepositoryGateway::unavailable);
|
||||
if (!authorityId.equals(path.authorityId()) || !alias.issuerId().orElseThrow().equals(path.issuerId())
|
||||
|| !alias.targetCommitment().equals(path.pathCommitment())) {
|
||||
throw unavailable();
|
||||
}
|
||||
AccessDecision decision = decide(path.pathId(), authorityId, DisclosureService.ObjectType.CA_CHAIN, true,
|
||||
Permission.Action.CA_CHAIN_DOWNLOAD, access);
|
||||
return chain(path, decision, alias.recordCommitment(), true);
|
||||
}
|
||||
|
||||
/** Opens one exact leaf certificate; object identity alone grants no access. */
|
||||
public Content credential(PkiId credentialId, Access access) {
|
||||
Credential credential = repository.credential(credentialId).orElseThrow(PublicRepositoryGateway::unavailable);
|
||||
PkiId authorityId = credential.issuerRef().caId();
|
||||
requireAuthority(authorityId);
|
||||
AccessDecision decision = decide(credentialId, authorityId, DisclosureService.ObjectType.LEAF_CERTIFICATE,
|
||||
false, Permission.Action.CERTIFICATE_DOWNLOAD, access);
|
||||
return content(repository.openCredential(credentialId), "application/pkix-cert", decision);
|
||||
}
|
||||
|
||||
/** Opens one exact status object after role and disclosure checks. */
|
||||
public Content statusObject(PkiId statusObjectId, Access access) {
|
||||
StatusObject status = repository.statusObject(statusObjectId).orElseThrow(PublicRepositoryGateway::unavailable);
|
||||
requireAuthority(status.issuerCaId());
|
||||
boolean crl = status.type() == StatusObjectType.CRL;
|
||||
DisclosureService.ObjectType type = crl ? DisclosureService.ObjectType.CRL
|
||||
: DisclosureService.ObjectType.STATUS_OBJECT;
|
||||
AccessDecision decision = decide(statusObjectId, status.issuerCaId(), type, crl,
|
||||
crl ? Permission.Action.CRL_DOWNLOAD : Permission.Action.CERTIFICATE_READ_CONTENT, access);
|
||||
return content(repository.openStatusObject(statusObjectId), crl ? "application/pkix-crl"
|
||||
: "application/octet-stream", decision);
|
||||
}
|
||||
|
||||
/** Resolves the stable current-CRL route only through CURRENT_CRL metadata. */
|
||||
public Content currentCrl(PkiId authorityId, Access access) {
|
||||
requireAuthority(authorityId);
|
||||
RepositoryAliasService.Record alias = aliases.require(authorityId, RepositoryAliasService.Type.CURRENT_CRL);
|
||||
if (alias.state() != RepositoryAliasService.State.ACTIVE) {
|
||||
throw unavailable();
|
||||
}
|
||||
StatusObject status = repository.statusObject(alias.targetObjectId())
|
||||
.orElseThrow(PublicRepositoryGateway::unavailable);
|
||||
if (!authorityId.equals(status.issuerCaId()) || status.type() != StatusObjectType.CRL
|
||||
|| !alias.targetCommitment().equals(status.content().sha256())) {
|
||||
throw unavailable();
|
||||
}
|
||||
AccessDecision decision = decide(status.statusObjectId(), authorityId, DisclosureService.ObjectType.CRL,
|
||||
true, Permission.Action.CRL_DOWNLOAD, access);
|
||||
PkiRepositoryContent lease = repository.openStatusObject(status.statusObjectId());
|
||||
return new Content(lease, "application/pkix-crl", etag(alias.recordCommitment(), decision.commitment()),
|
||||
decision.cacheClass() == CacheClass.PUBLIC_IMMUTABLE ? CacheClass.PUBLIC_ALIAS
|
||||
: decision.cacheClass());
|
||||
}
|
||||
|
||||
private AccessDecision decide(PkiId objectId, PkiId authorityId, DisclosureService.ObjectType type,
|
||||
boolean normativePublic, Permission.Action action, Access access) {
|
||||
Objects.requireNonNull(access);
|
||||
Optional<DisclosureService.Record> record = control.findDisclosure(objectId);
|
||||
if (record.isEmpty()) {
|
||||
if (!normativePublic || access.capabilityBearer().isPresent()) {
|
||||
throw unavailable();
|
||||
}
|
||||
DisclosureService.Policy policy = disclosure.defaultPolicy(type, false, false);
|
||||
if (policy != DisclosureService.Policy.PUBLIC) {
|
||||
throw unavailable();
|
||||
}
|
||||
return new AccessDecision(CacheClass.PUBLIC_IMMUTABLE, "default:" + type.name());
|
||||
}
|
||||
DisclosureService.Record exact = record.orElseThrow();
|
||||
if (exact.objectType() != type) {
|
||||
throw unavailable();
|
||||
}
|
||||
boolean owner = access.principal().map(SecurityPrincipal::principalId)
|
||||
.flatMap(id -> exact.ownerPrincipalId().filter(id::equals)).isPresent();
|
||||
boolean permitted = access.principal().filter(SecurityPrincipal::enabled)
|
||||
.map(principal -> explicit(principal, action, authorityId, objectId, owner, type)).orElse(false);
|
||||
if (disclosure.decideRepository(objectId, access.principal(), owner, permitted,
|
||||
access.capabilityBearer()) != DisclosureService.Decision.ALLOWED) {
|
||||
throw unavailable();
|
||||
}
|
||||
CacheClass cache = access.capabilityBearer().isPresent() ? CacheClass.NO_STORE_CAPABILITY
|
||||
: access.principal().isPresent() ? CacheClass.PRIVATE_AUTHENTICATED
|
||||
: CacheClass.PUBLIC_IMMUTABLE;
|
||||
return new AccessDecision(cache, exact.policyCommitment() + ':' + exact.updatedAt().toEpochMilli());
|
||||
}
|
||||
|
||||
private boolean explicit(SecurityPrincipal principal, Permission.Action action, PkiId authorityId,
|
||||
PkiId objectId, boolean owner, DisclosureService.ObjectType objectType) {
|
||||
List<Permission.Grant> grants = new ArrayList<>(control.grantsFor(principal.principalId()));
|
||||
for (RoleTemplateCatalog.Assignment assignment : control.assignmentsFor(principal.principalId())) {
|
||||
grants.addAll(roles.instantiate(assignment));
|
||||
}
|
||||
BreakGlassService.ActiveGrants emergency = breakGlass.activeFor(principal.principalId());
|
||||
grants.addAll(emergency.grants());
|
||||
Permission.ResourceType resourceType = switch (objectType) {
|
||||
case CRL, STATUS_OBJECT -> Permission.ResourceType.STATUS_OBJECT;
|
||||
case CA_CHAIN -> Permission.ResourceType.AUTHORITY;
|
||||
case CA_CERTIFICATE, LEAF_CERTIFICATE -> Permission.ResourceType.CERTIFICATE;
|
||||
};
|
||||
Permission.Resource resource = new Permission.Resource(resourceType,
|
||||
new Permission.Scope(realmId, Optional.of(authorityId), Optional.empty(), Optional.empty()),
|
||||
Optional.of(objectId), Optional.empty());
|
||||
return authorization.authorize(new AuthorizationEngine.Request(realmId, exposure, principal, action,
|
||||
resource, owner ? Permission.Relationship.OWN : Permission.Relationship.ANY,
|
||||
Permission.DataView.CONTENT_FULL, Permission.Context.empty(), grants, emergency.grantIds())).allowed();
|
||||
}
|
||||
|
||||
private CaRecord requireAuthority(PkiId authorityId) {
|
||||
openCheck.run();
|
||||
if (!exposure.allows(authorityId)) {
|
||||
throw unavailable();
|
||||
}
|
||||
return repository.authority(authorityId).orElseThrow(PublicRepositoryGateway::unavailable);
|
||||
}
|
||||
|
||||
private static Content content(PkiRepositoryContent lease, String mediaType, AccessDecision decision) {
|
||||
return new Content(lease, mediaType, etag(lease.sha256(), decision.commitment()), decision.cacheClass());
|
||||
}
|
||||
|
||||
private static Chain chain(IssuerChainPath path, AccessDecision decision, String routeCommitment,
|
||||
boolean aliasRoute) {
|
||||
return new Chain(path.authorityId(), path.issuerId(), path.pathId(), path.orderedCredentialIds(),
|
||||
path.pathCommitment(), etag(routeCommitment, decision.commitment()),
|
||||
aliasRoute && decision.cacheClass() == CacheClass.PUBLIC_IMMUTABLE ? CacheClass.PUBLIC_ALIAS
|
||||
: decision.cacheClass());
|
||||
}
|
||||
|
||||
private static Authority authority(CaRecord value) {
|
||||
return new Authority(value.caId(), value.kind(), value.issuerIds(), value.currentIssuanceIssuerId());
|
||||
}
|
||||
|
||||
private static String etag(String first, String second) {
|
||||
try {
|
||||
String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest((first + '\n' + second).getBytes(StandardCharsets.UTF_8)));
|
||||
return '"' + hash + '"';
|
||||
} catch (NoSuchAlgorithmException impossible) {
|
||||
throw new IllegalStateException("SHA-256 unavailable", impossible);
|
||||
}
|
||||
}
|
||||
|
||||
private static IllegalArgumentException unavailable() {
|
||||
return new IllegalArgumentException("Repository resource unavailable");
|
||||
}
|
||||
|
||||
private record AccessDecision(CacheClass cacheClass, String commitment) { }
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/*******************************************************************************
|
||||
* 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.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.api.ca.IssuerChainPath;
|
||||
import zeroecho.pki.api.ca.IssuerGeneration;
|
||||
import zeroecho.pki.api.status.StatusObject;
|
||||
import zeroecho.pki.api.status.StatusObjectType;
|
||||
import zeroecho.pki.application.PkiRepository;
|
||||
|
||||
/** Durable non-authoritative repository alias administration. */
|
||||
@SuppressWarnings({ "PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.LinguisticNaming",
|
||||
"PMD.ExcessiveParameterList" })
|
||||
public final class RepositoryAliasService {
|
||||
/** Closed stable repository alias types. */
|
||||
public enum Type {
|
||||
/** Stable route to one explicit immutable CRL. */
|
||||
CURRENT_CRL(1),
|
||||
/** Stable route to one explicit immutable issuer chain path. */
|
||||
CURRENT_CHAIN(2);
|
||||
private final int code;
|
||||
Type(int code) { this.code = code; }
|
||||
/** @return stable persistence code */ public int code() { return code; }
|
||||
/** Resolves a stable persistence code. */
|
||||
public static Type fromCode(int code) {
|
||||
return switch (code) { case 1 -> CURRENT_CRL; case 2 -> CURRENT_CHAIN;
|
||||
default -> throw new IllegalArgumentException("Unknown repository alias type"); };
|
||||
}
|
||||
}
|
||||
|
||||
/** Durable alias state; removal never deletes its immutable target. */
|
||||
public enum State {
|
||||
/** Alias resolves its exact target. */ ACTIVE(1),
|
||||
/** Alias has been deliberately removed. */ REMOVED(2);
|
||||
private final int code;
|
||||
State(int code) { this.code = code; }
|
||||
/** @return stable persistence code */ public int code() { return code; }
|
||||
/** Resolves a stable persistence code. */
|
||||
public static State fromCode(int code) {
|
||||
return switch (code) { case 1 -> ACTIVE; case 2 -> REMOVED;
|
||||
default -> throw new IllegalArgumentException("Unknown repository alias state"); };
|
||||
}
|
||||
}
|
||||
|
||||
/** Immutable finite alias metadata; no content bytes or filesystem paths. */
|
||||
public record Record(String aliasId, RealmId realmId, PkiId authorityId, Type type, PkiId targetObjectId,
|
||||
Optional<PkiId> issuerId, Optional<PkiId> pathId, String targetCommitment, Instant createdAt,
|
||||
Instant updatedAt, String actorPrincipalId, State state, String recordCommitment) {
|
||||
/** Validates exact alias identity and commitments. */
|
||||
public Record {
|
||||
Permission.requireId(aliasId, "repository alias");
|
||||
Objects.requireNonNull(realmId, "realmId");
|
||||
Objects.requireNonNull(authorityId, "authorityId");
|
||||
Objects.requireNonNull(type, "type");
|
||||
Objects.requireNonNull(targetObjectId, "targetObjectId");
|
||||
issuerId = Objects.requireNonNull(issuerId, "issuerId");
|
||||
pathId = Objects.requireNonNull(pathId, "pathId");
|
||||
requireDigest(targetCommitment);
|
||||
Objects.requireNonNull(createdAt, "createdAt");
|
||||
Objects.requireNonNull(updatedAt, "updatedAt");
|
||||
Permission.requirePrincipal(actorPrincipalId);
|
||||
Objects.requireNonNull(state, "state");
|
||||
requireDigest(recordCommitment);
|
||||
if (!aliasId.equals(idFor(realmId, authorityId, type))) {
|
||||
throw new IllegalArgumentException("Repository alias identity mismatch");
|
||||
}
|
||||
if (type == Type.CURRENT_CHAIN != issuerId.isPresent()
|
||||
|| type == Type.CURRENT_CHAIN != pathId.isPresent()) {
|
||||
throw new IllegalArgumentException("Repository alias target shape mismatch");
|
||||
}
|
||||
if (!recordCommitment.equals(commitmentFor(realmId, authorityId, type, targetObjectId,
|
||||
issuerId, pathId, targetCommitment, state))) {
|
||||
throw new IllegalArgumentException("Repository alias record commitment mismatch");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final RealmId realmId;
|
||||
private final ServerControlStore store;
|
||||
private final PkiRepository repository;
|
||||
private final DisclosureService disclosure;
|
||||
private final Clock clock;
|
||||
private final SafeAudit audit;
|
||||
|
||||
/** Creates a repository alias authority over one realm control store. */
|
||||
public RepositoryAliasService(RealmId realmId, ServerControlStore store, PkiRepository repository,
|
||||
DisclosureService disclosure, Clock clock, zeroecho.pki.spi.audit.AuditSink auditSink) {
|
||||
this.realmId = Objects.requireNonNull(realmId, "realmId");
|
||||
this.store = Objects.requireNonNull(store, "store");
|
||||
this.repository = Objects.requireNonNull(repository, "repository");
|
||||
this.disclosure = Objects.requireNonNull(disclosure, "disclosure");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
this.audit = new SafeAudit(clock, auditSink);
|
||||
}
|
||||
|
||||
/** Returns an exact alias, including a durable removed record. */
|
||||
public Record require(PkiId authorityId, Type type) {
|
||||
return store.requireRepositoryAlias(idFor(realmId, authorityId, type));
|
||||
}
|
||||
|
||||
/** Returns a bounded alias page. */
|
||||
public ServerControlStore.Page<Record> list(int offset, int limit) {
|
||||
return store.repositoryAliases(offset, limit);
|
||||
}
|
||||
|
||||
/** Atomically publishes a validated current-CRL alias. */
|
||||
public Record setCurrentCrl(PkiId authorityId, PkiId statusObjectId, Optional<String> expected,
|
||||
String actorPrincipalId) {
|
||||
StatusObject status = repository.statusObject(statusObjectId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Status object is unavailable"));
|
||||
if (!authorityId.equals(status.issuerCaId()) || status.type() != StatusObjectType.CRL) {
|
||||
throw new IllegalArgumentException("Current CRL target is invalid");
|
||||
}
|
||||
requirePublic(statusObjectId, DisclosureService.ObjectType.CRL, true);
|
||||
return set(authorityId, Type.CURRENT_CRL, statusObjectId, Optional.empty(), Optional.empty(),
|
||||
status.content().sha256(), expected, actorPrincipalId);
|
||||
}
|
||||
|
||||
/** Atomically publishes one exact current public chain path. */
|
||||
public Record setCurrentChain(PkiId authorityId, PkiId issuerId, PkiId pathId, Optional<String> expected,
|
||||
String actorPrincipalId) {
|
||||
IssuerGeneration issuer = repository.issuer(issuerId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Issuer generation is unavailable"));
|
||||
IssuerChainPath path = repository.chainPath(pathId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Issuer chain path is unavailable"));
|
||||
if (!authorityId.equals(issuer.authorityId()) || !authorityId.equals(path.authorityId())
|
||||
|| !issuerId.equals(path.issuerId())) {
|
||||
throw new IllegalArgumentException("Current chain target is outside the authority");
|
||||
}
|
||||
for (PkiId credentialId : path.orderedCredentialIds()) {
|
||||
requirePublic(credentialId, DisclosureService.ObjectType.CA_CERTIFICATE, false);
|
||||
}
|
||||
return set(authorityId, Type.CURRENT_CHAIN, pathId, Optional.of(issuerId), Optional.of(pathId),
|
||||
path.pathCommitment(), expected, actorPrincipalId);
|
||||
}
|
||||
|
||||
/** Atomically removes an alias without deleting its target. */
|
||||
public Record remove(PkiId authorityId, Type type, String expectedCommitment, String actorPrincipalId) {
|
||||
Record current = require(authorityId, type);
|
||||
if (current.state() == State.REMOVED) {
|
||||
return current;
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
Record removed = record(current.realmId(), current.authorityId(), current.type(), current.targetObjectId(),
|
||||
current.issuerId(), current.pathId(), current.targetCommitment(), current.createdAt(), now,
|
||||
actorPrincipalId, State.REMOVED);
|
||||
store.replaceRepositoryAlias(current, removed, Optional.of(expectedCommitment));
|
||||
audit.record("REPOSITORY_ALIAS_REMOVE", actorPrincipalId, Optional.of(current.targetObjectId()),
|
||||
Map.of("type", type.name()));
|
||||
return removed;
|
||||
}
|
||||
|
||||
private Record set(PkiId authorityId, Type type, PkiId targetId, Optional<PkiId> issuerId,
|
||||
Optional<PkiId> pathId, String targetCommitment, Optional<String> expected, String actor) {
|
||||
Instant now = clock.instant();
|
||||
String aliasId = idFor(realmId, authorityId, type);
|
||||
Optional<Record> current = store.findRepositoryAlias(aliasId);
|
||||
if (current.isPresent() && expected.isEmpty()) {
|
||||
Record existing = current.orElseThrow();
|
||||
if (existing.state() == State.ACTIVE && existing.targetObjectId().equals(targetId)
|
||||
&& existing.issuerId().equals(issuerId) && existing.pathId().equals(pathId)
|
||||
&& existing.targetCommitment().equals(targetCommitment)) {
|
||||
return existing;
|
||||
}
|
||||
throw new IllegalStateException("Repository alias update requires expected-current commitment");
|
||||
}
|
||||
Record value = record(realmId, authorityId, type, targetId, issuerId, pathId, targetCommitment,
|
||||
current.map(Record::createdAt).orElse(now), now, actor, State.ACTIVE);
|
||||
if (current.isEmpty()) {
|
||||
if (expected.isPresent()) {
|
||||
throw new IllegalStateException("Repository alias expectation conflicts");
|
||||
}
|
||||
store.createRepositoryAlias(value);
|
||||
} else {
|
||||
store.replaceRepositoryAlias(current.orElseThrow(), value, expected);
|
||||
}
|
||||
audit.record("REPOSITORY_ALIAS_SET", actor, Optional.of(targetId), Map.of("type", type.name()));
|
||||
return value;
|
||||
}
|
||||
|
||||
private void requirePublic(PkiId objectId, DisclosureService.ObjectType type, boolean crl) {
|
||||
DisclosureService.Policy effective = store.findDisclosure(objectId).map(DisclosureService.Record::policy)
|
||||
.orElseGet(() -> disclosure.defaultPolicy(type, false, false));
|
||||
if (effective != DisclosureService.Policy.PUBLIC) {
|
||||
throw new SecurityException(crl ? "CRL is not public" : "Chain credential is not public");
|
||||
}
|
||||
}
|
||||
|
||||
private static Record record(RealmId realmId, PkiId authorityId, Type type, PkiId target,
|
||||
Optional<PkiId> issuer, Optional<PkiId> path, String targetCommitment, Instant createdAt,
|
||||
Instant updatedAt, String actor, State state) {
|
||||
String commitment = commitmentFor(realmId, authorityId, type, target, issuer, path, targetCommitment, state);
|
||||
return new Record(idFor(realmId, authorityId, type), realmId, authorityId, type, target, issuer, path,
|
||||
targetCommitment, createdAt, updatedAt, actor, state, commitment);
|
||||
}
|
||||
|
||||
/** Returns the deterministic alias ID for one realm, authority, and type. */
|
||||
public static String idFor(RealmId realmId, PkiId authorityId, Type type) {
|
||||
return "repository-alias-" + digest(realmId.value() + "\n" + authorityId.value() + "\n" + type.name())
|
||||
.substring(0, 32);
|
||||
}
|
||||
|
||||
private static String commitmentFor(RealmId realmId, PkiId authorityId, Type type, PkiId target,
|
||||
Optional<PkiId> issuer, Optional<PkiId> path, String targetCommitment, State state) {
|
||||
return digest(String.join("\n", realmId.value(), authorityId.value(), type.name(), target.value(),
|
||||
issuer.map(PkiId::value).orElse(""), path.map(PkiId::value).orElse(""), targetCommitment,
|
||||
state.name()));
|
||||
}
|
||||
|
||||
private static String digest(String value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException impossible) {
|
||||
throw new IllegalStateException("SHA-256 unavailable", impossible);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireDigest(String value) {
|
||||
if (value == null || !value.matches("[0-9a-f]{64}")) {
|
||||
throw new IllegalArgumentException("Repository alias commitment is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,9 @@ public sealed interface ServerControlOperation permits ServerControlOperation.Re
|
||||
ServerControlOperation.InspectBreakGlass, ServerControlOperation.ListBreakGlass,
|
||||
ServerControlOperation.RevokeBreakGlass, ServerControlOperation.InspectDisclosure,
|
||||
ServerControlOperation.SetDisclosure, ServerControlOperation.IssueCapability,
|
||||
ServerControlOperation.RevokeCapability, ServerControlOperation.InspectAuditView {
|
||||
ServerControlOperation.RevokeCapability, ServerControlOperation.InspectAuditView,
|
||||
ServerControlOperation.InspectRepositoryAlias, ServerControlOperation.ListRepositoryAliases,
|
||||
ServerControlOperation.SetRepositoryAlias, ServerControlOperation.RemoveRepositoryAlias {
|
||||
|
||||
/** @return stable operation identity */
|
||||
String name();
|
||||
@@ -271,6 +273,41 @@ public sealed interface ServerControlOperation permits ServerControlOperation.Re
|
||||
case METADATA_FULL, CONTENT_FULL -> FULL; case PII_FULL -> PII; }; }
|
||||
}
|
||||
|
||||
/** Exact repository alias inspection. */
|
||||
record InspectRepositoryAlias(PkiId authorityId, RepositoryAliasService.Type type)
|
||||
implements ServerControlOperation {
|
||||
public static final String NAME = "repository.alias.inspect";
|
||||
public InspectRepositoryAlias { Objects.requireNonNull(authorityId); Objects.requireNonNull(type); }
|
||||
@Override public String name() { return NAME; }
|
||||
}
|
||||
|
||||
/** Bounded repository alias page. */
|
||||
record ListRepositoryAliases(int offset, int limit) implements ServerControlOperation {
|
||||
public static final String NAME = "repository.alias.list";
|
||||
public ListRepositoryAliases { page(offset, limit); }
|
||||
@Override public String name() { return NAME; }
|
||||
}
|
||||
|
||||
/** Exact CRL or chain alias publication with expected-current protection. */
|
||||
record SetRepositoryAlias(PkiId authorityId, RepositoryAliasService.Type type, PkiId targetObjectId,
|
||||
Optional<PkiId> issuerId, Optional<String> expectedCurrentCommitment) implements ServerControlOperation {
|
||||
public static final String NAME = "repository.alias.set";
|
||||
public SetRepositoryAlias {
|
||||
Objects.requireNonNull(authorityId); Objects.requireNonNull(type); Objects.requireNonNull(targetObjectId);
|
||||
issuerId = Objects.requireNonNull(issuerId); expectedCurrentCommitment = Objects.requireNonNull(expectedCurrentCommitment);
|
||||
}
|
||||
@Override public String name() { return NAME; }
|
||||
}
|
||||
|
||||
/** Durable repository alias removal without target mutation. */
|
||||
record RemoveRepositoryAlias(PkiId authorityId, RepositoryAliasService.Type type,
|
||||
String expectedCurrentCommitment) implements ServerControlOperation {
|
||||
public static final String NAME = "repository.alias.remove";
|
||||
public RemoveRepositoryAlias { Objects.requireNonNull(authorityId); Objects.requireNonNull(type);
|
||||
if (expectedCurrentCommitment == null || !expectedCurrentCommitment.matches("[0-9a-f]{64}")) throw invalid(); }
|
||||
@Override public String name() { return NAME; }
|
||||
}
|
||||
|
||||
private static void page(int offset, int limit) {
|
||||
if (offset < 0 || limit <= 0 || limit > 256) throw invalid();
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ public final class ServerControlOperationExecutor {
|
||||
private final BreakGlassService breakGlass;
|
||||
private final DisclosureService disclosure;
|
||||
private final AuditorViews auditorViews;
|
||||
private final Optional<RepositoryAliasService> repositoryAliases;
|
||||
private final OperationSecurityDescriptors descriptors;
|
||||
private final Map<OperationSecurityDescriptors.ApprovalCategory, ApprovalService.Policy> approvalPolicies;
|
||||
|
||||
@@ -68,6 +69,7 @@ public final class ServerControlOperationExecutor {
|
||||
ServerControlStore store, RoleTemplateCatalog roles, AuthorizationEngine authorization,
|
||||
ApprovalService approvals, BreakGlassService breakGlass, DisclosureService disclosure,
|
||||
AuditorViews auditorViews,
|
||||
Optional<RepositoryAliasService> repositoryAliases,
|
||||
OperationSecurityDescriptors descriptors,
|
||||
Map<OperationSecurityDescriptors.ApprovalCategory, ApprovalService.Policy> approvalPolicies) {
|
||||
this.realmId = Objects.requireNonNull(realmId, "realmId");
|
||||
@@ -79,6 +81,7 @@ public final class ServerControlOperationExecutor {
|
||||
this.breakGlass = Objects.requireNonNull(breakGlass, "breakGlass");
|
||||
this.disclosure = Objects.requireNonNull(disclosure, "disclosure");
|
||||
this.auditorViews = Objects.requireNonNull(auditorViews, "auditorViews");
|
||||
this.repositoryAliases = Objects.requireNonNull(repositoryAliases, "repositoryAliases");
|
||||
this.descriptors = Objects.requireNonNull(descriptors, "descriptors");
|
||||
this.approvalPolicies = Map.copyOf(Objects.requireNonNull(approvalPolicies, "approvalPolicies"));
|
||||
}
|
||||
@@ -165,6 +168,29 @@ public final class ServerControlOperationExecutor {
|
||||
case ServerControlOperation.RevokeCapability value -> ordinary(operation,
|
||||
capability(disclosure.revokeCapability(value.capabilityId(), actor)));
|
||||
case ServerControlOperation.InspectAuditView value -> ordinary(operation, auditView(value, actor));
|
||||
case ServerControlOperation.InspectRepositoryAlias value -> ordinary(operation,
|
||||
alias(aliases().require(value.authorityId(), value.type())));
|
||||
case ServerControlOperation.ListRepositoryAliases value -> ordinary(operation,
|
||||
page(aliases().list(value.offset(), value.limit()), ServerControlOperationExecutor::alias));
|
||||
case ServerControlOperation.SetRepositoryAlias value -> ordinary(operation,
|
||||
alias(publishAlias(value, actor)));
|
||||
case ServerControlOperation.RemoveRepositoryAlias value -> ordinary(operation,
|
||||
alias(aliases().remove(value.authorityId(), value.type(), value.expectedCurrentCommitment(), actor)));
|
||||
};
|
||||
}
|
||||
|
||||
private RepositoryAliasService aliases() {
|
||||
return repositoryAliases.orElseThrow(() -> new IllegalStateException("Repository aliases are unavailable"));
|
||||
}
|
||||
|
||||
private RepositoryAliasService.Record publishAlias(ServerControlOperation.SetRepositoryAlias value,
|
||||
String actor) {
|
||||
return switch (value.type()) {
|
||||
case CURRENT_CRL -> aliases().setCurrentCrl(value.authorityId(), value.targetObjectId(),
|
||||
value.expectedCurrentCommitment(), actor);
|
||||
case CURRENT_CHAIN -> aliases().setCurrentChain(value.authorityId(),
|
||||
value.issuerId().orElseThrow(() -> new IllegalArgumentException("issuerId is required")),
|
||||
value.targetObjectId(), value.expectedCurrentCommitment(), actor);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -291,6 +317,19 @@ public final class ServerControlOperationExecutor {
|
||||
return object("principalId", text(value.principalId()), "type", text(value.type().name()),
|
||||
"displayName", text(value.displayName()), "enabled", bool(value.enabled()));
|
||||
}
|
||||
|
||||
private static PkiOperationValue alias(RepositoryAliasService.Record value) {
|
||||
Map<String, PkiOperationValue> fields = new LinkedHashMap<>();
|
||||
fields.put("aliasId", text(value.aliasId()));
|
||||
fields.put("authorityId", text(value.authorityId().value()));
|
||||
fields.put("type", text(value.type().name()));
|
||||
fields.put("targetObjectId", text(value.targetObjectId().value()));
|
||||
value.issuerId().ifPresent(id -> fields.put("issuerId", text(id.value())));
|
||||
value.pathId().ifPresent(id -> fields.put("pathId", text(id.value())));
|
||||
fields.put("state", text(value.state().name()));
|
||||
fields.put("recordCommitment", text(value.recordCommitment()));
|
||||
return new PkiOperationValue.ObjectValue(fields);
|
||||
}
|
||||
private static PkiOperationValue template(RoleTemplateCatalog.Template value) {
|
||||
List<PkiOperationValue> actions = value.actions().stream().sorted(Comparator.comparingInt(Permission.Action::code))
|
||||
.map(item -> (PkiOperationValue) text(item.name())).toList();
|
||||
|
||||
@@ -79,7 +79,7 @@ import zeroecho.pki.spi.store.TransactionalMetadataStore;
|
||||
"PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.AvoidSynchronizedAtMethodLevel",
|
||||
"PMD.ControlStatementBraces", "PMD.PreserveStackTrace", "PMD.CloseResource",
|
||||
"PMD.UseEnumCollections", "PMD.CommentRequired", "PMD.UncommentedEmptyMethodBody",
|
||||
"PMD.TooManyMethods" })
|
||||
"PMD.TooManyMethods", "PMD.ExcessivePublicCount" })
|
||||
public final class ServerControlStore implements AutoCloseable {
|
||||
/** Stable namespace for the realm record. */ public static final String REALM = "io.zeroecho.server.realm";
|
||||
/** Stable namespace for principals. */ public static final String PRINCIPAL = "io.zeroecho.server.principal";
|
||||
@@ -89,9 +89,11 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
/** Stable namespace for break-glass records. */ public static final String BREAK_GLASS = "io.zeroecho.server.break-glass";
|
||||
/** Stable namespace for disclosure records. */ public static final String DISCLOSURE = "io.zeroecho.server.disclosure";
|
||||
/** Stable namespace for capability commitments. */ public static final String CAPABILITY = "io.zeroecho.server.capability";
|
||||
/** Stable namespace for non-authoritative public repository aliases. */
|
||||
public static final String REPOSITORY_ALIAS = "io.zeroecho.server.repository-alias";
|
||||
|
||||
private static final int MAGIC = 0x5a455331;
|
||||
private static final int SCHEMA = 2;
|
||||
private static final int SCHEMA = 3;
|
||||
private static final int MAXIMUM_RECORD_BYTES = 1_048_576;
|
||||
private static final int MAXIMUM_STRING_BYTES = 16_384;
|
||||
private static final int MAXIMUM_COLLECTION = 4_096;
|
||||
@@ -103,6 +105,7 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
private static final int KIND_BREAK_GLASS = 6;
|
||||
private static final int KIND_DISCLOSURE = 7;
|
||||
private static final int KIND_CAPABILITY = 8;
|
||||
private static final int KIND_REPOSITORY_ALIAS = 9;
|
||||
|
||||
/**
|
||||
* Durable realm-control identity and commitments.
|
||||
@@ -308,6 +311,11 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
.orElseThrow(() -> new IllegalArgumentException("Disclosure record is unavailable"));
|
||||
}
|
||||
|
||||
/** Finds one disclosure record without converting absence into an error. */
|
||||
public synchronized Optional<DisclosureService.Record> findDisclosure(PkiId objectId) {
|
||||
return read(DISCLOSURE, objectId.value(), KIND_DISCLOSURE, ServerControlStore::readDisclosure);
|
||||
}
|
||||
|
||||
/** Returns a bounded stable page of disclosure records. */
|
||||
public synchronized Page<DisclosureService.Record> disclosures(int offset, int limit) {
|
||||
return scanPage(DISCLOSURE, KIND_DISCLOSURE, ServerControlStore::readDisclosure, offset, limit);
|
||||
@@ -346,6 +354,43 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
.filter(item -> item.objectId().equals(objectId)).toList();
|
||||
}
|
||||
|
||||
/** Creates one exact repository alias record. */
|
||||
public synchronized void createRepositoryAlias(RepositoryAliasService.Record value) {
|
||||
create(REPOSITORY_ALIAS, value.aliasId(), output -> writeRepositoryAlias(output, value));
|
||||
}
|
||||
|
||||
/** Finds one exact repository alias record. */
|
||||
public synchronized Optional<RepositoryAliasService.Record> findRepositoryAlias(String aliasId) {
|
||||
return read(REPOSITORY_ALIAS, aliasId, KIND_REPOSITORY_ALIAS, ServerControlStore::readRepositoryAlias);
|
||||
}
|
||||
|
||||
/** Returns one exact repository alias record. */
|
||||
public synchronized RepositoryAliasService.Record requireRepositoryAlias(String aliasId) {
|
||||
return findRepositoryAlias(aliasId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Repository alias is unavailable"));
|
||||
}
|
||||
|
||||
/** Atomically replaces an alias after exact expected-current validation. */
|
||||
public synchronized void replaceRepositoryAlias(RepositoryAliasService.Record current,
|
||||
RepositoryAliasService.Record updated, Optional<String> expectedCurrentCommitment) {
|
||||
requireSame(current.aliasId(), updated.aliasId());
|
||||
RepositoryAliasService.Record authoritative = requireRepositoryAlias(current.aliasId());
|
||||
if (!authoritative.equals(current)) {
|
||||
throw new IllegalStateException("Repository alias changed concurrently");
|
||||
}
|
||||
if (expectedCurrentCommitment.isPresent()
|
||||
&& !expectedCurrentCommitment.orElseThrow().equals(current.recordCommitment())) {
|
||||
throw new IllegalStateException("Repository alias expected-current commitment differs");
|
||||
}
|
||||
replace(REPOSITORY_ALIAS, current.aliasId(), output -> writeRepositoryAlias(output, updated));
|
||||
}
|
||||
|
||||
/** Returns a bounded repository alias page. */
|
||||
public synchronized Page<RepositoryAliasService.Record> repositoryAliases(int offset, int limit) {
|
||||
return scanPage(REPOSITORY_ALIAS, KIND_REPOSITORY_ALIAS, ServerControlStore::readRepositoryAlias,
|
||||
offset, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strictly decodes and cross-checks every authoritative control record from
|
||||
* stable metadata snapshots before the realm is exposed.
|
||||
@@ -359,6 +404,7 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
scan(BREAK_GLASS, KIND_BREAK_GLASS, ServerControlStore::readBreakGlass);
|
||||
scan(DISCLOSURE, KIND_DISCLOSURE, ServerControlStore::readDisclosure);
|
||||
scan(CAPABILITY, KIND_CAPABILITY, ServerControlStore::readCapability);
|
||||
scan(REPOSITORY_ALIAS, KIND_REPOSITORY_ALIAS, ServerControlStore::readRepositoryAlias);
|
||||
}
|
||||
|
||||
/** Validates durable relationships against the active strict role catalog. */
|
||||
@@ -392,6 +438,10 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
: scan(CAPABILITY, KIND_CAPABILITY, ServerControlStore::readCapability)) {
|
||||
requirePrincipalReference(principals, capability.issuerPrincipalId());
|
||||
}
|
||||
for (RepositoryAliasService.Record alias
|
||||
: scan(REPOSITORY_ALIAS, KIND_REPOSITORY_ALIAS, ServerControlStore::readRepositoryAlias)) {
|
||||
requirePrincipalReference(principals, alias.actorPrincipalId());
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens no sidecar authority and closes the sole metadata authority idempotently. */
|
||||
@@ -555,6 +605,7 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
case BreakGlassService.Record item -> item.breakGlassId();
|
||||
case DisclosureService.Record item -> item.objectId().value();
|
||||
case DisclosureService.Capability item -> item.capabilityId();
|
||||
case RepositoryAliasService.Record item -> item.aliasId();
|
||||
default -> throw new IllegalArgumentException("Unsupported control record type");
|
||||
};
|
||||
}
|
||||
@@ -704,6 +755,32 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
DisclosureService.DeliveryState.fromCode(in.readInt()), in.readBoolean());
|
||||
}
|
||||
|
||||
private static void writeRepositoryAlias(DataOutputStream out, RepositoryAliasService.Record value)
|
||||
throws IOException {
|
||||
out.writeInt(KIND_REPOSITORY_ALIAS);
|
||||
writeString(out, value.aliasId());
|
||||
writeString(out, value.realmId().value());
|
||||
writeString(out, value.authorityId().value());
|
||||
out.writeInt(value.type().code());
|
||||
writeString(out, value.targetObjectId().value());
|
||||
writeOptionalPki(out, value.issuerId());
|
||||
writeOptionalPki(out, value.pathId());
|
||||
writeString(out, value.targetCommitment());
|
||||
writeInstant(out, value.createdAt());
|
||||
writeInstant(out, value.updatedAt());
|
||||
writeString(out, value.actorPrincipalId());
|
||||
out.writeInt(value.state().code());
|
||||
writeString(out, value.recordCommitment());
|
||||
}
|
||||
|
||||
private static RepositoryAliasService.Record readRepositoryAlias(DataInputStream in) throws IOException {
|
||||
return new RepositoryAliasService.Record(readString(in), new RealmId(readString(in)),
|
||||
new PkiId(readString(in)), RepositoryAliasService.Type.fromCode(in.readInt()),
|
||||
new PkiId(readString(in)), readOptionalPki(in), readOptionalPki(in), readString(in),
|
||||
readInstant(in), readInstant(in), readString(in), RepositoryAliasService.State.fromCode(in.readInt()),
|
||||
readString(in));
|
||||
}
|
||||
|
||||
private static void writeScope(DataOutputStream out, Permission.Scope value) throws IOException {
|
||||
writeString(out, value.realmId().value()); writeOptionalPki(out, value.authorityId());
|
||||
writeOptionalPki(out, value.issuerId()); writeOptionalString(out, value.profileId());
|
||||
|
||||
@@ -163,6 +163,7 @@ public final class ServerOperationGateway {
|
||||
public ServerOperationGateway(RealmId realmId, AuthorityExposurePolicy exposure, ServerControlStore control,
|
||||
RoleTemplateCatalog roles, AuthorizationEngine authorization, ApprovalService approvals,
|
||||
BreakGlassService breakGlass, DisclosureService disclosure, AuditorViews auditorViews,
|
||||
Optional<RepositoryAliasService> repositoryAliases,
|
||||
OperationSecurityDescriptors descriptors, PkiOperationExecutor executor,
|
||||
PkiResourceScopeResolver resourceScopes,
|
||||
Map<OperationSecurityDescriptors.ApprovalCategory, ApprovalService.Policy> approvalPolicies,
|
||||
@@ -177,7 +178,8 @@ public final class ServerOperationGateway {
|
||||
this.descriptors = Objects.requireNonNull(descriptors, "descriptors");
|
||||
this.executor = Objects.requireNonNull(executor, "executor");
|
||||
this.controlExecutor = new ServerControlOperationExecutor(realmId, exposure, control, roles,
|
||||
authorization, approvals, breakGlass, disclosure, auditorViews, descriptors, approvalPolicies);
|
||||
authorization, approvals, breakGlass, disclosure, auditorViews, repositoryAliases, descriptors,
|
||||
approvalPolicies);
|
||||
this.resourceScopes = Objects.requireNonNull(resourceScopes, "resourceScopes");
|
||||
this.approvalPolicies = Map.copyOf(Objects.requireNonNull(approvalPolicies, "approvalPolicies"));
|
||||
this.audit = new SafeAudit(clock, auditSink);
|
||||
@@ -194,7 +196,8 @@ public final class ServerOperationGateway {
|
||||
this(realmId, exposure, control, roles, authorization, approvals, breakGlass,
|
||||
new DisclosureService(realmId, control, DisclosureService.Defaults.recommended(), clock,
|
||||
new java.security.SecureRandom(), auditSink), new AuditorViews(clock, auditSink),
|
||||
descriptors, executor, resourceScopes, approvalPolicies, clock, auditSink, openCheck);
|
||||
Optional.empty(), descriptors, executor, resourceScopes, approvalPolicies, clock, auditSink,
|
||||
openCheck);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -76,6 +76,8 @@ public final class ServerRealmContext implements AutoCloseable {
|
||||
private final ApprovalService approvals;
|
||||
private final BreakGlassService breakGlass;
|
||||
private final DisclosureService disclosure;
|
||||
private final RepositoryAliasService repositoryAliases;
|
||||
private final PublicRepositoryGateway publicRepository;
|
||||
private final AuditorViews auditorViews;
|
||||
private final ServerOperationGateway gateway;
|
||||
private final SafeAudit audit;
|
||||
@@ -84,6 +86,7 @@ public final class ServerRealmContext implements AutoCloseable {
|
||||
private ServerRealmContext(ServerRealmConfiguration configuration, ServerControlStore control,
|
||||
PkiSession session, RoleTemplateCatalog roles, AuthorizationEngine authorization,
|
||||
ApprovalService approvals, BreakGlassService breakGlass, DisclosureService disclosure,
|
||||
RepositoryAliasService repositoryAliases,
|
||||
AuditorViews auditorViews, AuditSink auditSink, Clock clock) {
|
||||
this.configuration = configuration;
|
||||
this.control = control;
|
||||
@@ -93,10 +96,15 @@ public final class ServerRealmContext implements AutoCloseable {
|
||||
this.approvals = approvals;
|
||||
this.breakGlass = breakGlass;
|
||||
this.disclosure = disclosure;
|
||||
this.repositoryAliases = repositoryAliases;
|
||||
this.publicRepository = new PublicRepositoryGateway(configuration.realmId(), configuration.authorityExposure(),
|
||||
session.repository(), control, roles, authorization, breakGlass, disclosure, repositoryAliases,
|
||||
this::requireOpen);
|
||||
this.auditorViews = auditorViews;
|
||||
this.audit = new SafeAudit(clock, auditSink);
|
||||
this.gateway = new ServerOperationGateway(configuration.realmId(), configuration.authorityExposure(),
|
||||
control, roles, authorization, approvals, breakGlass, disclosure, auditorViews,
|
||||
Optional.of(repositoryAliases),
|
||||
new OperationSecurityDescriptors(),
|
||||
session.operations(), session.resourceScopes(), configuration.approvalPolicies(), clock, auditSink,
|
||||
this::requireOpen);
|
||||
@@ -143,9 +151,11 @@ public final class ServerRealmContext implements AutoCloseable {
|
||||
BreakGlassService breakGlass = new BreakGlassService(control, clock, audit);
|
||||
DisclosureService disclosure = new DisclosureService(exact.realmId(), control,
|
||||
exact.disclosureDefaults(), clock, random, audit);
|
||||
RepositoryAliasService aliases = new RepositoryAliasService(exact.realmId(), control,
|
||||
session.repository(), disclosure, clock, audit);
|
||||
AuditorViews views = new AuditorViews(clock, audit);
|
||||
ServerRealmContext result = new ServerRealmContext(exact, control, session, roles, authorization,
|
||||
approvals, breakGlass, disclosure, views, audit, clock);
|
||||
approvals, breakGlass, disclosure, aliases, views, audit, clock);
|
||||
result.audit.record("REALM_OPEN", "system", Optional.empty(), Map.of("realm", exact.realmId().value()));
|
||||
return result;
|
||||
} catch (RuntimeException | Error primary) {
|
||||
@@ -168,6 +178,10 @@ public final class ServerRealmContext implements AutoCloseable {
|
||||
public BreakGlassService breakGlass() { requireOpen(); return breakGlass; }
|
||||
/** @return durable disclosure decision service */
|
||||
public DisclosureService disclosure() { requireOpen(); return disclosure; }
|
||||
/** @return durable non-authoritative public repository alias service */
|
||||
public RepositoryAliasService repositoryAliases() { requireOpen(); return repositoryAliases; }
|
||||
/** @return read-only disclosed public repository gateway */
|
||||
public PublicRepositoryGateway publicRepository() { requireOpen(); return publicRepository; }
|
||||
/** @return explicit auditor projection service */
|
||||
public AuditorViews auditorViews() { requireOpen(); return auditorViews; }
|
||||
/** @return authorized typed-operation gateway */
|
||||
|
||||
@@ -122,6 +122,35 @@ public final class AdministrativeAuthenticator implements PkiServerAuthenticator
|
||||
return proxy(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticates only the backend transport for an anonymous public request.
|
||||
* This method is valid solely in trusted-proxy mode and never manufactures an
|
||||
* end-client principal.
|
||||
*
|
||||
* @param context backend TLS peer context without operation data
|
||||
* @return authorized trusted-proxy principal, or empty on any safe failure
|
||||
*/
|
||||
public Optional<String> authenticatePublicProxyTransport(PkiServerAuthenticationContext context) {
|
||||
Objects.requireNonNull(context, "context");
|
||||
if (configuration.mode() != AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY) {
|
||||
return Optional.empty();
|
||||
}
|
||||
PkiServerAuthenticationResult result = transportMapper.authenticate(context);
|
||||
if (!(result instanceof PkiServerAuthenticationResult.Authenticated authenticated)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String principalId = authenticated.endClientPrincipalId();
|
||||
if (!configuration.trustedProxyPrincipalIds().contains(principalId)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return forwardingAuthorization.apply(principalId, context.requestId()).allowed()
|
||||
? Optional.of(principalId) : Optional.empty();
|
||||
} catch (RuntimeException unavailable) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private PkiServerAuthenticationResult direct(PkiServerAuthenticationContext context) {
|
||||
if (ForwardedClientCertificateParser.containsForwardedIdentity(context.requestHeaders())) {
|
||||
return rejected(AdministrativeAuthenticationMode.DIRECT_MTLS, Optional.empty(),
|
||||
|
||||
@@ -56,6 +56,7 @@ import zeroecho.pki.server.ApprovalService;
|
||||
import zeroecho.pki.server.DisclosureService;
|
||||
import zeroecho.pki.server.Permission;
|
||||
import zeroecho.pki.server.RealmId;
|
||||
import zeroecho.pki.server.RepositoryAliasService;
|
||||
import zeroecho.pki.server.RoleTemplateCatalog;
|
||||
import zeroecho.pki.server.SecurityPrincipal;
|
||||
import zeroecho.pki.server.ServerControlOperation;
|
||||
@@ -366,6 +367,30 @@ final class HttpOperationCodec {
|
||||
yield new ServerControlOperation.InspectAuditView(fields.pkiId("objectId"), view,
|
||||
fields.optionalText("reasonReference"));
|
||||
}
|
||||
case ServerControlOperation.InspectRepositoryAlias.NAME -> {
|
||||
fields.exact("authorityId", "type");
|
||||
yield new ServerControlOperation.InspectRepositoryAlias(fields.pkiId("authorityId"),
|
||||
RepositoryAliasService.Type.valueOf(fields.text("type")));
|
||||
}
|
||||
case ServerControlOperation.ListRepositoryAliases.NAME -> {
|
||||
fields.exact("offset", "limit");
|
||||
yield new ServerControlOperation.ListRepositoryAliases(fields.integer("offset"),
|
||||
fields.integer("limit"));
|
||||
}
|
||||
case ServerControlOperation.SetRepositoryAlias.NAME -> {
|
||||
fields.allowed(Set.of("authorityId", "type", "targetObjectId", "issuerId",
|
||||
"expectedCurrentCommitment"));
|
||||
yield new ServerControlOperation.SetRepositoryAlias(fields.pkiId("authorityId"),
|
||||
RepositoryAliasService.Type.valueOf(fields.text("type")),
|
||||
fields.pkiId("targetObjectId"), fields.optionalText("issuerId").map(PkiId::new),
|
||||
fields.optionalText("expectedCurrentCommitment"));
|
||||
}
|
||||
case ServerControlOperation.RemoveRepositoryAlias.NAME -> {
|
||||
fields.exact("authorityId", "type", "expectedCurrentCommitment");
|
||||
yield new ServerControlOperation.RemoveRepositoryAlias(fields.pkiId("authorityId"),
|
||||
RepositoryAliasService.Type.valueOf(fields.text("type")),
|
||||
fields.text("expectedCurrentCommitment"));
|
||||
}
|
||||
default -> throw new SecurityException("Control operation is not exposed");
|
||||
};
|
||||
}
|
||||
@@ -378,6 +403,12 @@ final class HttpOperationCodec {
|
||||
case ServerControlOperation.EvaluateAuthorization value -> value.resource().scope();
|
||||
case ServerControlOperation.RequestApproval value -> value.targetResource().scope();
|
||||
case ServerControlOperation.CreateBreakGlass value -> value.grant().scope();
|
||||
case ServerControlOperation.InspectRepositoryAlias value -> authorityScope(realmId, authority,
|
||||
value.authorityId());
|
||||
case ServerControlOperation.SetRepositoryAlias value -> authorityScope(realmId, authority,
|
||||
value.authorityId());
|
||||
case ServerControlOperation.RemoveRepositoryAlias value -> authorityScope(realmId, authority,
|
||||
value.authorityId());
|
||||
default -> new Permission.Scope(realmId, authority, Optional.empty(), Optional.empty());
|
||||
};
|
||||
}
|
||||
@@ -388,10 +419,19 @@ final class HttpOperationCodec {
|
||||
case ServerControlOperation.SetDisclosure value -> Optional.of(value.objectId());
|
||||
case ServerControlOperation.IssueCapability value -> Optional.of(value.objectId());
|
||||
case ServerControlOperation.InspectAuditView value -> Optional.of(value.objectId());
|
||||
case ServerControlOperation.SetRepositoryAlias value -> Optional.of(value.targetObjectId());
|
||||
default -> Optional.empty();
|
||||
};
|
||||
}
|
||||
|
||||
private static Permission.Scope authorityScope(RealmId realmId, Optional<PkiId> requested,
|
||||
PkiId embedded) {
|
||||
if (requested.isEmpty() || !requested.orElseThrow().equals(embedded)) {
|
||||
throw new IllegalArgumentException("Repository alias and authority scope differ");
|
||||
}
|
||||
return new Permission.Scope(realmId, requested, Optional.empty(), Optional.empty());
|
||||
}
|
||||
|
||||
private static boolean realmWide(PkiOperation operation) {
|
||||
return operation instanceof PkiOperation.ListAuthorities
|
||||
|| operation instanceof PkiOperation.CreateAuthority
|
||||
|
||||
@@ -84,12 +84,12 @@ final class HttpResponses {
|
||||
byte[] token = sensitive.consumeToken();
|
||||
try {
|
||||
MapBuilder result = new MapBuilder(sensitive.safeResult().fields());
|
||||
result.put("token", new PkiOperationValue.Text(java.util.Base64.getUrlEncoder()
|
||||
.withoutPadding().encodeToString(token)));
|
||||
Response encoded = encode(200, requestId, operation, "SUCCEEDED",
|
||||
new PkiOperationValue.ObjectValue(result.values()), null);
|
||||
String capabilityId = ((PkiOperationValue.Text) sensitive.safeResult().fields()
|
||||
.get("capabilityId")).value();
|
||||
result.put("token", new PkiOperationValue.Text(
|
||||
zeroecho.pki.server.DisclosureService.bearerCredential(capabilityId, token)));
|
||||
Response encoded = encode(200, requestId, operation, "SUCCEEDED",
|
||||
new PkiOperationValue.ObjectValue(result.values()), null);
|
||||
return new Response(encoded.statusCode(), encoded.body(), true, java.util.Optional.of(capabilityId));
|
||||
} finally {
|
||||
java.util.Arrays.fill(token, (byte) 0);
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
/*******************************************************************************
|
||||
* 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.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
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.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.api.PkiId;
|
||||
import zeroecho.pki.application.PkiOperationValue;
|
||||
import zeroecho.pki.server.AdministrativeAuthenticationMode;
|
||||
import zeroecho.pki.server.PublicRepositoryGateway;
|
||||
import zeroecho.pki.server.PkiServerConfiguration;
|
||||
import zeroecho.pki.server.SecurityPrincipal;
|
||||
import zeroecho.pki.server.ServerRealmContext;
|
||||
import zeroecho.pki.server.spi.PkiServerAuthenticationContext;
|
||||
import zeroecho.pki.server.spi.PkiServerAuthenticationResult;
|
||||
|
||||
/** Exact read-only HTTP adapter for the isolated public repository namespace. */
|
||||
@SuppressWarnings("PMD")
|
||||
final class PublicRepositoryHttpHandler implements HttpHandler {
|
||||
private static final int BUFFER_SIZE = 16_384;
|
||||
private static final String JSON = "application/json; charset=utf-8";
|
||||
private final PkiServerConfiguration.PublicListener configuration;
|
||||
private final ServerRealmContext realm;
|
||||
private final AdministrativeAuthenticator authenticator;
|
||||
private final ServerRuntime runtime;
|
||||
private final Clock clock;
|
||||
private final RequestIds requestIds;
|
||||
private final BooleanSupplier ready;
|
||||
|
||||
PublicRepositoryHttpHandler(PkiServerConfiguration.PublicListener configuration,
|
||||
ServerRealmContext realm, AdministrativeAuthenticator authenticator, ServerRuntime runtime,
|
||||
Clock clock, RequestIds requestIds, BooleanSupplier ready) {
|
||||
this.configuration = java.util.Objects.requireNonNull(configuration);
|
||||
this.realm = java.util.Objects.requireNonNull(realm);
|
||||
this.authenticator = java.util.Objects.requireNonNull(authenticator);
|
||||
this.runtime = java.util.Objects.requireNonNull(runtime);
|
||||
this.clock = java.util.Objects.requireNonNull(clock);
|
||||
this.requestIds = java.util.Objects.requireNonNull(requestIds);
|
||||
this.ready = java.util.Objects.requireNonNull(ready);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(HttpExchange exchange) throws IOException {
|
||||
String requestId = "unavailable-request";
|
||||
String auditPrincipal = "anonymous";
|
||||
boolean admitted = false;
|
||||
try {
|
||||
requireHeadersBounded(exchange.getRequestHeaders());
|
||||
requireNoBody(exchange.getRequestHeaders());
|
||||
requestId = requestIds.resolve(exchange.getRequestHeaders().get(RequestIds.HEADER));
|
||||
if (!ready.getAsBoolean()) {
|
||||
sendFailure(exchange, requestId, 503, "NOT_READY");
|
||||
return;
|
||||
}
|
||||
URI uri = exchange.getRequestURI();
|
||||
if (uri.getRawFragment() != null || uri.getRawPath().indexOf('%') >= 0) {
|
||||
sendFailure(exchange, requestId, 400, "MALFORMED_REQUEST");
|
||||
return;
|
||||
}
|
||||
if (!runtime.tryAdmit()) {
|
||||
realm.auditTransport("PUBLIC_OVERLOAD", "anonymous",
|
||||
Map.of("request", requestId, "classification", "ADMISSION_REJECTED"));
|
||||
sendFailure(exchange, requestId, 429, "ADMISSION_REJECTED");
|
||||
return;
|
||||
}
|
||||
admitted = true;
|
||||
Identity identity = authenticate(exchange, requestId);
|
||||
if (!identity.accepted()) {
|
||||
sendFailure(exchange, requestId, 401, "PUBLIC_AUTHENTICATION_FAILED");
|
||||
return;
|
||||
}
|
||||
PublicRepositoryGateway.Access access = access(exchange.getRequestHeaders(), identity, requestId);
|
||||
auditPrincipal = access.principal().map(SecurityPrincipal::principalId)
|
||||
.orElseGet(() -> access.capabilityBearer().isPresent() ? "capability" : "anonymous");
|
||||
Instant deadline = clock.instant().plus(configuration.maximumStreamDuration());
|
||||
execute(exchange, uri, requestId, access, deadline);
|
||||
realm.auditTransport("PUBLIC_RETRIEVAL_COMPLETE", auditPrincipal,
|
||||
Map.of("request", requestId, "classification", "COMPLETED"));
|
||||
} catch (AuthenticationFailure rejected) {
|
||||
realm.auditTransport("PUBLIC_AUTHENTICATION_DENIED", "anonymous",
|
||||
Map.of("request", requestId, "classification", "DENIED"));
|
||||
sendFailure(exchange, requestId, 401, "PUBLIC_AUTHENTICATION_FAILED");
|
||||
} catch (MalformedRequest malformed) {
|
||||
sendFailure(exchange, requestId, 400, "MALFORMED_REQUEST");
|
||||
} catch (IllegalArgumentException unavailable) {
|
||||
realm.auditTransport("PUBLIC_RETRIEVAL_DENIED", auditPrincipal,
|
||||
Map.of("request", requestId, "classification", "NOT_DISCLOSED"));
|
||||
sendFailure(exchange, requestId, 404, "RESOURCE_UNAVAILABLE");
|
||||
} catch (RuntimeException failure) {
|
||||
sendFailure(exchange, requestId, 500, "SAFE_INTERNAL_FAILURE");
|
||||
} finally {
|
||||
if (admitted) runtime.releaseAdmission();
|
||||
exchange.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireNoBody(Headers headers) {
|
||||
if (headers.containsKey("Transfer-Encoding")) {
|
||||
throw new MalformedRequest();
|
||||
}
|
||||
List<String> contentLength = headers.get("Content-Length");
|
||||
if (contentLength != null && (contentLength.size() != 1 || !"0".equals(contentLength.get(0)))) {
|
||||
throw new MalformedRequest();
|
||||
}
|
||||
}
|
||||
|
||||
private void execute(HttpExchange exchange, URI uri, String requestId,
|
||||
PublicRepositoryGateway.Access access, Instant deadline) throws IOException {
|
||||
ServerRuntime.Submitted<Void> submitted;
|
||||
java.util.concurrent.atomic.AtomicBoolean started = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
try {
|
||||
submitted = runtime.submit(cancellation -> {
|
||||
started.set(true);
|
||||
cancellation.throwIfCancelled();
|
||||
if (!clock.instant().isBefore(deadline)) {
|
||||
throw new StreamDeadlineExceeded();
|
||||
}
|
||||
route(exchange, uri, requestId, access, deadline);
|
||||
return null;
|
||||
});
|
||||
} catch (RejectedExecutionException overloaded) {
|
||||
sendFailure(exchange, requestId, 429, "PUBLIC_STREAM_QUEUE_FULL");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
long remaining = Math.max(1L, java.time.Duration.between(clock.instant(), deadline).toMillis());
|
||||
submitted.future().get(remaining, TimeUnit.MILLISECONDS);
|
||||
} catch (TimeoutException timeout) {
|
||||
submitted.cancellation().cancel();
|
||||
submitted.future().cancel(true);
|
||||
if (!started.get()) {
|
||||
sendFailure(exchange, requestId, 504, "PUBLIC_STREAM_DEADLINE");
|
||||
}
|
||||
} catch (InterruptedException interrupted) {
|
||||
submitted.cancellation().cancel();
|
||||
submitted.future().cancel(true);
|
||||
Thread.currentThread().interrupt();
|
||||
if (!started.get()) {
|
||||
sendFailure(exchange, requestId, 504, "PUBLIC_STREAM_CANCELLED");
|
||||
}
|
||||
} catch (ExecutionException failure) {
|
||||
Throwable cause = failure.getCause();
|
||||
if (cause instanceof StreamDeadlineExceeded) {
|
||||
sendFailure(exchange, requestId, 504, "PUBLIC_STREAM_DEADLINE");
|
||||
} else if (cause instanceof IOException ioFailure) {
|
||||
throw ioFailure;
|
||||
} else if (cause instanceof RuntimeException runtimeFailure) {
|
||||
throw runtimeFailure;
|
||||
} else if (cause instanceof Error error) {
|
||||
throw error;
|
||||
} else {
|
||||
throw new IllegalStateException("Public repository execution failed");
|
||||
}
|
||||
} finally {
|
||||
submitted.finish();
|
||||
}
|
||||
}
|
||||
|
||||
private void route(HttpExchange exchange, URI uri, String requestId,
|
||||
PublicRepositoryGateway.Access access, Instant deadline) throws IOException {
|
||||
boolean head = "HEAD".equals(exchange.getRequestMethod());
|
||||
if (!head && !"GET".equals(exchange.getRequestMethod())) {
|
||||
sendFailure(exchange, requestId, 405, "METHOD_NOT_ALLOWED");
|
||||
return;
|
||||
}
|
||||
String[] part = uri.getRawPath().split("/", -1);
|
||||
if (part.length == 4 && "".equals(part[0]) && "public".equals(part[1])
|
||||
&& "v1".equals(part[2]) && "authorities".equals(part[3])) {
|
||||
if (!configuration.authorityListExposed()) throw unavailable();
|
||||
AuthorityPage page = authorityPage(uri.getRawQuery());
|
||||
authorities(exchange, requestId, head, page);
|
||||
return;
|
||||
}
|
||||
if (uri.getRawQuery() != null) throw new MalformedRequest();
|
||||
if (part.length == 5 && prefix(part, "authorities")) {
|
||||
authority(exchange, id(part[4]), requestId, head);
|
||||
return;
|
||||
}
|
||||
if (part.length == 8 && prefix(part, "authorities") && "issuers".equals(part[5])
|
||||
&& "certificate".equals(part[7])) {
|
||||
content(exchange, realm.publicRepository().issuerCertificate(id(part[4]), id(part[6]), access),
|
||||
requestId, head, false, deadline);
|
||||
return;
|
||||
}
|
||||
if (part.length == 9 && prefix(part, "authorities") && "issuers".equals(part[5])
|
||||
&& "paths".equals(part[7])) {
|
||||
chain(exchange, realm.publicRepository().chainPath(id(part[4]), id(part[6]), id(part[8]), access),
|
||||
requestId, head);
|
||||
return;
|
||||
}
|
||||
if (part.length == 6 && prefix(part, "authorities") && "chain".equals(part[5])) {
|
||||
chain(exchange, realm.publicRepository().currentChain(id(part[4]), access), requestId, head);
|
||||
return;
|
||||
}
|
||||
if (part.length == 6 && prefix(part, "authorities") && "crl".equals(part[5])) {
|
||||
content(exchange, realm.publicRepository().currentCrl(id(part[4]), access), requestId, head, true,
|
||||
deadline);
|
||||
return;
|
||||
}
|
||||
if (part.length == 5 && "".equals(part[0]) && "public".equals(part[1])
|
||||
&& "v1".equals(part[2]) && "certificates".equals(part[3])) {
|
||||
content(exchange, realm.publicRepository().credential(id(part[4]), access), requestId, head, false,
|
||||
deadline);
|
||||
return;
|
||||
}
|
||||
if (part.length == 5 && "".equals(part[0]) && "public".equals(part[1])
|
||||
&& "v1".equals(part[2]) && "status".equals(part[3])) {
|
||||
content(exchange, realm.publicRepository().statusObject(id(part[4]), access), requestId, head, false,
|
||||
deadline);
|
||||
return;
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
private static boolean prefix(String[] part, String resource) {
|
||||
return "".equals(part[0]) && "public".equals(part[1]) && "v1".equals(part[2])
|
||||
&& resource.equals(part[3]);
|
||||
}
|
||||
|
||||
private void authorities(HttpExchange exchange, String requestId, boolean head, AuthorityPage page)
|
||||
throws IOException {
|
||||
List<PkiOperationValue> entries = realm.publicRepository().authorities(page.after(), page.limit()).stream()
|
||||
.map(PublicRepositoryHttpHandler::authorityValue).map(PkiOperationValue.class::cast).toList();
|
||||
Map<String, PkiOperationValue> fields = new LinkedHashMap<>();
|
||||
fields.put("count", new PkiOperationValue.IntegerValue(entries.size()));
|
||||
fields.put("authorities", new PkiOperationValue.ListValue(entries));
|
||||
cache(exchange.getResponseHeaders(), PublicRepositoryGateway.CacheClass.PUBLIC_ALIAS);
|
||||
metadata(exchange, requestId, "repository.authority.list", new PkiOperationValue.ObjectValue(fields), head);
|
||||
}
|
||||
|
||||
private void authority(HttpExchange exchange, PkiId authorityId, String requestId, boolean head)
|
||||
throws IOException {
|
||||
cache(exchange.getResponseHeaders(), PublicRepositoryGateway.CacheClass.PUBLIC_ALIAS);
|
||||
metadata(exchange, requestId, "repository.authority.inspect",
|
||||
authorityValue(realm.publicRepository().authority(authorityId)), head);
|
||||
}
|
||||
|
||||
private static AuthorityPage authorityPage(String query) {
|
||||
if (query == null) return new AuthorityPage(Optional.empty(), 100);
|
||||
if (query.isEmpty() || query.length() > 1_024 || query.indexOf('%') >= 0) throw new MalformedRequest();
|
||||
Optional<PkiId> after = Optional.empty();
|
||||
int limit = 100;
|
||||
boolean foundAfter = false;
|
||||
boolean foundLimit = false;
|
||||
for (String field : query.split("&", -1)) {
|
||||
int separator = field.indexOf('=');
|
||||
if (separator <= 0 || separator != field.lastIndexOf('=')) throw new MalformedRequest();
|
||||
String name = field.substring(0, separator);
|
||||
String value = field.substring(separator + 1);
|
||||
if ("after".equals(name) && !foundAfter && !value.isEmpty()) {
|
||||
if (!value.matches("[A-Za-z0-9][A-Za-z0-9._:-]{0,255}")) throw new MalformedRequest();
|
||||
after = Optional.of(new PkiId(value));
|
||||
foundAfter = true;
|
||||
} else if ("limit".equals(name) && !foundLimit && value.matches("[1-9][0-9]{0,3}")) {
|
||||
try { limit = Integer.parseInt(value); } catch (NumberFormatException failure) {
|
||||
throw new MalformedRequest();
|
||||
}
|
||||
if (limit > 1_000) throw new MalformedRequest();
|
||||
foundLimit = true;
|
||||
} else {
|
||||
throw new MalformedRequest();
|
||||
}
|
||||
}
|
||||
return new AuthorityPage(after, limit);
|
||||
}
|
||||
|
||||
private static PkiOperationValue.ObjectValue authorityValue(PublicRepositoryGateway.Authority value) {
|
||||
Map<String, PkiOperationValue> fields = new LinkedHashMap<>();
|
||||
fields.put("authorityId", new PkiOperationValue.Text(value.authorityId().value()));
|
||||
fields.put("kind", new PkiOperationValue.Text(value.kind().name()));
|
||||
fields.put("currentIssuanceIssuerId", new PkiOperationValue.Text(
|
||||
value.currentIssuanceIssuerId().value()));
|
||||
fields.put("issuerIds", new PkiOperationValue.ListValue(value.issuerIds().stream()
|
||||
.map(PkiId::value).map(PkiOperationValue.Text::new).map(PkiOperationValue.class::cast).toList()));
|
||||
return new PkiOperationValue.ObjectValue(fields);
|
||||
}
|
||||
|
||||
private void chain(HttpExchange exchange, PublicRepositoryGateway.Chain chain, String requestId,
|
||||
boolean head) throws IOException {
|
||||
Map<String, PkiOperationValue> fields = new LinkedHashMap<>();
|
||||
fields.put("authorityId", new PkiOperationValue.Text(chain.authorityId().value()));
|
||||
fields.put("issuerId", new PkiOperationValue.Text(chain.issuerId().value()));
|
||||
fields.put("pathId", new PkiOperationValue.Text(chain.pathId().value()));
|
||||
fields.put("pathCommitment", new PkiOperationValue.Text(chain.commitment()));
|
||||
fields.put("certificates", new PkiOperationValue.ListValue(chain.orderedCredentialIds().stream()
|
||||
.map(id -> "/public/v1/certificates/" + id.value()).map(PkiOperationValue.Text::new)
|
||||
.map(PkiOperationValue.class::cast).toList()));
|
||||
cache(exchange.getResponseHeaders(), chain.cacheClass());
|
||||
exchange.getResponseHeaders().set("ETag", chain.etag());
|
||||
if (notModified(exchange, chain.etag())) {
|
||||
exchange.sendResponseHeaders(304, -1);
|
||||
return;
|
||||
}
|
||||
metadata(exchange, requestId, "repository.chain.inspect", new PkiOperationValue.ObjectValue(fields), head);
|
||||
}
|
||||
|
||||
private void metadata(HttpExchange exchange, String requestId, String operation,
|
||||
PkiOperationValue.ObjectValue value, boolean head) throws IOException {
|
||||
byte[] body = HttpResponses.success(requestId, operation, value).body();
|
||||
exchange.getResponseHeaders().set("Content-Type", JSON);
|
||||
exchange.getResponseHeaders().set("X-Content-Type-Options", "nosniff");
|
||||
exchange.getResponseHeaders().set(RequestIds.HEADER, requestId);
|
||||
exchange.sendResponseHeaders(200, head ? -1 : body.length);
|
||||
if (!head) try (OutputStream output = exchange.getResponseBody()) { output.write(body); }
|
||||
}
|
||||
|
||||
private void content(HttpExchange exchange, PublicRepositoryGateway.Content content, String requestId,
|
||||
boolean head, boolean alias, Instant deadline) throws IOException {
|
||||
try (content) {
|
||||
if (!accepts(exchange.getRequestHeaders(), content.mediaType())) {
|
||||
sendFailure(exchange, requestId, 406, "REPRESENTATION_NOT_ACCEPTABLE");
|
||||
return;
|
||||
}
|
||||
prevalidate(content.lease(), deadline);
|
||||
Headers headers = exchange.getResponseHeaders();
|
||||
headers.set("Content-Type", content.mediaType());
|
||||
headers.set("Content-Length", Long.toString(content.lease().length()));
|
||||
headers.set("ETag", content.etag());
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
headers.set(RequestIds.HEADER, requestId);
|
||||
cache(headers, alias && content.cacheClass() == PublicRepositoryGateway.CacheClass.PUBLIC_IMMUTABLE
|
||||
? PublicRepositoryGateway.CacheClass.PUBLIC_ALIAS : content.cacheClass());
|
||||
if (notModified(exchange, content.etag())) {
|
||||
headers.remove("Content-Length");
|
||||
exchange.sendResponseHeaders(304, -1);
|
||||
return;
|
||||
}
|
||||
if (head) {
|
||||
exchange.sendResponseHeaders(200, -1);
|
||||
return;
|
||||
}
|
||||
exchange.sendResponseHeaders(200, content.lease().length());
|
||||
try (InputStream input = content.lease().openStream(); OutputStream output = exchange.getResponseBody()) {
|
||||
transfer(input, output, deadline);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void prevalidate(zeroecho.pki.application.PkiRepositoryContent content, Instant deadline)
|
||||
throws IOException {
|
||||
try (InputStream input = content.openStream()) { transfer(input, OutputStream.nullOutputStream(), deadline); }
|
||||
}
|
||||
|
||||
private void transfer(InputStream input, OutputStream output, Instant deadline) throws IOException {
|
||||
byte[] buffer = new byte[BUFFER_SIZE];
|
||||
int read;
|
||||
while ((read = input.read(buffer)) >= 0) {
|
||||
if (!clock.instant().isBefore(deadline) || Thread.currentThread().isInterrupted()) {
|
||||
throw new IOException("Public repository stream deadline exceeded");
|
||||
}
|
||||
output.write(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
|
||||
private Identity authenticate(HttpExchange exchange, String requestId) {
|
||||
Map<String, List<String>> headers = authenticationHeaders(exchange.getRequestHeaders());
|
||||
boolean forwarded = ForwardedClientCertificateParser.containsForwardedIdentity(headers);
|
||||
AdministrativeAuthenticationMode mode = configuration.authentication().mode();
|
||||
if (mode == AdministrativeAuthenticationMode.DIRECT_MTLS && forwarded) return Identity.rejected();
|
||||
Optional<PkiServerAuthenticationContext> context = tlsContext(exchange, requestId, headers);
|
||||
if (mode == AdministrativeAuthenticationMode.DIRECT_MTLS) {
|
||||
if (context.isEmpty()) return Identity.anonymous();
|
||||
return identity(authenticator.authenticate(context.orElseThrow()));
|
||||
}
|
||||
if (context.isEmpty()) return Identity.rejected();
|
||||
if (!forwarded) return authenticator.authenticatePublicProxyTransport(context.orElseThrow())
|
||||
.map(transport -> new Identity(true, Optional.of(transport), Optional.empty()))
|
||||
.orElseGet(Identity::rejected);
|
||||
return identity(authenticator.authenticate(context.orElseThrow()));
|
||||
}
|
||||
|
||||
private Identity identity(PkiServerAuthenticationResult result) {
|
||||
if (!(result instanceof PkiServerAuthenticationResult.Authenticated authenticated)) {
|
||||
return Identity.rejected();
|
||||
}
|
||||
return new Identity(true, authenticated.transportPrincipalId(),
|
||||
Optional.of(realm.principal(authenticated.endClientPrincipalId())));
|
||||
}
|
||||
|
||||
private Optional<PkiServerAuthenticationContext> tlsContext(HttpExchange exchange, String requestId,
|
||||
Map<String, List<String>> headers) {
|
||||
if (!(exchange instanceof HttpsExchange https)) return Optional.empty();
|
||||
try {
|
||||
Certificate[] peer = https.getSSLSession().getPeerCertificates();
|
||||
List<X509Certificate> chain = new ArrayList<>();
|
||||
for (Certificate certificate : peer) {
|
||||
if (!(certificate instanceof X509Certificate x509)) return Optional.empty();
|
||||
chain.add(x509);
|
||||
}
|
||||
return Optional.of(new PkiServerAuthenticationContext(chain, https.getSSLSession().getProtocol(),
|
||||
https.getSSLSession().getCipherSuite(), requestId, realm.configuration().realmId(), headers));
|
||||
} catch (SSLPeerUnverifiedException failure) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private PublicRepositoryGateway.Access access(Headers headers, Identity identity, String requestId) {
|
||||
List<String> values = headers.get("Authorization");
|
||||
if (headers.containsKey("Cookie")) throw new AuthenticationFailure();
|
||||
Optional<String> bearer = Optional.empty();
|
||||
if (values != null) {
|
||||
if (values.size() != 1 || !values.getFirst().matches("Bearer cap-[0-9a-f]{32}\\.[A-Za-z0-9_-]{43}")) {
|
||||
throw new AuthenticationFailure();
|
||||
}
|
||||
bearer = Optional.of(values.getFirst().substring("Bearer ".length()));
|
||||
}
|
||||
if (bearer.isPresent() && identity.principal().isPresent()) {
|
||||
throw new AuthenticationFailure();
|
||||
}
|
||||
return new PublicRepositoryGateway.Access(identity.principal(), bearer, requestId);
|
||||
}
|
||||
|
||||
private Map<String, List<String>> authenticationHeaders(Headers headers) {
|
||||
java.util.Set<String> accepted = new java.util.TreeSet<>(String.CASE_INSENSITIVE_ORDER);
|
||||
accepted.add(ForwardedClientCertificateParser.RFC_CERTIFICATE_HEADER);
|
||||
accepted.add(ForwardedClientCertificateParser.RFC_CHAIN_HEADER);
|
||||
accepted.add(ForwardedClientCertificateParser.DIRECT_REJECTED_NGINX_HEADER);
|
||||
configuration.authentication().forwardedCertificateHeaderName().ifPresent(accepted::add);
|
||||
configuration.authentication().forwardedCertificateChainHeaderName().ifPresent(accepted::add);
|
||||
Map<String, List<String>> result = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
|
||||
if (accepted.contains(entry.getKey())) result.put(entry.getKey(), List.copyOf(entry.getValue()));
|
||||
}
|
||||
return Map.copyOf(result);
|
||||
}
|
||||
|
||||
private void requireHeadersBounded(Headers headers) {
|
||||
int total = 0;
|
||||
for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
|
||||
total = Math.addExact(total, entry.getKey().getBytes(StandardCharsets.UTF_8).length);
|
||||
for (String value : entry.getValue()) {
|
||||
total = Math.addExact(total, value.getBytes(StandardCharsets.UTF_8).length);
|
||||
if (total > configuration.maximumHeaderBytes()) {
|
||||
throw new IllegalArgumentException("Public request headers are oversized");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void cache(Headers headers, PublicRepositoryGateway.CacheClass value) {
|
||||
switch (value) {
|
||||
case PUBLIC_IMMUTABLE -> headers.set("Cache-Control", "public, max-age="
|
||||
+ configuration.publicImmutableCache().toSeconds() + ", immutable");
|
||||
case PUBLIC_ALIAS -> headers.set("Cache-Control", "public, max-age="
|
||||
+ configuration.publicAliasCache().toSeconds());
|
||||
case PRIVATE_AUTHENTICATED -> headers.set("Cache-Control", "private, no-store");
|
||||
case NO_STORE_CAPABILITY -> {
|
||||
headers.set("Cache-Control", "private, no-store");
|
||||
headers.set("Pragma", "no-cache");
|
||||
headers.set("Referrer-Policy", "no-referrer");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean accepts(Headers headers, String mediaType) {
|
||||
List<String> values = headers.get("Accept");
|
||||
return values == null || values.size() == 1 && ("*/*".equals(values.getFirst())
|
||||
|| mediaType.equalsIgnoreCase(values.getFirst()));
|
||||
}
|
||||
|
||||
private static boolean notModified(HttpExchange exchange, String etag) {
|
||||
List<String> values = exchange.getRequestHeaders().get("If-None-Match");
|
||||
return values != null && values.size() == 1 && etag.equals(values.getFirst());
|
||||
}
|
||||
|
||||
private void sendFailure(HttpExchange exchange, String requestId, int status, String code) throws IOException {
|
||||
byte[] body = HttpResponses.failure(status, requestId, "public.repository", "REPOSITORY_FAILURE",
|
||||
code, code, false, false).body();
|
||||
exchange.getResponseHeaders().set("Content-Type", JSON);
|
||||
exchange.getResponseHeaders().set("Cache-Control", "no-store");
|
||||
exchange.getResponseHeaders().set("X-Content-Type-Options", "nosniff");
|
||||
exchange.getResponseHeaders().set(RequestIds.HEADER, requestId);
|
||||
exchange.sendResponseHeaders(status, body.length);
|
||||
try (OutputStream output = exchange.getResponseBody()) { output.write(body); }
|
||||
}
|
||||
|
||||
private static PkiId id(String value) {
|
||||
if (!value.matches("[A-Za-z0-9][A-Za-z0-9._:-]{0,255}")) throw unavailable();
|
||||
return new PkiId(value);
|
||||
}
|
||||
|
||||
private static IllegalArgumentException unavailable() {
|
||||
return new IllegalArgumentException("Public repository resource unavailable");
|
||||
}
|
||||
|
||||
private static final class AuthenticationFailure extends RuntimeException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
}
|
||||
|
||||
private static final class MalformedRequest extends RuntimeException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
}
|
||||
|
||||
private static final class StreamDeadlineExceeded extends RuntimeException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
}
|
||||
|
||||
private record AuthorityPage(Optional<PkiId> after, int limit) { }
|
||||
|
||||
private record Identity(boolean accepted, Optional<String> transportPrincipal,
|
||||
Optional<SecurityPrincipal> principal) {
|
||||
private static Identity anonymous() { return new Identity(true, Optional.empty(), Optional.empty()); }
|
||||
private static Identity rejected() { return new Identity(false, Optional.empty(), Optional.empty()); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/*******************************************************************************
|
||||
* 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.HttpServer;
|
||||
import com.sun.net.httpserver.HttpsConfigurator;
|
||||
import com.sun.net.httpserver.HttpsParameters;
|
||||
import com.sun.net.httpserver.HttpsServer;
|
||||
|
||||
import zeroecho.pki.server.AdministrativeAuthenticationMode;
|
||||
import zeroecho.pki.server.PkiServerConfiguration;
|
||||
import zeroecho.pki.server.ServerRealmContext;
|
||||
|
||||
/** Lifecycle owner for the isolated, optionally enabled public repository listener. */
|
||||
public final class PublicRepositoryTransport implements AutoCloseable {
|
||||
private final HttpServer listener;
|
||||
private final ServerRuntime runtime;
|
||||
|
||||
private PublicRepositoryTransport(HttpServer listener, ServerRuntime runtime) {
|
||||
this.listener = listener;
|
||||
this.runtime = runtime;
|
||||
}
|
||||
|
||||
/** Starts one separately bounded public listener over the shared realm. */
|
||||
@SuppressWarnings("PMD.AvoidCatchingGenericException")
|
||||
public static PublicRepositoryTransport start(PkiServerConfiguration.PublicListener configuration,
|
||||
ServerRealmContext realm, AdministrativeAuthenticator authenticator, Clock clock,
|
||||
SecureRandom random, ClassLoader loader, BooleanSupplier ready) {
|
||||
Objects.requireNonNull(configuration, "configuration");
|
||||
Objects.requireNonNull(realm, "realm");
|
||||
Objects.requireNonNull(authenticator, "authenticator");
|
||||
ServerRuntime runtime = null;
|
||||
HttpServer listener = null;
|
||||
try {
|
||||
runtime = new ServerRuntime(configuration.execution(), true);
|
||||
if (configuration.tlsProvider().isPresent()) {
|
||||
SSLContext context = TlsProviders.create(configuration.tlsProvider().orElseThrow(), loader);
|
||||
HttpsServer secure = HttpsServer.create(configuration.socketAddress(),
|
||||
configuration.execution().transportQueueCapacity());
|
||||
secure.setHttpsConfigurator(configurator(context,
|
||||
configuration.authentication().mode()
|
||||
== AdministrativeAuthenticationMode.TRUSTED_REVERSE_PROXY));
|
||||
listener = secure;
|
||||
} else {
|
||||
listener = HttpServer.create(configuration.socketAddress(),
|
||||
configuration.execution().transportQueueCapacity());
|
||||
}
|
||||
listener.setExecutor(runtime.transportExecutor());
|
||||
listener.createContext("/", new PublicRepositoryHttpHandler(configuration, realm, authenticator,
|
||||
runtime, clock, new RequestIds(random), ready));
|
||||
listener.start();
|
||||
return new PublicRepositoryTransport(listener, runtime);
|
||||
} catch (IOException failure) {
|
||||
closePartial(listener, runtime);
|
||||
throw new IllegalStateException("Public repository listener initialization failed", failure);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
closePartial(listener, runtime);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return actual public listener address */
|
||||
public InetSocketAddress address() { return listener.getAddress(); }
|
||||
|
||||
/** Prevents new public stream admission. */
|
||||
public void quiesce() { runtime.quiesce(); }
|
||||
|
||||
/** Stops the listener and its independent bounded resources. */
|
||||
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();
|
||||
}
|
||||
|
||||
@Override public void close() { shutdown(Duration.ZERO); }
|
||||
|
||||
private static HttpsConfigurator configurator(SSLContext context, boolean proxyMode) {
|
||||
return new HttpsConfigurator(context) {
|
||||
@Override public void configure(HttpsParameters parameters) {
|
||||
SSLParameters secure = context.getDefaultSSLParameters();
|
||||
if (proxyMode) {
|
||||
secure.setNeedClientAuth(true);
|
||||
} else {
|
||||
secure.setWantClientAuth(true);
|
||||
}
|
||||
parameters.setSSLParameters(secure);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static void closePartial(HttpServer listener, ServerRuntime runtime) {
|
||||
if (listener != null) {
|
||||
listener.stop(0);
|
||||
}
|
||||
if (runtime != null) {
|
||||
runtime.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,8 @@ import zeroecho.pki.server.PkiServerConfiguration;
|
||||
final class ServerRuntime implements AutoCloseable {
|
||||
static final String TRANSPORT_PREFIX = "zeroecho-pki-https-";
|
||||
static final String OPERATION_PREFIX = "zeroecho-pki-operation-";
|
||||
static final String PUBLIC_TRANSPORT_PREFIX = "zeroecho-pki-public-https-";
|
||||
static final String PUBLIC_STREAM_PREFIX = "zeroecho-pki-public-stream-";
|
||||
static final String SHUTDOWN_NAME = "zeroecho-pki-shutdown";
|
||||
|
||||
private final ThreadPoolExecutor transport;
|
||||
@@ -65,11 +67,15 @@ final class ServerRuntime implements AutoCloseable {
|
||||
private final AtomicBoolean accepting = new AtomicBoolean(true);
|
||||
|
||||
ServerRuntime(PkiServerConfiguration.Execution configuration) {
|
||||
this(configuration, false);
|
||||
}
|
||||
|
||||
ServerRuntime(PkiServerConfiguration.Execution configuration, boolean publicLane) {
|
||||
this.configuration = configuration;
|
||||
transport = pool(configuration.transportWorkers(), configuration.transportQueueCapacity(),
|
||||
new NamedThreadFactory(TRANSPORT_PREFIX));
|
||||
new NamedThreadFactory(publicLane ? PUBLIC_TRANSPORT_PREFIX : TRANSPORT_PREFIX));
|
||||
operations = pool(configuration.operationWorkers(), configuration.operationQueueCapacity(),
|
||||
new NamedThreadFactory(OPERATION_PREFIX));
|
||||
new NamedThreadFactory(publicLane ? PUBLIC_STREAM_PREFIX : OPERATION_PREFIX));
|
||||
admitted = new Semaphore(configuration.maximumAdmittedRequests(), true);
|
||||
}
|
||||
|
||||
@@ -132,8 +138,10 @@ final class ServerRuntime implements AutoCloseable {
|
||||
quiesce();
|
||||
transport.shutdown();
|
||||
operations.shutdown();
|
||||
boolean transportWorker = Thread.currentThread().getName().startsWith(TRANSPORT_PREFIX);
|
||||
boolean operationWorker = Thread.currentThread().getName().startsWith(OPERATION_PREFIX);
|
||||
boolean transportWorker = Thread.currentThread().getName().startsWith(TRANSPORT_PREFIX)
|
||||
|| Thread.currentThread().getName().startsWith(PUBLIC_TRANSPORT_PREFIX);
|
||||
boolean operationWorker = Thread.currentThread().getName().startsWith(OPERATION_PREFIX)
|
||||
|| Thread.currentThread().getName().startsWith(PUBLIC_STREAM_PREFIX);
|
||||
if (!operationWorker) {
|
||||
await(operations, configuration.gracefulShutdown());
|
||||
}
|
||||
|
||||
@@ -126,6 +126,18 @@ public final class HttpServerTestSupport {
|
||||
return configuration(directory, authentication);
|
||||
}
|
||||
|
||||
public static PkiServerConfiguration withPublicListener(PkiServerConfiguration source,
|
||||
PkiServerConfiguration.Authentication authentication) throws Exception {
|
||||
PkiServerConfiguration.Execution publicExecution = new PkiServerConfiguration.Execution(2, 4, 2, 4, 4,
|
||||
Duration.ofSeconds(5), Duration.ofSeconds(10), Duration.ofSeconds(1), Duration.ofSeconds(1));
|
||||
PkiServerConfiguration.PublicListener listener = new PkiServerConfiguration.PublicListener(
|
||||
InetAddress.getByName("127.0.0.1"), 0,
|
||||
Optional.of(new ProviderConfig("test-tls", Map.of())), false, authentication, 16_384,
|
||||
1_024, publicExecution, Duration.ofSeconds(10), Duration.ofHours(1), Duration.ofMinutes(1), true);
|
||||
return new PkiServerConfiguration(source.version(), source.serverName(), source.realm(), source.listener(),
|
||||
source.authentication(), source.execution(), source.runtime(), Optional.of(listener));
|
||||
}
|
||||
|
||||
private static PkiServerConfiguration.Authentication directAuthentication(X509Certificate client)
|
||||
throws Exception {
|
||||
PkiServerConfiguration.ClientCertificateMapping mapping = mapping("administrator-map", "administrator",
|
||||
|
||||
@@ -43,6 +43,7 @@ import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.io.OutputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.util.HexFormat;
|
||||
@@ -56,6 +57,28 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import zeroecho.pki.application.PkiSessionRuntimeDependencies;
|
||||
import zeroecho.pki.api.Encoding;
|
||||
import zeroecho.pki.api.FormatId;
|
||||
import zeroecho.pki.api.IssuerRef;
|
||||
import zeroecho.pki.api.KeyRef;
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.api.SubjectRef;
|
||||
import zeroecho.pki.api.Validity;
|
||||
import zeroecho.pki.api.ca.CaKind;
|
||||
import zeroecho.pki.api.ca.CaRecord;
|
||||
import zeroecho.pki.api.ca.CaState;
|
||||
import zeroecho.pki.api.ca.IssuerChainPath;
|
||||
import zeroecho.pki.api.ca.IssuerGeneration;
|
||||
import zeroecho.pki.api.ca.IssuerGenerationState;
|
||||
import zeroecho.pki.api.content.DurableContentReference;
|
||||
import zeroecho.pki.api.credential.CaProfileBinding;
|
||||
import zeroecho.pki.api.credential.Credential;
|
||||
import zeroecho.pki.api.credential.CredentialStatus;
|
||||
import zeroecho.pki.api.profile.CertificateProfileRef;
|
||||
import zeroecho.pki.impl.framework.x509.bc.SimpleAttributeSet;
|
||||
import zeroecho.pki.impl.fs.FilesystemPkiStore;
|
||||
import zeroecho.pki.impl.fs.FsPkiStoreOptions;
|
||||
import zeroecho.pki.spi.store.ContentSink;
|
||||
import zeroecho.pki.server.http.MutualTlsAuthenticator;
|
||||
import zeroecho.pki.server.http.TestTlsProvider;
|
||||
import zeroecho.pki.server.spi.PkiServerAuthenticationContext;
|
||||
@@ -79,6 +102,33 @@ class PkiHttpsServerTest {
|
||||
"{\"version\":1,\"version\":1}".getBytes(java.nio.charset.StandardCharsets.UTF_8)));
|
||||
assertThrows(IllegalArgumentException.class, () -> new PkiServerConfiguration.Execution(1, 1, 1, 1,
|
||||
1, Duration.ofSeconds(2), Duration.ofSeconds(1), Duration.ofSeconds(1), Duration.ofSeconds(1)));
|
||||
PkiServerConfiguration.Execution publicExecution = new PkiServerConfiguration.Execution(1, 1, 1, 1, 1,
|
||||
Duration.ofSeconds(1), Duration.ofSeconds(1), Duration.ofSeconds(1), Duration.ofSeconds(1));
|
||||
assertThrows(IllegalArgumentException.class, () -> new PkiServerConfiguration.PublicListener(
|
||||
java.net.InetAddress.getByName("192.0.2.1"), 8444, Optional.empty(), true,
|
||||
valid.authentication(), 4_096, 1_024, publicExecution, Duration.ofSeconds(1), Duration.ZERO,
|
||||
Duration.ZERO, true));
|
||||
PkiServerConfiguration.PublicListener conflicting = new PkiServerConfiguration.PublicListener(
|
||||
valid.listener().address(), 8443, Optional.of(valid.listener().tlsProvider()), false,
|
||||
valid.authentication(), 4_096, 1_024, publicExecution, Duration.ofSeconds(1), Duration.ZERO,
|
||||
Duration.ZERO, true);
|
||||
PkiServerConfiguration.Listener fixedAdministrative = new PkiServerConfiguration.Listener(
|
||||
valid.listener().address(), 8443, valid.listener().tlsProvider(), true, 16_384, 65_536);
|
||||
assertThrows(IllegalArgumentException.class, () -> new PkiServerConfiguration(valid.version(),
|
||||
valid.serverName(), valid.realm(), fixedAdministrative, valid.authentication(), valid.execution(),
|
||||
valid.runtime(), Optional.of(conflicting)));
|
||||
assertThrows(IllegalArgumentException.class, () -> new PkiServerConfiguration(2, valid.serverName(),
|
||||
valid.realm(), valid.listener(), valid.authentication(), valid.execution(), valid.runtime()));
|
||||
String production = java.nio.file.Files.readString(java.nio.file.Path.of(
|
||||
"..", "docs", "pki-server-production-example.json"));
|
||||
assertTrue(PkiServerConfigurationCodec.decode(production.getBytes(
|
||||
java.nio.charset.StandardCharsets.UTF_8)).publicListener().isEmpty());
|
||||
assertThrows(IllegalArgumentException.class, () -> PkiServerConfigurationCodec.decode(production
|
||||
.replace("\"publicListener\": {\"enabled\": false},\n", "")
|
||||
.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
|
||||
assertThrows(IllegalArgumentException.class, () -> PkiServerConfigurationCodec.decode(production
|
||||
.replace("{\"enabled\": false}", "{\"enabled\":false,\"port\":8444}")
|
||||
.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
|
||||
System.out.println("...strict-security-fields=true");
|
||||
System.out.println("...ok");
|
||||
}
|
||||
@@ -184,6 +234,124 @@ class PkiHttpsServerTest {
|
||||
System.out.println("...ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void isolatedPublicListenerAllowsAnonymousMetadataWithoutAdminRouteOverlap() throws Exception {
|
||||
System.out.println("isolatedPublicListenerAllowsAnonymousMetadataWithoutAdminRouteOverlap");
|
||||
HttpServerTestSupport.Fixture tls = HttpServerTestSupport.tls();
|
||||
PkiServerConfiguration administrative = HttpServerTestSupport.configuration(
|
||||
temporaryDirectory.resolve("public-direct"), tls.clientCertificate());
|
||||
PkiServerConfiguration configuration = HttpServerTestSupport.withPublicListener(administrative,
|
||||
administrative.authentication());
|
||||
seed(configuration);
|
||||
try (PkiHttpsServer server = PkiHttpsServer.start(configuration, PkiSessionRuntimeDependencies.none(),
|
||||
ServerTestSupport.CLOCK, HttpServerTestSupport.random(), TestTlsProvider.class.getClassLoader())) {
|
||||
HttpClient anonymous = HttpClient.newBuilder().sslContext(tls.anonymousContext()).build();
|
||||
URI publicBase = URI.create("https://localhost:" + server.publicAddress().orElseThrow().getPort());
|
||||
HttpResponse<String> authorities = get(anonymous, publicBase.resolve("/public/v1/authorities"));
|
||||
assertEquals(200, authorities.statusCode());
|
||||
assertTrue(authorities.body().contains("\"count\":0"));
|
||||
assertEquals(404, get(anonymous, publicBase.resolve("/admin/v1/realm")).statusCode());
|
||||
|
||||
HttpClient administrator = HttpClient.newBuilder().sslContext(tls.clientContext()).build();
|
||||
URI adminBase = URI.create("https://localhost:" + server.address().getPort());
|
||||
assertEquals(404, get(administrator, adminBase.resolve("/public/v1/authorities")).statusCode());
|
||||
System.out.println("...adminPort=" + server.address().getPort() + " publicPort="
|
||||
+ server.publicAddress().orElseThrow().getPort());
|
||||
}
|
||||
assertFalse(Thread.getAllStackTraces().keySet().stream().anyMatch(thread -> thread.isAlive()
|
||||
&& thread.getName().startsWith("zeroecho-pki-public-")));
|
||||
System.out.println("...ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void trustedProxyPublicRequestMayRemainAnonymousButTransportIsAuthenticated() throws Exception {
|
||||
System.out.println("trustedProxyPublicRequestMayRemainAnonymousButTransportIsAuthenticated");
|
||||
HttpServerTestSupport.Fixture tls = HttpServerTestSupport.tls();
|
||||
PkiServerConfiguration administrative = HttpServerTestSupport.proxyConfiguration(
|
||||
temporaryDirectory.resolve("public-proxy"), tls, ForwardedCertificateFormat.RFC9440);
|
||||
PkiServerConfiguration configuration = HttpServerTestSupport.withPublicListener(administrative,
|
||||
administrative.authentication());
|
||||
seed(configuration);
|
||||
try (PkiHttpsServer server = PkiHttpsServer.start(configuration, PkiSessionRuntimeDependencies.none(),
|
||||
ServerTestSupport.CLOCK, HttpServerTestSupport.random(), TestTlsProvider.class.getClassLoader())) {
|
||||
HttpClient proxy = HttpClient.newBuilder().sslContext(tls.proxyContext()).build();
|
||||
URI uri = URI.create("https://localhost:" + server.publicAddress().orElseThrow().getPort()
|
||||
+ "/public/v1/authorities");
|
||||
assertEquals(200, get(proxy, uri).statusCode());
|
||||
HttpClient untrusted = HttpClient.newBuilder().sslContext(tls.clientContext()).build();
|
||||
assertEquals(401, get(untrusted, uri).statusCode());
|
||||
System.out.println("...proxyTransport=authenticated,endClient=anonymous");
|
||||
}
|
||||
System.out.println("...ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicIssuerAndAliasedChainUseExactAuthoritativeRecordsAndValidators() throws Exception {
|
||||
System.out.println("publicIssuerAndAliasedChainUseExactAuthoritativeRecordsAndValidators");
|
||||
HttpServerTestSupport.Fixture tls = HttpServerTestSupport.tls();
|
||||
java.nio.file.Path directory = temporaryDirectory.resolve("public-content");
|
||||
PkiServerConfiguration administrative = HttpServerTestSupport.configuration(directory,
|
||||
tls.clientCertificate());
|
||||
PkiServerConfiguration configuration = HttpServerTestSupport.withPublicListener(administrative,
|
||||
administrative.authentication());
|
||||
SeededIssuer seeded = seedIssuer(directory.resolve("pki-store"), tls.rootCertificate());
|
||||
seed(configuration);
|
||||
try (PkiHttpsServer server = PkiHttpsServer.start(configuration, PkiSessionRuntimeDependencies.none(),
|
||||
ServerTestSupport.CLOCK, HttpServerTestSupport.random(), TestTlsProvider.class.getClassLoader())) {
|
||||
server.realm().repositoryAliases().setCurrentChain(seeded.authorityId(), seeded.issuerId(),
|
||||
seeded.pathId(), Optional.empty(), "administrator");
|
||||
HttpClient anonymous = HttpClient.newBuilder().sslContext(tls.anonymousContext()).build();
|
||||
URI base = URI.create("https://localhost:" + server.publicAddress().orElseThrow().getPort());
|
||||
URI certificateUri = base.resolve("/public/v1/authorities/" + seeded.authorityId().value()
|
||||
+ "/issuers/" + seeded.issuerId().value() + "/certificate");
|
||||
HttpResponse<byte[]> certificate = anonymous.send(HttpRequest.newBuilder(certificateUri).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofByteArray());
|
||||
assertEquals(200, certificate.statusCode());
|
||||
assertTrue(MessageDigest.isEqual(tls.rootCertificate().getEncoded(), certificate.body()));
|
||||
String etag = certificate.headers().firstValue("ETag").orElseThrow();
|
||||
HttpResponse<Void> head = anonymous.send(HttpRequest.newBuilder(certificateUri)
|
||||
.method("HEAD", HttpRequest.BodyPublishers.noBody()).build(), HttpResponse.BodyHandlers.discarding());
|
||||
assertEquals(200, head.statusCode());
|
||||
assertEquals(Long.toString(tls.rootCertificate().getEncoded().length),
|
||||
head.headers().firstValue("Content-Length").orElseThrow());
|
||||
HttpResponse<Void> unchanged = anonymous.send(HttpRequest.newBuilder(certificateUri)
|
||||
.header("If-None-Match", etag).GET().build(), HttpResponse.BodyHandlers.discarding());
|
||||
assertEquals(304, unchanged.statusCode());
|
||||
URI chainUri = base.resolve("/public/v1/authorities/" + seeded.authorityId().value() + "/chain");
|
||||
HttpResponse<String> chain = get(anonymous, chainUri);
|
||||
assertEquals(200, chain.statusCode());
|
||||
assertTrue(chain.body().contains(seeded.pathId().value()));
|
||||
assertTrue(chain.body().contains(seeded.credentialId().value()));
|
||||
server.realm().disclosure().register(seeded.credentialId(), DisclosureService.ObjectType.LEAF_CERTIFICATE,
|
||||
DisclosureService.Policy.PUBLIC_UNLISTED, Optional.empty(), "2".repeat(64), "administrator");
|
||||
DisclosureService.IssuedCapability issued = server.realm().disclosure().issueCapability(
|
||||
seeded.credentialId(), ServerTestSupport.CLOCK.instant().plusSeconds(600), "administrator");
|
||||
byte[] rawToken = issued.token();
|
||||
String bearer;
|
||||
try {
|
||||
bearer = DisclosureService.bearerCredential(issued.capability(), rawToken);
|
||||
} finally {
|
||||
java.util.Arrays.fill(rawToken, (byte) 0);
|
||||
}
|
||||
URI leafUri = base.resolve("/public/v1/certificates/" + seeded.credentialId().value());
|
||||
assertEquals(404, get(anonymous, leafUri).statusCode());
|
||||
HttpResponse<byte[]> capable = anonymous.send(HttpRequest.newBuilder(leafUri)
|
||||
.header("Authorization", "Bearer " + bearer).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofByteArray());
|
||||
assertEquals(200, capable.statusCode());
|
||||
assertEquals("private, no-store", capable.headers().firstValue("Cache-Control").orElseThrow());
|
||||
assertTrue(MessageDigest.isEqual(tls.rootCertificate().getEncoded(), capable.body()));
|
||||
assertEquals(400, get(anonymous, URI.create(leafUri + "?token=" + bearer)).statusCode());
|
||||
server.realm().disclosure().revokeCapability(issued.capability().capabilityId(), "administrator");
|
||||
assertEquals(404, anonymous.send(HttpRequest.newBuilder(leafUri)
|
||||
.header("Authorization", "Bearer " + bearer).GET().build(),
|
||||
HttpResponse.BodyHandlers.discarding()).statusCode());
|
||||
System.out.println("...issuer=" + seeded.issuerId().value().substring(0, 16)
|
||||
+ " path=" + seeded.pathId().value().substring(0, 16));
|
||||
}
|
||||
System.out.println("...ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void packagedEntryPointProvidesHelpAndSafeValidationFailure() {
|
||||
System.out.println("packagedEntryPointProvidesHelpAndSafeValidationFailure");
|
||||
@@ -321,6 +489,40 @@ class PkiHttpsServerTest {
|
||||
System.out.println("...ok");
|
||||
}
|
||||
|
||||
private SeededIssuer seedIssuer(java.nio.file.Path root, java.security.cert.X509Certificate certificate)
|
||||
throws Exception {
|
||||
PkiId authorityId = new PkiId("authority-public-root");
|
||||
PkiId credentialId = new PkiId("credential-public-root");
|
||||
PkiId issuerId = IssuerGeneration.idFor(authorityId, credentialId);
|
||||
IssuerChainPath path = IssuerChainPath.create(authorityId, issuerId, List.of(credentialId));
|
||||
DurableContentReference reference;
|
||||
try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(),
|
||||
ServerTestSupport.CLOCK);
|
||||
ContentSink sink = store.stagedContent().beginContent(Encoding.DER,
|
||||
DurableContentReference.Lifecycle.PERSISTED);
|
||||
OutputStream output = sink.outputStream()) {
|
||||
output.write(certificate.getEncoded());
|
||||
reference = sink.complete();
|
||||
InstantBounds bounds = new InstantBounds(certificate.getNotBefore().toInstant(),
|
||||
certificate.getNotAfter().toInstant());
|
||||
KeyRef key = new KeyRef("public-root-key");
|
||||
SubjectRef subject = new SubjectRef(certificate.getSubjectX500Principal().getName());
|
||||
Credential credential = new Credential(credentialId, new FormatId("x509"),
|
||||
new IssuerRef(authorityId, issuerId, path.pathId()), subject,
|
||||
new Validity(bounds.notBefore(), bounds.notAfter()), certificate.getSerialNumber().toString(),
|
||||
new PkiId("public-key-public-root"),
|
||||
new CaProfileBinding(new CertificateProfileRef("public-root-profile", 1, new byte[32])),
|
||||
CredentialStatus.ISSUED, reference, new SimpleAttributeSet());
|
||||
store.putCredential(credential);
|
||||
store.putIssuerGeneration(new IssuerGeneration(issuerId, authorityId, credentialId, key,
|
||||
IssuerGenerationState.ACTIVE, "0".repeat(64), "1".repeat(64)));
|
||||
store.putIssuerChainPath(path);
|
||||
store.putCa(new CaRecord(authorityId, CaKind.ROOT, CaState.ACTIVE, key, subject,
|
||||
List.of(issuerId), issuerId, path.pathId()));
|
||||
}
|
||||
return new SeededIssuer(authorityId, credentialId, issuerId, path.pathId());
|
||||
}
|
||||
|
||||
private void seed(PkiServerConfiguration configuration) throws Exception {
|
||||
seed(configuration, true);
|
||||
}
|
||||
@@ -394,4 +596,8 @@ class PkiHttpsServerTest {
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
private record SeededIssuer(PkiId authorityId, PkiId credentialId, PkiId issuerId, PkiId pathId) { }
|
||||
|
||||
private record InstantBounds(java.time.Instant notBefore, java.time.Instant notAfter) { }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.server;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import zeroecho.pki.api.Encoding;
|
||||
import zeroecho.pki.api.FormatId;
|
||||
import zeroecho.pki.api.KeyRef;
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.api.ca.CaRecord;
|
||||
import zeroecho.pki.api.ca.IssuerChainPath;
|
||||
import zeroecho.pki.api.ca.IssuerGeneration;
|
||||
import zeroecho.pki.api.ca.IssuerGenerationState;
|
||||
import zeroecho.pki.api.content.DurableContentReference;
|
||||
import zeroecho.pki.api.credential.Credential;
|
||||
import zeroecho.pki.api.status.StatusObject;
|
||||
import zeroecho.pki.api.status.StatusObjectType;
|
||||
import zeroecho.pki.application.PkiRepository;
|
||||
import zeroecho.pki.application.PkiRepositoryContent;
|
||||
import zeroecho.pki.impl.framework.x509.bc.SimpleAttributeSet;
|
||||
|
||||
/** Durable explicit repository-alias authority coverage. */
|
||||
final class RepositoryAliasServiceTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test
|
||||
void currentCrlUsesExpectedCurrentAndSurvivesRecovery() throws Exception {
|
||||
System.out.println("currentCrlUsesExpectedCurrentAndSurvivesRecovery");
|
||||
PkiId statusId = new PkiId("status-current-crl");
|
||||
RepositoryFixtures repository = new RepositoryFixtures(statusId);
|
||||
RepositoryAliasService.Record created;
|
||||
try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(directory)) {
|
||||
opened.store().createPrincipal(ServerTestSupport.principal("alias-admin"));
|
||||
RepositoryAliasService aliases = service(opened, repository);
|
||||
created = aliases.setCurrentCrl(ServerTestSupport.AUTHORITY, statusId, Optional.empty(), "alias-admin");
|
||||
assertEquals(RepositoryAliasService.Type.CURRENT_CRL, created.type());
|
||||
assertEquals(created, aliases.setCurrentCrl(ServerTestSupport.AUTHORITY, statusId,
|
||||
Optional.empty(), "alias-admin"));
|
||||
assertThrows(IllegalStateException.class, () -> aliases.setCurrentCrl(ServerTestSupport.AUTHORITY,
|
||||
new PkiId("status-other"), Optional.empty(), "alias-admin"));
|
||||
System.out.println("...alias=" + created.aliasId() + " commitment="
|
||||
+ created.recordCommitment().substring(0, 12));
|
||||
}
|
||||
try (ServerControlStore store = new ServerControlStore(
|
||||
zeroecho.pki.impl.fs.PosixTransactionalMetadataStore.open(
|
||||
directory.resolve("server-control.log"), java.util.OptionalLong.of(1_048_576)))) {
|
||||
ServerTestSupport.OpenedStore reopened = new ServerTestSupport.OpenedStore(
|
||||
directory.resolve("server-control.log"), store, new ServerTestSupport.RecordingAudit());
|
||||
assertEquals(created, service(reopened, repository).require(ServerTestSupport.AUTHORITY,
|
||||
RepositoryAliasService.Type.CURRENT_CRL));
|
||||
}
|
||||
System.out.println("...ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void currentChainBindsExactIssuerPathWithoutChangingIssuanceAuthority() throws Exception {
|
||||
System.out.println("currentChainBindsExactIssuerPathWithoutChangingIssuanceAuthority");
|
||||
RepositoryFixtures repository = new RepositoryFixtures(new PkiId("status-current-crl"));
|
||||
try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(directory)) {
|
||||
opened.store().createPrincipal(ServerTestSupport.principal("alias-admin"));
|
||||
DisclosureService disclosure = disclosure(opened);
|
||||
RepositoryAliasService aliases = service(opened, repository, disclosure);
|
||||
RepositoryAliasService.Record alias = aliases.setCurrentChain(ServerTestSupport.AUTHORITY,
|
||||
repository.issuer.issuerId(), repository.path.pathId(), Optional.empty(), "alias-admin");
|
||||
assertEquals(repository.path.pathCommitment(), alias.targetCommitment());
|
||||
assertEquals(repository.path.pathId(), alias.pathId().orElseThrow());
|
||||
assertEquals(repository.issuer.issuerId(), alias.issuerId().orElseThrow());
|
||||
assertEquals(repository.authority.issuanceChainPathId(), repository.path.pathId());
|
||||
PublicRepositoryGateway gateway = gateway(opened, repository, disclosure, aliases);
|
||||
PublicRepositoryGateway.Chain publicChain = gateway.currentChain(ServerTestSupport.AUTHORITY,
|
||||
new PublicRepositoryGateway.Access(Optional.empty(), Optional.empty(), "request-chain"));
|
||||
assertEquals(repository.path.orderedCredentialIds(), publicChain.orderedCredentialIds());
|
||||
assertEquals(repository.path.pathCommitment(), publicChain.commitment());
|
||||
RepositoryAliasService.Record removed = aliases.remove(ServerTestSupport.AUTHORITY,
|
||||
RepositoryAliasService.Type.CURRENT_CHAIN, alias.recordCommitment(), "alias-admin");
|
||||
assertEquals(RepositoryAliasService.State.REMOVED, removed.state());
|
||||
assertEquals(repository.path.pathId(), repository.authority.issuanceChainPathId());
|
||||
assertThrows(IllegalArgumentException.class, () -> gateway.currentChain(ServerTestSupport.AUTHORITY,
|
||||
new PublicRepositoryGateway.Access(Optional.empty(), Optional.empty(), "request-removed")));
|
||||
System.out.println("...path=" + repository.path.pathId().value() + " state=" + removed.state());
|
||||
}
|
||||
System.out.println("...ok");
|
||||
}
|
||||
|
||||
private RepositoryAliasService service(ServerTestSupport.OpenedStore opened, PkiRepository repository)
|
||||
throws Exception {
|
||||
return service(opened, repository, disclosure(opened));
|
||||
}
|
||||
|
||||
private RepositoryAliasService service(ServerTestSupport.OpenedStore opened, PkiRepository repository,
|
||||
DisclosureService disclosure) {
|
||||
return new RepositoryAliasService(ServerTestSupport.REALM, opened.store(), repository, disclosure,
|
||||
ServerTestSupport.CLOCK, opened.audit());
|
||||
}
|
||||
|
||||
private DisclosureService disclosure(ServerTestSupport.OpenedStore opened) throws Exception {
|
||||
SecureRandom random = SecureRandom.getInstance("SHA1PRNG");
|
||||
random.setSeed(new byte[] { 7, 6, 5, 4 });
|
||||
return new DisclosureService(ServerTestSupport.REALM, opened.store(),
|
||||
DisclosureService.Defaults.recommended(), ServerTestSupport.CLOCK, random, opened.audit());
|
||||
}
|
||||
|
||||
private PublicRepositoryGateway gateway(ServerTestSupport.OpenedStore opened, PkiRepository repository,
|
||||
DisclosureService disclosure, RepositoryAliasService aliases) {
|
||||
return new PublicRepositoryGateway(ServerTestSupport.REALM,
|
||||
new AuthorityExposurePolicy(AuthorityExposurePolicy.Mode.ALL_REALM_AUTHORITIES, Set.of(), true),
|
||||
repository, opened.store(), RoleTemplateCatalog.load(getClass().getClassLoader()),
|
||||
new AuthorizationEngine(ServerTestSupport.CLOCK),
|
||||
new BreakGlassService(opened.store(), ServerTestSupport.CLOCK, opened.audit()),
|
||||
disclosure, aliases, () -> { });
|
||||
}
|
||||
|
||||
private static final class RepositoryFixtures implements PkiRepository {
|
||||
private static final String ZERO = "0".repeat(64);
|
||||
private final PkiId credentialId = new PkiId("credential-authority");
|
||||
private final IssuerGeneration issuer = new IssuerGeneration(
|
||||
IssuerGeneration.idFor(ServerTestSupport.AUTHORITY, credentialId), ServerTestSupport.AUTHORITY,
|
||||
credentialId, new KeyRef("issuer-key"), IssuerGenerationState.ACTIVE, ZERO, ZERO);
|
||||
private final IssuerChainPath path = IssuerChainPath.create(ServerTestSupport.AUTHORITY,
|
||||
issuer.issuerId(), List.of(credentialId));
|
||||
private final CaRecord authority = new CaRecord(ServerTestSupport.AUTHORITY,
|
||||
zeroecho.pki.api.ca.CaKind.ROOT, zeroecho.pki.api.ca.CaState.ACTIVE,
|
||||
new KeyRef("issuer-key"), new zeroecho.pki.api.SubjectRef("CN=Authority"),
|
||||
List.of(issuer.issuerId()), issuer.issuerId(), path.pathId());
|
||||
private final StatusObject status;
|
||||
|
||||
private RepositoryFixtures(PkiId statusId) {
|
||||
status = new StatusObject(statusId, new FormatId("x509"), ServerTestSupport.AUTHORITY,
|
||||
StatusObjectType.CRL, Instant.EPOCH, Optional.empty(), reference(statusId),
|
||||
new SimpleAttributeSet());
|
||||
}
|
||||
|
||||
@Override public Optional<CaRecord> authority(PkiId id) {
|
||||
return authority.caId().equals(id) ? Optional.of(authority) : Optional.empty();
|
||||
}
|
||||
@Override public List<CaRecord> authorities(Optional<PkiId> after, int limit) { return List.of(authority); }
|
||||
@Override public Optional<IssuerGeneration> issuer(PkiId id) {
|
||||
return issuer.issuerId().equals(id) ? Optional.of(issuer) : Optional.empty();
|
||||
}
|
||||
@Override public Optional<IssuerChainPath> chainPath(PkiId id) {
|
||||
return path.pathId().equals(id) ? Optional.of(path) : Optional.empty();
|
||||
}
|
||||
@Override public Optional<Credential> credential(PkiId id) { return Optional.empty(); }
|
||||
@Override public Optional<StatusObject> statusObject(PkiId id) {
|
||||
if (status.statusObjectId().equals(id)) return Optional.of(status);
|
||||
if ("status-other".equals(id.value())) {
|
||||
return Optional.of(new StatusObject(id, status.formatId(), status.issuerCaId(), status.type(),
|
||||
status.thisUpdate(), status.nextUpdate(), reference(id), status.attributes()));
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
@Override public PkiRepositoryContent openCredential(PkiId id) { throw new UnsupportedOperationException(); }
|
||||
@Override public PkiRepositoryContent openStatusObject(PkiId id) { throw new UnsupportedOperationException(); }
|
||||
|
||||
private static DurableContentReference reference(PkiId id) {
|
||||
return new DurableContentReference() {
|
||||
@Override public String storeId() { return "store"; }
|
||||
@Override public String contentId() { return id.value(); }
|
||||
@Override public Encoding encoding() { return Encoding.DER; }
|
||||
@Override public long length() { return 1L; }
|
||||
@Override public String sha256() { return ZERO; }
|
||||
@Override public Lifecycle lifecycle() { return Lifecycle.PERSISTED; }
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,10 +63,10 @@ class ServerControlOperationExecutorTest {
|
||||
void catalogContainsOneExplicitPairOfOperationFamilies() {
|
||||
System.out.println("catalogContainsOneExplicitPairOfOperationFamilies");
|
||||
OperationSecurityDescriptors catalog = new OperationSecurityDescriptors();
|
||||
assertEquals(50, catalog.descriptors().size());
|
||||
assertEquals(54, catalog.descriptors().size());
|
||||
assertEquals(16, catalog.descriptors().values().stream()
|
||||
.filter(value -> value.family() == OperationSecurityDescriptors.Family.PKI_OPERATION).count());
|
||||
assertEquals(34, catalog.descriptors().values().stream()
|
||||
assertEquals(38, catalog.descriptors().values().stream()
|
||||
.filter(value -> value.family() == OperationSecurityDescriptors.Family.SERVER_CONTROL_OPERATION)
|
||||
.count());
|
||||
assertEquals(OperationSecurityDescriptors.Family.SERVER_CONTROL_OPERATION,
|
||||
@@ -211,7 +211,7 @@ class ServerControlOperationExecutorTest {
|
||||
new AuthorizationEngine(ServerTestSupport.CLOCK),
|
||||
new ApprovalService(opened.store(), ServerTestSupport.CLOCK, opened.audit()),
|
||||
new BreakGlassService(opened.store(), ServerTestSupport.CLOCK, opened.audit()), disclosure,
|
||||
new AuditorViews(ServerTestSupport.CLOCK, opened.audit()), descriptors,
|
||||
new AuditorViews(ServerTestSupport.CLOCK, opened.audit()), Optional.empty(), descriptors,
|
||||
Map.of(OperationSecurityDescriptors.ApprovalCategory.HIGH_RISK, policy));
|
||||
}
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ class ServerOperationGatewayTest {
|
||||
void descriptorRegistryRejectsUnknownAndDuplicateOperations() {
|
||||
System.out.println("descriptorRegistryRejectsUnknownAndDuplicateOperations");
|
||||
OperationSecurityDescriptors descriptors = new OperationSecurityDescriptors();
|
||||
assertEquals(50, descriptors.descriptors().size());
|
||||
assertEquals(54, descriptors.descriptors().size());
|
||||
assertEquals(16, descriptors.descriptors().values().stream()
|
||||
.filter(value -> value.family() == OperationSecurityDescriptors.Family.PKI_OPERATION).count());
|
||||
assertThrows(SecurityException.class, () -> descriptors.require(new PkiOperation.ValidateConfiguration()));
|
||||
|
||||
Reference in New Issue
Block a user