feat(pki-server): add ACME certificate management
Add directory-bound ACME accounts, orders, authorizations, challenge evidence, strict JWS processing, issuance, rollover and revocation. Isolate bounded ACME execution from administrative and public services while preserving explicit authority, profile, issuer and chain-path selection.
This commit is contained in:
@@ -90,6 +90,23 @@ public final class DisclosureService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Typed disclosure owner, keeping ACME accounts outside administrative principals. */
|
||||
public record Owner(OwnerType type, String ownerId) {
|
||||
/** Closed owner categories. */
|
||||
public enum OwnerType { SECURITY_PRINCIPAL, ACME_ACCOUNT }
|
||||
/** Validates the canonical owner identity. */
|
||||
public Owner {
|
||||
Objects.requireNonNull(type, "type");
|
||||
if (ownerId == null || !ownerId.matches("[a-z0-9][a-z0-9._:-]{0,127}")) {
|
||||
throw new IllegalArgumentException("Disclosure owner identity is invalid");
|
||||
}
|
||||
}
|
||||
/** Creates an administrative-principal owner. */
|
||||
public static Owner principal(String id) { Permission.requirePrincipal(id); return new Owner(OwnerType.SECURITY_PRINCIPAL, id); }
|
||||
/** Creates a protocol-scoped ACME-account owner. */
|
||||
public static Owner acmeAccount(String id) { return new Owner(OwnerType.ACME_ACCOUNT, id); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration-driven safe defaults.
|
||||
*
|
||||
@@ -125,7 +142,7 @@ public final class DisclosureService {
|
||||
|
||||
/** Durable policy bound to exact object and profile/policy commitment. */
|
||||
public record Record(PkiId objectId, ObjectType objectType, Policy policy, Optional<String> ownerPrincipalId,
|
||||
String policyCommitment, Instant updatedAt) {
|
||||
Optional<String> ownerAcmeAccountId, String policyCommitment, Instant updatedAt) {
|
||||
/** Validates the durable disclosure record. */
|
||||
public Record {
|
||||
Objects.requireNonNull(objectId, "objectId");
|
||||
@@ -133,15 +150,26 @@ public final class DisclosureService {
|
||||
Objects.requireNonNull(policy, "policy");
|
||||
ownerPrincipalId = Objects.requireNonNull(ownerPrincipalId, "ownerPrincipalId")
|
||||
.map(Permission::requirePrincipal);
|
||||
ownerAcmeAccountId = Objects.requireNonNull(ownerAcmeAccountId, "ownerAcmeAccountId");
|
||||
ownerAcmeAccountId.ifPresent(value -> new Owner(Owner.OwnerType.ACME_ACCOUNT, value));
|
||||
if (ownerPrincipalId.isPresent() && ownerAcmeAccountId.isPresent()) {
|
||||
throw new IllegalArgumentException("Disclosure owner categories are mutually exclusive");
|
||||
}
|
||||
if (policyCommitment == null || !policyCommitment.matches("[0-9a-f]{64}")) {
|
||||
throw new IllegalArgumentException("Disclosure policy commitment is invalid");
|
||||
}
|
||||
Objects.requireNonNull(updatedAt, "updatedAt");
|
||||
if (objectType == ObjectType.LEAF_CERTIFICATE && policy == Policy.OWNER_ONLY
|
||||
&& ownerPrincipalId.isEmpty()) {
|
||||
&& ownerPrincipalId.isEmpty() && ownerAcmeAccountId.isEmpty()) {
|
||||
throw new IllegalArgumentException("Owner-only leaf disclosure requires an owner");
|
||||
}
|
||||
}
|
||||
|
||||
/** Legacy constructor for administrative-principal ownership. */
|
||||
public Record(PkiId objectId, ObjectType objectType, Policy policy, Optional<String> ownerPrincipalId,
|
||||
String policyCommitment, Instant updatedAt) {
|
||||
this(objectId, objectType, policy, ownerPrincipalId, Optional.empty(), policyCommitment, updatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
/** Persisted commitment-only capability authority. */
|
||||
@@ -254,12 +282,32 @@ public final class DisclosureService {
|
||||
throw new IllegalStateException("Increased disclosure requires approval");
|
||||
}
|
||||
Record updated = new Record(current.objectId(), current.objectType(), policy, current.ownerPrincipalId(),
|
||||
current.policyCommitment(), clock.instant());
|
||||
current.ownerAcmeAccountId(), current.policyCommitment(), clock.instant());
|
||||
store.replaceDisclosure(current, updated);
|
||||
audit.record("DISCLOSURE_CHANGE", actorPrincipalId, Optional.of(objectId), Map.of("policy", policy.name()));
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Registers a leaf owned by a protocol-scoped ACME account. */
|
||||
public synchronized Record registerAcme(PkiId objectId, Policy policy, String accountId,
|
||||
String profileOrPolicyCommitment) {
|
||||
Optional<Record> existing = store.findDisclosure(objectId);
|
||||
if (existing.isPresent()) {
|
||||
Record exact = existing.orElseThrow();
|
||||
if (exact.objectType() != ObjectType.LEAF_CERTIFICATE || exact.policy() != policy
|
||||
|| !exact.ownerAcmeAccountId().equals(Optional.of(accountId))
|
||||
|| !exact.policyCommitment().equals(profileOrPolicyCommitment)) {
|
||||
throw new IllegalStateException("ACME disclosure correlation conflict");
|
||||
}
|
||||
return exact;
|
||||
}
|
||||
Record record = new Record(objectId, ObjectType.LEAF_CERTIFICATE, policy,
|
||||
Optional.empty(), Optional.of(accountId), requireDigest(profileOrPolicyCommitment), clock.instant());
|
||||
store.createDisclosure(record);
|
||||
audit.record("DISCLOSURE_REGISTER", "system", Optional.of(objectId), Map.of("policy", policy.name()));
|
||||
return record;
|
||||
}
|
||||
|
||||
/** Evaluates direct retrieval separately from search authorization. */
|
||||
public synchronized Decision decide(PkiId objectId, Optional<SecurityPrincipal> principal,
|
||||
boolean ownerRelationship,
|
||||
|
||||
@@ -148,7 +148,23 @@ public final class OperationSecurityDescriptors {
|
||||
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)));
|
||||
Permission.ResourceType.REPOSITORY_ALIAS, true, true),
|
||||
control(ServerControlOperation.RegisterAcmeDirectory.NAME, Permission.Action.ACME_DIRECTORY_MANAGE,
|
||||
Permission.ResourceType.ACME_DIRECTORY, true, true),
|
||||
control(ServerControlOperation.InspectAcmeDirectory.NAME, Permission.Action.ACME_DIRECTORY_READ,
|
||||
Permission.ResourceType.ACME_DIRECTORY, false, false),
|
||||
control(ServerControlOperation.ListAcmeDirectories.NAME, Permission.Action.ACME_DIRECTORY_READ,
|
||||
Permission.ResourceType.ACME_DIRECTORY, false, false),
|
||||
control(ServerControlOperation.SetAcmeDirectoryActive.ACTIVATE,
|
||||
Permission.Action.ACME_DIRECTORY_MANAGE, Permission.ResourceType.ACME_DIRECTORY, true, true),
|
||||
control(ServerControlOperation.SetAcmeDirectoryActive.DEACTIVATE,
|
||||
Permission.Action.ACME_DIRECTORY_MANAGE, Permission.ResourceType.ACME_DIRECTORY, true, false),
|
||||
control(ServerControlOperation.InspectAcmeAccount.NAME, Permission.Action.ACME_ACCOUNT_READ,
|
||||
Permission.ResourceType.ACME_ACCOUNT, false, false),
|
||||
control(ServerControlOperation.ListAcmeAccounts.NAME, Permission.Action.ACME_ACCOUNT_READ,
|
||||
Permission.ResourceType.ACME_ACCOUNT, false, false),
|
||||
control(ServerControlOperation.DeactivateAcmeAccount.NAME, Permission.Action.ACME_ACCOUNT_MANAGE,
|
||||
Permission.ResourceType.ACME_ACCOUNT, true, false)));
|
||||
}
|
||||
|
||||
/** Creates a registry and rejects duplicate operation identities. */
|
||||
@@ -380,6 +396,30 @@ public final class OperationSecurityDescriptors {
|
||||
case ServerControlOperation.RemoveRepositoryAlias value -> "authority="
|
||||
+ atom(value.authorityId().value()) + ";type=" + value.type().name() + ";expected="
|
||||
+ atom(value.expectedCurrentCommitment());
|
||||
case ServerControlOperation.RegisterAcmeDirectory value -> "alias="
|
||||
+ atom(value.registration().alias()) + ";authority="
|
||||
+ atom(value.registration().authorityId().value()) + ";profile="
|
||||
+ atom(value.registration().profileId()) + ";namespaces="
|
||||
+ atom(value.registration().dnsNamespaces().stream().sorted().collect(java.util.stream.Collectors.joining(",")))
|
||||
+ ";validity=" + value.registration().maximumValidity().toMillis()
|
||||
+ ";keyAlgorithms=" + atom(value.registration().publicKeyAlgorithms().stream().sorted()
|
||||
.collect(java.util.stream.Collectors.joining(",")))
|
||||
+ ";bindings=" + atom(value.registration().x509BindingPolicies().stream().sorted()
|
||||
.collect(java.util.stream.Collectors.joining(",")))
|
||||
+ ";challenges=" + atom(value.registration().challengeTypes().stream().map(Enum::name)
|
||||
.sorted().collect(java.util.stream.Collectors.joining(",")))
|
||||
+ ";providers=" + atom(value.registration().challengeProviderIds().stream().sorted()
|
||||
.collect(java.util.stream.Collectors.joining(",")))
|
||||
+ ";eabProvider=" + atom(value.registration().eabProviderId().orElse("NONE"))
|
||||
+ ";eabRequired=" + value.registration().eabRequired()
|
||||
+ ";disclosure=" + value.registration().disclosurePolicy().name();
|
||||
case ServerControlOperation.InspectAcmeDirectory value -> "directory=" + atom(value.directoryId());
|
||||
case ServerControlOperation.ListAcmeDirectories value -> "offset=" + value.offset() + ";limit=" + value.limit();
|
||||
case ServerControlOperation.SetAcmeDirectoryActive value -> "directory="
|
||||
+ atom(value.directoryId()) + ";active=" + value.active();
|
||||
case ServerControlOperation.InspectAcmeAccount value -> "account=" + atom(value.accountId());
|
||||
case ServerControlOperation.ListAcmeAccounts value -> "offset=" + value.offset() + ";limit=" + value.limit();
|
||||
case ServerControlOperation.DeactivateAcmeAccount value -> "account=" + atom(value.accountId());
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,8 @@ 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), REPOSITORY_ALIAS_READ(164), REPOSITORY_ALIAS_MANAGE(165);
|
||||
DISCLOSURE_CAPABILITY_REVOKE(163), REPOSITORY_ALIAS_READ(164), REPOSITORY_ALIAS_MANAGE(165),
|
||||
ACME_DIRECTORY_READ(180), ACME_DIRECTORY_MANAGE(181), ACME_ACCOUNT_READ(182), ACME_ACCOUNT_MANAGE(183);
|
||||
|
||||
private final int code;
|
||||
|
||||
@@ -112,7 +113,8 @@ 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), REPOSITORY_ALIAS(37);
|
||||
DISCLOSURE(33), CAPABILITY(34), APPROVAL(35), BREAK_GLASS(36), REPOSITORY_ALIAS(37),
|
||||
ACME_DIRECTORY(38), ACME_ACCOUNT(39);
|
||||
private final int code;
|
||||
ResourceType(int code) { this.code = code; }
|
||||
/** @return stable code */ public int code() { return code; }
|
||||
|
||||
@@ -45,6 +45,7 @@ import javax.net.ssl.SSLContext;
|
||||
|
||||
import zeroecho.pki.application.PkiSessionRuntimeDependencies;
|
||||
import zeroecho.pki.server.http.AdministrativeAuthenticator;
|
||||
import zeroecho.pki.server.http.AcmeTransport;
|
||||
import zeroecho.pki.server.http.PkiHttpsTransport;
|
||||
import zeroecho.pki.server.http.PublicRepositoryTransport;
|
||||
import zeroecho.pki.server.spi.PkiServerAuthenticator;
|
||||
@@ -68,12 +69,14 @@ public final class PkiHttpsServer implements AutoCloseable {
|
||||
private final PkiHttpsTransport transport;
|
||||
private final Optional<PkiServerAuthenticator> publicAuthenticator;
|
||||
private final Optional<PublicRepositoryTransport> publicTransport;
|
||||
private final Optional<AcmeTransport> acmeTransport;
|
||||
private final AtomicReference<State> state;
|
||||
|
||||
private PkiHttpsServer(PkiServerConfiguration configuration, ServerRealmContext realm,
|
||||
PkiServerAuthenticator authenticator, PkiHttpsTransport transport,
|
||||
Optional<PkiServerAuthenticator> publicAuthenticator,
|
||||
Optional<PublicRepositoryTransport> publicTransport,
|
||||
Optional<AcmeTransport> acmeTransport,
|
||||
AtomicReference<State> state) {
|
||||
this.configuration = configuration;
|
||||
this.realm = realm;
|
||||
@@ -81,6 +84,7 @@ public final class PkiHttpsServer implements AutoCloseable {
|
||||
this.transport = transport;
|
||||
this.publicAuthenticator = publicAuthenticator;
|
||||
this.publicTransport = publicTransport;
|
||||
this.acmeTransport = acmeTransport;
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
@@ -111,6 +115,7 @@ public final class PkiHttpsServer implements AutoCloseable {
|
||||
PkiHttpsTransport transport = null;
|
||||
PkiServerAuthenticator publicAuthenticator = null;
|
||||
PublicRepositoryTransport publicTransport = null;
|
||||
AcmeTransport acmeTransport = null;
|
||||
try {
|
||||
SSLContext tls = PkiHttpsTransport.resolveTls(exact, loader);
|
||||
realm = ServerRealmContext.open(exact.realm(), runtimeDependencies, clock, random);
|
||||
@@ -140,12 +145,29 @@ public final class PkiHttpsServer implements AutoCloseable {
|
||||
resolvedPublicAuthenticator, clock, random, loader,
|
||||
() -> state.get() == State.READY && sharedRealm.state() == ServerRealmContext.State.OPEN);
|
||||
}
|
||||
if (exact.acmeListener().isPresent()) {
|
||||
PkiServerConfiguration.AcmeListener acmeConfiguration = exact.acmeListener().orElseThrow();
|
||||
if (acmeConfiguration.transportMode()
|
||||
== PkiServerConfiguration.AcmeTransportMode.TRUSTED_REVERSE_PROXY) {
|
||||
for (String principalId : acmeConfiguration.trustedProxyPrincipalIds()) {
|
||||
SecurityPrincipal principal = realm.principal(principalId);
|
||||
if (!principal.enabled() || principal.type() != SecurityPrincipal.Type.SERVICE) {
|
||||
throw new IllegalArgumentException("ACME trusted proxy principal is unavailable");
|
||||
}
|
||||
realm.gateway().validateForwardingPrincipal(principalId);
|
||||
}
|
||||
}
|
||||
acmeTransport = AcmeTransport.start(acmeConfiguration, sharedRealm, 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,
|
||||
Optional.ofNullable(publicAuthenticator), Optional.ofNullable(publicTransport), state);
|
||||
Optional.ofNullable(publicAuthenticator), Optional.ofNullable(publicTransport),
|
||||
Optional.ofNullable(acmeTransport), state);
|
||||
} catch (RuntimeException | Error primary) {
|
||||
closePartial(publicTransport, publicAuthenticator, transport, realm, authenticator, state, primary);
|
||||
closePartial(acmeTransport, publicTransport, publicAuthenticator, transport, realm, authenticator,
|
||||
state, primary);
|
||||
throw primary;
|
||||
}
|
||||
}
|
||||
@@ -167,6 +189,12 @@ public final class PkiHttpsServer implements AutoCloseable {
|
||||
return publicTransport.map(PublicRepositoryTransport::address);
|
||||
}
|
||||
|
||||
/** @return actual ACME listener address when enabled */
|
||||
public Optional<InetSocketAddress> acmeAddress() {
|
||||
if (state.get() == State.TERMINATED) throw new IllegalStateException("HTTPS server is terminated");
|
||||
return acmeTransport.map(AcmeTransport::address);
|
||||
}
|
||||
|
||||
/** @return the one lifecycle-owned realm context */
|
||||
public ServerRealmContext realm() {
|
||||
if (state.get() != State.READY) throw new IllegalStateException("HTTPS server is not ready");
|
||||
@@ -203,6 +231,15 @@ public final class PkiHttpsServer implements AutoCloseable {
|
||||
}
|
||||
transport.quiesce();
|
||||
publicTransport.ifPresent(PublicRepositoryTransport::quiesce);
|
||||
acmeTransport.ifPresent(AcmeTransport::quiesce);
|
||||
try {
|
||||
if (acmeTransport.isPresent()) {
|
||||
acmeTransport.orElseThrow().shutdown(configuration.acmeListener().orElseThrow()
|
||||
.execution().gracefulShutdown());
|
||||
}
|
||||
} catch (Throwable failure) {
|
||||
primary = suppress(primary, failure);
|
||||
}
|
||||
try {
|
||||
if (publicTransport.isPresent()) {
|
||||
publicTransport.orElseThrow().shutdown(configuration.publicListener().orElseThrow()
|
||||
@@ -223,9 +260,10 @@ public final class PkiHttpsServer implements AutoCloseable {
|
||||
rethrow(primary);
|
||||
}
|
||||
|
||||
private static void closePartial(PublicRepositoryTransport publicTransport,
|
||||
private static void closePartial(AcmeTransport acmeTransport, PublicRepositoryTransport publicTransport,
|
||||
PkiServerAuthenticator publicAuthenticator, PkiHttpsTransport transport, ServerRealmContext realm,
|
||||
PkiServerAuthenticator authenticator, AtomicReference<State> state, Throwable primary) {
|
||||
primary = close(acmeTransport, primary);
|
||||
primary = close(publicTransport, primary);
|
||||
primary = close(publicAuthenticator, primary);
|
||||
primary = close(transport, primary);
|
||||
|
||||
@@ -35,6 +35,7 @@ package zeroecho.pki.server;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
@@ -59,14 +60,15 @@ import zeroecho.pki.spi.ProviderConfig;
|
||||
* @param execution bounded execution and shutdown policy
|
||||
* @param runtime process-local capability references
|
||||
* @param publicListener optional separately bounded public repository listener
|
||||
* @param acmeListener optional separately bounded ACME protocol listener
|
||||
*/
|
||||
@SuppressWarnings("PMD")
|
||||
public record PkiServerConfiguration(int version, String serverName, ServerRealmConfiguration realm,
|
||||
Listener listener, Authentication authentication, Execution execution, RuntimeCapabilities runtime,
|
||||
Optional<PublicListener> publicListener) {
|
||||
Optional<PublicListener> publicListener, Optional<AcmeListener> acmeListener) {
|
||||
|
||||
/** Current server configuration schema. */
|
||||
public static final int CURRENT_VERSION = 3;
|
||||
public static final int CURRENT_VERSION = 4;
|
||||
|
||||
/** Validates all security-sensitive fields before resource allocation. */
|
||||
public PkiServerConfiguration {
|
||||
@@ -78,6 +80,7 @@ public record PkiServerConfiguration(int version, String serverName, ServerRealm
|
||||
Objects.requireNonNull(execution, "execution");
|
||||
Objects.requireNonNull(runtime, "runtime");
|
||||
publicListener = Objects.requireNonNull(publicListener, "publicListener");
|
||||
acmeListener = Objects.requireNonNull(acmeListener, "acmeListener");
|
||||
if (!listener.clientCertificateRequired()) {
|
||||
throw new IllegalArgumentException("Administrative HTTPS requires client certificates");
|
||||
}
|
||||
@@ -92,12 +95,29 @@ public record PkiServerConfiguration(int version, String serverName, ServerRealm
|
||||
}
|
||||
}
|
||||
});
|
||||
Optional<PublicListener> exactPublicListener = publicListener;
|
||||
acmeListener.ifPresent(acme -> {
|
||||
requireDistinct(listener.address(), listener.port(), acme.address(), acme.port(),
|
||||
"Administrative and ACME listeners conflict");
|
||||
exactPublicListener.ifPresent(publicConfiguration -> requireDistinct(publicConfiguration.address(),
|
||||
publicConfiguration.port(), acme.address(), acme.port(),
|
||||
"Public and ACME listeners conflict"));
|
||||
});
|
||||
}
|
||||
|
||||
/** Creates a configuration with ACME disabled. */
|
||||
public PkiServerConfiguration(int version, String serverName, ServerRealmConfiguration realm,
|
||||
Listener listener, Authentication authentication, Execution execution, RuntimeCapabilities runtime,
|
||||
Optional<PublicListener> publicListener) {
|
||||
this(version, serverName, realm, listener, authentication, execution, runtime, publicListener,
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
/** 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());
|
||||
this(version, serverName, realm, listener, authentication, execution, runtime, Optional.empty(),
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -395,6 +415,109 @@ public record PkiServerConfiguration(int version, String serverName, ServerRealm
|
||||
}
|
||||
}
|
||||
|
||||
/** Closed ACME listener transport topologies. */
|
||||
public enum AcmeTransportMode { DIRECT_TLS, TRUSTED_REVERSE_PROXY }
|
||||
|
||||
/**
|
||||
* Separately bounded ACME listener and protocol-security configuration.
|
||||
*
|
||||
* @param listenerId stable process-local listener identity used for nonce binding
|
||||
* @param address canonical bind address
|
||||
* @param port TCP port, including zero for deterministic tests
|
||||
* @param tlsProvider explicit server TLS identity and proxy trust provider
|
||||
* @param transportMode direct server-auth TLS or authenticated trusted proxy
|
||||
* @param proxyTransportMappings exact proxy certificate commitments
|
||||
* @param trustedProxyPrincipalIds explicitly admitted infrastructure principals
|
||||
* @param externalBaseUri configured public ACME URL authority for JWS binding
|
||||
* @param maximumHeaderBytes bounded request-header bytes
|
||||
* @param maximumBodyBytes bounded JWS/CSR request body bytes
|
||||
* @param execution isolated protocol workers, queue, admission, and deadlines
|
||||
* @param validationExecution isolated challenge-validation workers and queue
|
||||
* @param nonceLifetime finite replay-nonce lifetime
|
||||
* @param maximumOutstandingNonces bounded process-local nonce population
|
||||
* @param maximumAccountsPresented bounded administrative account page size
|
||||
* @param maximumOrdersPresented bounded account order page size
|
||||
* @param admissionWindow finite in-memory rate window
|
||||
* @param maximumNewAccountsPerWindow bounded directory-wide account admission
|
||||
* @param maximumNewOrdersPerAccountWindow bounded per-account order admission
|
||||
* @param maximumPendingOrdersPerAccount bounded durable pending-order population
|
||||
* @param maximumChallengeValidationsPerAccountWindow bounded validation admission
|
||||
* @param maximumConcurrentFinalizations bounded listener-wide finalization concurrency
|
||||
* @param challengeProviders explicitly enabled provider configurations
|
||||
* @param eabProviders explicitly enabled secret-confining EAB provider configurations
|
||||
*/
|
||||
public record AcmeListener(String listenerId, InetAddress address, int port, ProviderConfig tlsProvider,
|
||||
AcmeTransportMode transportMode, List<ClientCertificateMapping> proxyTransportMappings,
|
||||
Set<String> trustedProxyPrincipalIds, URI externalBaseUri,
|
||||
int maximumHeaderBytes, int maximumBodyBytes, Execution execution, Execution validationExecution,
|
||||
Duration nonceLifetime, int maximumOutstandingNonces,
|
||||
int maximumAccountsPresented, int maximumOrdersPresented,
|
||||
Duration admissionWindow, int maximumNewAccountsPerWindow,
|
||||
int maximumNewOrdersPerAccountWindow, int maximumPendingOrdersPerAccount,
|
||||
int maximumChallengeValidationsPerAccountWindow, int maximumConcurrentFinalizations,
|
||||
List<ProviderConfig> challengeProviders, List<ProviderConfig> eabProviders) {
|
||||
/** Validates listener isolation, URL authority, proxy policy, and finite bounds. */
|
||||
public AcmeListener {
|
||||
Permission.requireId(listenerId, "ACME listener"); Objects.requireNonNull(address, "address");
|
||||
if (port < 0 || port > 65_535) throw new IllegalArgumentException("ACME listener port is invalid");
|
||||
Objects.requireNonNull(tlsProvider, "tlsProvider"); Objects.requireNonNull(transportMode, "transportMode");
|
||||
proxyTransportMappings = List.copyOf(Objects.requireNonNull(proxyTransportMappings,
|
||||
"proxyTransportMappings"));
|
||||
trustedProxyPrincipalIds = Set.copyOf(Objects.requireNonNull(trustedProxyPrincipalIds,
|
||||
"trustedProxyPrincipalIds"));
|
||||
trustedProxyPrincipalIds.forEach(Permission::requirePrincipal);
|
||||
externalBaseUri = Objects.requireNonNull(externalBaseUri, "externalBaseUri");
|
||||
if (!"https".equalsIgnoreCase(externalBaseUri.getScheme()) || externalBaseUri.getHost() == null
|
||||
|| externalBaseUri.getUserInfo() != null || externalBaseUri.getQuery() != null
|
||||
|| externalBaseUri.getFragment() != null || !externalBaseUri.getPath().isEmpty()) {
|
||||
throw new IllegalArgumentException("ACME external base URI is invalid");
|
||||
}
|
||||
bounded(maximumHeaderBytes, 1_024, 1_048_576, "ACME header bound");
|
||||
bounded(maximumBodyBytes, 1_024, StrictBounds.MAXIMUM_ACME_BODY, "ACME body bound");
|
||||
Objects.requireNonNull(execution, "execution"); Objects.requireNonNull(validationExecution, "validationExecution");
|
||||
positive(nonceLifetime, Duration.ofHours(1), "ACME nonce lifetime");
|
||||
bounded(maximumOutstandingNonces, 16, 1_000_000, "ACME nonce capacity");
|
||||
bounded(maximumAccountsPresented, 1, 256, "ACME account presentation limit");
|
||||
bounded(maximumOrdersPresented, 1, 256, "ACME order presentation limit");
|
||||
positive(admissionWindow, Duration.ofDays(1), "ACME admission window");
|
||||
bounded(maximumNewAccountsPerWindow, 1, 1_000_000, "ACME account rate");
|
||||
bounded(maximumNewOrdersPerAccountWindow, 1, 1_000_000, "ACME order rate");
|
||||
bounded(maximumPendingOrdersPerAccount, 1, 100_000, "ACME pending order bound");
|
||||
bounded(maximumChallengeValidationsPerAccountWindow, 1, 1_000_000, "ACME validation rate");
|
||||
bounded(maximumConcurrentFinalizations, 1, 10_000, "ACME finalization bound");
|
||||
challengeProviders = providers(challengeProviders, "challenge");
|
||||
eabProviders = providers(eabProviders, "EAB");
|
||||
if (transportMode == AcmeTransportMode.DIRECT_TLS
|
||||
&& (!proxyTransportMappings.isEmpty() || !trustedProxyPrincipalIds.isEmpty())) {
|
||||
throw new IllegalArgumentException("Direct ACME transport cannot configure proxy identity");
|
||||
}
|
||||
if (transportMode == AcmeTransportMode.TRUSTED_REVERSE_PROXY) {
|
||||
boolean mappingOutsideTrusted = false;
|
||||
for (ClientCertificateMapping mapping : proxyTransportMappings) {
|
||||
if (!trustedProxyPrincipalIds.contains(mapping.principalId())) mappingOutsideTrusted = true;
|
||||
}
|
||||
if (proxyTransportMappings.isEmpty() || trustedProxyPrincipalIds.isEmpty()
|
||||
|| mappingOutsideTrusted
|
||||
|| !proxyTransportMappings.stream().map(ClientCertificateMapping::principalId)
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet())
|
||||
.equals(trustedProxyPrincipalIds)) {
|
||||
throw new IllegalArgumentException("ACME trusted-proxy transport is incomplete");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return exact listener socket address */
|
||||
public InetSocketAddress socketAddress() { return new InetSocketAddress(address, port); }
|
||||
|
||||
private static List<ProviderConfig> providers(List<ProviderConfig> source, String name) {
|
||||
List<ProviderConfig> values = List.copyOf(Objects.requireNonNull(source, name));
|
||||
if (values.size() > 64 || values.stream().map(ProviderConfig::backendId).distinct().count() != values.size()) {
|
||||
throw new IllegalArgumentException("ACME " + name + " provider identities are invalid");
|
||||
}
|
||||
return values;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process-local capability references.
|
||||
*
|
||||
@@ -430,4 +553,17 @@ public record PkiServerConfiguration(int version, String serverName, ServerRealm
|
||||
throw new IllegalArgumentException(name + " is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireDistinct(InetAddress firstAddress, int firstPort,
|
||||
InetAddress secondAddress, int secondPort, String message) {
|
||||
if (firstPort != 0 && firstPort == secondPort && (firstAddress.equals(secondAddress)
|
||||
|| firstAddress.isAnyLocalAddress() || secondAddress.isAnyLocalAddress())) {
|
||||
throw new IllegalArgumentException(message);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class StrictBounds {
|
||||
private static final int MAXIMUM_ACME_BODY = 16_777_216;
|
||||
private StrictBounds() { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,11 +86,12 @@ public final class PkiServerConfigurationCodec {
|
||||
public static PkiServerConfiguration decode(byte[] document) {
|
||||
Fields root = Fields.of(StrictJson.parse(document, MAXIMUM_CONFIGURATION_BYTES));
|
||||
root.allowed(Set.of("version", "serverName", "realm", "listener", "authentication", "execution",
|
||||
"runtime", "publicListener"));
|
||||
"runtime", "publicListener", "acmeListener"));
|
||||
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")), publicListener(root.object("publicListener")));
|
||||
runtime(root.object("runtime")), publicListener(root.object("publicListener")),
|
||||
acmeListener(root.object("acmeListener")));
|
||||
root.complete();
|
||||
return configuration;
|
||||
}
|
||||
@@ -297,6 +298,48 @@ public final class PkiServerConfigurationCodec {
|
||||
return Optional.of(result);
|
||||
}
|
||||
|
||||
private static Optional<PkiServerConfiguration.AcmeListener> acmeListener(Fields value) {
|
||||
value.allowed(Set.of("enabled", "listenerId", "address", "port", "tlsProvider", "transportMode",
|
||||
"proxyTransportMappings", "trustedProxyPrincipalIds", "externalBaseUri",
|
||||
"maximumHeaderBytes", "maximumBodyBytes", "execution", "validationExecution",
|
||||
"nonceLifetimeMillis", "maximumOutstandingNonces", "maximumAccountsPresented",
|
||||
"maximumOrdersPresented", "admissionWindowMillis", "maximumNewAccountsPerWindow",
|
||||
"maximumNewOrdersPerAccountWindow", "maximumPendingOrdersPerAccount",
|
||||
"maximumChallengeValidationsPerAccountWindow", "maximumConcurrentFinalizations",
|
||||
"challengeProviders", "eabProviders"));
|
||||
if (!value.bool("enabled")) {
|
||||
value.exact("enabled"); value.complete(); return Optional.empty();
|
||||
}
|
||||
PkiServerConfiguration.AcmeTransportMode mode = PkiServerConfiguration.AcmeTransportMode
|
||||
.valueOf(value.text("transportMode"));
|
||||
List<PkiServerConfiguration.ClientCertificateMapping> proxy = mode
|
||||
== PkiServerConfiguration.AcmeTransportMode.TRUSTED_REVERSE_PROXY
|
||||
? mappings(value.list("proxyTransportMappings")) : List.of();
|
||||
Set<String> trusted = mode == PkiServerConfiguration.AcmeTransportMode.TRUSTED_REVERSE_PROXY
|
||||
? strings(value.list("trustedProxyPrincipalIds")) : Set.of();
|
||||
PkiServerConfiguration.AcmeListener result = new PkiServerConfiguration.AcmeListener(
|
||||
value.text("listenerId"), address(value.text("address")), value.integer("port"),
|
||||
provider(value.object("tlsProvider")), mode, proxy, trusted,
|
||||
java.net.URI.create(value.text("externalBaseUri")), value.integer("maximumHeaderBytes"),
|
||||
value.integer("maximumBodyBytes"), execution(value.object("execution")),
|
||||
execution(value.object("validationExecution")),
|
||||
Duration.ofMillis(value.longValue("nonceLifetimeMillis")),
|
||||
value.integer("maximumOutstandingNonces"), value.integer("maximumAccountsPresented"),
|
||||
value.integer("maximumOrdersPresented"), Duration.ofMillis(value.longValue("admissionWindowMillis")),
|
||||
value.integer("maximumNewAccountsPerWindow"),
|
||||
value.integer("maximumNewOrdersPerAccountWindow"), value.integer("maximumPendingOrdersPerAccount"),
|
||||
value.integer("maximumChallengeValidationsPerAccountWindow"),
|
||||
value.integer("maximumConcurrentFinalizations"), providers(value.list("challengeProviders")),
|
||||
providers(value.list("eabProviders")));
|
||||
value.complete(); return Optional.of(result);
|
||||
}
|
||||
|
||||
private static List<ProviderConfig> providers(List<PkiOperationValue> source) {
|
||||
List<ProviderConfig> result = new ArrayList<>();
|
||||
for (PkiOperationValue item : source) result.add(provider(Fields.of(item)));
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private static PkiServerConfiguration.RuntimeCapabilities runtime(Fields value) {
|
||||
value.allowed(Set.of("keyUnlockEnvironmentVariable"));
|
||||
PkiServerConfiguration.RuntimeCapabilities result = new PkiServerConfiguration.RuntimeCapabilities(
|
||||
|
||||
@@ -38,6 +38,7 @@ import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.server.acme.AcmeService;
|
||||
|
||||
/** Closed transport-neutral server-control administration operation hierarchy. */
|
||||
@SuppressWarnings("PMD.ControlStatementBraces")
|
||||
@@ -58,7 +59,11 @@ public sealed interface ServerControlOperation permits ServerControlOperation.Re
|
||||
ServerControlOperation.SetDisclosure, ServerControlOperation.IssueCapability,
|
||||
ServerControlOperation.RevokeCapability, ServerControlOperation.InspectAuditView,
|
||||
ServerControlOperation.InspectRepositoryAlias, ServerControlOperation.ListRepositoryAliases,
|
||||
ServerControlOperation.SetRepositoryAlias, ServerControlOperation.RemoveRepositoryAlias {
|
||||
ServerControlOperation.SetRepositoryAlias, ServerControlOperation.RemoveRepositoryAlias,
|
||||
ServerControlOperation.RegisterAcmeDirectory, ServerControlOperation.InspectAcmeDirectory,
|
||||
ServerControlOperation.ListAcmeDirectories, ServerControlOperation.SetAcmeDirectoryActive,
|
||||
ServerControlOperation.InspectAcmeAccount, ServerControlOperation.ListAcmeAccounts,
|
||||
ServerControlOperation.DeactivateAcmeAccount {
|
||||
|
||||
/** @return stable operation identity */
|
||||
String name();
|
||||
@@ -308,6 +313,50 @@ public sealed interface ServerControlOperation permits ServerControlOperation.Re
|
||||
@Override public String name() { return NAME; }
|
||||
}
|
||||
|
||||
/** Registers one immutable inactive ACME directory revision. */
|
||||
record RegisterAcmeDirectory(AcmeService.DirectoryRegistration registration) implements ServerControlOperation {
|
||||
public static final String NAME = "acme.directory.register";
|
||||
public RegisterAcmeDirectory { Objects.requireNonNull(registration, "registration"); }
|
||||
@Override public String name() { return NAME; }
|
||||
}
|
||||
/** Inspects one exact ACME directory revision. */
|
||||
record InspectAcmeDirectory(String directoryId) implements ServerControlOperation {
|
||||
public static final String NAME = "acme.directory.inspect";
|
||||
public InspectAcmeDirectory { Permission.requireId(directoryId, "ACME directory"); }
|
||||
@Override public String name() { return NAME; }
|
||||
}
|
||||
/** Lists a bounded page of ACME directory revisions. */
|
||||
record ListAcmeDirectories(int offset, int limit) implements ServerControlOperation {
|
||||
public static final String NAME = "acme.directory.list";
|
||||
public ListAcmeDirectories { page(offset, limit); }
|
||||
@Override public String name() { return NAME; }
|
||||
}
|
||||
/** Activates or deactivates one exact ACME directory revision. */
|
||||
record SetAcmeDirectoryActive(String directoryId, boolean active) implements ServerControlOperation {
|
||||
public static final String ACTIVATE = "acme.directory.activate";
|
||||
public static final String DEACTIVATE = "acme.directory.deactivate";
|
||||
public SetAcmeDirectoryActive { Permission.requireId(directoryId, "ACME directory"); }
|
||||
@Override public String name() { return active ? ACTIVATE : DEACTIVATE; }
|
||||
}
|
||||
/** Inspects one protocol-scoped ACME account. */
|
||||
record InspectAcmeAccount(String accountId) implements ServerControlOperation {
|
||||
public static final String NAME = "acme.account.inspect";
|
||||
public InspectAcmeAccount { Permission.requireId(accountId, "ACME account"); }
|
||||
@Override public String name() { return NAME; }
|
||||
}
|
||||
/** Lists a bounded account page without contact PII. */
|
||||
record ListAcmeAccounts(int offset, int limit) implements ServerControlOperation {
|
||||
public static final String NAME = "acme.account.list";
|
||||
public ListAcmeAccounts { page(offset, limit); }
|
||||
@Override public String name() { return NAME; }
|
||||
}
|
||||
/** Durably deactivates one ACME account without deleting history. */
|
||||
record DeactivateAcmeAccount(String accountId) implements ServerControlOperation {
|
||||
public static final String NAME = "acme.account.deactivate";
|
||||
public DeactivateAcmeAccount { Permission.requireId(accountId, "ACME account"); }
|
||||
@Override public String name() { return NAME; }
|
||||
}
|
||||
|
||||
private static void page(int offset, int limit) {
|
||||
if (offset < 0 || limit <= 0 || limit > 256) throw invalid();
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ import zeroecho.pki.application.PkiOperationFailure;
|
||||
import zeroecho.pki.application.PkiOperationOutcome;
|
||||
import zeroecho.pki.application.PkiOperationResult;
|
||||
import zeroecho.pki.application.PkiOperationValue;
|
||||
import zeroecho.pki.server.acme.AcmeService;
|
||||
import zeroecho.pki.server.acme.AcmeState;
|
||||
|
||||
/** Sole closed dispatcher for transport-neutral server-control operations. */
|
||||
@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.ExcessiveParameterList",
|
||||
@@ -63,6 +65,7 @@ public final class ServerControlOperationExecutor {
|
||||
private final Optional<RepositoryAliasService> repositoryAliases;
|
||||
private final OperationSecurityDescriptors descriptors;
|
||||
private final Map<OperationSecurityDescriptors.ApprovalCategory, ApprovalService.Policy> approvalPolicies;
|
||||
private final java.util.concurrent.atomic.AtomicReference<AcmeService> acme = new java.util.concurrent.atomic.AtomicReference<>();
|
||||
|
||||
/** Creates the one control dispatcher over existing durable authorities. */
|
||||
public ServerControlOperationExecutor(RealmId realmId, AuthorityExposurePolicy exposure,
|
||||
@@ -107,6 +110,32 @@ public final class ServerControlOperationExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/** Installs the explicitly configured ACME capability exactly once before listener readiness. */
|
||||
public void installAcme(AcmeService service) {
|
||||
if (!acme.compareAndSet(null, Objects.requireNonNull(service, "service"))) {
|
||||
throw new IllegalStateException("ACME administration capability is already installed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves exact ACME authority/profile scope from durable records. */
|
||||
/* default */ Permission.Scope acmeScope(ServerControlOperation operation) {
|
||||
AcmeState.Directory directory = switch (operation) {
|
||||
case ServerControlOperation.RegisterAcmeDirectory ignored -> null;
|
||||
case ServerControlOperation.InspectAcmeDirectory value -> acme().directory(value.directoryId());
|
||||
case ServerControlOperation.SetAcmeDirectoryActive value -> acme().directory(value.directoryId());
|
||||
case ServerControlOperation.InspectAcmeAccount value -> acme().directory(acme().account(value.accountId()).directoryId());
|
||||
case ServerControlOperation.DeactivateAcmeAccount value -> acme().directory(acme().account(value.accountId()).directoryId());
|
||||
default -> null;
|
||||
};
|
||||
if (operation instanceof ServerControlOperation.RegisterAcmeDirectory registration) {
|
||||
return new Permission.Scope(realmId, Optional.of(registration.registration().authorityId()),
|
||||
Optional.empty(), Optional.of(registration.registration().profileId()));
|
||||
}
|
||||
if (directory == null) return new Permission.Scope(realmId, Optional.empty(), Optional.empty(), Optional.empty());
|
||||
return new Permission.Scope(realmId, Optional.of(directory.authorityId()), Optional.empty(),
|
||||
Optional.of(directory.profile().profileId()));
|
||||
}
|
||||
|
||||
private ServerControlOperationOutcome dispatch(ServerControlOperation operation, String actor,
|
||||
Permission.Resource authorizedResource, Optional<String> approvalId) {
|
||||
return switch (operation) {
|
||||
@@ -176,9 +205,28 @@ public final class ServerControlOperationExecutor {
|
||||
alias(publishAlias(value, actor)));
|
||||
case ServerControlOperation.RemoveRepositoryAlias value -> ordinary(operation,
|
||||
alias(aliases().remove(value.authorityId(), value.type(), value.expectedCurrentCommitment(), actor)));
|
||||
case ServerControlOperation.RegisterAcmeDirectory value -> ordinary(operation,
|
||||
directory(acme().registerDirectory(value.registration())));
|
||||
case ServerControlOperation.InspectAcmeDirectory value -> ordinary(operation,
|
||||
directory(acme().directory(value.directoryId())));
|
||||
case ServerControlOperation.ListAcmeDirectories value -> ordinary(operation,
|
||||
page(acme().directories(value.offset(), value.limit()), ServerControlOperationExecutor::directory));
|
||||
case ServerControlOperation.SetAcmeDirectoryActive value -> ordinary(operation,
|
||||
directory(value.active() ? acme().activateDirectory(value.directoryId())
|
||||
: acme().deactivateDirectory(value.directoryId())));
|
||||
case ServerControlOperation.InspectAcmeAccount value -> ordinary(operation,
|
||||
account(acme().account(value.accountId())));
|
||||
case ServerControlOperation.ListAcmeAccounts value -> ordinary(operation,
|
||||
page(acme().accounts(value.offset(), value.limit()), ServerControlOperationExecutor::account));
|
||||
case ServerControlOperation.DeactivateAcmeAccount value -> ordinary(operation,
|
||||
account(acme().deactivateAccount(value.accountId())));
|
||||
};
|
||||
}
|
||||
|
||||
private AcmeService acme() {
|
||||
return Optional.ofNullable(acme.get()).orElseThrow(() -> new IllegalStateException("ACME capability unavailable"));
|
||||
}
|
||||
|
||||
private RepositoryAliasService aliases() {
|
||||
return repositoryAliases.orElseThrow(() -> new IllegalStateException("Repository aliases are unavailable"));
|
||||
}
|
||||
@@ -330,6 +378,18 @@ public final class ServerControlOperationExecutor {
|
||||
fields.put("recordCommitment", text(value.recordCommitment()));
|
||||
return new PkiOperationValue.ObjectValue(fields);
|
||||
}
|
||||
private static PkiOperationValue directory(AcmeState.Directory value) {
|
||||
return object("directoryId", text(value.directoryId()), "alias", text(value.alias()),
|
||||
"revision", integer(value.revision()), "authorityId", text(value.authorityId().value()),
|
||||
"profileId", text(value.profile().profileId()), "issuerId", text(value.issuerId().value()),
|
||||
"pathId", text(value.issuancePathId().value()), "status", text(value.status().name()),
|
||||
"policyCommitment", text(value.policyCommitment()));
|
||||
}
|
||||
private static PkiOperationValue account(AcmeState.Account value) {
|
||||
return object("accountId", text(value.accountId()), "directoryId", text(value.directoryId()),
|
||||
"directoryRevision", integer(value.directoryRevision()), "status", text(value.status().name()),
|
||||
"createdAt", text(value.createdAt().toString()), "updatedAt", text(value.updatedAt().toString()));
|
||||
}
|
||||
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.ExcessivePublicCount" })
|
||||
"PMD.TooManyMethods", "PMD.ExcessivePublicCount", "PMD.AvoidInstantiatingObjectsInLoops" })
|
||||
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";
|
||||
@@ -91,9 +91,21 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
/** 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";
|
||||
/** Stable namespace for ACME directory records. */
|
||||
public static final String ACME_DIRECTORY = "io.zeroecho.server.acme-directory";
|
||||
/** Stable namespace for ACME account records. */
|
||||
public static final String ACME_ACCOUNT = "io.zeroecho.server.acme-account";
|
||||
/** Stable namespace for ACME order records. */
|
||||
public static final String ACME_ORDER = "io.zeroecho.server.acme-order";
|
||||
/** Stable namespace for ACME authorization records. */
|
||||
public static final String ACME_AUTHORIZATION = "io.zeroecho.server.acme-authorization";
|
||||
/** Stable namespace for ACME challenge records. */
|
||||
public static final String ACME_CHALLENGE = "io.zeroecho.server.acme-challenge";
|
||||
/** Stable namespace for ACME validation-evidence records. */
|
||||
public static final String ACME_EVIDENCE = "io.zeroecho.server.acme-evidence";
|
||||
|
||||
private static final int MAGIC = 0x5a455331;
|
||||
private static final int SCHEMA = 3;
|
||||
private static final int SCHEMA = 4;
|
||||
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;
|
||||
@@ -106,6 +118,39 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
private static final int KIND_DISCLOSURE = 7;
|
||||
private static final int KIND_CAPABILITY = 8;
|
||||
private static final int KIND_REPOSITORY_ALIAS = 9;
|
||||
private static final int KIND_ACME = 10;
|
||||
|
||||
/**
|
||||
* Strict opaque ACME payload framed by the server-control authority.
|
||||
*
|
||||
* <p>The ACME domain codec owns the payload schema. This record keeps the
|
||||
* transactional metadata layer independent of protocol classes while still
|
||||
* enforcing canonical key identity and bounded content.</p>
|
||||
*
|
||||
* @param namespace one closed ACME namespace
|
||||
* @param recordId canonical domain identity
|
||||
* @param commitment SHA-256 commitment of the complete domain payload
|
||||
* @param payload strict versioned ACME domain encoding
|
||||
*/
|
||||
public record AcmeRecord(String namespace, String recordId, String commitment, byte[] payload) {
|
||||
/** Validates namespace, identity, commitment, and defensive payload bounds. */
|
||||
public AcmeRecord {
|
||||
if (!ACME_NAMESPACES.contains(namespace)) {
|
||||
throw new IllegalArgumentException("Unknown ACME control namespace");
|
||||
}
|
||||
Permission.requireId(recordId, "ACME record");
|
||||
requireDigest(commitment);
|
||||
payload = Objects.requireNonNull(payload, "payload").clone();
|
||||
if (payload.length == 0 || payload.length > MAXIMUM_RECORD_BYTES / 2) {
|
||||
throw new IllegalArgumentException("ACME control payload bound is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
@Override public byte[] payload() { return payload.clone(); }
|
||||
}
|
||||
|
||||
private static final Set<String> ACME_NAMESPACES = Set.of(ACME_DIRECTORY, ACME_ACCOUNT, ACME_ORDER,
|
||||
ACME_AUTHORIZATION, ACME_CHALLENGE, ACME_EVIDENCE);
|
||||
|
||||
/**
|
||||
* Durable realm-control identity and commitments.
|
||||
@@ -348,6 +393,75 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
/** One compare-and-set mutation participating in an atomic ACME state change. */
|
||||
public record AcmeMutation(AcmeRecord record, Optional<String> expectedCommitment) {
|
||||
/** Validates the immutable mutation request. */
|
||||
public AcmeMutation {
|
||||
Objects.requireNonNull(record, "record");
|
||||
expectedCommitment = Objects.requireNonNull(expectedCommitment, "expectedCommitment");
|
||||
expectedCommitment.ifPresent(ServerControlStore::requireDigest);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads one exact ACME record from its closed namespace. */
|
||||
public synchronized Optional<AcmeRecord> acmeRecord(String namespace, String recordId) {
|
||||
requireAcmeNamespace(namespace);
|
||||
return read(namespace, recordId, KIND_ACME, input -> readAcme(input, namespace));
|
||||
}
|
||||
|
||||
/** Returns one bounded deterministic page of ACME records. */
|
||||
public synchronized Page<AcmeRecord> acmeRecords(String namespace, int offset, int limit) {
|
||||
requireAcmeNamespace(namespace);
|
||||
return scanPage(namespace, KIND_ACME, input -> readAcme(input, namespace), offset, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically creates or compare-and-replaces a finite set of ACME records.
|
||||
* Empty expected commitments mean create-only; present commitments mean exact
|
||||
* compare-and-replace. Provider I/O and PKI operations must occur outside this
|
||||
* method.
|
||||
*/
|
||||
public synchronized void mutateAcme(List<AcmeMutation> requested) {
|
||||
requireOpen();
|
||||
List<AcmeMutation> mutations = List.copyOf(Objects.requireNonNull(requested, "requested"));
|
||||
if (mutations.isEmpty() || mutations.size() > 256) {
|
||||
throw new IllegalArgumentException("ACME transaction size is invalid");
|
||||
}
|
||||
Set<String> keys = new HashSet<>();
|
||||
if (mutations.stream().anyMatch(item -> !keys.add(item.record().namespace() + '\n'
|
||||
+ item.record().recordId()))) {
|
||||
throw new IllegalArgumentException("Duplicate ACME transaction identity");
|
||||
}
|
||||
try (MetadataSnapshot snapshot = metadata.snapshot();
|
||||
MetadataTransaction transaction = metadata.beginTransaction()) {
|
||||
for (AcmeMutation mutation : mutations) {
|
||||
AcmeRecord value = mutation.record();
|
||||
MetadataKey metadataKey = key(value.namespace(), value.recordId());
|
||||
Optional<MetadataSnapshot.Record> existing = snapshot.get(metadataKey);
|
||||
byte[] encoded = encode(output -> writeAcme(output, value));
|
||||
RepeatableContent content = new ByteContent(encoded);
|
||||
if (mutation.expectedCommitment().isEmpty()) {
|
||||
if (existing.isPresent()) throw new IllegalStateException("ACME record already exists");
|
||||
transaction.create(metadataKey, content, CancellationSignal.NONE);
|
||||
} else {
|
||||
MetadataSnapshot.Record current = existing
|
||||
.orElseThrow(() -> new IllegalStateException("ACME record is unavailable"));
|
||||
AcmeRecord decoded = decode(current, KIND_ACME, input -> readAcme(input, value.namespace()));
|
||||
if (!mutation.expectedCommitment().orElseThrow().equals(decoded.commitment())) {
|
||||
throw new IllegalStateException("ACME record commitment conflict");
|
||||
}
|
||||
transaction.replace(metadataKey, current.recordRevision(), content, CancellationSignal.NONE);
|
||||
}
|
||||
}
|
||||
MetadataCommitResult result = transaction.commit();
|
||||
if (result.outcome() != MetadataCommitResult.Outcome.COMMITTED) {
|
||||
throw new IllegalStateException("ACME metadata commit requires reconciliation");
|
||||
}
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("ACME metadata mutation failed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Lists capability commitments bound to one object. */
|
||||
public synchronized List<DisclosureService.Capability> capabilitiesFor(PkiId objectId) {
|
||||
return scan(CAPABILITY, KIND_CAPABILITY, ServerControlStore::readCapability).stream()
|
||||
@@ -405,6 +519,9 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
scan(DISCLOSURE, KIND_DISCLOSURE, ServerControlStore::readDisclosure);
|
||||
scan(CAPABILITY, KIND_CAPABILITY, ServerControlStore::readCapability);
|
||||
scan(REPOSITORY_ALIAS, KIND_REPOSITORY_ALIAS, ServerControlStore::readRepositoryAlias);
|
||||
for (String namespace : ACME_NAMESPACES) {
|
||||
scan(namespace, KIND_ACME, input -> readAcme(input, namespace));
|
||||
}
|
||||
}
|
||||
|
||||
/** Validates durable relationships against the active strict role catalog. */
|
||||
@@ -606,10 +723,17 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
case DisclosureService.Record item -> item.objectId().value();
|
||||
case DisclosureService.Capability item -> item.capabilityId();
|
||||
case RepositoryAliasService.Record item -> item.aliasId();
|
||||
case AcmeRecord item -> item.recordId();
|
||||
default -> throw new IllegalArgumentException("Unsupported control record type");
|
||||
};
|
||||
}
|
||||
|
||||
private static void requireAcmeNamespace(String namespace) {
|
||||
if (!ACME_NAMESPACES.contains(namespace)) {
|
||||
throw new IllegalArgumentException("Unknown ACME control namespace");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireSame(String first, String second) {
|
||||
if (!Objects.equals(first, second)) throw new IllegalArgumentException("Control record identity mismatch");
|
||||
}
|
||||
@@ -640,6 +764,18 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
readString(in), readString(in), readString(in), new MetadataStoreId(readString(in)));
|
||||
}
|
||||
|
||||
private static void writeAcme(DataOutputStream out, AcmeRecord value) throws IOException {
|
||||
out.writeInt(KIND_ACME); writeString(out, value.namespace()); writeString(out, value.recordId());
|
||||
writeString(out, value.commitment()); writeBytes(out, value.payload());
|
||||
}
|
||||
|
||||
private static AcmeRecord readAcme(DataInputStream in, String expectedNamespace) throws IOException {
|
||||
String namespace = readString(in);
|
||||
if (!expectedNamespace.equals(namespace)) throw new IllegalArgumentException("ACME namespace mismatch");
|
||||
return new AcmeRecord(namespace, readString(in), readString(in),
|
||||
readBoundedBytes(in, MAXIMUM_RECORD_BYTES / 2));
|
||||
}
|
||||
|
||||
private static void writePrincipal(DataOutputStream out, SecurityPrincipal value) throws IOException {
|
||||
out.writeInt(KIND_PRINCIPAL); writeString(out, value.principalId()); out.writeInt(value.type().code());
|
||||
writeString(out, value.displayName()); writeOptionalString(out, value.organization()); writeStringMap(out, value.attributes());
|
||||
@@ -734,12 +870,14 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
private static void writeDisclosure(DataOutputStream out, DisclosureService.Record value) throws IOException {
|
||||
out.writeInt(KIND_DISCLOSURE); writeString(out, value.objectId().value()); out.writeInt(value.objectType().code());
|
||||
out.writeInt(value.policy().code()); writeOptionalString(out, value.ownerPrincipalId());
|
||||
writeOptionalString(out, value.ownerAcmeAccountId());
|
||||
writeString(out, value.policyCommitment()); writeInstant(out, value.updatedAt());
|
||||
}
|
||||
|
||||
private static DisclosureService.Record readDisclosure(DataInputStream in) throws IOException {
|
||||
return new DisclosureService.Record(new PkiId(readString(in)), DisclosureService.ObjectType.fromCode(in.readInt()),
|
||||
DisclosureService.Policy.fromCode(in.readInt()), readOptionalString(in), readString(in), readInstant(in));
|
||||
DisclosureService.Policy.fromCode(in.readInt()), readOptionalString(in), readOptionalString(in),
|
||||
readString(in), readInstant(in));
|
||||
}
|
||||
|
||||
private static void writeCapability(DataOutputStream out, DisclosureService.Capability value) throws IOException {
|
||||
@@ -823,6 +961,7 @@ public final class ServerControlStore implements AutoCloseable {
|
||||
private static int readCount(DataInputStream in) throws IOException { int count = in.readInt(); if (count < 0 || count > MAXIMUM_COLLECTION) throw new IllegalArgumentException("Control collection size invalid"); return count; }
|
||||
private static void writeBytes(DataOutputStream out, byte[] value) throws IOException { out.writeInt(value.length); out.write(value); }
|
||||
private static byte[] readBytes(DataInputStream in, int expected) throws IOException { int length = in.readInt(); if (length != expected) throw new IllegalArgumentException("Control byte value length invalid"); byte[] value = in.readNBytes(length); if (value.length != length) throw new IllegalArgumentException("Truncated control byte value"); return value; }
|
||||
private static byte[] readBoundedBytes(DataInputStream in, int maximum) throws IOException { int length = in.readInt(); if (length <= 0 || length > maximum) throw new IllegalArgumentException("Control byte value bound invalid"); byte[] value = in.readNBytes(length); if (value.length != length) throw new IllegalArgumentException("Truncated control byte value"); return value; }
|
||||
private static void requireDigest(String value) { if (value == null || !value.matches("[0-9a-f]{64}")) throw new IllegalArgumentException("Control commitment invalid"); }
|
||||
|
||||
@FunctionalInterface private interface Encoder { void write(DataOutputStream output) throws IOException; }
|
||||
|
||||
@@ -47,6 +47,7 @@ import zeroecho.pki.application.PkiOperationExecutor;
|
||||
import zeroecho.pki.application.PkiOperationOutcome;
|
||||
import zeroecho.pki.application.PkiOperationResult;
|
||||
import zeroecho.pki.application.PkiOperationValue;
|
||||
import zeroecho.pki.server.acme.AcmeService;
|
||||
import zeroecho.pki.application.PkiResourceScopeResolver;
|
||||
|
||||
/**
|
||||
@@ -59,7 +60,8 @@ import zeroecho.pki.application.PkiResourceScopeResolver;
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.FieldDeclarationsShouldBeAtStartOfClass",
|
||||
"PMD.ExcessiveParameterList", "PMD.CyclomaticComplexity", "PMD.AvoidInstantiatingObjectsInLoops",
|
||||
"PMD.NcssCount", "PMD.ConfusingTernary", "PMD.ExceptionAsFlowControl" })
|
||||
"PMD.NcssCount", "PMD.ConfusingTernary", "PMD.ExceptionAsFlowControl",
|
||||
"PMD.CouplingBetweenObjects" })
|
||||
public final class ServerOperationGateway {
|
||||
/**
|
||||
* Complete transport-neutral request admission input.
|
||||
@@ -186,6 +188,9 @@ public final class ServerOperationGateway {
|
||||
this.openCheck = Objects.requireNonNull(openCheck, "openCheck");
|
||||
}
|
||||
|
||||
/** Installs the optional configured ACME control capability once before listener readiness. */
|
||||
public void installAcme(AcmeService service) { controlExecutor.installAcme(service); }
|
||||
|
||||
/** Creates the pre-control-plane gateway surface for embedded source compatibility. */
|
||||
public ServerOperationGateway(RealmId realmId, AuthorityExposurePolicy exposure, ServerControlStore control,
|
||||
RoleTemplateCatalog roles, AuthorizationEngine authorization, ApprovalService approvals,
|
||||
@@ -526,6 +531,13 @@ public final class ServerOperationGateway {
|
||||
case ServerControlOperation.ActivateBreakGlass value -> breakGlass.requireCurrent(value.breakGlassId()).grant().scope();
|
||||
case ServerControlOperation.InspectBreakGlass value -> breakGlass.requireCurrent(value.breakGlassId()).grant().scope();
|
||||
case ServerControlOperation.RevokeBreakGlass value -> breakGlass.requireCurrent(value.breakGlassId()).grant().scope();
|
||||
case ServerControlOperation.RegisterAcmeDirectory value -> new Permission.Scope(realmId,
|
||||
Optional.of(value.registration().authorityId()), Optional.empty(),
|
||||
Optional.of(value.registration().profileId()));
|
||||
case ServerControlOperation.InspectAcmeDirectory value -> controlExecutor.acmeScope(value);
|
||||
case ServerControlOperation.SetAcmeDirectoryActive value -> controlExecutor.acmeScope(value);
|
||||
case ServerControlOperation.InspectAcmeAccount value -> controlExecutor.acmeScope(value);
|
||||
case ServerControlOperation.DeactivateAcmeAccount value -> controlExecutor.acmeScope(value);
|
||||
default -> resource.scope();
|
||||
};
|
||||
if (!actual.equals(resource.scope())) throw new SecurityException("Control scope differs");
|
||||
|
||||
@@ -50,6 +50,7 @@ import zeroecho.pki.application.PkiSessionRuntimeDependencies;
|
||||
import zeroecho.pki.impl.fs.PosixTransactionalMetadataStore;
|
||||
import zeroecho.pki.spi.audit.AuditSink;
|
||||
import zeroecho.pki.spi.bootstrap.PkiBootstrap;
|
||||
import zeroecho.pki.server.acme.AcmeControlStore;
|
||||
|
||||
/**
|
||||
* Lifecycle owner for one server realm, one long-lived PKI session, and one
|
||||
@@ -77,6 +78,7 @@ public final class ServerRealmContext implements AutoCloseable {
|
||||
private final BreakGlassService breakGlass;
|
||||
private final DisclosureService disclosure;
|
||||
private final RepositoryAliasService repositoryAliases;
|
||||
private final AcmeControlStore acmeControl;
|
||||
private final PublicRepositoryGateway publicRepository;
|
||||
private final AuditorViews auditorViews;
|
||||
private final ServerOperationGateway gateway;
|
||||
@@ -97,6 +99,8 @@ public final class ServerRealmContext implements AutoCloseable {
|
||||
this.breakGlass = breakGlass;
|
||||
this.disclosure = disclosure;
|
||||
this.repositoryAliases = repositoryAliases;
|
||||
this.acmeControl = new AcmeControlStore(control);
|
||||
this.acmeControl.validateAndRecover(clock);
|
||||
this.publicRepository = new PublicRepositoryGateway(configuration.realmId(), configuration.authorityExposure(),
|
||||
session.repository(), control, roles, authorization, breakGlass, disclosure, repositoryAliases,
|
||||
this::requireOpen);
|
||||
@@ -180,6 +184,8 @@ public final class ServerRealmContext implements AutoCloseable {
|
||||
public DisclosureService disclosure() { requireOpen(); return disclosure; }
|
||||
/** @return durable non-authoritative public repository alias service */
|
||||
public RepositoryAliasService repositoryAliases() { requireOpen(); return repositoryAliases; }
|
||||
/** @return typed ACME records in the realm's sole durable control authority */
|
||||
public AcmeControlStore acmeControl() { requireOpen(); return acmeControl; }
|
||||
/** @return read-only disclosed public repository gateway */
|
||||
public PublicRepositoryGateway publicRepository() { requireOpen(); return publicRepository; }
|
||||
/** @return explicit auditor projection service */
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
/*******************************************************************************
|
||||
* 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.acme;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
|
||||
import zeroecho.pki.server.ServerControlStore;
|
||||
|
||||
/** Typed ACME record facade over the realm's sole transactional control authority. */
|
||||
@SuppressWarnings("PMD")
|
||||
public final class AcmeControlStore {
|
||||
private final ServerControlStore control;
|
||||
private final AcmeStateCodec codec = new AcmeStateCodec();
|
||||
|
||||
/** Binds ACME state to the lifecycle-owned realm control store. */
|
||||
public AcmeControlStore(ServerControlStore control) {
|
||||
this.control = Objects.requireNonNull(control, "control");
|
||||
}
|
||||
|
||||
/** Creates one record after canonical sealing. */
|
||||
public <T> T create(T unsealed) {
|
||||
T sealed = seal(unsealed);
|
||||
control.mutateAcme(List.of(new ServerControlStore.AcmeMutation(record(sealed), Optional.empty())));
|
||||
return sealed;
|
||||
}
|
||||
|
||||
/** Atomically creates an order and its complete authorization/challenge graph. */
|
||||
public Graph createOrderGraph(AcmeState.Order order, List<AcmeState.Authorization> authorizations,
|
||||
List<AcmeState.Challenge> challenges) {
|
||||
AcmeState.Order sealedOrder = seal(order);
|
||||
List<AcmeState.Authorization> sealedAuthorizations = authorizations.stream()
|
||||
.map(this::<AcmeState.Authorization>seal).toList();
|
||||
List<AcmeState.Challenge> sealedChallenges = challenges.stream()
|
||||
.map(this::<AcmeState.Challenge>seal).toList();
|
||||
List<ServerControlStore.AcmeMutation> changes = new ArrayList<>();
|
||||
changes.add(new ServerControlStore.AcmeMutation(record(sealedOrder), Optional.empty()));
|
||||
sealedAuthorizations.forEach(value -> changes.add(new ServerControlStore.AcmeMutation(record(value), Optional.empty())));
|
||||
sealedChallenges.forEach(value -> changes.add(new ServerControlStore.AcmeMutation(record(value), Optional.empty())));
|
||||
control.mutateAcme(changes);
|
||||
return new Graph(sealedOrder, sealedAuthorizations, sealedChallenges);
|
||||
}
|
||||
|
||||
/** Atomically compare-and-replaces a finite related record set. */
|
||||
public List<Object> replace(List<Replacement> replacements) {
|
||||
return transition(replacements, List.of()).replaced();
|
||||
}
|
||||
|
||||
/** Atomically creates immutable records and replaces their related owners. */
|
||||
public Transition transition(List<Replacement> replacements, List<?> creations) {
|
||||
List<Object> sealed = replacements.stream().map(Replacement::next).map(this::sealObject).toList();
|
||||
List<Object> created = creations.stream().map(this::sealObject).toList();
|
||||
List<ServerControlStore.AcmeMutation> changes = new ArrayList<>();
|
||||
for (int index = 0; index < replacements.size(); index++) {
|
||||
Object prior = replacements.get(index).prior(); Object next = sealed.get(index);
|
||||
changes.add(new ServerControlStore.AcmeMutation(record(next), Optional.of(commitment(prior))));
|
||||
}
|
||||
created.forEach(value -> changes.add(new ServerControlStore.AcmeMutation(record(value), Optional.empty())));
|
||||
control.mutateAcme(changes); return new Transition(sealed, created);
|
||||
}
|
||||
|
||||
/** Results of one atomic ACME graph transition. */
|
||||
public record Transition(List<Object> replaced, List<Object> created) {
|
||||
/** Defensively snapshots transition results. */
|
||||
public Transition { replaced = List.copyOf(replaced); created = List.copyOf(created); }
|
||||
}
|
||||
|
||||
/** One exact prior-to-next ACME compare-and-set transition. */
|
||||
public record Replacement(Object prior, Object next) {
|
||||
/** Validates stable type and identity before persistence. */
|
||||
public Replacement {
|
||||
Objects.requireNonNull(prior, "prior"); Objects.requireNonNull(next, "next");
|
||||
if (!prior.getClass().equals(next.getClass()) || !identity(prior).equals(identity(next))) {
|
||||
throw new IllegalArgumentException("ACME replacement identity is immutable");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads one exact typed record and cross-checks its identity. */
|
||||
public <T> Optional<T> get(Class<T> type, String recordId) {
|
||||
String namespace = namespace(type);
|
||||
return control.acmeRecord(namespace, recordId).map(value -> {
|
||||
Object decoded = codec.decode(value.payload());
|
||||
if (!type.isInstance(decoded) || !recordId.equals(identity(decoded))
|
||||
|| !value.commitment().equals(commitment(decoded))) {
|
||||
throw new IllegalStateException("ACME record identity or commitment mismatch");
|
||||
}
|
||||
return type.cast(decoded);
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns one bounded typed page without aggregating the namespace. */
|
||||
public <T> ServerControlStore.Page<T> page(Class<T> type, int offset, int limit) {
|
||||
ServerControlStore.Page<ServerControlStore.AcmeRecord> page =
|
||||
control.acmeRecords(namespace(type), offset, limit);
|
||||
List<T> values = page.values().stream().map(value -> {
|
||||
Object decoded = codec.decode(value.payload());
|
||||
if (!type.isInstance(decoded) || !value.recordId().equals(identity(decoded))
|
||||
|| !value.commitment().equals(commitment(decoded))) {
|
||||
throw new IllegalStateException("ACME page record mismatch");
|
||||
}
|
||||
return type.cast(decoded);
|
||||
}).toList();
|
||||
return new ServerControlStore.Page<>(values, page.nextOffset(), page.hasMore());
|
||||
}
|
||||
|
||||
/**
|
||||
* Strictly decodes, cross-checks, expires, and safely recovers every ACME
|
||||
* control record without re-running validation or issuance.
|
||||
*/
|
||||
public void validateAndRecover(Clock clock) {
|
||||
Instant now = Objects.requireNonNull(clock, "clock").instant();
|
||||
visit(AcmeState.Directory.class, ignored -> { });
|
||||
visit(AcmeState.Account.class, account -> {
|
||||
AcmeState.Directory directory = get(AcmeState.Directory.class, account.directoryId()).orElseThrow();
|
||||
if (account.directoryRevision() != directory.revision()
|
||||
|| !account.directoryCommitment().equals(directory.commitment())) {
|
||||
throw new IllegalStateException("ACME account directory binding mismatch");
|
||||
}
|
||||
});
|
||||
visit(AcmeState.Order.class, order -> validateOrder(order, now));
|
||||
visit(AcmeState.Authorization.class, authorization -> validateAuthorization(authorization, now));
|
||||
visit(AcmeState.Challenge.class, challenge -> validateChallenge(challenge, now));
|
||||
visit(AcmeState.AuthorizationEvidence.class, evidence -> {
|
||||
AcmeState.Authorization authorization = get(AcmeState.Authorization.class,
|
||||
evidence.authorizationId()).orElseThrow();
|
||||
if (!authorization.identifier().equals(evidence.identifier())) {
|
||||
throw new IllegalStateException("ACME evidence authorization binding mismatch");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void validateOrder(AcmeState.Order order, Instant now) {
|
||||
AcmeState.Account account = get(AcmeState.Account.class, order.accountId()).orElseThrow();
|
||||
AcmeState.Directory directory = get(AcmeState.Directory.class, order.directoryId()).orElseThrow();
|
||||
if (!account.directoryId().equals(order.directoryId()) || order.directoryRevision() != directory.revision()
|
||||
|| !order.directoryCommitment().equals(directory.commitment())) {
|
||||
throw new IllegalStateException("ACME order authority binding mismatch");
|
||||
}
|
||||
for (String authorizationId : order.authorizationIds()) {
|
||||
AcmeState.Authorization authorization = get(AcmeState.Authorization.class, authorizationId).orElseThrow();
|
||||
if (!authorization.orderId().equals(order.orderId())
|
||||
|| !authorization.accountId().equals(order.accountId())) {
|
||||
throw new IllegalStateException("ACME order authorization binding mismatch");
|
||||
}
|
||||
}
|
||||
if (!order.expiresAt().isAfter(now) && order.status() != AcmeState.OrderStatus.VALID
|
||||
&& order.status() != AcmeState.OrderStatus.INVALID) {
|
||||
replace(List.of(new Replacement(order, new AcmeState.Order(order.orderId(), order.accountId(),
|
||||
order.directoryId(), order.directoryRevision(), order.directoryCommitment(), order.authorityId(),
|
||||
order.profile(), order.issuerId(), order.issuancePathId(), order.issuancePathCommitment(),
|
||||
order.identifiers(), order.notBefore(), order.notAfter(), AcmeState.OrderStatus.INVALID,
|
||||
order.authorizationIds(), Optional.empty(), order.createdAt(), order.expiresAt(), ZERO))));
|
||||
}
|
||||
}
|
||||
|
||||
private void validateAuthorization(AcmeState.Authorization authorization, Instant now) {
|
||||
AcmeState.Order order = get(AcmeState.Order.class, authorization.orderId()).orElseThrow();
|
||||
if (!order.accountId().equals(authorization.accountId())
|
||||
|| !order.authorizationIds().contains(authorization.authorizationId())) {
|
||||
throw new IllegalStateException("ACME authorization order binding mismatch");
|
||||
}
|
||||
for (String challengeId : authorization.challengeIds()) {
|
||||
AcmeState.Challenge challenge = get(AcmeState.Challenge.class, challengeId).orElseThrow();
|
||||
if (!challenge.authorizationId().equals(authorization.authorizationId())) {
|
||||
throw new IllegalStateException("ACME authorization challenge binding mismatch");
|
||||
}
|
||||
}
|
||||
if (!authorization.expiresAt().isAfter(now)
|
||||
&& authorization.status() == AcmeState.AuthorizationStatus.PENDING) {
|
||||
replace(List.of(new Replacement(authorization, new AcmeState.Authorization(
|
||||
authorization.authorizationId(), authorization.orderId(), authorization.accountId(),
|
||||
authorization.identifier(), AcmeState.AuthorizationStatus.EXPIRED,
|
||||
authorization.challengeIds(), authorization.expiresAt(), Optional.empty(), ZERO))));
|
||||
}
|
||||
}
|
||||
|
||||
private void validateChallenge(AcmeState.Challenge challenge, Instant now) {
|
||||
AcmeState.Authorization authorization = get(AcmeState.Authorization.class,
|
||||
challenge.authorizationId()).orElseThrow();
|
||||
if (!authorization.challengeIds().contains(challenge.challengeId())) {
|
||||
throw new IllegalStateException("ACME challenge authorization binding mismatch");
|
||||
}
|
||||
if (challenge.status() == AcmeState.ChallengeStatus.PROCESSING) {
|
||||
replace(List.of(new Replacement(challenge, new AcmeState.Challenge(challenge.challengeId(),
|
||||
challenge.authorizationId(), challenge.type(), challenge.token(), AcmeState.ChallengeStatus.PENDING,
|
||||
challenge.providerId(), challenge.attempt(), Optional.of("RECOVERY_REQUIRED"), Optional.empty(),
|
||||
challenge.createdAt(), now, ZERO))));
|
||||
}
|
||||
}
|
||||
|
||||
private <T> void visit(Class<T> type, java.util.function.Consumer<T> consumer) {
|
||||
int offset = 0;
|
||||
do {
|
||||
ServerControlStore.Page<T> current = page(type, offset, 256);
|
||||
current.values().forEach(consumer);
|
||||
if (!current.hasMore()) {
|
||||
return;
|
||||
}
|
||||
offset = current.nextOffset();
|
||||
} while (true);
|
||||
}
|
||||
|
||||
private static final String ZERO = "0".repeat(64);
|
||||
|
||||
/** Immutable atomically committed order graph. */
|
||||
public record Graph(AcmeState.Order order, List<AcmeState.Authorization> authorizations,
|
||||
List<AcmeState.Challenge> challenges) {
|
||||
/** Defensively snapshots the graph. */
|
||||
public Graph {
|
||||
Objects.requireNonNull(order, "order"); authorizations = List.copyOf(authorizations);
|
||||
challenges = List.copyOf(challenges);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private <T> T seal(T value) { return (T) codec.seal(Objects.requireNonNull(value, "value")); }
|
||||
private Object sealObject(Object value) { return codec.seal(Objects.requireNonNull(value, "value")); }
|
||||
private ServerControlStore.AcmeRecord record(Object value) {
|
||||
return new ServerControlStore.AcmeRecord(namespace(value.getClass()), identity(value),
|
||||
commitment(value), codec.encode(value));
|
||||
}
|
||||
private static String commitment(Object value) {
|
||||
return switch (value) {
|
||||
case AcmeState.Directory item -> item.commitment(); case AcmeState.Account item -> item.commitment();
|
||||
case AcmeState.Order item -> item.commitment(); case AcmeState.Authorization item -> item.commitment();
|
||||
case AcmeState.Challenge item -> item.commitment(); case AcmeState.AuthorizationEvidence item -> item.commitment();
|
||||
default -> throw new IllegalArgumentException("Unsupported ACME record type");
|
||||
};
|
||||
}
|
||||
private static String identity(Object value) {
|
||||
return switch (value) {
|
||||
case AcmeState.Directory item -> item.directoryId(); case AcmeState.Account item -> item.accountId();
|
||||
case AcmeState.Order item -> item.orderId(); case AcmeState.Authorization item -> item.authorizationId();
|
||||
case AcmeState.Challenge item -> item.challengeId(); case AcmeState.AuthorizationEvidence item -> item.evidenceId();
|
||||
default -> throw new IllegalArgumentException("Unsupported ACME record type");
|
||||
};
|
||||
}
|
||||
private static String namespace(Class<?> type) {
|
||||
if (type == AcmeState.Directory.class) return ServerControlStore.ACME_DIRECTORY;
|
||||
if (type == AcmeState.Account.class) return ServerControlStore.ACME_ACCOUNT;
|
||||
if (type == AcmeState.Order.class) return ServerControlStore.ACME_ORDER;
|
||||
if (type == AcmeState.Authorization.class) return ServerControlStore.ACME_AUTHORIZATION;
|
||||
if (type == AcmeState.Challenge.class) return ServerControlStore.ACME_CHALLENGE;
|
||||
if (type == AcmeState.AuthorizationEvidence.class) return ServerControlStore.ACME_EVIDENCE;
|
||||
throw new IllegalArgumentException("Unsupported ACME record type");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
/*******************************************************************************
|
||||
* 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.acme;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.AlgorithmParameters;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PublicKey;
|
||||
import java.security.Signature;
|
||||
import java.security.spec.ECGenParameterSpec;
|
||||
import java.security.spec.ECParameterSpec;
|
||||
import java.security.spec.ECPoint;
|
||||
import java.security.spec.ECPublicKeySpec;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import tools.jackson.core.JsonParser;
|
||||
import tools.jackson.core.JsonToken;
|
||||
import tools.jackson.core.ObjectReadContext;
|
||||
import tools.jackson.core.StreamReadConstraints;
|
||||
import tools.jackson.core.StreamReadFeature;
|
||||
import tools.jackson.core.json.JsonFactory;
|
||||
import tools.jackson.core.json.JsonFactoryBuilder;
|
||||
import tools.jackson.core.json.JsonReadFeature;
|
||||
import zeroecho.pki.application.PkiOperationValue;
|
||||
import zeroecho.pki.server.http.StrictJson;
|
||||
|
||||
/** Strict narrow ACME flattened-JWS verifier supporting only ES256. */
|
||||
@SuppressWarnings("PMD")
|
||||
public final class AcmeJwsVerifier {
|
||||
/** Required protected-header account-key mode. */
|
||||
public enum KeyMode { JWK, KID }
|
||||
|
||||
/** Resolved durable account public key with exact revision commitment. */
|
||||
public record AccountKey(String accountId, String kid, String keyThumbprint,
|
||||
PublicKey publicKey, String recordCommitment) {
|
||||
/** Validates finite account authority metadata. */
|
||||
public AccountKey {
|
||||
requireId(accountId); Objects.requireNonNull(kid, "kid"); digest(keyThumbprint);
|
||||
Objects.requireNonNull(publicKey, "publicKey"); digest(recordCommitment);
|
||||
}
|
||||
}
|
||||
|
||||
/** Account lookup used only after strict KID framing. */
|
||||
@FunctionalInterface
|
||||
public interface AccountKeyResolver {
|
||||
/** Resolves one exact KID or fails without revealing another account. */
|
||||
AccountKey resolve(String kid);
|
||||
}
|
||||
|
||||
/** Verified JWS data safe to pass to one closed endpoint decoder. */
|
||||
public record Verified(byte[] payload, String keyThumbprint, Optional<AccountKey> account,
|
||||
PublicKey publicKey) {
|
||||
/** Defensively snapshots verified payload. */
|
||||
public Verified {
|
||||
payload = Objects.requireNonNull(payload, "payload").clone(); digest(keyThumbprint);
|
||||
account = Objects.requireNonNull(account, "account"); Objects.requireNonNull(publicKey, "publicKey");
|
||||
}
|
||||
@Override public byte[] payload() { return payload.clone(); }
|
||||
}
|
||||
|
||||
private static final Base64.Decoder URL_DECODER = Base64.getUrlDecoder();
|
||||
|
||||
/**
|
||||
* Verifies one complete flattened JWS against the exact configured external URL.
|
||||
* The admitted replay nonce is consumed before signature verification.
|
||||
*/
|
||||
public Verified verify(byte[] document, int maximumDocumentBytes, URI expectedUrl,
|
||||
String directoryId, KeyMode keyMode, AcmeNonceService nonces,
|
||||
AccountKeyResolver accounts) {
|
||||
Objects.requireNonNull(expectedUrl, "expectedUrl"); requireId(directoryId);
|
||||
Objects.requireNonNull(keyMode, "keyMode"); Objects.requireNonNull(nonces, "nonces");
|
||||
Objects.requireNonNull(accounts, "accounts");
|
||||
Flattened flattened = parseFlattened(document, maximumDocumentBytes);
|
||||
byte[] protectedBytes = decodeCanonical(flattened.protectedValue(), 16_384);
|
||||
PkiOperationValue.ObjectValue header = object(StrictJson.parse(protectedBytes, 16_384));
|
||||
Map<String, PkiOperationValue> fields = header.fields();
|
||||
if (!fields.keySet().stream().allMatch(SetHolder.PROTECTED::contains)
|
||||
|| !"ES256".equals(text(fields, "alg"))
|
||||
|| !expectedUrl.toASCIIString().equals(text(fields, "url"))) {
|
||||
throw malformed("ACME protected header is invalid");
|
||||
}
|
||||
String nonce = text(fields, "nonce");
|
||||
if (!nonces.consume(nonce, directoryId)) throw new AcmeProblem("badNonce");
|
||||
|
||||
Optional<AccountKey> account = Optional.empty();
|
||||
PublicKey key;
|
||||
String thumbprint;
|
||||
if (keyMode == KeyMode.JWK) {
|
||||
if (!fields.keySet().equals(SetHolder.JWK_PROTECTED)) throw malformed("ACME JWK header is invalid");
|
||||
Jwk jwk = jwk(object(fields.get("jwk")));
|
||||
key = jwk.key(); thumbprint = jwk.thumbprint();
|
||||
} else {
|
||||
if (!fields.keySet().equals(SetHolder.KID_PROTECTED)) throw malformed("ACME KID header is invalid");
|
||||
AccountKey resolved = accounts.resolve(text(fields, "kid"));
|
||||
account = Optional.of(resolved); key = resolved.publicKey(); thumbprint = resolved.keyThumbprint();
|
||||
}
|
||||
byte[] payload = decodeCanonical(flattened.payload(), maximumDocumentBytes);
|
||||
verifySignature(flattened, key);
|
||||
return new Verified(payload, thumbprint, account, key);
|
||||
}
|
||||
|
||||
/** Verifies the RFC key-change inner JWS signed by the replacement key. */
|
||||
public Verified verifyKeyChange(byte[] document, int maximumDocumentBytes, URI expectedUrl,
|
||||
String expectedAccountKid, String oldKeyThumbprint) {
|
||||
Objects.requireNonNull(expectedUrl, "expectedUrl");
|
||||
Objects.requireNonNull(expectedAccountKid, "expectedAccountKid"); digest(oldKeyThumbprint);
|
||||
Flattened flattened = parseFlattened(document, maximumDocumentBytes);
|
||||
PkiOperationValue.ObjectValue header = object(StrictJson.parse(
|
||||
decodeCanonical(flattened.protectedValue(), 16_384), 16_384));
|
||||
if (!header.fields().keySet().equals(SetHolder.INNER_PROTECTED)
|
||||
|| !"ES256".equals(text(header.fields(), "alg"))
|
||||
|| !expectedUrl.toASCIIString().equals(text(header.fields(), "url"))) {
|
||||
throw malformed("ACME key-change header is invalid");
|
||||
}
|
||||
Jwk replacement = jwk(object(header.fields().get("jwk")));
|
||||
byte[] payload = decodeCanonical(flattened.payload(), maximumDocumentBytes);
|
||||
PkiOperationValue.ObjectValue claims = object(StrictJson.parse(payload, 65_536));
|
||||
if (!claims.fields().keySet().equals(SetHolder.INNER_PAYLOAD)
|
||||
|| !expectedAccountKid.equals(text(claims.fields(), "account"))
|
||||
|| !oldKeyThumbprint.equals(jwk(object(claims.fields().get("oldKey"))).thumbprint())
|
||||
|| replacement.thumbprint().equals(oldKeyThumbprint)) {
|
||||
throw new AcmeProblem("unauthorized");
|
||||
}
|
||||
verifySignature(flattened, replacement.key());
|
||||
return new Verified(payload, replacement.thumbprint(), Optional.empty(), replacement.key());
|
||||
}
|
||||
|
||||
private static void verifySignature(Flattened flattened, PublicKey key) {
|
||||
byte[] signature = decodeCanonical(flattened.signature(), 64);
|
||||
if (signature.length != 64) throw new AcmeProblem("malformed");
|
||||
byte[] signingInput = (flattened.protectedValue() + "." + flattened.payload())
|
||||
.getBytes(StandardCharsets.US_ASCII);
|
||||
try {
|
||||
Signature verifier = Signature.getInstance("SHA256withECDSAinP1363Format");
|
||||
verifier.initVerify(key); verifier.update(signingInput);
|
||||
if (!verifier.verify(signature)) throw new AcmeProblem("unauthorized");
|
||||
} catch (AcmeProblem failure) {
|
||||
throw failure;
|
||||
} catch (Exception failure) {
|
||||
throw new AcmeProblem("serverInternal");
|
||||
} finally {
|
||||
java.util.Arrays.fill(signature, (byte) 0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Safe finite protocol failure, never carrying provider or parser details. */
|
||||
public static final class AcmeProblem extends IllegalArgumentException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
private final String type;
|
||||
/** Creates a safe ACME problem classification. */
|
||||
public AcmeProblem(String type) { super("ACME request rejected"); this.type = type; }
|
||||
/** @return stable RFC-style problem suffix */
|
||||
public String type() { return type; }
|
||||
}
|
||||
|
||||
private static Flattened parseFlattened(byte[] document, int maximum) {
|
||||
if (document == null || document.length == 0 || maximum < 1
|
||||
|| maximum > StrictJson.MAXIMUM_DOCUMENT_BYTES || document.length > maximum) {
|
||||
throw malformed("ACME JWS framing is invalid");
|
||||
}
|
||||
JsonFactory factory = factory(maximum);
|
||||
try (JsonParser parser = factory.createParser(ObjectReadContext.empty(), document, 0, document.length)) {
|
||||
if (parser.nextToken() != JsonToken.START_OBJECT) throw malformed("ACME JWS framing is invalid");
|
||||
String protectedValue = null; String payload = null; String signature = null;
|
||||
int count = 0;
|
||||
while (parser.nextToken() != JsonToken.END_OBJECT) {
|
||||
if (parser.currentToken() != JsonToken.PROPERTY_NAME || ++count > 3) throw malformed("ACME JWS framing is invalid");
|
||||
String name = parser.currentName();
|
||||
if (parser.nextToken() != JsonToken.VALUE_STRING) throw malformed("ACME JWS field type is invalid");
|
||||
String value = parser.getString();
|
||||
switch (name) {
|
||||
case "protected" -> protectedValue = once(protectedValue, value);
|
||||
case "payload" -> payload = once(payload, value);
|
||||
case "signature" -> signature = once(signature, value);
|
||||
default -> throw malformed("Unknown ACME JWS field");
|
||||
}
|
||||
}
|
||||
if (parser.nextToken() != null || protectedValue == null || payload == null || signature == null) {
|
||||
throw malformed("ACME JWS is incomplete");
|
||||
}
|
||||
return new Flattened(protectedValue, payload, signature);
|
||||
} catch (AcmeProblem failure) {
|
||||
throw failure;
|
||||
} catch (RuntimeException failure) {
|
||||
throw malformed("ACME JWS cannot be decoded");
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonFactory factory(int maximum) {
|
||||
StreamReadConstraints constraints = StreamReadConstraints.builder().maxNestingDepth(2)
|
||||
.maxDocumentLength(maximum).maxTokenCount(16).maxNumberLength(4)
|
||||
.maxStringLength(maximum).maxNameLength(16).build();
|
||||
JsonFactoryBuilder builder = JsonFactory.builder().streamReadConstraints(constraints)
|
||||
.enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION).disable(StreamReadFeature.AUTO_CLOSE_SOURCE);
|
||||
for (JsonReadFeature feature : JsonReadFeature.values()) builder.disable(feature);
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private static Jwk jwk(PkiOperationValue.ObjectValue value) {
|
||||
Map<String, PkiOperationValue> fields = value.fields();
|
||||
if (!fields.keySet().equals(SetHolder.JWK_FIELDS)
|
||||
|| !"EC".equals(text(fields, "kty")) || !"P-256".equals(text(fields, "crv"))) {
|
||||
throw malformed("ACME account JWK is invalid");
|
||||
}
|
||||
byte[] x = decodeCanonical(text(fields, "x"), 32);
|
||||
byte[] y = decodeCanonical(text(fields, "y"), 32);
|
||||
if (x.length != 32 || y.length != 32) throw malformed("ACME account JWK coordinate is invalid");
|
||||
try {
|
||||
AlgorithmParameters parameters = AlgorithmParameters.getInstance("EC");
|
||||
parameters.init(new ECGenParameterSpec("secp256r1"));
|
||||
ECParameterSpec spec = parameters.getParameterSpec(ECParameterSpec.class);
|
||||
ECPoint point = new ECPoint(new BigInteger(1, x), new BigInteger(1, y));
|
||||
PublicKey key = KeyFactory.getInstance("EC").generatePublic(new ECPublicKeySpec(point, spec));
|
||||
String canonical = "{\"crv\":\"P-256\",\"kty\":\"EC\",\"x\":\""
|
||||
+ text(fields, "x") + "\",\"y\":\"" + text(fields, "y") + "\"}";
|
||||
return new Jwk(key, sha256(canonical.getBytes(StandardCharsets.US_ASCII)));
|
||||
} catch (Exception failure) {
|
||||
throw malformed("ACME account JWK is invalid");
|
||||
} finally {
|
||||
java.util.Arrays.fill(x, (byte) 0); java.util.Arrays.fill(y, (byte) 0);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] decodeCanonical(String value, int maximumDecoded) {
|
||||
if (value == null || value.indexOf('=') >= 0 || !value.matches("[A-Za-z0-9_-]*")) {
|
||||
throw malformed("ACME Base64url value is invalid");
|
||||
}
|
||||
try {
|
||||
byte[] decoded = URL_DECODER.decode(value);
|
||||
if (decoded.length > maximumDecoded
|
||||
|| !Base64.getUrlEncoder().withoutPadding().encodeToString(decoded).equals(value)) {
|
||||
throw malformed("ACME Base64url value is not canonical");
|
||||
}
|
||||
return decoded;
|
||||
} catch (IllegalArgumentException failure) {
|
||||
if (failure instanceof AcmeProblem problem) throw problem;
|
||||
throw malformed("ACME Base64url value is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private static PkiOperationValue.ObjectValue object(PkiOperationValue value) {
|
||||
if (value instanceof PkiOperationValue.ObjectValue object) return object;
|
||||
throw malformed("ACME JSON object is required");
|
||||
}
|
||||
private static String text(Map<String, PkiOperationValue> fields, String name) {
|
||||
if (fields.get(name) instanceof PkiOperationValue.Text text) return text.value();
|
||||
throw malformed("ACME protected field is missing");
|
||||
}
|
||||
private static String once(String previous, String value) {
|
||||
if (previous != null) throw malformed("Duplicate ACME JWS field");
|
||||
return value;
|
||||
}
|
||||
private static void requireId(String value) {
|
||||
if (value == null || !value.matches("[a-z0-9][a-z0-9._:-]{0,127}")) throw malformed("ACME identity is invalid");
|
||||
}
|
||||
private static void digest(String value) {
|
||||
if (value == null || !value.matches("[0-9a-f]{64}")) throw malformed("ACME commitment is invalid");
|
||||
}
|
||||
private static String sha256(byte[] value) {
|
||||
try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); }
|
||||
catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException("SHA-256 unavailable", impossible); }
|
||||
}
|
||||
private static AcmeProblem malformed(String ignored) { return new AcmeProblem("malformed"); }
|
||||
|
||||
private record Flattened(String protectedValue, String payload, String signature) { }
|
||||
private record Jwk(PublicKey key, String thumbprint) { }
|
||||
private static final class SetHolder {
|
||||
private static final java.util.Set<String> PROTECTED = java.util.Set.of("alg", "nonce", "url", "jwk", "kid");
|
||||
private static final java.util.Set<String> JWK_PROTECTED = java.util.Set.of("alg", "nonce", "url", "jwk");
|
||||
private static final java.util.Set<String> KID_PROTECTED = java.util.Set.of("alg", "nonce", "url", "kid");
|
||||
private static final java.util.Set<String> INNER_PROTECTED = java.util.Set.of("alg", "url", "jwk");
|
||||
private static final java.util.Set<String> INNER_PAYLOAD = java.util.Set.of("account", "oldKey");
|
||||
private static final java.util.Set<String> JWK_FIELDS = java.util.Set.of("kty", "crv", "x", "y");
|
||||
private SetHolder() { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*******************************************************************************
|
||||
* 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.acme;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.NavigableMap;
|
||||
import java.util.Objects;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/**
|
||||
* Bounded process-local one-time ACME replay-nonce authority.
|
||||
*
|
||||
* <p>Only SHA-256 commitments are retained. Restart intentionally invalidates all
|
||||
* outstanding nonces. Expiry buckets are removed eagerly on every issue and
|
||||
* consume operation, so retained state cannot grow beyond the configured bound.</p>
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.AvoidSynchronizedAtMethodLevel", "PMD.ControlStatementBraces",
|
||||
"PMD.UselessParentheses" })
|
||||
public final class AcmeNonceService {
|
||||
private static final int NONCE_BYTES = 32;
|
||||
private final String listenerId;
|
||||
private final Duration lifetime;
|
||||
private final int maximumOutstanding;
|
||||
private final Clock clock;
|
||||
private final SecureRandom random;
|
||||
private final Map<String, Instant> byCommitment = new HashMap<>();
|
||||
private final NavigableMap<Instant, java.util.Set<String>> byExpiry = new TreeMap<>();
|
||||
|
||||
/** Creates a bounded nonce service for one exact ACME listener. */
|
||||
public AcmeNonceService(String listenerId, Duration lifetime, int maximumOutstanding,
|
||||
Clock clock, SecureRandom random) {
|
||||
if (listenerId == null || !listenerId.matches("[a-z0-9][a-z0-9._:-]{0,127}")) {
|
||||
throw new IllegalArgumentException("ACME listener identity is invalid");
|
||||
}
|
||||
if (lifetime == null || lifetime.isZero() || lifetime.isNegative()
|
||||
|| lifetime.compareTo(Duration.ofHours(1)) > 0) {
|
||||
throw new IllegalArgumentException("ACME nonce lifetime is invalid");
|
||||
}
|
||||
if (maximumOutstanding < 16 || maximumOutstanding > 1_000_000) {
|
||||
throw new IllegalArgumentException("ACME nonce capacity is invalid");
|
||||
}
|
||||
this.listenerId = listenerId;
|
||||
this.lifetime = lifetime;
|
||||
this.maximumOutstanding = maximumOutstanding;
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
this.random = Objects.requireNonNull(random, "random");
|
||||
}
|
||||
|
||||
/** Issues one canonical unpadded Base64url nonce with 256 bits of entropy. */
|
||||
public synchronized String issue(String directoryId) {
|
||||
requireDirectory(directoryId);
|
||||
expire(clock.instant());
|
||||
if (byCommitment.size() >= maximumOutstanding) {
|
||||
throw new IllegalStateException("ACME nonce capacity is exhausted");
|
||||
}
|
||||
byte[] raw = new byte[NONCE_BYTES];
|
||||
String nonce;
|
||||
String commitment;
|
||||
do {
|
||||
random.nextBytes(raw);
|
||||
nonce = Base64.getUrlEncoder().withoutPadding().encodeToString(raw);
|
||||
commitment = commitment(nonce, directoryId);
|
||||
} while (byCommitment.containsKey(commitment));
|
||||
java.util.Arrays.fill(raw, (byte) 0);
|
||||
Instant expiry = clock.instant().plus(lifetime);
|
||||
byCommitment.put(commitment, expiry);
|
||||
byExpiry.computeIfAbsent(expiry, ignored -> new java.util.HashSet<>()).add(commitment);
|
||||
return nonce;
|
||||
}
|
||||
|
||||
/** Consumes one nonce exactly once for the bound listener and directory. */
|
||||
public synchronized boolean consume(String nonce, String directoryId) {
|
||||
Instant now = clock.instant();
|
||||
expire(now);
|
||||
if (nonce == null || !nonce.matches("[A-Za-z0-9_-]{43}")
|
||||
|| directoryId == null || directoryId.isBlank()) return false;
|
||||
String key = commitment(nonce, directoryId);
|
||||
Instant expiry = byCommitment.remove(key);
|
||||
if (expiry == null) return false;
|
||||
java.util.Set<String> bucket = byExpiry.get(expiry);
|
||||
if (bucket != null && (bucket.remove(key) && bucket.isEmpty())) byExpiry.remove(expiry);
|
||||
return expiry.isAfter(now);
|
||||
}
|
||||
|
||||
/** @return current bounded outstanding nonce count for diagnostics */
|
||||
public synchronized int outstanding() { expire(clock.instant()); return byCommitment.size(); }
|
||||
|
||||
private void expire(Instant now) {
|
||||
while (!byExpiry.isEmpty() && !byExpiry.firstKey().isAfter(now)) {
|
||||
byExpiry.pollFirstEntry().getValue().forEach(byCommitment::remove);
|
||||
}
|
||||
}
|
||||
|
||||
private String commitment(String nonce, String directoryId) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
digest.update(listenerId.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
digest.update((byte) 0);
|
||||
digest.update(directoryId.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
digest.update((byte) 0);
|
||||
digest.update(nonce.getBytes(java.nio.charset.StandardCharsets.US_ASCII));
|
||||
return java.util.HexFormat.of().formatHex(digest.digest());
|
||||
} catch (NoSuchAlgorithmException impossible) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", impossible);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireDirectory(String directoryId) {
|
||||
if (directoryId == null || !directoryId.matches("[a-z0-9][a-z0-9._:-]{0,127}")) {
|
||||
throw new IllegalArgumentException("ACME directory identity is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*******************************************************************************
|
||||
* 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.acme;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.ServiceLoader;
|
||||
|
||||
import zeroecho.pki.server.spi.AcmeChallengeProvider;
|
||||
import zeroecho.pki.server.spi.AcmeExternalAccountBindingProvider;
|
||||
import zeroecho.pki.spi.ProviderConfig;
|
||||
|
||||
/** Deterministic explicitly enabled ACME ServiceLoader composition. */
|
||||
@SuppressWarnings("PMD.ControlStatementBraces")
|
||||
public final class AcmeProviders {
|
||||
private AcmeProviders() { }
|
||||
|
||||
/** Resolves only explicitly configured challenge-provider identities. */
|
||||
public static Map<String, AcmeChallengeProvider> challenges(List<ProviderConfig> enabled,
|
||||
ClassLoader loader) {
|
||||
return resolve(enabled, ServiceLoader.load(AcmeChallengeProvider.class, loader).stream()
|
||||
.map(ServiceLoader.Provider::get).toList(), AcmeChallengeProvider::id);
|
||||
}
|
||||
|
||||
/** Resolves only explicitly configured EAB-provider identities. */
|
||||
public static Map<String, AcmeExternalAccountBindingProvider> eab(List<ProviderConfig> enabled,
|
||||
ClassLoader loader) {
|
||||
return resolve(enabled, ServiceLoader.load(AcmeExternalAccountBindingProvider.class, loader).stream()
|
||||
.map(ServiceLoader.Provider::get).toList(), AcmeExternalAccountBindingProvider::id);
|
||||
}
|
||||
|
||||
private static <T> Map<String, T> resolve(List<ProviderConfig> enabled, List<T> discovered,
|
||||
java.util.function.Function<T, String> identity) {
|
||||
Objects.requireNonNull(loaderMarker(enabled), "enabled");
|
||||
Map<String, T> available = new LinkedHashMap<>();
|
||||
for (T provider : discovered.stream().sorted(java.util.Comparator.comparing(identity)).toList()) {
|
||||
String id = identity.apply(provider);
|
||||
if (available.putIfAbsent(id, provider) != null) throw new IllegalStateException("Duplicate ACME provider identity");
|
||||
}
|
||||
Map<String, T> result = new LinkedHashMap<>();
|
||||
for (ProviderConfig configuration : enabled.stream().sorted(java.util.Comparator.comparing(ProviderConfig::backendId)).toList()) {
|
||||
T provider = available.get(configuration.backendId());
|
||||
if (provider == null || result.putIfAbsent(configuration.backendId(), provider) != null) {
|
||||
throw new IllegalArgumentException("Configured ACME provider is unavailable or duplicated");
|
||||
}
|
||||
}
|
||||
return Map.copyOf(result);
|
||||
}
|
||||
|
||||
private static List<ProviderConfig> loaderMarker(List<ProviderConfig> enabled) {
|
||||
return List.copyOf(Objects.requireNonNull(enabled, "enabled"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*******************************************************************************/
|
||||
package zeroecho.pki.server.acme;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Semaphore;
|
||||
|
||||
import zeroecho.pki.server.PkiServerConfiguration;
|
||||
|
||||
/** Bounded restart-local ACME rate and finalization admission authority. */
|
||||
@SuppressWarnings({ "PMD.AvoidSynchronizedAtMethodLevel", "PMD.ControlStatementBraces" })
|
||||
public final class AcmeRateAdmission {
|
||||
private final Duration window;
|
||||
private final int accountLimit;
|
||||
private final int orderLimit;
|
||||
private final int validationLimit;
|
||||
private final int maximumKeys;
|
||||
private final Clock clock;
|
||||
private final Map<String, Counter> counters = new HashMap<>();
|
||||
private final Semaphore finalizations;
|
||||
|
||||
/** Creates admission limits from one exact ACME listener configuration. */
|
||||
public AcmeRateAdmission(PkiServerConfiguration.AcmeListener configuration, Clock clock) {
|
||||
this.window = configuration.admissionWindow();
|
||||
this.accountLimit = configuration.maximumNewAccountsPerWindow();
|
||||
this.orderLimit = configuration.maximumNewOrdersPerAccountWindow();
|
||||
this.validationLimit = configuration.maximumChallengeValidationsPerAccountWindow();
|
||||
this.maximumKeys = Math.min(1_000_000, configuration.maximumOutstandingNonces());
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
this.finalizations = new Semaphore(configuration.maximumConcurrentFinalizations(), true);
|
||||
}
|
||||
|
||||
/** Admits one new-account request within the directory-wide window. */
|
||||
public synchronized boolean admitAccount(String directoryId) {
|
||||
return admit("account:" + directoryId, accountLimit);
|
||||
}
|
||||
|
||||
/** Admits one new-order request within the exact account window. */
|
||||
public synchronized boolean admitOrder(String accountId) { return admit("order:" + accountId, orderLimit); }
|
||||
|
||||
/** Admits one explicit challenge-validation attempt. */
|
||||
public synchronized boolean admitValidation(String accountId) {
|
||||
return admit("validation:" + accountId, validationLimit);
|
||||
}
|
||||
|
||||
/** Acquires one listener-wide finalization permit without waiting. */
|
||||
public boolean tryAcquireFinalization() { return finalizations.tryAcquire(); }
|
||||
|
||||
/** Releases one previously acquired finalization permit. */
|
||||
public void releaseFinalization() { finalizations.release(); }
|
||||
|
||||
/** @return bounded live counter population */
|
||||
public synchronized int trackedKeys() { expire(clock.instant()); return counters.size(); }
|
||||
|
||||
private boolean admit(String key, int limit) {
|
||||
Instant now = clock.instant(); expire(now);
|
||||
Counter current = counters.get(key);
|
||||
if (current == null) {
|
||||
if (counters.size() >= maximumKeys) return false;
|
||||
counters.put(key, new Counter(1, now.plus(window))); return true;
|
||||
}
|
||||
if (current.count() >= limit) return false;
|
||||
counters.put(key, new Counter(current.count() + 1, current.expiresAt())); return true;
|
||||
}
|
||||
|
||||
private void expire(Instant now) { counters.entrySet().removeIf(entry -> !entry.getValue().expiresAt().isAfter(now)); }
|
||||
private record Counter(int count, Instant expiresAt) { }
|
||||
}
|
||||
@@ -0,0 +1,790 @@
|
||||
/*******************************************************************************
|
||||
* 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.acme;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PublicKey;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import zeroecho.core.io.CancellationSignal;
|
||||
import zeroecho.pki.api.CertificationRequestService;
|
||||
import zeroecho.pki.api.EncodedObject;
|
||||
import zeroecho.pki.api.Encoding;
|
||||
import zeroecho.pki.api.IssuanceService;
|
||||
import zeroecho.pki.api.PkiException;
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.api.Validity;
|
||||
import zeroecho.pki.api.attr.AttributeId;
|
||||
import zeroecho.pki.api.attr.AttributeSet;
|
||||
import zeroecho.pki.api.attr.AttributeValue;
|
||||
import zeroecho.pki.api.ca.CaRecord;
|
||||
import zeroecho.pki.api.ca.CaState;
|
||||
import zeroecho.pki.api.ca.IssuerChainPath;
|
||||
import zeroecho.pki.api.credential.CredentialBundle;
|
||||
import zeroecho.pki.api.issuance.IssueEndEntityCommand;
|
||||
import zeroecho.pki.api.issuance.IssuanceIntent;
|
||||
import zeroecho.pki.api.profile.ActiveCertificateProfile;
|
||||
import zeroecho.pki.api.profile.CertificateProfileKind;
|
||||
import zeroecho.pki.api.request.CertificationRequest;
|
||||
import zeroecho.pki.api.request.ParsedCertificationRequest;
|
||||
import zeroecho.pki.api.request.SubjectAlternativeName;
|
||||
import zeroecho.pki.api.revocation.RevocationCommand;
|
||||
import zeroecho.pki.api.revocation.RevocationReason;
|
||||
import zeroecho.pki.api.revocation.RevocationRecord;
|
||||
import zeroecho.pki.application.PkiSession;
|
||||
import zeroecho.pki.server.DisclosureService;
|
||||
import zeroecho.pki.server.ServerControlStore;
|
||||
import zeroecho.pki.server.ServerRealmContext;
|
||||
import zeroecho.pki.server.spi.AcmeChallengeProvider;
|
||||
import zeroecho.pki.server.spi.AcmeExternalAccountBindingProvider;
|
||||
import zeroecho.pki.spi.ProviderConfig;
|
||||
|
||||
/**
|
||||
* Transport-neutral durable ACME state machine and constrained PKI integration.
|
||||
*
|
||||
* <p>Account identity is protocol-scoped. No method accepts an administrative
|
||||
* principal or permission as issuance authority. Directory activation freezes
|
||||
* one exact authority, profile, issuer generation, chain path, provider set, and
|
||||
* disclosure policy. External validators return evidence only.</p>
|
||||
*/
|
||||
@SuppressWarnings("PMD")
|
||||
public final class AcmeService {
|
||||
private static final String ZERO = "0".repeat(64);
|
||||
private final ServerRealmContext realm;
|
||||
private final AcmeControlStore store;
|
||||
private final Clock clock;
|
||||
private final SecureRandom random;
|
||||
private final Map<String, AcmeChallengeProvider> providers;
|
||||
private final Map<String, ProviderConfig> providerConfigurations;
|
||||
private final Map<String, AcmeExternalAccountBindingProvider> eabProviders;
|
||||
private final Map<String, ProviderConfig> eabProviderConfigurations;
|
||||
|
||||
/** Creates a realm-bound ACME service from explicitly enabled providers. */
|
||||
public AcmeService(ServerRealmContext realm, Clock clock, SecureRandom random,
|
||||
Map<String, AcmeChallengeProvider> providers,
|
||||
Map<String, ProviderConfig> providerConfigurations,
|
||||
Map<String, AcmeExternalAccountBindingProvider> eabProviders,
|
||||
Map<String, ProviderConfig> eabProviderConfigurations) {
|
||||
this.realm = Objects.requireNonNull(realm, "realm");
|
||||
this.store = realm.acmeControl(); this.clock = Objects.requireNonNull(clock, "clock");
|
||||
this.random = Objects.requireNonNull(random, "random");
|
||||
this.providers = Map.copyOf(Objects.requireNonNull(providers, "providers"));
|
||||
this.providerConfigurations = Map.copyOf(Objects.requireNonNull(providerConfigurations,
|
||||
"providerConfigurations"));
|
||||
this.eabProviders = Map.copyOf(Objects.requireNonNull(eabProviders, "eabProviders"));
|
||||
this.eabProviderConfigurations = Map.copyOf(Objects.requireNonNull(eabProviderConfigurations,
|
||||
"eabProviderConfigurations"));
|
||||
if (!this.providers.keySet().equals(this.providerConfigurations.keySet())
|
||||
|| this.providers.entrySet().stream().anyMatch(entry -> !entry.getKey().equals(entry.getValue().id()))) {
|
||||
throw new IllegalArgumentException("ACME provider composition is inconsistent");
|
||||
}
|
||||
if (!this.eabProviders.keySet().equals(this.eabProviderConfigurations.keySet())
|
||||
|| this.eabProviders.entrySet().stream()
|
||||
.anyMatch(entry -> !entry.getKey().equals(entry.getValue().id()))) {
|
||||
throw new IllegalArgumentException("ACME EAB provider composition is inconsistent");
|
||||
}
|
||||
validateRecoveredState();
|
||||
}
|
||||
|
||||
/** Creates an ACME service without EAB providers. */
|
||||
public AcmeService(ServerRealmContext realm, Clock clock, SecureRandom random,
|
||||
Map<String, AcmeChallengeProvider> providers,
|
||||
Map<String, ProviderConfig> providerConfigurations) {
|
||||
this(realm, clock, random, providers, providerConfigurations, Map.of(), Map.of());
|
||||
}
|
||||
|
||||
private void validateRecoveredState() {
|
||||
int offset = 0;
|
||||
do {
|
||||
ServerControlStore.Page<AcmeState.Directory> page = store.page(AcmeState.Directory.class, offset, 256);
|
||||
for (AcmeState.Directory directory : page.values()) {
|
||||
if (directory.status() == AcmeState.DirectoryStatus.ACTIVE) {
|
||||
validateFrozenDirectory(directory);
|
||||
if (directory.eabRequired() && directory.eabProviderId().isEmpty()) {
|
||||
throw new IllegalStateException("Required ACME EAB provider is unavailable");
|
||||
}
|
||||
directory.eabProviderId().ifPresent(id -> {
|
||||
if (!eabProviders.containsKey(id)) {
|
||||
throw new IllegalStateException("ACME EAB provider is unavailable");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!page.hasMore()) {
|
||||
return;
|
||||
}
|
||||
offset = page.nextOffset();
|
||||
} while (true);
|
||||
}
|
||||
|
||||
/** Safe immutable directory-registration input. */
|
||||
public record DirectoryRegistration(String alias, PkiId authorityId, String profileId,
|
||||
Set<String> dnsNamespaces, Duration maximumValidity, Set<String> publicKeyAlgorithms,
|
||||
Set<String> x509BindingPolicies, Set<AcmeState.ChallengeType> challengeTypes,
|
||||
Set<String> challengeProviderIds, Optional<String> eabProviderId, boolean eabRequired,
|
||||
DisclosureService.Policy disclosurePolicy) {
|
||||
/** Validates required registration input before PKI resolution. */
|
||||
public DirectoryRegistration {
|
||||
Objects.requireNonNull(alias, "alias"); Objects.requireNonNull(authorityId, "authorityId");
|
||||
Objects.requireNonNull(profileId, "profileId"); dnsNamespaces = Set.copyOf(dnsNamespaces);
|
||||
Objects.requireNonNull(maximumValidity, "maximumValidity");
|
||||
publicKeyAlgorithms = Set.copyOf(publicKeyAlgorithms); x509BindingPolicies = Set.copyOf(x509BindingPolicies);
|
||||
challengeTypes = Set.copyOf(challengeTypes); challengeProviderIds = Set.copyOf(challengeProviderIds);
|
||||
eabProviderId = Objects.requireNonNull(eabProviderId, "eabProviderId");
|
||||
Objects.requireNonNull(disclosurePolicy, "disclosurePolicy");
|
||||
}
|
||||
}
|
||||
|
||||
/** Registers one immutable inactive directory revision. */
|
||||
public synchronized AcmeState.Directory registerDirectory(DirectoryRegistration registration) {
|
||||
Objects.requireNonNull(registration, "registration");
|
||||
requireExposed(registration.authorityId());
|
||||
PkiSession session = realm.session();
|
||||
CaRecord authority = session.repository().authority(registration.authorityId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("ACME authority is unavailable"));
|
||||
ActiveCertificateProfile profile = session.profiles().requireActiveProfile(registration.profileId());
|
||||
if (profile.definition().certificateType() != CertificateProfileKind.END_ENTITY
|
||||
|| registration.maximumValidity().compareTo(profile.definition().maximumValidity()) > 0) {
|
||||
throw new IllegalArgumentException("ACME profile or validity policy is invalid");
|
||||
}
|
||||
if (!registration.publicKeyAlgorithms().equals(
|
||||
profile.definition().leafPolicy().allowedSubjectKeyAlgorithmIds())
|
||||
|| !registration.x509BindingPolicies().equals(bindingPolicy(profile))) {
|
||||
throw new IllegalArgumentException("ACME algorithm policy must exactly match the active profile");
|
||||
}
|
||||
IssuerChainPath path = requireCurrentPath(authority);
|
||||
requireProviders(registration.challengeTypes(), registration.challengeProviderIds());
|
||||
if (registration.eabRequired() && registration.eabProviderId().isEmpty()) {
|
||||
throw new IllegalArgumentException("ACME EAB policy is contradictory");
|
||||
}
|
||||
registration.eabProviderId().ifPresent(value -> {
|
||||
if (!eabProviders.containsKey(value)) throw new IllegalArgumentException("ACME EAB provider is unavailable");
|
||||
});
|
||||
int revision = nextDirectoryRevision(registration.alias());
|
||||
String directoryId = "directory:" + registration.alias() + ":r" + revision;
|
||||
String policy = directoryPolicyCommitment(registration, profile, authority, path);
|
||||
AcmeState.Directory unsealed = new AcmeState.Directory(directoryId, registration.alias(), revision,
|
||||
realm.configuration().realmId(), authority.caId(), profile.reference(),
|
||||
authority.currentIssuanceIssuerId(), path.pathId(), path.pathCommitment(),
|
||||
Set.of(AcmeState.IdentifierType.DNS), registration.dnsNamespaces(),
|
||||
registration.maximumValidity(), registration.publicKeyAlgorithms(),
|
||||
registration.x509BindingPolicies(), registration.challengeTypes(),
|
||||
registration.challengeProviderIds(), registration.eabProviderId(), registration.eabRequired(),
|
||||
registration.disclosurePolicy(), AcmeState.DirectoryStatus.INACTIVE, policy, clock.instant(), ZERO);
|
||||
return store.create(unsealed);
|
||||
}
|
||||
|
||||
/** Activates one exact directory revision after revalidating all frozen PKI authority. */
|
||||
public synchronized AcmeState.Directory activateDirectory(String directoryId) {
|
||||
AcmeState.Directory current = requireDirectory(directoryId);
|
||||
if (current.status() == AcmeState.DirectoryStatus.ACTIVE) return current;
|
||||
validateFrozenDirectory(current);
|
||||
for (AcmeState.Directory other : directoriesForAlias(current.alias())) {
|
||||
if (other.status() == AcmeState.DirectoryStatus.ACTIVE) {
|
||||
throw new IllegalStateException("Another ACME directory revision is active");
|
||||
}
|
||||
}
|
||||
AcmeState.Directory updated = copyDirectory(current, AcmeState.DirectoryStatus.ACTIVE);
|
||||
return (AcmeState.Directory) store.replace(List.of(new AcmeControlStore.Replacement(current, updated))).get(0);
|
||||
}
|
||||
|
||||
/** Deactivates new account/order admission while existing unexpired orders remain usable. */
|
||||
public synchronized AcmeState.Directory deactivateDirectory(String directoryId) {
|
||||
AcmeState.Directory current = requireDirectory(directoryId);
|
||||
if (current.status() == AcmeState.DirectoryStatus.INACTIVE) return current;
|
||||
AcmeState.Directory updated = copyDirectory(current, AcmeState.DirectoryStatus.INACTIVE);
|
||||
return (AcmeState.Directory) store.replace(List.of(new AcmeControlStore.Replacement(current, updated))).get(0);
|
||||
}
|
||||
|
||||
/** Resolves the sole active directory revision for one public alias. */
|
||||
public AcmeState.Directory activeDirectory(String alias) {
|
||||
List<AcmeState.Directory> active = directoriesForAlias(alias).stream()
|
||||
.filter(value -> value.status() == AcmeState.DirectoryStatus.ACTIVE).toList();
|
||||
if (active.size() != 1) throw new IllegalArgumentException("ACME directory is unavailable");
|
||||
return active.get(0);
|
||||
}
|
||||
|
||||
/** Returns one exact directory revision for administrative and protocol inspection. */
|
||||
public AcmeState.Directory directory(String directoryId) { return requireDirectory(directoryId); }
|
||||
|
||||
/** Returns a deterministic bounded page of directory revisions. */
|
||||
public ServerControlStore.Page<AcmeState.Directory> directories(int offset, int limit) {
|
||||
return store.page(AcmeState.Directory.class, offset, limit);
|
||||
}
|
||||
|
||||
/** Creates or returns an account under one exact active directory. */
|
||||
public synchronized AcmeState.Account createAccount(AcmeState.Directory directory, String keyThumbprint,
|
||||
byte[] publicKeySpki, List<String> contacts, boolean termsAgreed,
|
||||
Optional<String> eabPolicyCommitment, boolean onlyReturnExisting) {
|
||||
requireActiveExact(directory);
|
||||
Optional<AcmeState.Account> existing = accountByThumbprint(directory.directoryId(), keyThumbprint);
|
||||
if (existing.isPresent()) return existing.orElseThrow();
|
||||
if (onlyReturnExisting) throw new IllegalArgumentException("ACME account does not exist");
|
||||
if (directory.eabRequired() && eabPolicyCommitment.isEmpty()) {
|
||||
throw new IllegalArgumentException("ACME external account binding is required");
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
AcmeState.Account account = new AcmeState.Account(id("account"), directory.directoryId(),
|
||||
directory.revision(), directory.commitment(), keyThumbprint, publicKeySpki,
|
||||
AcmeState.AccountStatus.VALID, contacts, termsAgreed, eabPolicyCommitment, now, now, ZERO);
|
||||
AcmeState.Account created = store.create(account);
|
||||
audit("ACME_ACCOUNT_CREATED", created.accountId(), Map.of("directory", created.directoryId()));
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Resolves an account by its authenticated key identity within one directory. */
|
||||
public Optional<AcmeState.Account> accountByKey(String directoryId, String keyThumbprint) {
|
||||
return accountByThumbprint(directoryId, keyThumbprint);
|
||||
}
|
||||
|
||||
/** Verifies EAB inside its explicit secret-confining provider and returns only a safe commitment. */
|
||||
public Optional<String> verifyExternalAccountBinding(AcmeState.Directory directory, String keyThumbprint,
|
||||
Optional<byte[]> nestedJws) {
|
||||
Objects.requireNonNull(nestedJws, "nestedJws");
|
||||
if (directory.eabProviderId().isEmpty()) {
|
||||
if (nestedJws.isPresent()) throw new IllegalArgumentException("ACME EAB is not enabled");
|
||||
return Optional.empty();
|
||||
}
|
||||
if (nestedJws.isEmpty()) {
|
||||
if (directory.eabRequired()) {
|
||||
throw new IllegalArgumentException("ACME EAB is required");
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
byte[] document = nestedJws.orElseThrow();
|
||||
String providerId = directory.eabProviderId().orElseThrow();
|
||||
AcmeExternalAccountBindingProvider provider = Optional.ofNullable(eabProviders.get(providerId))
|
||||
.orElseThrow(() -> new IllegalStateException("ACME EAB provider is unavailable"));
|
||||
AcmeExternalAccountBindingProvider.Binding binding = provider.verify(
|
||||
new AcmeExternalAccountBindingProvider.Request(directory.directoryId(), directory.authorityId(),
|
||||
directory.profile().profileId(), keyThumbprint, document, clock.instant()),
|
||||
eabProviderConfigurations.get(providerId));
|
||||
if (!binding.consumed() || !binding.expiresAt().isAfter(clock.instant())
|
||||
|| !binding.dnsNamespaces().containsAll(directory.dnsNamespaces())) {
|
||||
throw new SecurityException("ACME EAB policy is unavailable");
|
||||
}
|
||||
return Optional.of(binding.policyCommitment());
|
||||
}
|
||||
|
||||
/** Resolves one active account key for strict KID authentication. */
|
||||
public AcmeJwsVerifier.AccountKey accountKey(String accountId, String kid) {
|
||||
AcmeState.Account account = requireAccount(accountId);
|
||||
if (account.status() != AcmeState.AccountStatus.VALID) throw new IllegalArgumentException("ACME account unavailable");
|
||||
try {
|
||||
PublicKey key = KeyFactory.getInstance("EC").generatePublic(new X509EncodedKeySpec(account.publicKeySpki()));
|
||||
return new AcmeJwsVerifier.AccountKey(account.accountId(), kid, account.keyThumbprint(), key,
|
||||
account.commitment());
|
||||
} catch (Exception failure) {
|
||||
throw new IllegalStateException("ACME account key is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns one account record without exposing contact data through transport automatically. */
|
||||
public AcmeState.Account account(String accountId) { return requireAccount(accountId); }
|
||||
|
||||
/** Durably changes account status while preserving its key and history commitment. */
|
||||
public synchronized AcmeState.Account deactivateAccount(String accountId) {
|
||||
return deactivateAccount(requireAccount(accountId));
|
||||
}
|
||||
|
||||
/** Durably changes account status after exact account-key authority revalidation. */
|
||||
public synchronized AcmeState.Account deactivateAccount(String accountId, String expectedCommitment) {
|
||||
return deactivateAccount(requireAuthenticatedAccount(accountId, expectedCommitment));
|
||||
}
|
||||
|
||||
private AcmeState.Account deactivateAccount(AcmeState.Account current) {
|
||||
if (current.status() == AcmeState.AccountStatus.DEACTIVATED) return current;
|
||||
AcmeState.Account updated = new AcmeState.Account(current.accountId(), current.directoryId(),
|
||||
current.directoryRevision(), current.directoryCommitment(), current.keyThumbprint(),
|
||||
current.publicKeySpki(), AcmeState.AccountStatus.DEACTIVATED, current.contacts(),
|
||||
current.termsAgreed(), current.eabPolicyCommitment(), current.createdAt(), clock.instant(), ZERO);
|
||||
AcmeState.Account deactivated = (AcmeState.Account) store.replace(
|
||||
List.of(new AcmeControlStore.Replacement(current, updated))).get(0);
|
||||
audit("ACME_ACCOUNT_DEACTIVATED", current.accountId(), Map.of("directory", current.directoryId()));
|
||||
return deactivated;
|
||||
}
|
||||
|
||||
/** Atomically replaces bounded account contact metadata without changing key authority. */
|
||||
public synchronized AcmeState.Account updateAccountContacts(String accountId, String expectedCommitment,
|
||||
List<String> contacts) {
|
||||
AcmeState.Account current = requireAuthenticatedAccount(accountId, expectedCommitment);
|
||||
if (current.status() != AcmeState.AccountStatus.VALID) {
|
||||
throw new IllegalStateException("ACME account is unavailable");
|
||||
}
|
||||
AcmeState.Account updated = new AcmeState.Account(current.accountId(), current.directoryId(),
|
||||
current.directoryRevision(), current.directoryCommitment(), current.keyThumbprint(),
|
||||
current.publicKeySpki(), current.status(), contacts, current.termsAgreed(),
|
||||
current.eabPolicyCommitment(), current.createdAt(), clock.instant(), ZERO);
|
||||
AcmeState.Account replaced = (AcmeState.Account) store.replace(
|
||||
List.of(new AcmeControlStore.Replacement(current, updated))).get(0);
|
||||
audit("ACME_ACCOUNT_CONTACT_UPDATED", accountId, Map.of("directory", current.directoryId()));
|
||||
return replaced;
|
||||
}
|
||||
|
||||
/** Atomically replaces one account key after exact old-record authentication. */
|
||||
public synchronized AcmeState.Account rolloverAccount(String accountId, String expectedCommitment,
|
||||
String replacementThumbprint, byte[] replacementSpki) {
|
||||
AcmeState.Account current = requireAccount(accountId);
|
||||
if (current.status() != AcmeState.AccountStatus.VALID
|
||||
|| !current.commitment().equals(expectedCommitment)
|
||||
|| current.keyThumbprint().equals(replacementThumbprint)
|
||||
|| accountByThumbprint(current.directoryId(), replacementThumbprint).isPresent()) {
|
||||
throw new SecurityException("ACME account key rollover is unavailable");
|
||||
}
|
||||
AcmeState.Account updated = new AcmeState.Account(current.accountId(), current.directoryId(),
|
||||
current.directoryRevision(), current.directoryCommitment(), replacementThumbprint,
|
||||
replacementSpki, current.status(), current.contacts(), current.termsAgreed(),
|
||||
current.eabPolicyCommitment(), current.createdAt(), clock.instant(), ZERO);
|
||||
AcmeState.Account replaced = (AcmeState.Account) store.replace(
|
||||
List.of(new AcmeControlStore.Replacement(current, updated))).get(0);
|
||||
audit("ACME_ACCOUNT_KEY_ROLLOVER", accountId, Map.of("directory", current.directoryId()));
|
||||
return replaced;
|
||||
}
|
||||
|
||||
/** Returns a deterministic bounded page of accounts for administrative inspection. */
|
||||
public ServerControlStore.Page<AcmeState.Account> accounts(int offset, int limit) {
|
||||
return store.page(AcmeState.Account.class, offset, limit);
|
||||
}
|
||||
|
||||
/** Atomically creates one account-owned order, authorization, and challenge graph. */
|
||||
public synchronized AcmeState.Order createOrder(String accountId, String expectedAccountCommitment,
|
||||
AcmeState.Directory directory,
|
||||
List<AcmeState.Identifier> requested, Optional<Instant> notBefore, Optional<Instant> notAfter,
|
||||
Duration orderLifetime) {
|
||||
AcmeState.Account account = requireAuthenticatedAccount(accountId, expectedAccountCommitment);
|
||||
requireAccountDirectory(account, directory);
|
||||
requireActiveExact(directory); List<AcmeState.Identifier> identifiers = canonicalIdentifiers(requested, directory);
|
||||
Instant now = clock.instant(); Instant expires = now.plus(orderLifetime);
|
||||
List<AcmeState.Authorization> authorizations = new ArrayList<>();
|
||||
List<AcmeState.Challenge> challenges = new ArrayList<>();
|
||||
for (AcmeState.Identifier identifier : identifiers) {
|
||||
String authorizationId = id("authorization"); List<String> challengeIds = new ArrayList<>();
|
||||
for (AcmeState.ChallengeType type : directory.challengeTypes().stream().sorted().toList()) {
|
||||
if (identifier.wildcard() && type != AcmeState.ChallengeType.DNS_01) continue;
|
||||
String provider = providerFor(directory, type); String challengeId = id("challenge");
|
||||
challengeIds.add(challengeId);
|
||||
challenges.add(new AcmeState.Challenge(challengeId, authorizationId, type, token(),
|
||||
AcmeState.ChallengeStatus.PENDING, provider, 0, Optional.empty(), Optional.empty(), now, now, ZERO));
|
||||
}
|
||||
if (challengeIds.isEmpty()) throw new IllegalArgumentException("No ACME challenge supports an identifier");
|
||||
authorizations.add(new AcmeState.Authorization(authorizationId, "order-pending", accountId, identifier,
|
||||
AcmeState.AuthorizationStatus.PENDING, challengeIds, expires, Optional.empty(), ZERO));
|
||||
}
|
||||
String orderId = id("order");
|
||||
authorizations = authorizations.stream().map(value -> new AcmeState.Authorization(value.authorizationId(),
|
||||
orderId, value.accountId(), value.identifier(), value.status(), value.challengeIds(), value.expiresAt(),
|
||||
value.evidenceId(), ZERO)).toList();
|
||||
AcmeState.Order order = new AcmeState.Order(orderId, accountId, directory.directoryId(), directory.revision(),
|
||||
directory.commitment(), directory.authorityId(), directory.profile(), directory.issuerId(),
|
||||
directory.issuancePathId(), directory.issuancePathCommitment(), identifiers, notBefore, notAfter,
|
||||
AcmeState.OrderStatus.PENDING, authorizations.stream().map(AcmeState.Authorization::authorizationId).toList(),
|
||||
Optional.empty(), now, expires, ZERO);
|
||||
AcmeState.Order created = store.createOrderGraph(order, authorizations, challenges).order();
|
||||
audit("ACME_ORDER_CREATED", accountId, Map.of("order", created.orderId(),
|
||||
"directory", created.directoryId()));
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Counts pending nonterminal orders up to a caller-supplied finite bound. */
|
||||
public int pendingOrderCount(String accountId, int stopAt) {
|
||||
if (stopAt <= 0) throw new IllegalArgumentException("ACME pending-order bound is invalid");
|
||||
int offset = 0; int count = 0;
|
||||
while (true) {
|
||||
ServerControlStore.Page<AcmeState.Order> page = store.page(AcmeState.Order.class, offset, 256);
|
||||
for (AcmeState.Order order : page.values()) {
|
||||
if (order.accountId().equals(accountId) && order.status() != AcmeState.OrderStatus.VALID
|
||||
&& order.status() != AcmeState.OrderStatus.INVALID && ++count >= stopAt) return count;
|
||||
}
|
||||
if (!page.hasMore()) return count;
|
||||
offset = page.nextOffset();
|
||||
}
|
||||
}
|
||||
|
||||
/** Revokes one certificate issued to the authenticated account through the bound directory. */
|
||||
public synchronized RevocationRecord revokeCertificate(String accountId, String expectedAccountCommitment,
|
||||
AcmeState.Directory directory,
|
||||
byte[] certificateDer, RevocationReason reason) {
|
||||
Objects.requireNonNull(certificateDer, "certificateDer"); Objects.requireNonNull(reason, "reason");
|
||||
requireAuthenticatedAccount(accountId, expectedAccountCommitment);
|
||||
String digest = digestBytes(certificateDer); AcmeState.Order owning = null; int offset = 0;
|
||||
while (owning == null) {
|
||||
ServerControlStore.Page<AcmeState.Order> page = store.page(AcmeState.Order.class, offset, 256);
|
||||
for (AcmeState.Order order : page.values()) {
|
||||
if (order.accountId().equals(accountId) && order.directoryId().equals(directory.directoryId())
|
||||
&& order.status() == AcmeState.OrderStatus.VALID && order.credentialId().isPresent()) {
|
||||
zeroecho.pki.api.credential.Credential credential = realm.session().repository()
|
||||
.credential(order.credentialId().orElseThrow()).orElseThrow();
|
||||
if (credential.content().sha256().equals(digest)) { owning = order; break; }
|
||||
}
|
||||
}
|
||||
if (owning != null || !page.hasMore()) break;
|
||||
offset = page.nextOffset();
|
||||
}
|
||||
if (owning == null) throw new SecurityException("ACME certificate is unavailable");
|
||||
RevocationRecord revoked = realm.session().revocations().revokePermanently(new RevocationCommand.RevokePermanently(
|
||||
owning.credentialId().orElseThrow(), reason, EMPTY_ATTRIBUTES));
|
||||
audit("ACME_CERTIFICATE_REVOKED", accountId, Map.of("order", owning.orderId()));
|
||||
return revoked;
|
||||
}
|
||||
|
||||
private static final AttributeSet EMPTY_ATTRIBUTES = new AttributeSet() {
|
||||
@Override public Set<AttributeId> ids() { return Set.of(); }
|
||||
@Override public Optional<AttributeValue> get(AttributeId id) { Objects.requireNonNull(id); return Optional.empty(); }
|
||||
@Override public List<AttributeValue> getAll(AttributeId id) { Objects.requireNonNull(id); return List.of(); }
|
||||
};
|
||||
|
||||
/** Claims and performs one explicit challenge validation attempt. */
|
||||
public AcmeState.Challenge validateChallenge(String accountId, String expectedAccountCommitment,
|
||||
String challengeId, String accountKeyThumbprint, Instant deadline, CancellationSignal cancellation) {
|
||||
AcmeState.Challenge current; AcmeState.Authorization authorization;
|
||||
synchronized (this) {
|
||||
requireAuthenticatedAccount(accountId, expectedAccountCommitment);
|
||||
current = requireChallenge(challengeId); authorization = requireAuthorization(current.authorizationId());
|
||||
if (!authorization.accountId().equals(accountId)) throw new IllegalArgumentException("ACME challenge unavailable");
|
||||
if (current.status() == AcmeState.ChallengeStatus.VALID) return current;
|
||||
if (current.status() == AcmeState.ChallengeStatus.PROCESSING) throw new IllegalStateException("ACME challenge is processing");
|
||||
current = (AcmeState.Challenge) store.replace(List.of(new AcmeControlStore.Replacement(current,
|
||||
new AcmeState.Challenge(current.challengeId(), current.authorizationId(), current.type(),
|
||||
current.token(), AcmeState.ChallengeStatus.PROCESSING, current.providerId(),
|
||||
Math.addExact(current.attempt(), 1), Optional.empty(), Optional.empty(),
|
||||
current.createdAt(), clock.instant(), ZERO)))).get(0);
|
||||
}
|
||||
AcmeState.Order order = requireOrder(authorization.orderId()); AcmeState.Directory directory = requireDirectory(order.directoryId());
|
||||
String keyAuthorization = current.token() + "." + accountKeyAuthorizationThumbprint(accountKeyThumbprint);
|
||||
AcmeChallengeProvider provider = providers.get(current.providerId());
|
||||
AcmeChallengeProvider.Result result;
|
||||
try {
|
||||
result = provider.validate(new AcmeChallengeProvider.Context(directory.directoryId(),
|
||||
authorization.authorizationId(), authorization.identifier(), current.type(), current.token(),
|
||||
keyAuthorization, current.attempt(), clock.instant(), deadline),
|
||||
providerConfigurations.get(provider.id()), cancellation);
|
||||
} catch (RuntimeException failure) {
|
||||
result = new AcmeChallengeProvider.Result(false, "PROVIDER_FAILURE", clock.instant(), clock.instant().plus(Duration.ofMinutes(5)));
|
||||
}
|
||||
if (cancellation.isCancelled()) throw new IllegalStateException("ACME validation was cancelled");
|
||||
return completeChallenge(current, authorization, order, directory, accountId, expectedAccountCommitment,
|
||||
accountKeyThumbprint, keyAuthorization, result);
|
||||
}
|
||||
|
||||
/** Finalizes a ready order through the existing strict CSR and issuance services. */
|
||||
public synchronized CredentialBundle finalizeOrder(String accountId, String expectedAccountCommitment,
|
||||
String orderId, byte[] csrDer) {
|
||||
requireAuthenticatedAccount(accountId, expectedAccountCommitment);
|
||||
AcmeState.Order order = requireOrder(orderId);
|
||||
if (!order.accountId().equals(accountId)) throw new IllegalArgumentException("ACME order unavailable");
|
||||
if (clock.instant().isAfter(order.expiresAt())) throw new IllegalStateException("ACME order expired");
|
||||
if (order.status() == AcmeState.OrderStatus.VALID) {
|
||||
return realm.session().issuance().orElseThrow().buildBundle(
|
||||
new zeroecho.pki.api.issuance.BundleCommand(order.credentialId().orElseThrow(),
|
||||
Optional.empty(), Optional.empty()));
|
||||
}
|
||||
if (order.status() != AcmeState.OrderStatus.READY && order.status() != AcmeState.OrderStatus.PROCESSING) {
|
||||
throw new IllegalStateException("ACME order is not ready");
|
||||
}
|
||||
AcmeState.Directory directory = requireDirectory(order.directoryId()); validateFrozenOrder(order, directory);
|
||||
ActiveCertificateProfile profile = realm.session().profiles().requireActiveProfile(order.profile().profileId());
|
||||
if (!profile.reference().equals(order.profile())) throw new IllegalStateException("ACME order profile changed");
|
||||
CertificationRequestService requests = realm.session().requests()
|
||||
.orElseThrow(() -> new IllegalStateException("ACME CSR capability is unavailable"));
|
||||
ParsedCertificationRequest parsed = requests.parse(new CertificationRequest(profile.definition().formatId(),
|
||||
new EncodedObject(Encoding.DER, csrDer.clone())));
|
||||
requireExactIdentifiers(parsed, order.identifiers());
|
||||
if (order.status() == AcmeState.OrderStatus.READY) {
|
||||
order = (AcmeState.Order) store.replace(List.of(new AcmeControlStore.Replacement(order,
|
||||
copyOrder(order, AcmeState.OrderStatus.PROCESSING, Optional.empty())))).get(0);
|
||||
}
|
||||
IssuanceService issuance = realm.session().issuance()
|
||||
.orElseThrow(() -> new IllegalStateException("ACME issuance capability is unavailable"));
|
||||
Optional<Validity> validity = order.notBefore().isPresent()
|
||||
? Optional.of(new Validity(order.notBefore().orElseThrow(), order.notAfter().orElseThrow()))
|
||||
: Optional.empty();
|
||||
IssuanceIntent intent = new IssuanceIntent(order.orderId(), issuanceCommitment(order, parsed), order.profile(),
|
||||
order.issuerId(), order.issuancePathId(), order.issuancePathCommitment());
|
||||
CredentialBundle bundle = issuance.issueEndEntity(new IssueEndEntityCommand(order.authorityId(), parsed,
|
||||
order.profile().profileId(), validity, Optional.of(intent)));
|
||||
if (!bundle.credential().issuerRef().issuerId().equals(order.issuerId())
|
||||
|| !bundle.credential().issuerRef().chainPathId().equals(order.issuancePathId())) {
|
||||
throw new IllegalStateException("ACME issuance selection mismatch");
|
||||
}
|
||||
realm.disclosure().registerAcme(bundle.credential().credentialId(), directory.disclosurePolicy(), accountId,
|
||||
directory.policyCommitment());
|
||||
AcmeState.Order valid = copyOrder(order, AcmeState.OrderStatus.VALID,
|
||||
Optional.of(bundle.credential().credentialId()));
|
||||
store.replace(List.of(new AcmeControlStore.Replacement(order, valid)));
|
||||
audit("ACME_CERTIFICATE_ISSUED", accountId, Map.of("order", order.orderId(),
|
||||
"credential", bundle.credential().credentialId().value()));
|
||||
return bundle;
|
||||
}
|
||||
|
||||
/** Returns one account-owned order without URL-based authority. */
|
||||
public AcmeState.Order order(String accountId, String orderId) {
|
||||
AcmeState.Order order = requireOrder(orderId);
|
||||
if (!order.accountId().equals(accountId)) throw new IllegalArgumentException("ACME order unavailable");
|
||||
return order;
|
||||
}
|
||||
|
||||
/** Returns one account-owned authorization without revealing another account's state. */
|
||||
public AcmeState.Authorization authorization(String accountId, String authorizationId) {
|
||||
AcmeState.Authorization value = requireAuthorization(authorizationId);
|
||||
if (!value.accountId().equals(accountId)) throw new IllegalArgumentException("ACME authorization unavailable");
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Returns one account-owned challenge without URL-derived authority. */
|
||||
public AcmeState.Challenge challenge(String accountId, String challengeId) {
|
||||
AcmeState.Challenge value = requireChallenge(challengeId);
|
||||
authorization(accountId, value.authorizationId());
|
||||
return value;
|
||||
}
|
||||
|
||||
private synchronized AcmeState.Challenge completeChallenge(AcmeState.Challenge claimed,
|
||||
AcmeState.Authorization authorization, AcmeState.Order order, AcmeState.Directory directory,
|
||||
String accountId, String expectedAccountCommitment, String accountKey, String keyAuthorization,
|
||||
AcmeChallengeProvider.Result result) {
|
||||
requireAuthenticatedAccount(accountId, expectedAccountCommitment);
|
||||
AcmeState.Challenge current = requireChallenge(claimed.challengeId());
|
||||
if (!current.commitment().equals(claimed.commitment()) || current.status() != AcmeState.ChallengeStatus.PROCESSING) {
|
||||
throw new IllegalStateException("ACME challenge attempt changed");
|
||||
}
|
||||
String evidenceId = id("evidence"); Instant observed = result.observedAt();
|
||||
AcmeState.AuthorizationEvidence evidence = new AcmeState.AuthorizationEvidence(evidenceId,
|
||||
authorization.authorizationId(), current.providerId(), current.type(), authorization.identifier(),
|
||||
digest(accountKey), digest(keyAuthorization), directory.policyCommitment(), current.attempt(), observed,
|
||||
result.expiresAt(), result.valid() ? AcmeState.EvidenceResult.VALID : AcmeState.EvidenceResult.INVALID,
|
||||
result.valid() ? Optional.empty() : Optional.of(result.classification()), ZERO);
|
||||
AcmeState.Challenge updatedChallenge = new AcmeState.Challenge(current.challengeId(), current.authorizationId(),
|
||||
current.type(), current.token(), result.valid() ? AcmeState.ChallengeStatus.VALID : AcmeState.ChallengeStatus.INVALID,
|
||||
current.providerId(), current.attempt(), result.valid() ? Optional.empty() : Optional.of(result.classification()),
|
||||
result.valid() ? Optional.of(evidence.evidenceId()) : Optional.empty(), current.createdAt(), clock.instant(), ZERO);
|
||||
AcmeState.Authorization updatedAuthorization = new AcmeState.Authorization(authorization.authorizationId(),
|
||||
authorization.orderId(), authorization.accountId(), authorization.identifier(),
|
||||
result.valid() ? AcmeState.AuthorizationStatus.VALID : AcmeState.AuthorizationStatus.INVALID,
|
||||
authorization.challengeIds(), authorization.expiresAt(),
|
||||
result.valid() ? Optional.of(evidence.evidenceId()) : Optional.empty(), ZERO);
|
||||
AcmeState.OrderStatus orderStatus = result.valid() && allOtherAuthorizationsValid(order, authorization.authorizationId())
|
||||
? AcmeState.OrderStatus.READY : result.valid() ? AcmeState.OrderStatus.PENDING : AcmeState.OrderStatus.INVALID;
|
||||
AcmeState.Order updatedOrder = copyOrder(order, orderStatus, Optional.empty());
|
||||
AcmeControlStore.Transition transition = store.transition(List.of(new AcmeControlStore.Replacement(current, updatedChallenge),
|
||||
new AcmeControlStore.Replacement(authorization, updatedAuthorization),
|
||||
new AcmeControlStore.Replacement(order, updatedOrder)), List.of(evidence));
|
||||
audit("ACME_CHALLENGE_RESULT", authorization.accountId(), Map.of("challenge", current.challengeId(),
|
||||
"outcome", result.valid() ? "VALID" : "INVALID"));
|
||||
return (AcmeState.Challenge) transition.replaced().get(0);
|
||||
}
|
||||
|
||||
private boolean allOtherAuthorizationsValid(AcmeState.Order order, String currentId) {
|
||||
return order.authorizationIds().stream().filter(id -> !id.equals(currentId))
|
||||
.map(this::requireAuthorization).allMatch(value -> value.status() == AcmeState.AuthorizationStatus.VALID);
|
||||
}
|
||||
private void validateFrozenDirectory(AcmeState.Directory directory) {
|
||||
requireExposed(directory.authorityId()); CaRecord authority = realm.session().repository().authority(directory.authorityId())
|
||||
.orElseThrow(() -> new IllegalStateException("ACME directory authority is unavailable"));
|
||||
if (authority.state() != CaState.ACTIVE || !authority.currentIssuanceIssuerId().equals(directory.issuerId())
|
||||
|| !authority.issuanceChainPathId().equals(directory.issuancePathId())) {
|
||||
throw new IllegalStateException("ACME directory issuance selection changed");
|
||||
}
|
||||
IssuerChainPath path = requireCurrentPath(authority);
|
||||
if (!path.pathCommitment().equals(directory.issuancePathCommitment())
|
||||
|| !realm.session().profiles().requireActiveProfile(directory.profile().profileId()).reference()
|
||||
.equals(directory.profile())) {
|
||||
throw new IllegalStateException("ACME directory frozen policy is unavailable");
|
||||
}
|
||||
requireProviders(directory.challengeTypes(), directory.challengeProviderIds());
|
||||
}
|
||||
private void validateFrozenOrder(AcmeState.Order order, AcmeState.Directory directory) {
|
||||
if (order.directoryRevision() != directory.revision()
|
||||
|| !order.directoryCommitment().equals(directory.commitment())
|
||||
|| !order.authorityId().equals(directory.authorityId())
|
||||
|| !order.issuerId().equals(directory.issuerId())
|
||||
|| !order.issuancePathId().equals(directory.issuancePathId())
|
||||
|| !order.issuancePathCommitment().equals(directory.issuancePathCommitment())) {
|
||||
throw new IllegalStateException("ACME order directory binding mismatch");
|
||||
}
|
||||
validateFrozenDirectory(directory);
|
||||
}
|
||||
private void requireActiveExact(AcmeState.Directory directory) {
|
||||
AcmeState.Directory exact = requireDirectory(directory.directoryId());
|
||||
if (exact.status() != AcmeState.DirectoryStatus.ACTIVE || !exact.commitment().equals(directory.commitment())) {
|
||||
throw new IllegalStateException("ACME directory is inactive or changed");
|
||||
}
|
||||
}
|
||||
private void requireAccountDirectory(AcmeState.Account account, AcmeState.Directory directory) {
|
||||
if (account.status() != AcmeState.AccountStatus.VALID || !account.directoryId().equals(directory.directoryId())
|
||||
|| account.directoryRevision() != directory.revision()
|
||||
|| !account.directoryCommitment().equals(directory.commitment())) {
|
||||
throw new IllegalArgumentException("ACME account directory mismatch");
|
||||
}
|
||||
}
|
||||
private IssuerChainPath requireCurrentPath(CaRecord authority) {
|
||||
IssuerChainPath path = realm.session().repository().chainPath(authority.issuanceChainPathId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("ACME issuance path is unavailable"));
|
||||
if (!path.authorityId().equals(authority.caId())
|
||||
|| !path.issuerId().equals(authority.currentIssuanceIssuerId())) {
|
||||
throw new IllegalArgumentException("ACME issuance path authority mismatch");
|
||||
}
|
||||
return path;
|
||||
}
|
||||
private void requireProviders(Set<AcmeState.ChallengeType> types, Set<String> ids) {
|
||||
if (!providers.keySet().containsAll(ids)) throw new IllegalArgumentException("ACME challenge provider unavailable");
|
||||
for (AcmeState.ChallengeType type : types) {
|
||||
if (ids.stream().map(providers::get).noneMatch(provider -> provider.challengeTypes().contains(type))) {
|
||||
throw new IllegalArgumentException("ACME challenge type has no provider");
|
||||
}
|
||||
}
|
||||
}
|
||||
private String providerFor(AcmeState.Directory directory, AcmeState.ChallengeType type) {
|
||||
return directory.challengeProviderIds().stream().sorted().filter(id -> providers.get(id).challengeTypes().contains(type))
|
||||
.findFirst().orElseThrow(() -> new IllegalStateException("ACME challenge provider is unavailable"));
|
||||
}
|
||||
private List<AcmeState.Identifier> canonicalIdentifiers(List<AcmeState.Identifier> requested,
|
||||
AcmeState.Directory directory) {
|
||||
List<AcmeState.Identifier> values = requested.stream()
|
||||
.map(value -> new AcmeState.Identifier(value.type(), value.value(), value.wildcard()))
|
||||
.distinct().sorted(Comparator.comparing(AcmeState.Identifier::presentation)).toList();
|
||||
if (values.isEmpty()) throw new IllegalArgumentException("ACME identifier count is invalid");
|
||||
for (AcmeState.Identifier value : values) {
|
||||
if (directory.dnsNamespaces().stream().noneMatch(namespace -> value.value().equals(namespace)
|
||||
|| value.value().endsWith("." + namespace))) {
|
||||
throw new IllegalArgumentException("ACME identifier namespace is rejected");
|
||||
}
|
||||
if (value.wildcard() && !directory.challengeTypes().contains(AcmeState.ChallengeType.DNS_01)) {
|
||||
throw new IllegalArgumentException("Wildcard ACME identifier requires DNS-01");
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
private static Set<String> bindingPolicy(ActiveCertificateProfile profile) {
|
||||
zeroecho.pki.api.profile.X509AlgorithmBindingPolicy policy = profile.definition().algorithmBindingPolicy();
|
||||
if (policy.mode() == zeroecho.pki.api.profile.X509AlgorithmBindingPolicy.Mode.STANDARD_ONLY) {
|
||||
return Set.of("STANDARD_ONLY");
|
||||
}
|
||||
return java.util.stream.Stream.of(policy.subjectPublicKey(), policy.csrSignature(),
|
||||
policy.certificateSignature(), policy.crlSignature()).flatMap(Optional::stream)
|
||||
.map(zeroecho.pki.api.profile.X509AlgorithmBindingPolicy.BindingReference::bindingId)
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
}
|
||||
private void requireExactIdentifiers(ParsedCertificationRequest parsed, List<AcmeState.Identifier> expected) {
|
||||
Set<AcmeState.Identifier> actual = new LinkedHashSet<>();
|
||||
for (SubjectAlternativeName name : parsed.subjectAlternativeNames()) {
|
||||
if (!(name instanceof SubjectAlternativeName.DnsName dns)) throw new PkiException("ACME CSR contains a non-DNS identifier");
|
||||
boolean wildcard = dns.value().startsWith("*."); String value = wildcard ? dns.value().substring(2) : dns.value();
|
||||
if (!actual.add(new AcmeState.Identifier(AcmeState.IdentifierType.DNS, value, wildcard))) {
|
||||
throw new PkiException("ACME CSR contains duplicate identifiers");
|
||||
}
|
||||
}
|
||||
if (!actual.equals(Set.copyOf(expected))) throw new PkiException("ACME CSR identifiers do not match the order");
|
||||
}
|
||||
private void requireExposed(PkiId authorityId) {
|
||||
if (!realm.configuration().authorityExposure().allows(authorityId)) throw new IllegalArgumentException("ACME authority is outside realm exposure");
|
||||
}
|
||||
private int nextDirectoryRevision(String alias) {
|
||||
return directoriesForAlias(alias).stream().mapToInt(AcmeState.Directory::revision).max().orElse(0) + 1;
|
||||
}
|
||||
private List<AcmeState.Directory> directoriesForAlias(String alias) {
|
||||
List<AcmeState.Directory> result = new ArrayList<>(); int offset = 0;
|
||||
do {
|
||||
ServerControlStore.Page<AcmeState.Directory> page = store.page(AcmeState.Directory.class, offset, 256);
|
||||
result.addAll(page.values().stream().filter(value -> value.alias().equals(alias)).toList());
|
||||
if (!page.hasMore()) break; offset = page.nextOffset();
|
||||
} while (true);
|
||||
return List.copyOf(result);
|
||||
}
|
||||
private Optional<AcmeState.Account> accountByThumbprint(String directoryId, String thumbprint) {
|
||||
int offset = 0;
|
||||
do {
|
||||
ServerControlStore.Page<AcmeState.Account> page = store.page(AcmeState.Account.class, offset, 256);
|
||||
Optional<AcmeState.Account> found = page.values().stream().filter(value -> value.directoryId().equals(directoryId)
|
||||
&& value.keyThumbprint().equals(thumbprint)).findFirst();
|
||||
if (found.isPresent() || !page.hasMore()) return found; offset = page.nextOffset();
|
||||
} while (true);
|
||||
}
|
||||
private AcmeState.Directory requireDirectory(String id) { return store.get(AcmeState.Directory.class, id).orElseThrow(() -> new IllegalArgumentException("ACME directory unavailable")); }
|
||||
private AcmeState.Account requireAccount(String id) { return store.get(AcmeState.Account.class, id).orElseThrow(() -> new IllegalArgumentException("ACME account unavailable")); }
|
||||
private AcmeState.Account requireAuthenticatedAccount(String id, String expectedCommitment) {
|
||||
AcmeState.Account account = requireAccount(id);
|
||||
if (account.status() != AcmeState.AccountStatus.VALID
|
||||
|| !account.commitment().equals(Objects.requireNonNull(expectedCommitment,
|
||||
"expectedCommitment"))) {
|
||||
throw new SecurityException("ACME account key authority changed");
|
||||
}
|
||||
return account;
|
||||
}
|
||||
private AcmeState.Order requireOrder(String id) { return store.get(AcmeState.Order.class, id).orElseThrow(() -> new IllegalArgumentException("ACME order unavailable")); }
|
||||
private AcmeState.Authorization requireAuthorization(String id) { return store.get(AcmeState.Authorization.class, id).orElseThrow(() -> new IllegalArgumentException("ACME authorization unavailable")); }
|
||||
private AcmeState.Challenge requireChallenge(String id) { return store.get(AcmeState.Challenge.class, id).orElseThrow(() -> new IllegalArgumentException("ACME challenge unavailable")); }
|
||||
private AcmeState.Directory copyDirectory(AcmeState.Directory v, AcmeState.DirectoryStatus status) { return new AcmeState.Directory(v.directoryId(), v.alias(), v.revision(), v.realmId(), v.authorityId(), v.profile(), v.issuerId(), v.issuancePathId(), v.issuancePathCommitment(), v.identifierTypes(), v.dnsNamespaces(), v.maximumValidity(), v.publicKeyAlgorithms(), v.x509BindingPolicies(), v.challengeTypes(), v.challengeProviderIds(), v.eabProviderId(), v.eabRequired(), v.disclosurePolicy(), status, v.policyCommitment(), v.createdAt(), ZERO); }
|
||||
private AcmeState.Order copyOrder(AcmeState.Order v, AcmeState.OrderStatus status, Optional<PkiId> credential) { return new AcmeState.Order(v.orderId(), v.accountId(), v.directoryId(), v.directoryRevision(), v.directoryCommitment(), v.authorityId(), v.profile(), v.issuerId(), v.issuancePathId(), v.issuancePathCommitment(), v.identifiers(), v.notBefore(), v.notAfter(), status, v.authorizationIds(), credential, v.createdAt(), v.expiresAt(), ZERO); }
|
||||
private String id(String prefix) { byte[] value = new byte[16]; random.nextBytes(value); return prefix + ":" + java.util.HexFormat.of().formatHex(value); }
|
||||
private String token() { byte[] value = new byte[32]; random.nextBytes(value); String result = java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(value); java.util.Arrays.fill(value, (byte) 0); return result; }
|
||||
private static String directoryPolicyCommitment(DirectoryRegistration r, ActiveCertificateProfile profile,
|
||||
CaRecord ca, IssuerChainPath path) {
|
||||
return digest(r.alias() + '\n' + ca.caId().value() + '\n' + profile.reference() + '\n'
|
||||
+ ca.currentIssuanceIssuerId().value() + '\n' + path.pathId().value() + '\n'
|
||||
+ path.pathCommitment() + '\n' + r.dnsNamespaces().stream().sorted().toList() + '\n'
|
||||
+ r.maximumValidity() + '\n' + r.publicKeyAlgorithms().stream().sorted().toList() + '\n'
|
||||
+ r.x509BindingPolicies().stream().sorted().toList() + '\n'
|
||||
+ r.challengeTypes().stream().sorted().toList() + '\n'
|
||||
+ r.challengeProviderIds().stream().sorted().toList() + '\n'
|
||||
+ r.eabProviderId().orElse("NONE") + '\n' + r.eabRequired() + '\n' + r.disclosurePolicy());
|
||||
}
|
||||
private void audit(String action, String actor, Map<String, String> details) {
|
||||
realm.auditTransport(action, actor, details);
|
||||
}
|
||||
private static String issuanceCommitment(AcmeState.Order order, ParsedCertificationRequest parsed) { return digest(order.commitment() + '\n' + parsed.requestId().value()); }
|
||||
private static String digest(String value) { try { return java.util.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 String digestBytes(byte[] value) { try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); } catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException("SHA-256 unavailable", impossible); } }
|
||||
private static String accountKeyAuthorizationThumbprint(String commitment) {
|
||||
byte[] digest = java.util.HexFormat.of().parseHex(commitment);
|
||||
try {
|
||||
return java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
|
||||
} finally {
|
||||
java.util.Arrays.fill(digest, (byte) 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
305
pki-server/src/main/java/zeroecho/pki/server/acme/AcmeState.java
Normal file
305
pki-server/src/main/java/zeroecho/pki/server/acme/AcmeState.java
Normal file
@@ -0,0 +1,305 @@
|
||||
/*******************************************************************************
|
||||
* 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.acme;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.api.profile.CertificateProfileRef;
|
||||
import zeroecho.pki.server.DisclosureService;
|
||||
import zeroecho.pki.server.RealmId;
|
||||
|
||||
/**
|
||||
* Closed durable ACME domain records.
|
||||
*
|
||||
* <p>Protocol URLs are never state authority. Every relationship is represented
|
||||
* by a canonical identity and every mutable record carries a complete SHA-256
|
||||
* commitment used for compare-and-set persistence.</p>
|
||||
*/
|
||||
@SuppressWarnings("PMD")
|
||||
public final class AcmeState {
|
||||
/** Supported identifier kinds. */
|
||||
public enum IdentifierType { DNS }
|
||||
/** Supported proof challenges. */
|
||||
public enum ChallengeType { HTTP_01, DNS_01 }
|
||||
/** Directory lifecycle. */
|
||||
public enum DirectoryStatus { INACTIVE, ACTIVE }
|
||||
/** Account lifecycle. */
|
||||
public enum AccountStatus { VALID, DEACTIVATED, REVOKED }
|
||||
/** Order lifecycle. */
|
||||
public enum OrderStatus { PENDING, READY, PROCESSING, VALID, INVALID }
|
||||
/** Authorization lifecycle. */
|
||||
public enum AuthorizationStatus { PENDING, VALID, INVALID, EXPIRED, DEACTIVATED }
|
||||
/** Challenge lifecycle. */
|
||||
public enum ChallengeStatus { PENDING, PROCESSING, VALID, INVALID }
|
||||
/** Durable validation result. */
|
||||
public enum EvidenceResult { VALID, INVALID }
|
||||
|
||||
/** One canonical DNS identifier with an explicit wildcard bit. */
|
||||
public record Identifier(IdentifierType type, String value, boolean wildcard) {
|
||||
/** Validates the canonical lower-case ASCII DNS representation. */
|
||||
public Identifier {
|
||||
Objects.requireNonNull(type, "type");
|
||||
value = Objects.requireNonNull(value, "value");
|
||||
if (!value.equals(value.toLowerCase(java.util.Locale.ROOT)) || value.length() > 253
|
||||
|| value.startsWith(".") || value.endsWith(".")) {
|
||||
throw new IllegalArgumentException("ACME DNS identifier is not canonical");
|
||||
}
|
||||
String[] labels = value.split("\\.", -1);
|
||||
for (String label : labels) {
|
||||
if (label.isEmpty() || label.length() > 63 || !label.matches("[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?")) {
|
||||
throw new IllegalArgumentException("ACME DNS label is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the ACME presentation form. */
|
||||
public String presentation() { return wildcard ? "*." + value : value; }
|
||||
}
|
||||
|
||||
/** Immutable directory policy revision bound to one exact PKI authority. */
|
||||
public record Directory(String directoryId, String alias, int revision, RealmId realmId,
|
||||
PkiId authorityId, CertificateProfileRef profile, PkiId issuerId, PkiId issuancePathId,
|
||||
String issuancePathCommitment, Set<IdentifierType> identifierTypes, Set<String> dnsNamespaces,
|
||||
Duration maximumValidity, Set<String> publicKeyAlgorithms, Set<String> x509BindingPolicies,
|
||||
Set<ChallengeType> challengeTypes, Set<String> challengeProviderIds,
|
||||
Optional<String> eabProviderId, boolean eabRequired, DisclosureService.Policy disclosurePolicy,
|
||||
DirectoryStatus status, String policyCommitment, Instant createdAt, String commitment) {
|
||||
/** Validates the complete frozen directory revision. */
|
||||
public Directory {
|
||||
id(directoryId); AcmeState.alias(alias); positive(revision, "directory revision");
|
||||
Objects.requireNonNull(realmId, "realmId"); Objects.requireNonNull(authorityId, "authorityId");
|
||||
Objects.requireNonNull(profile, "profile"); Objects.requireNonNull(issuerId, "issuerId");
|
||||
Objects.requireNonNull(issuancePathId, "issuancePathId"); digest(issuancePathCommitment);
|
||||
identifierTypes = nonEmpty(identifierTypes, "identifier types");
|
||||
dnsNamespaces = nonEmptyStrings(dnsNamespaces, "DNS namespaces");
|
||||
if (!identifierTypes.equals(Set.of(IdentifierType.DNS))) {
|
||||
throw new IllegalArgumentException("Only DNS ACME identifiers are supported");
|
||||
}
|
||||
positive(maximumValidity, Duration.ofDays(398), "maximum validity");
|
||||
publicKeyAlgorithms = nonEmptyStrings(publicKeyAlgorithms, "public-key algorithms");
|
||||
x509BindingPolicies = nonEmptyStrings(x509BindingPolicies, "X.509 binding policies");
|
||||
challengeTypes = nonEmpty(challengeTypes, "challenge types");
|
||||
challengeProviderIds = nonEmptyStrings(challengeProviderIds, "challenge providers");
|
||||
eabProviderId = Objects.requireNonNull(eabProviderId, "eabProviderId");
|
||||
eabProviderId.ifPresent(AcmeState::id);
|
||||
if (eabRequired && eabProviderId.isEmpty()) {
|
||||
throw new IllegalArgumentException("ACME EAB policy is contradictory");
|
||||
}
|
||||
Objects.requireNonNull(disclosurePolicy, "disclosurePolicy");
|
||||
Objects.requireNonNull(status, "status"); digest(policyCommitment);
|
||||
Objects.requireNonNull(createdAt, "createdAt"); digest(commitment);
|
||||
}
|
||||
}
|
||||
|
||||
/** Durable protocol-scoped ACME account. */
|
||||
public record Account(String accountId, String directoryId, int directoryRevision,
|
||||
String directoryCommitment, String keyThumbprint, byte[] publicKeySpki,
|
||||
AccountStatus status, List<String> contacts, boolean termsAgreed,
|
||||
Optional<String> eabPolicyCommitment, Instant createdAt, Instant updatedAt, String commitment) {
|
||||
/** Validates account identity, bounded public material, PII and commitments. */
|
||||
public Account {
|
||||
id(accountId); id(directoryId); positive(directoryRevision, "directory revision");
|
||||
digest(directoryCommitment); digest(keyThumbprint);
|
||||
publicKeySpki = Objects.requireNonNull(publicKeySpki, "publicKeySpki").clone();
|
||||
if (publicKeySpki.length < 32 || publicKeySpki.length > 16_384) {
|
||||
throw new IllegalArgumentException("ACME account public key bound is invalid");
|
||||
}
|
||||
Objects.requireNonNull(status, "status");
|
||||
contacts = List.copyOf(Objects.requireNonNull(contacts, "contacts"));
|
||||
if (contacts.size() > 16 || contacts.stream().anyMatch(value -> value.length() > 512
|
||||
|| !value.startsWith("mailto:") || value.indexOf('@') < 8)) {
|
||||
throw new IllegalArgumentException("ACME account contact is invalid");
|
||||
}
|
||||
eabPolicyCommitment = Objects.requireNonNull(eabPolicyCommitment, "eabPolicyCommitment");
|
||||
eabPolicyCommitment.ifPresent(AcmeState::digest);
|
||||
Objects.requireNonNull(createdAt, "createdAt"); Objects.requireNonNull(updatedAt, "updatedAt");
|
||||
digest(commitment);
|
||||
}
|
||||
@Override public byte[] publicKeySpki() { return publicKeySpki.clone(); }
|
||||
}
|
||||
|
||||
/** Durable ACME order frozen to one directory revision and issuance selection. */
|
||||
public record Order(String orderId, String accountId, String directoryId, int directoryRevision,
|
||||
String directoryCommitment, PkiId authorityId, CertificateProfileRef profile,
|
||||
PkiId issuerId, PkiId issuancePathId, String issuancePathCommitment,
|
||||
List<Identifier> identifiers, Optional<Instant> notBefore, Optional<Instant> notAfter,
|
||||
OrderStatus status, List<String> authorizationIds, Optional<PkiId> credentialId,
|
||||
Instant createdAt, Instant expiresAt, String commitment) {
|
||||
/** Validates exact policy and object bindings. */
|
||||
public Order {
|
||||
id(orderId); id(accountId); id(directoryId); positive(directoryRevision, "directory revision");
|
||||
digest(directoryCommitment); Objects.requireNonNull(authorityId, "authorityId");
|
||||
Objects.requireNonNull(profile, "profile"); Objects.requireNonNull(issuerId, "issuerId");
|
||||
Objects.requireNonNull(issuancePathId, "issuancePathId"); digest(issuancePathCommitment);
|
||||
identifiers = distinctIdentifiers(identifiers);
|
||||
notBefore = Objects.requireNonNull(notBefore, "notBefore");
|
||||
notAfter = Objects.requireNonNull(notAfter, "notAfter");
|
||||
if (notBefore.isPresent() != notAfter.isPresent()
|
||||
|| notBefore.isPresent() && !notAfter.orElseThrow().isAfter(notBefore.orElseThrow())) {
|
||||
throw new IllegalArgumentException("ACME order validity is invalid");
|
||||
}
|
||||
Objects.requireNonNull(status, "status");
|
||||
authorizationIds = distinctIds(authorizationIds, identifiers.size(), "authorization");
|
||||
credentialId = Objects.requireNonNull(credentialId, "credentialId");
|
||||
if ((status == OrderStatus.VALID) != credentialId.isPresent()) {
|
||||
throw new IllegalArgumentException("ACME order credential state mismatch");
|
||||
}
|
||||
Objects.requireNonNull(createdAt, "createdAt"); Objects.requireNonNull(expiresAt, "expiresAt");
|
||||
if (!expiresAt.isAfter(createdAt)) throw new IllegalArgumentException("ACME order expiry is invalid");
|
||||
digest(commitment);
|
||||
}
|
||||
}
|
||||
|
||||
/** Durable identifier authorization owned by one account order. */
|
||||
public record Authorization(String authorizationId, String orderId, String accountId,
|
||||
Identifier identifier, AuthorizationStatus status, List<String> challengeIds,
|
||||
Instant expiresAt, Optional<String> evidenceId, String commitment) {
|
||||
/** Validates exact ownership and evidence state. */
|
||||
public Authorization {
|
||||
id(authorizationId); id(orderId); id(accountId); Objects.requireNonNull(identifier, "identifier");
|
||||
Objects.requireNonNull(status, "status");
|
||||
challengeIds = distinctIds(challengeIds, 16, "challenge");
|
||||
Objects.requireNonNull(expiresAt, "expiresAt");
|
||||
evidenceId = Objects.requireNonNull(evidenceId, "evidenceId"); evidenceId.ifPresent(AcmeState::id);
|
||||
if ((status == AuthorizationStatus.VALID) != evidenceId.isPresent()) {
|
||||
throw new IllegalArgumentException("ACME authorization evidence state mismatch");
|
||||
}
|
||||
digest(commitment);
|
||||
}
|
||||
}
|
||||
|
||||
/** Durable explicitly triggered challenge attempt. */
|
||||
public record Challenge(String challengeId, String authorizationId, ChallengeType type,
|
||||
String token, ChallengeStatus status, String providerId, int attempt,
|
||||
Optional<String> failureCode, Optional<String> evidenceId,
|
||||
Instant createdAt, Instant updatedAt, String commitment) {
|
||||
/** Validates token, provider, monotonic attempt and state. */
|
||||
public Challenge {
|
||||
id(challengeId); id(authorizationId); Objects.requireNonNull(type, "type");
|
||||
if (token == null || !token.matches("[A-Za-z0-9_-]{43,128}")) {
|
||||
throw new IllegalArgumentException("ACME challenge token is invalid");
|
||||
}
|
||||
Objects.requireNonNull(status, "status"); id(providerId);
|
||||
if (attempt < 0) throw new IllegalArgumentException("ACME challenge attempt is invalid");
|
||||
failureCode = Objects.requireNonNull(failureCode, "failureCode");
|
||||
failureCode.ifPresent(value -> { if (!value.matches("[A-Z0-9_]{1,64}")) throw new IllegalArgumentException("ACME failure code is invalid"); });
|
||||
evidenceId = Objects.requireNonNull(evidenceId, "evidenceId"); evidenceId.ifPresent(AcmeState::id);
|
||||
if ((status == ChallengeStatus.VALID) != evidenceId.isPresent()) {
|
||||
throw new IllegalArgumentException("ACME challenge evidence state mismatch");
|
||||
}
|
||||
Objects.requireNonNull(createdAt, "createdAt"); Objects.requireNonNull(updatedAt, "updatedAt");
|
||||
digest(commitment);
|
||||
}
|
||||
}
|
||||
|
||||
/** Immutable validator evidence; no control operation may construct it. */
|
||||
public record AuthorizationEvidence(String evidenceId, String authorizationId, String providerId,
|
||||
ChallengeType challengeType, Identifier identifier, String accountKeyCommitment,
|
||||
String keyAuthorizationCommitment, String directoryPolicyCommitment, int attempt,
|
||||
Instant validatedAt, Instant expiresAt, EvidenceResult result,
|
||||
Optional<String> failureCode, String commitment) {
|
||||
/** Validates complete authorization-evidence binding. */
|
||||
public AuthorizationEvidence {
|
||||
id(evidenceId); id(authorizationId); id(providerId); Objects.requireNonNull(challengeType, "challengeType");
|
||||
Objects.requireNonNull(identifier, "identifier"); digest(accountKeyCommitment);
|
||||
digest(keyAuthorizationCommitment); digest(directoryPolicyCommitment);
|
||||
positive(attempt, "validation attempt"); Objects.requireNonNull(validatedAt, "validatedAt");
|
||||
Objects.requireNonNull(expiresAt, "expiresAt");
|
||||
if (!expiresAt.isAfter(validatedAt)) throw new IllegalArgumentException("ACME evidence expiry is invalid");
|
||||
Objects.requireNonNull(result, "result");
|
||||
failureCode = Objects.requireNonNull(failureCode, "failureCode");
|
||||
if ((result == EvidenceResult.INVALID) != failureCode.isPresent()) {
|
||||
throw new IllegalArgumentException("ACME evidence result classification mismatch");
|
||||
}
|
||||
digest(commitment);
|
||||
}
|
||||
}
|
||||
|
||||
private AcmeState() { }
|
||||
|
||||
private static void id(String value) {
|
||||
if (value == null || !value.matches("[a-z0-9][a-z0-9._:-]{0,127}")) {
|
||||
throw new IllegalArgumentException("ACME identity is invalid");
|
||||
}
|
||||
}
|
||||
private static void alias(String value) {
|
||||
if (value == null || !value.matches("[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?")) {
|
||||
throw new IllegalArgumentException("ACME directory alias is invalid");
|
||||
}
|
||||
}
|
||||
private static void digest(String value) {
|
||||
if (value == null || !value.matches("[0-9a-f]{64}")) {
|
||||
throw new IllegalArgumentException("ACME commitment is invalid");
|
||||
}
|
||||
}
|
||||
private static void positive(int value, String name) {
|
||||
if (value <= 0) throw new IllegalArgumentException(name + " is invalid");
|
||||
}
|
||||
private static void positive(Duration value, Duration maximum, String name) {
|
||||
if (value == null || value.isZero() || value.isNegative() || value.compareTo(maximum) > 0) {
|
||||
throw new IllegalArgumentException(name + " is invalid");
|
||||
}
|
||||
}
|
||||
private static <T> Set<T> nonEmpty(Set<T> source, String name) {
|
||||
Set<T> result = Set.copyOf(Objects.requireNonNull(source, name));
|
||||
if (result.isEmpty() || result.size() > 64) throw new IllegalArgumentException(name + " is invalid");
|
||||
return result;
|
||||
}
|
||||
private static Set<String> nonEmptyStrings(Set<String> source, String name) {
|
||||
Set<String> result = nonEmpty(source, name);
|
||||
result.forEach(value -> { if (value.isBlank() || value.length() > 256) throw new IllegalArgumentException(name + " is invalid"); });
|
||||
return result;
|
||||
}
|
||||
private static List<Identifier> distinctIdentifiers(List<Identifier> source) {
|
||||
List<Identifier> result = List.copyOf(Objects.requireNonNull(source, "identifiers"));
|
||||
if (result.isEmpty() || result.stream().distinct().count() != result.size()) {
|
||||
throw new IllegalArgumentException("ACME identifiers are invalid");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
private static List<String> distinctIds(List<String> source, int maximum, String name) {
|
||||
List<String> result = List.copyOf(Objects.requireNonNull(source, name));
|
||||
result.forEach(AcmeState::id);
|
||||
if (result.isEmpty() || result.size() > maximum || result.stream().distinct().count() != result.size()) {
|
||||
throw new IllegalArgumentException("ACME " + name + " identities are invalid");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
/*******************************************************************************
|
||||
* 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.acme;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.api.profile.CertificateProfileRef;
|
||||
import zeroecho.pki.server.DisclosureService;
|
||||
import zeroecho.pki.server.RealmId;
|
||||
|
||||
/** Strict versioned canonical binary codec for durable ACME records. */
|
||||
@SuppressWarnings("PMD")
|
||||
final class AcmeStateCodec {
|
||||
private static final int MAGIC = 0x5a454143;
|
||||
private static final int VERSION = 1;
|
||||
private static final int MAX_TEXT = 16_384;
|
||||
private static final int MAX_COLLECTION = 256;
|
||||
private static final String ZERO = "0".repeat(64);
|
||||
private static final int DIRECTORY = 1;
|
||||
private static final int ACCOUNT = 2;
|
||||
private static final int ORDER = 3;
|
||||
private static final int AUTHORIZATION = 4;
|
||||
private static final int CHALLENGE = 5;
|
||||
private static final int EVIDENCE = 6;
|
||||
|
||||
byte[] encode(Object record) { return encode(record, true); }
|
||||
String commitment(Object record) { return sha256(encode(record, false)); }
|
||||
|
||||
Object seal(Object record) {
|
||||
String value = commitment(record);
|
||||
return switch (record) {
|
||||
case AcmeState.Directory item -> new AcmeState.Directory(item.directoryId(), item.alias(), item.revision(),
|
||||
item.realmId(), item.authorityId(), item.profile(), item.issuerId(), item.issuancePathId(),
|
||||
item.issuancePathCommitment(), item.identifierTypes(), item.dnsNamespaces(), item.maximumValidity(),
|
||||
item.publicKeyAlgorithms(), item.x509BindingPolicies(), item.challengeTypes(),
|
||||
item.challengeProviderIds(), item.eabProviderId(), item.eabRequired(), item.disclosurePolicy(),
|
||||
item.status(), item.policyCommitment(), item.createdAt(), value);
|
||||
case AcmeState.Account item -> new AcmeState.Account(item.accountId(), item.directoryId(),
|
||||
item.directoryRevision(), item.directoryCommitment(), item.keyThumbprint(), item.publicKeySpki(),
|
||||
item.status(), item.contacts(), item.termsAgreed(), item.eabPolicyCommitment(), item.createdAt(),
|
||||
item.updatedAt(), value);
|
||||
case AcmeState.Order item -> new AcmeState.Order(item.orderId(), item.accountId(), item.directoryId(),
|
||||
item.directoryRevision(), item.directoryCommitment(), item.authorityId(), item.profile(),
|
||||
item.issuerId(), item.issuancePathId(), item.issuancePathCommitment(), item.identifiers(),
|
||||
item.notBefore(), item.notAfter(), item.status(), item.authorizationIds(), item.credentialId(),
|
||||
item.createdAt(), item.expiresAt(), value);
|
||||
case AcmeState.Authorization item -> new AcmeState.Authorization(item.authorizationId(), item.orderId(),
|
||||
item.accountId(), item.identifier(), item.status(), item.challengeIds(), item.expiresAt(),
|
||||
item.evidenceId(), value);
|
||||
case AcmeState.Challenge item -> new AcmeState.Challenge(item.challengeId(), item.authorizationId(),
|
||||
item.type(), item.token(), item.status(), item.providerId(), item.attempt(), item.failureCode(),
|
||||
item.evidenceId(), item.createdAt(), item.updatedAt(), value);
|
||||
case AcmeState.AuthorizationEvidence item -> new AcmeState.AuthorizationEvidence(item.evidenceId(),
|
||||
item.authorizationId(), item.providerId(), item.challengeType(), item.identifier(),
|
||||
item.accountKeyCommitment(), item.keyAuthorizationCommitment(),
|
||||
item.directoryPolicyCommitment(), item.attempt(), item.validatedAt(), item.expiresAt(),
|
||||
item.result(), item.failureCode(), value);
|
||||
default -> throw new IllegalArgumentException("Unsupported ACME record");
|
||||
};
|
||||
}
|
||||
|
||||
Object decode(byte[] encoded) {
|
||||
try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(encoded))) {
|
||||
if (in.readInt() != MAGIC || in.readInt() != VERSION) throw invalid();
|
||||
Object result = switch (in.readInt()) {
|
||||
case DIRECTORY -> readDirectory(in);
|
||||
case ACCOUNT -> readAccount(in);
|
||||
case ORDER -> readOrder(in);
|
||||
case AUTHORIZATION -> readAuthorization(in);
|
||||
case CHALLENGE -> readChallenge(in);
|
||||
case EVIDENCE -> readEvidence(in);
|
||||
default -> throw invalid();
|
||||
};
|
||||
if (in.read() != -1 || !storedCommitment(result).equals(commitment(result))) throw invalid();
|
||||
return result;
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
throw new IllegalArgumentException("ACME control record is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] encode(Object record, boolean includeCommitment) {
|
||||
try (ByteArrayOutputStream bytes = new ByteArrayOutputStream(); DataOutputStream out = new DataOutputStream(bytes)) {
|
||||
out.writeInt(MAGIC); out.writeInt(VERSION);
|
||||
switch (record) {
|
||||
case AcmeState.Directory item -> writeDirectory(out, item, includeCommitment);
|
||||
case AcmeState.Account item -> writeAccount(out, item, includeCommitment);
|
||||
case AcmeState.Order item -> writeOrder(out, item, includeCommitment);
|
||||
case AcmeState.Authorization item -> writeAuthorization(out, item, includeCommitment);
|
||||
case AcmeState.Challenge item -> writeChallenge(out, item, includeCommitment);
|
||||
case AcmeState.AuthorizationEvidence item -> writeEvidence(out, item, includeCommitment);
|
||||
default -> throw new IllegalArgumentException("Unsupported ACME record");
|
||||
}
|
||||
out.flush(); return bytes.toByteArray();
|
||||
} catch (IOException impossible) {
|
||||
throw new IllegalStateException("ACME in-memory encoding failed", impossible);
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeDirectory(DataOutputStream out, AcmeState.Directory v, boolean committed) throws IOException {
|
||||
out.writeInt(DIRECTORY); string(out, v.directoryId()); string(out, v.alias()); out.writeInt(v.revision());
|
||||
string(out, v.realmId().value()); string(out, v.authorityId().value()); profile(out, v.profile());
|
||||
string(out, v.issuerId().value()); string(out, v.issuancePathId().value()); string(out, v.issuancePathCommitment());
|
||||
enums(out, v.identifierTypes()); strings(out, v.dnsNamespaces()); out.writeLong(v.maximumValidity().toSeconds());
|
||||
strings(out, v.publicKeyAlgorithms()); strings(out, v.x509BindingPolicies()); enums(out, v.challengeTypes());
|
||||
strings(out, v.challengeProviderIds()); optional(out, v.eabProviderId()); out.writeBoolean(v.eabRequired());
|
||||
string(out, v.disclosurePolicy().name()); string(out, v.status().name()); string(out, v.policyCommitment());
|
||||
instant(out, v.createdAt()); string(out, committed ? v.commitment() : ZERO);
|
||||
}
|
||||
private static AcmeState.Directory readDirectory(DataInputStream in) throws IOException {
|
||||
String id = string(in), alias = string(in); int revision = in.readInt(); RealmId realm = new RealmId(string(in));
|
||||
PkiId authority = new PkiId(string(in)); CertificateProfileRef profile = profile(in);
|
||||
PkiId issuer = new PkiId(string(in)), path = new PkiId(string(in)); String pathCommitment = string(in);
|
||||
Set<AcmeState.IdentifierType> types = enumSet(in, AcmeState.IdentifierType.class);
|
||||
Set<String> namespaces = strings(in); Duration validity = Duration.ofSeconds(in.readLong());
|
||||
Set<String> algorithms = strings(in), bindings = strings(in);
|
||||
Set<AcmeState.ChallengeType> challenges = enumSet(in, AcmeState.ChallengeType.class);
|
||||
Set<String> providers = strings(in); Optional<String> eab = optional(in); boolean required = in.readBoolean();
|
||||
DisclosureService.Policy disclosure = DisclosureService.Policy.valueOf(string(in));
|
||||
AcmeState.DirectoryStatus status = AcmeState.DirectoryStatus.valueOf(string(in));
|
||||
return new AcmeState.Directory(id, alias, revision, realm, authority, profile, issuer, path, pathCommitment,
|
||||
types, namespaces, validity, algorithms, bindings, challenges, providers, eab, required, disclosure,
|
||||
status, string(in), instant(in), string(in));
|
||||
}
|
||||
|
||||
private static void writeAccount(DataOutputStream out, AcmeState.Account v, boolean committed) throws IOException {
|
||||
out.writeInt(ACCOUNT); string(out, v.accountId()); string(out, v.directoryId()); out.writeInt(v.directoryRevision());
|
||||
string(out, v.directoryCommitment()); string(out, v.keyThumbprint()); bytes(out, v.publicKeySpki());
|
||||
string(out, v.status().name()); stringsList(out, v.contacts()); out.writeBoolean(v.termsAgreed());
|
||||
optional(out, v.eabPolicyCommitment()); instant(out, v.createdAt()); instant(out, v.updatedAt());
|
||||
string(out, committed ? v.commitment() : ZERO);
|
||||
}
|
||||
private static AcmeState.Account readAccount(DataInputStream in) throws IOException {
|
||||
return new AcmeState.Account(string(in), string(in), in.readInt(), string(in), string(in), bytes(in, 16_384),
|
||||
AcmeState.AccountStatus.valueOf(string(in)), stringsList(in), in.readBoolean(), optional(in),
|
||||
instant(in), instant(in), string(in));
|
||||
}
|
||||
|
||||
private static void writeOrder(DataOutputStream out, AcmeState.Order v, boolean committed) throws IOException {
|
||||
out.writeInt(ORDER); string(out, v.orderId()); string(out, v.accountId()); string(out, v.directoryId());
|
||||
out.writeInt(v.directoryRevision()); string(out, v.directoryCommitment()); string(out, v.authorityId().value());
|
||||
profile(out, v.profile()); string(out, v.issuerId().value()); string(out, v.issuancePathId().value());
|
||||
string(out, v.issuancePathCommitment()); identifiers(out, v.identifiers()); optionalInstant(out, v.notBefore());
|
||||
optionalInstant(out, v.notAfter()); string(out, v.status().name()); stringsList(out, v.authorizationIds());
|
||||
optional(out, v.credentialId().map(PkiId::value)); instant(out, v.createdAt()); instant(out, v.expiresAt());
|
||||
string(out, committed ? v.commitment() : ZERO);
|
||||
}
|
||||
private static AcmeState.Order readOrder(DataInputStream in) throws IOException {
|
||||
String id = string(in), account = string(in), directory = string(in); int revision = in.readInt();
|
||||
String directoryCommitment = string(in); PkiId authority = new PkiId(string(in)); CertificateProfileRef profile = profile(in);
|
||||
PkiId issuer = new PkiId(string(in)), path = new PkiId(string(in)); String pathCommitment = string(in);
|
||||
List<AcmeState.Identifier> identifiers = identifiers(in); Optional<Instant> from = optionalInstant(in), to = optionalInstant(in);
|
||||
AcmeState.OrderStatus status = AcmeState.OrderStatus.valueOf(string(in)); List<String> authorizations = stringsList(in);
|
||||
Optional<PkiId> credential = optional(in).map(PkiId::new); Instant created = instant(in), expires = instant(in);
|
||||
return new AcmeState.Order(id, account, directory, revision, directoryCommitment, authority, profile, issuer,
|
||||
path, pathCommitment, identifiers, from, to, status, authorizations, credential, created, expires, string(in));
|
||||
}
|
||||
|
||||
private static void writeAuthorization(DataOutputStream out, AcmeState.Authorization v, boolean committed) throws IOException {
|
||||
out.writeInt(AUTHORIZATION); string(out, v.authorizationId()); string(out, v.orderId()); string(out, v.accountId());
|
||||
identifier(out, v.identifier()); string(out, v.status().name()); stringsList(out, v.challengeIds());
|
||||
instant(out, v.expiresAt()); optional(out, v.evidenceId()); string(out, committed ? v.commitment() : ZERO);
|
||||
}
|
||||
private static AcmeState.Authorization readAuthorization(DataInputStream in) throws IOException {
|
||||
return new AcmeState.Authorization(string(in), string(in), string(in), identifier(in),
|
||||
AcmeState.AuthorizationStatus.valueOf(string(in)), stringsList(in), instant(in), optional(in), string(in));
|
||||
}
|
||||
|
||||
private static void writeChallenge(DataOutputStream out, AcmeState.Challenge v, boolean committed) throws IOException {
|
||||
out.writeInt(CHALLENGE); string(out, v.challengeId()); string(out, v.authorizationId()); string(out, v.type().name());
|
||||
string(out, v.token()); string(out, v.status().name()); string(out, v.providerId()); out.writeInt(v.attempt());
|
||||
optional(out, v.failureCode()); optional(out, v.evidenceId()); instant(out, v.createdAt()); instant(out, v.updatedAt());
|
||||
string(out, committed ? v.commitment() : ZERO);
|
||||
}
|
||||
private static AcmeState.Challenge readChallenge(DataInputStream in) throws IOException {
|
||||
return new AcmeState.Challenge(string(in), string(in), AcmeState.ChallengeType.valueOf(string(in)), string(in),
|
||||
AcmeState.ChallengeStatus.valueOf(string(in)), string(in), in.readInt(), optional(in), optional(in),
|
||||
instant(in), instant(in), string(in));
|
||||
}
|
||||
|
||||
private static void writeEvidence(DataOutputStream out, AcmeState.AuthorizationEvidence v, boolean committed) throws IOException {
|
||||
out.writeInt(EVIDENCE); string(out, v.evidenceId()); string(out, v.authorizationId()); string(out, v.providerId());
|
||||
string(out, v.challengeType().name()); identifier(out, v.identifier()); string(out, v.accountKeyCommitment());
|
||||
string(out, v.keyAuthorizationCommitment()); string(out, v.directoryPolicyCommitment()); out.writeInt(v.attempt());
|
||||
instant(out, v.validatedAt()); instant(out, v.expiresAt()); string(out, v.result().name()); optional(out, v.failureCode());
|
||||
string(out, committed ? v.commitment() : ZERO);
|
||||
}
|
||||
private static AcmeState.AuthorizationEvidence readEvidence(DataInputStream in) throws IOException {
|
||||
return new AcmeState.AuthorizationEvidence(string(in), string(in), string(in),
|
||||
AcmeState.ChallengeType.valueOf(string(in)), identifier(in), string(in), string(in), string(in),
|
||||
in.readInt(), instant(in), instant(in), AcmeState.EvidenceResult.valueOf(string(in)), optional(in), string(in));
|
||||
}
|
||||
|
||||
private static String storedCommitment(Object value) {
|
||||
return switch (value) {
|
||||
case AcmeState.Directory item -> item.commitment(); case AcmeState.Account item -> item.commitment();
|
||||
case AcmeState.Order item -> item.commitment(); case AcmeState.Authorization item -> item.commitment();
|
||||
case AcmeState.Challenge item -> item.commitment(); case AcmeState.AuthorizationEvidence item -> item.commitment();
|
||||
default -> throw invalid();
|
||||
};
|
||||
}
|
||||
private static void profile(DataOutputStream out, CertificateProfileRef value) throws IOException {
|
||||
string(out, value.profileId()); out.writeLong(value.profileVersion()); bytes(out, value.canonicalSha256());
|
||||
}
|
||||
private static CertificateProfileRef profile(DataInputStream in) throws IOException {
|
||||
return new CertificateProfileRef(string(in), in.readLong(), bytes(in, 32));
|
||||
}
|
||||
private static void identifier(DataOutputStream out, AcmeState.Identifier value) throws IOException {
|
||||
string(out, value.type().name()); string(out, value.value()); out.writeBoolean(value.wildcard());
|
||||
}
|
||||
private static AcmeState.Identifier identifier(DataInputStream in) throws IOException {
|
||||
return new AcmeState.Identifier(AcmeState.IdentifierType.valueOf(string(in)), string(in), in.readBoolean());
|
||||
}
|
||||
private static void identifiers(DataOutputStream out, List<AcmeState.Identifier> values) throws IOException {
|
||||
out.writeInt(values.size()); for (AcmeState.Identifier value : values) identifier(out, value);
|
||||
}
|
||||
private static List<AcmeState.Identifier> identifiers(DataInputStream in) throws IOException {
|
||||
int count = count(in); List<AcmeState.Identifier> result = new ArrayList<>(count);
|
||||
for (int index = 0; index < count; index++) result.add(identifier(in)); return List.copyOf(result);
|
||||
}
|
||||
private static <E extends Enum<E>> void enums(DataOutputStream out, Set<E> values) throws IOException {
|
||||
strings(out, values.stream().map(Enum::name).collect(java.util.stream.Collectors.toSet()));
|
||||
}
|
||||
private static <E extends Enum<E>> Set<E> enumSet(DataInputStream in, Class<E> type) throws IOException {
|
||||
Set<E> result = new HashSet<>(); for (String name : strings(in)) if (!result.add(Enum.valueOf(type, name))) throw invalid();
|
||||
return Set.copyOf(result);
|
||||
}
|
||||
private static void strings(DataOutputStream out, Set<String> values) throws IOException {
|
||||
List<String> sorted = values.stream().sorted().toList(); stringsList(out, sorted);
|
||||
}
|
||||
private static Set<String> strings(DataInputStream in) throws IOException {
|
||||
List<String> values = stringsList(in); Set<String> result = new HashSet<>(values);
|
||||
if (result.size() != values.size()) throw invalid(); return Set.copyOf(result);
|
||||
}
|
||||
private static void stringsList(DataOutputStream out, List<String> values) throws IOException {
|
||||
if (values.size() > MAX_COLLECTION) throw invalid(); out.writeInt(values.size()); for (String value : values) string(out, value);
|
||||
}
|
||||
private static List<String> stringsList(DataInputStream in) throws IOException {
|
||||
int count = count(in); List<String> result = new ArrayList<>(count);
|
||||
for (int index = 0; index < count; index++) result.add(string(in)); return List.copyOf(result);
|
||||
}
|
||||
private static void string(DataOutputStream out, String value) throws IOException {
|
||||
byte[] bytes = value.getBytes(StandardCharsets.UTF_8); if (bytes.length > MAX_TEXT) throw invalid();
|
||||
out.writeInt(bytes.length); out.write(bytes);
|
||||
}
|
||||
private static String string(DataInputStream in) throws IOException {
|
||||
int length = in.readInt(); if (length < 0 || length > MAX_TEXT) throw invalid();
|
||||
byte[] bytes = in.readNBytes(length); if (bytes.length != length) throw invalid();
|
||||
String value = new String(bytes, StandardCharsets.UTF_8);
|
||||
if (!java.util.Arrays.equals(bytes, value.getBytes(StandardCharsets.UTF_8))) throw invalid(); return value;
|
||||
}
|
||||
private static void bytes(DataOutputStream out, byte[] value) throws IOException { out.writeInt(value.length); out.write(value); }
|
||||
private static byte[] bytes(DataInputStream in, int maximum) throws IOException {
|
||||
int length = in.readInt(); if (length <= 0 || length > maximum) throw invalid();
|
||||
byte[] result = in.readNBytes(length); if (result.length != length) throw invalid(); return result;
|
||||
}
|
||||
private static void optional(DataOutputStream out, Optional<String> value) throws IOException { out.writeBoolean(value.isPresent()); if (value.isPresent()) string(out, value.orElseThrow()); }
|
||||
private static Optional<String> optional(DataInputStream in) throws IOException { return in.readBoolean() ? Optional.of(string(in)) : Optional.empty(); }
|
||||
private static void instant(DataOutputStream out, Instant value) throws IOException { out.writeLong(value.toEpochMilli()); }
|
||||
private static Instant instant(DataInputStream in) throws IOException { return Instant.ofEpochMilli(in.readLong()); }
|
||||
private static void optionalInstant(DataOutputStream out, Optional<Instant> value) throws IOException { out.writeBoolean(value.isPresent()); if (value.isPresent()) instant(out, value.orElseThrow()); }
|
||||
private static Optional<Instant> optionalInstant(DataInputStream in) throws IOException { return in.readBoolean() ? Optional.of(instant(in)) : Optional.empty(); }
|
||||
private static int count(DataInputStream in) throws IOException { int value = in.readInt(); if (value < 0 || value > MAX_COLLECTION) throw invalid(); return value; }
|
||||
private static String sha256(byte[] value) { try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); } catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException("SHA-256 unavailable", impossible); } }
|
||||
private static IllegalArgumentException invalid() { return new IllegalArgumentException("ACME control record is invalid"); }
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/*******************************************************************************
|
||||
* 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.acme;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.Inet4Address;
|
||||
import java.net.Inet6Address;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.Set;
|
||||
|
||||
import zeroecho.core.io.CancellationSignal;
|
||||
import zeroecho.pki.server.spi.AcmeChallengeProvider;
|
||||
import zeroecho.pki.spi.ProviderConfig;
|
||||
|
||||
/** Production HTTP-01 validator using policy-checked, IP-pinned port-80 sockets. */
|
||||
@SuppressWarnings("PMD")
|
||||
public final class Http01ChallengeProvider implements AcmeChallengeProvider {
|
||||
private static final int HTTP_PORT = 80;
|
||||
/** Stable built-in provider identity. */
|
||||
public static final String ID = "zeroecho.http-01.v1";
|
||||
|
||||
@Override public String id() { return ID; }
|
||||
@Override public Set<AcmeState.ChallengeType> challengeTypes() { return Set.of(AcmeState.ChallengeType.HTTP_01); }
|
||||
|
||||
@Override
|
||||
public Result validate(Context context, ProviderConfig configuration, CancellationSignal cancellation) {
|
||||
if (context.challengeType() != AcmeState.ChallengeType.HTTP_01 || context.identifier().wildcard()) {
|
||||
return invalid(context, "IDENTIFIER_UNSUPPORTED");
|
||||
}
|
||||
Settings settings = settings(configuration);
|
||||
try {
|
||||
InetAddress[] resolved = InetAddress.getAllByName(context.identifier().value());
|
||||
if (resolved.length == 0 || resolved.length > settings.maximumAddresses()
|
||||
|| Arrays.stream(resolved).anyMatch(address -> !settings.allowPrivate() && !globallyRoutable(address))) {
|
||||
return invalid(context, "TARGET_NETWORK_REJECTED");
|
||||
}
|
||||
Arrays.sort(resolved, Comparator.comparing(InetAddress::getHostAddress));
|
||||
boolean probeCompleted = false;
|
||||
for (InetAddress address : resolved) {
|
||||
cancellation.throwIfCancelled();
|
||||
try {
|
||||
Result result = probe(address, context, settings, cancellation);
|
||||
probeCompleted = true;
|
||||
if (result.valid()) return result;
|
||||
} catch (java.io.IOException unavailable) {
|
||||
// Continue across the complete bounded, policy-validated DNS answer set.
|
||||
}
|
||||
}
|
||||
return invalid(context, probeCompleted ? "KEY_AUTHORIZATION_MISMATCH" : "HTTP_VALIDATION_FAILED");
|
||||
} catch (Exception failure) {
|
||||
return invalid(context, "HTTP_VALIDATION_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
private static Result probe(InetAddress address, Context context, Settings settings,
|
||||
CancellationSignal cancellation) throws Exception {
|
||||
long remaining = Math.max(1L, Duration.between(context.validationTime(), context.deadline()).toMillis());
|
||||
int timeout = Math.toIntExact(Math.min(settings.timeoutMillis(), remaining));
|
||||
try (Socket socket = new Socket()) {
|
||||
socket.connect(new InetSocketAddress(address, settings.targetPort()), timeout); socket.setSoTimeout(timeout);
|
||||
String request = "GET /.well-known/acme-challenge/" + context.token()
|
||||
+ " HTTP/1.1\r\nHost: " + context.identifier().value()
|
||||
+ "\r\nConnection: close\r\nAccept: text/plain\r\n\r\n";
|
||||
OutputStream output = socket.getOutputStream(); output.write(request.getBytes(StandardCharsets.US_ASCII));
|
||||
output.flush(); cancellation.throwIfCancelled();
|
||||
byte[] response = bounded(socket.getInputStream(), settings.maximumBodyBytes() + 16_384, cancellation);
|
||||
int boundary = indexOf(response, "\r\n\r\n".getBytes(StandardCharsets.US_ASCII));
|
||||
if (boundary < 0 || boundary > 16_384) return invalid(context, "HTTP_RESPONSE_MALFORMED");
|
||||
String headers = new String(response, 0, boundary, StandardCharsets.US_ASCII);
|
||||
if (!headers.startsWith("HTTP/1.1 200 ") && !headers.startsWith("HTTP/1.0 200 ")) {
|
||||
return invalid(context, "HTTP_STATUS_REJECTED");
|
||||
}
|
||||
byte[] body = Arrays.copyOfRange(response, boundary + 4, response.length);
|
||||
String observed = new String(body, StandardCharsets.US_ASCII).stripTrailing();
|
||||
boolean valid = MessageDigestSupport.equalAscii(observed, context.expectedKeyAuthorization());
|
||||
Instant now = context.validationTime();
|
||||
return new Result(valid, valid ? "VALID" : "KEY_AUTHORIZATION_MISMATCH", now, now.plus(Duration.ofHours(1)));
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] bounded(InputStream input, int maximum, CancellationSignal cancellation) throws Exception {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream(Math.min(maximum, 8_192)); byte[] buffer = new byte[4_096];
|
||||
int read; while ((read = input.read(buffer)) >= 0) { cancellation.throwIfCancelled();
|
||||
if (bytes.size() + read > maximum) throw new IllegalArgumentException("HTTP response bound exceeded");
|
||||
bytes.write(buffer, 0, read); }
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
|
||||
private static boolean globallyRoutable(InetAddress address) {
|
||||
if (address.isAnyLocalAddress() || address.isLoopbackAddress() || address.isLinkLocalAddress()
|
||||
|| address.isSiteLocalAddress() || address.isMulticastAddress()) return false;
|
||||
byte[] raw = address.getAddress();
|
||||
if (address instanceof Inet4Address) {
|
||||
int first = Byte.toUnsignedInt(raw[0]), second = Byte.toUnsignedInt(raw[1]);
|
||||
return first != 0 && first != 127 && !(first == 100 && second >= 64 && second <= 127)
|
||||
&& !(first == 169 && second == 254) && first < 224;
|
||||
}
|
||||
if (address instanceof Inet6Address) {
|
||||
return !(raw[0] == 0x20 && raw[1] == 0x01 && raw[2] == 0x0d && raw[3] == (byte) 0xb8)
|
||||
&& (raw[0] & 0xfe) != 0xfc;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Settings settings(ProviderConfig config) {
|
||||
Set<String> required = Set.of("allowPrivate", "timeoutMillis", "maximumBodyBytes", "maximumAddresses");
|
||||
Set<String> allowed = Set.of("allowPrivate", "timeoutMillis", "maximumBodyBytes", "maximumAddresses",
|
||||
"targetPort");
|
||||
if (!ID.equals(config.backendId()) || !config.properties().keySet().containsAll(required)
|
||||
|| !allowed.containsAll(config.properties().keySet())) {
|
||||
throw new IllegalArgumentException("HTTP-01 provider configuration is invalid");
|
||||
}
|
||||
boolean allowPrivate = switch (config.require("allowPrivate")) { case "true" -> true; case "false" -> false;
|
||||
default -> throw new IllegalArgumentException("HTTP-01 network policy is invalid"); };
|
||||
int targetPort = config.get("targetPort").map(value -> integer(value, 1, 65_535)).orElse(HTTP_PORT);
|
||||
if (targetPort != HTTP_PORT && !allowPrivate) {
|
||||
throw new IllegalArgumentException("Nonstandard HTTP-01 port requires private-target policy");
|
||||
}
|
||||
return new Settings(allowPrivate, integer(config, "timeoutMillis", 100, 60_000),
|
||||
integer(config, "maximumBodyBytes", 64, 65_536), integer(config, "maximumAddresses", 1, 32),
|
||||
targetPort);
|
||||
}
|
||||
private static int integer(ProviderConfig config, String key, int minimum, int maximum) {
|
||||
return integer(config.require(key), minimum, maximum);
|
||||
}
|
||||
private static int integer(String source, int minimum, int maximum) {
|
||||
try { int value = Integer.parseInt(source); if (value < minimum || value > maximum) throw new NumberFormatException(); return value; }
|
||||
catch (NumberFormatException failure) { throw new IllegalArgumentException("HTTP-01 provider bound is invalid"); }
|
||||
}
|
||||
private static int indexOf(byte[] value, byte[] needle) { outer: for (int i = 0; i <= value.length - needle.length; i++) { for (int j = 0; j < needle.length; j++) if (value[i + j] != needle[j]) continue outer; return i; } return -1; }
|
||||
private static Result invalid(Context context, String code) { Instant now = context.validationTime(); return new Result(false, code, now, now.plus(Duration.ofMinutes(5))); }
|
||||
private record Settings(boolean allowPrivate, int timeoutMillis, int maximumBodyBytes, int maximumAddresses,
|
||||
int targetPort) { }
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/*******************************************************************************
|
||||
* 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.acme;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.HashSet;
|
||||
import java.util.Hashtable;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import javax.naming.NamingEnumeration;
|
||||
import javax.naming.directory.Attribute;
|
||||
import javax.naming.directory.Attributes;
|
||||
import javax.naming.directory.DirContext;
|
||||
import javax.naming.directory.InitialDirContext;
|
||||
|
||||
import zeroecho.core.io.CancellationSignal;
|
||||
import zeroecho.pki.server.spi.AcmeChallengeProvider;
|
||||
import zeroecho.pki.spi.ProviderConfig;
|
||||
|
||||
/** Production DNS-01 validator using one explicit absolute-name JNDI DNS resolver. */
|
||||
@SuppressWarnings("PMD")
|
||||
public final class JndiDns01ChallengeProvider implements AcmeChallengeProvider {
|
||||
/** Stable built-in provider identity. */
|
||||
public static final String ID = "zeroecho.dns-01.jndi.v1";
|
||||
|
||||
@Override public String id() { return ID; }
|
||||
@Override public Set<AcmeState.ChallengeType> challengeTypes() { return Set.of(AcmeState.ChallengeType.DNS_01); }
|
||||
|
||||
@Override
|
||||
public Result validate(Context context, ProviderConfig configuration, CancellationSignal cancellation) {
|
||||
Settings settings = settings(configuration);
|
||||
String expected = digest(context.expectedKeyAuthorization());
|
||||
String name = "_acme-challenge." + context.identifier().value() + ".";
|
||||
try {
|
||||
Set<String> seen = new HashSet<>();
|
||||
for (int depth = 0; ; depth++) {
|
||||
cancellation.throwIfCancelled();
|
||||
if (!seen.add(name) || depth > settings.maximumCnameDepth()) {
|
||||
return invalid(context, "DNS_DELEGATION_REJECTED");
|
||||
}
|
||||
Lookup lookup = lookup(name, settings);
|
||||
for (String value : lookup.txt()) {
|
||||
if (MessageDigestSupport.equalAscii(value, expected)) return valid(context);
|
||||
}
|
||||
if (lookup.cname().isEmpty() || depth == settings.maximumCnameDepth()) {
|
||||
return invalid(context, "DNS_VALUE_MISMATCH");
|
||||
}
|
||||
name = canonicalAbsolute(lookup.cname().get(0));
|
||||
}
|
||||
} catch (Exception failure) {
|
||||
return invalid(context, "DNS_VALIDATION_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
private static Lookup lookup(String name, Settings settings) throws Exception {
|
||||
Hashtable<String, String> environment = new Hashtable<>();
|
||||
environment.put("java.naming.factory.initial", "com.sun.jndi.dns.DnsContextFactory");
|
||||
environment.put("java.naming.provider.url", settings.providerUrl());
|
||||
environment.put("com.sun.jndi.dns.timeout.initial", Integer.toString(settings.timeoutMillis()));
|
||||
environment.put("com.sun.jndi.dns.timeout.retries", "1");
|
||||
DirContext context = new InitialDirContext(environment);
|
||||
try {
|
||||
Attributes attributes = context.getAttributes(name, new String[] { "TXT", "CNAME" });
|
||||
List<String> txt = values(attributes.get("TXT"), settings.maximumRecords(), settings.maximumRecordBytes(), true);
|
||||
List<String> cname = values(attributes.get("CNAME"), 1, 253, false);
|
||||
return new Lookup(txt, cname);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> values(Attribute attribute, int maximum, int maximumBytes, boolean txt) throws Exception {
|
||||
if (attribute == null) return List.of(); List<String> result = new ArrayList<>();
|
||||
NamingEnumeration<?> values = attribute.getAll();
|
||||
try {
|
||||
while (values.hasMore()) {
|
||||
if (result.size() >= maximum) throw new IllegalArgumentException("DNS record bound exceeded");
|
||||
String value = String.valueOf(values.next());
|
||||
String canonical = txt ? txt(value) : value;
|
||||
if (canonical.getBytes(StandardCharsets.US_ASCII).length > maximumBytes) {
|
||||
throw new IllegalArgumentException("DNS record size exceeded");
|
||||
}
|
||||
result.add(canonical);
|
||||
}
|
||||
} finally {
|
||||
values.close();
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private static String txt(String value) {
|
||||
StringBuilder result = new StringBuilder(); int index = 0;
|
||||
while (index < value.length()) {
|
||||
while (index < value.length() && value.charAt(index) == ' ') index++;
|
||||
if (index >= value.length() || value.charAt(index++) != '"') throw new IllegalArgumentException("DNS TXT framing invalid");
|
||||
while (index < value.length() && value.charAt(index) != '"') {
|
||||
char character = value.charAt(index++);
|
||||
if (character == '\\' || character < 0x21 || character > 0x7e) throw new IllegalArgumentException("DNS TXT value invalid");
|
||||
result.append(character);
|
||||
}
|
||||
if (index >= value.length() || value.charAt(index++) != '"') throw new IllegalArgumentException("DNS TXT framing invalid");
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private static String canonicalAbsolute(String value) {
|
||||
String lower = value.toLowerCase(java.util.Locale.ROOT);
|
||||
if (!lower.endsWith(".") || lower.length() > 254 || !lower.matches("[a-z0-9.-]+\\.")) {
|
||||
throw new IllegalArgumentException("DNS CNAME is invalid");
|
||||
}
|
||||
return lower;
|
||||
}
|
||||
|
||||
private static Settings settings(ProviderConfig config) {
|
||||
if (!ID.equals(config.backendId()) || !Set.of("providerUrl", "timeoutMillis", "maximumRecords",
|
||||
"maximumRecordBytes", "maximumCnameDepth").equals(config.properties().keySet())) {
|
||||
throw new IllegalArgumentException("DNS-01 provider configuration is invalid");
|
||||
}
|
||||
String providerUrl = config.require("providerUrl");
|
||||
if (!providerUrl.matches("dns://[0-9A-Fa-f:.]+(?::[0-9]{1,5})?/?")) {
|
||||
throw new IllegalArgumentException("DNS resolver URL is invalid");
|
||||
}
|
||||
return new Settings(providerUrl, integer(config, "timeoutMillis", 100, 60_000),
|
||||
integer(config, "maximumRecords", 1, 64), integer(config, "maximumRecordBytes", 43, 4_096),
|
||||
integer(config, "maximumCnameDepth", 0, 8));
|
||||
}
|
||||
private static int integer(ProviderConfig config, String key, int minimum, int maximum) {
|
||||
try { int value = Integer.parseInt(config.require(key)); if (value < minimum || value > maximum) throw new NumberFormatException(); return value; }
|
||||
catch (NumberFormatException failure) { throw new IllegalArgumentException("DNS-01 provider bound is invalid"); }
|
||||
}
|
||||
private static String digest(String value) { try { return Base64.getUrlEncoder().withoutPadding().encodeToString(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.US_ASCII))); } catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException("SHA-256 unavailable", impossible); } }
|
||||
private static Result valid(Context context) { Instant now = context.validationTime(); return new Result(true, "VALID", now, now.plus(Duration.ofHours(1))); }
|
||||
private static Result invalid(Context context, String code) { Instant now = context.validationTime(); return new Result(false, code, now, now.plus(Duration.ofMinutes(5))); }
|
||||
private record Settings(String providerUrl, int timeoutMillis, int maximumRecords, int maximumRecordBytes, int maximumCnameDepth) { }
|
||||
private record Lookup(List<String> txt, List<String> cname) { }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*******************************************************************************
|
||||
* 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.acme;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
/** Package-local constant-time comparison helpers for sensitive ACME values. */
|
||||
/* default */ final class MessageDigestSupport {
|
||||
private MessageDigestSupport() { }
|
||||
/* default */ static boolean equalAscii(String first, String second) {
|
||||
byte[] left = first.getBytes(StandardCharsets.US_ASCII);
|
||||
byte[] right = second.getBytes(StandardCharsets.US_ASCII);
|
||||
try { return MessageDigest.isEqual(left, right); }
|
||||
finally { java.util.Arrays.fill(left, (byte) 0); java.util.Arrays.fill(right, (byte) 0); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*******************************************************************************/
|
||||
package zeroecho.pki.server.http;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.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.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Future;
|
||||
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.api.ca.IssuerChainPath;
|
||||
import zeroecho.pki.api.revocation.RevocationReason;
|
||||
import zeroecho.pki.application.PkiOperationValue;
|
||||
import zeroecho.pki.application.PkiRepositoryContent;
|
||||
import zeroecho.pki.server.PkiServerConfiguration;
|
||||
import zeroecho.pki.server.ServerRealmContext;
|
||||
import zeroecho.pki.server.acme.AcmeJwsVerifier;
|
||||
import zeroecho.pki.server.acme.AcmeNonceService;
|
||||
import zeroecho.pki.server.acme.AcmeRateAdmission;
|
||||
import zeroecho.pki.server.acme.AcmeService;
|
||||
import zeroecho.pki.server.acme.AcmeState;
|
||||
import zeroecho.pki.server.spi.PkiServerAuthenticationContext;
|
||||
import zeroecho.pki.server.spi.PkiServerAuthenticationResult;
|
||||
|
||||
/** Strict ACME route adapter; protocol state and PKI business rules remain in {@link AcmeService}. */
|
||||
@SuppressWarnings("PMD")
|
||||
final class AcmeHttpHandler implements HttpHandler {
|
||||
private static final String JOSE = "application/jose+json";
|
||||
private static final String JSON = "application/json";
|
||||
private static final String PROBLEM = "application/problem+json";
|
||||
private static final String PEM_CHAIN = "application/pem-certificate-chain";
|
||||
private static final int TRANSFER_BUFFER = 8192;
|
||||
private final PkiServerConfiguration.AcmeListener configuration;
|
||||
private final ServerRealmContext realm;
|
||||
private final AcmeService service;
|
||||
private final AcmeNonceService nonces;
|
||||
private final AcmeJwsVerifier verifier = new AcmeJwsVerifier();
|
||||
private final AcmeRateAdmission rates;
|
||||
private final ServerRuntime runtime;
|
||||
private final ServerRuntime.ChallengeRuntime validations;
|
||||
private final Clock clock;
|
||||
private final RequestIds requestIds;
|
||||
private final BooleanSupplier ready;
|
||||
private final MutualTlsAuthenticator proxyAuthenticator;
|
||||
|
||||
AcmeHttpHandler(PkiServerConfiguration.AcmeListener configuration, ServerRealmContext realm,
|
||||
AcmeService service, AcmeNonceService nonces, ServerRuntime runtime,
|
||||
ServerRuntime.ChallengeRuntime validations, Clock clock, RequestIds requestIds,
|
||||
BooleanSupplier ready) {
|
||||
this.configuration = configuration; this.realm = realm; this.service = service; this.nonces = nonces;
|
||||
this.runtime = runtime; this.validations = validations; this.clock = clock;
|
||||
this.requestIds = requestIds; this.ready = ready;
|
||||
this.rates = new AcmeRateAdmission(configuration, clock);
|
||||
this.proxyAuthenticator = new MutualTlsAuthenticator(configuration.proxyTransportMappings(),
|
||||
realm::principal, clock);
|
||||
}
|
||||
|
||||
@Override public void handle(HttpExchange exchange) throws IOException {
|
||||
String requestId = "unavailable-request";
|
||||
try {
|
||||
requestId = requestIds.resolve(exchange.getRequestHeaders().get(RequestIds.HEADER));
|
||||
requireHeaders(exchange.getRequestHeaders());
|
||||
if (!ready.getAsBoolean()) { send(exchange, problem(503, "serverInternal"), requestId, null); return; }
|
||||
if (!authenticateTransport((HttpsExchange) exchange, requestId)) {
|
||||
audit("ACME_TRANSPORT_AUTH", "system", requestId, "REJECTED");
|
||||
send(exchange, problem(401, "unauthorized"), requestId, null); return;
|
||||
}
|
||||
audit("ACME_TRANSPORT_AUTH", "system", requestId, "ACCEPTED");
|
||||
if (containsForwardedIdentity(exchange.getRequestHeaders())) {
|
||||
send(exchange, problem(400, "malformed"), requestId, null); return;
|
||||
}
|
||||
if (!runtime.tryAdmit()) { send(exchange, problem(429, "rateLimited"), requestId, null); return; }
|
||||
try {
|
||||
String exactRequestId = requestId;
|
||||
Instant requestDeadline = clock.instant().plus(configuration.execution().maximumDeadline());
|
||||
ServerRuntime.Submitted<Response> submitted = runtime.submit(cancellation -> route(exchange,
|
||||
exactRequestId, cancellation, requestDeadline));
|
||||
Response response;
|
||||
try {
|
||||
response = submitted.future().get(configuration.execution().maximumDeadline().toMillis(),
|
||||
TimeUnit.MILLISECONDS);
|
||||
} catch (TimeoutException timeout) {
|
||||
submitted.cancellation().cancel(); submitted.future().cancel(true);
|
||||
response = problem(504, "serverInternal");
|
||||
} catch (InterruptedException interrupted) {
|
||||
submitted.cancellation().cancel(); submitted.future().cancel(true);
|
||||
Thread.currentThread().interrupt(); response = problem(504, "serverInternal");
|
||||
} catch (ExecutionException failure) {
|
||||
response = protocolFailure(failure.getCause());
|
||||
}
|
||||
try {
|
||||
send(exchange, response, requestId, response.directoryId());
|
||||
} finally {
|
||||
submitted.finish();
|
||||
}
|
||||
} catch (RejectedExecutionException overloaded) {
|
||||
send(exchange, problem(429, "rateLimited"), requestId, null);
|
||||
} finally {
|
||||
runtime.releaseAdmission();
|
||||
}
|
||||
} catch (IllegalArgumentException malformed) {
|
||||
send(exchange, problem(400, "malformed"), requestId, null);
|
||||
} catch (RuntimeException failure) {
|
||||
send(exchange, problem(500, "serverInternal"), requestId, null);
|
||||
}
|
||||
}
|
||||
|
||||
private Response route(HttpExchange exchange, String requestId,
|
||||
ServerRuntime.CancellationToken cancellation, Instant requestDeadline) throws Exception {
|
||||
URI requestUri = exchange.getRequestURI();
|
||||
if (requestUri.getRawQuery() != null || requestUri.getRawFragment() != null) return problem(400, "malformed");
|
||||
String[] parts = requestUri.getPath().split("/", -1);
|
||||
if (parts.length < 4 || !"".equals(parts[0]) || !"acme".equals(parts[1])) return problem(404, "malformed");
|
||||
AcmeState.Directory directory;
|
||||
try { directory = service.activeDirectory(parts[2]); }
|
||||
catch (RuntimeException unavailable) { return problem(404, "malformed"); }
|
||||
String resource = parts[3];
|
||||
if (parts.length == 4 && "directory".equals(resource) && "GET".equals(exchange.getRequestMethod())) {
|
||||
return json(200, directoryJson(directory), directory.directoryId());
|
||||
}
|
||||
if (parts.length == 4 && "new-nonce".equals(resource)
|
||||
&& ("GET".equals(exchange.getRequestMethod()) || "HEAD".equals(exchange.getRequestMethod()))) {
|
||||
return new Response(204, JSON, new byte[0], Optional.empty(), directory.directoryId(), Map.of());
|
||||
}
|
||||
if (!"POST".equals(exchange.getRequestMethod())) return problem(405, "malformed");
|
||||
if (!JOSE.equalsIgnoreCase(baseContentType(exchange.getRequestHeaders().getFirst("Content-Type")))) {
|
||||
return problem(415, "malformed");
|
||||
}
|
||||
byte[] body = readBody(exchange.getRequestBody(), configuration.maximumBodyBytes());
|
||||
URI exactUrl = configuration.externalBaseUri().resolve(requestUri.getRawPath());
|
||||
AcmeJwsVerifier.KeyMode mode = "new-account".equals(resource)
|
||||
? AcmeJwsVerifier.KeyMode.JWK : AcmeJwsVerifier.KeyMode.KID;
|
||||
AcmeJwsVerifier.Verified verified = verifier.verify(body, configuration.maximumBodyBytes(), exactUrl,
|
||||
directory.directoryId(), mode, nonces, kid -> accountKey(directory, kid));
|
||||
if (parts.length == 4 && "new-account".equals(resource)) return newAccount(directory, verified);
|
||||
AcmeJwsVerifier.AccountKey account = verified.account().orElseThrow();
|
||||
requireAccountDirectory(account.accountId(), directory);
|
||||
if (parts.length == 4 && "new-order".equals(resource)) return newOrder(directory, account, verified.payload());
|
||||
if (parts.length == 4 && "key-change".equals(resource)) {
|
||||
AcmeJwsVerifier.Verified replacement = verifier.verifyKeyChange(verified.payload(),
|
||||
configuration.maximumBodyBytes(), exactUrl, account.kid(), account.keyThumbprint());
|
||||
AcmeState.Account updated = service.rolloverAccount(account.accountId(), account.recordCommitment(),
|
||||
replacement.keyThumbprint(), replacement.publicKey().getEncoded());
|
||||
return json(200, accountJson(updated, directory), directory.directoryId());
|
||||
}
|
||||
if (parts.length == 4 && "revoke-cert".equals(resource)) {
|
||||
AcmePayloads.RevocationPayload revocation = AcmePayloads.revocation(verified.payload(),
|
||||
configuration.maximumBodyBytes());
|
||||
byte[] certificate = revocation.certificateDer();
|
||||
try { service.revokeCertificate(account.accountId(), account.recordCommitment(), directory, certificate,
|
||||
revocationReason(revocation.reasonCode())); }
|
||||
finally { java.util.Arrays.fill(certificate, (byte) 0); }
|
||||
return json(200, "{}", directory.directoryId());
|
||||
}
|
||||
if (parts.length != 5) return problem(404, "malformed");
|
||||
return switch (resource) {
|
||||
case "account" -> account(account, parts[4], verified.payload(), directory);
|
||||
case "order" -> order(account.accountId(), parts[4], verified.payload(), directory);
|
||||
case "authz" -> authorization(account.accountId(), parts[4], verified.payload(), directory);
|
||||
case "challenge" -> challenge(account, parts[4], verified.payload(), directory, cancellation,
|
||||
requestDeadline);
|
||||
case "finalize" -> finalizeOrder(account, parts[4], verified.payload(), directory);
|
||||
case "certificate" -> certificate(account.accountId(), parts[4], verified.payload(), directory,
|
||||
cancellation, requestDeadline);
|
||||
default -> problem(404, "malformed");
|
||||
};
|
||||
}
|
||||
|
||||
private Response newAccount(AcmeState.Directory directory, AcmeJwsVerifier.Verified verified) {
|
||||
PkiOperationValue.ObjectValue payload = object(StrictJson.parse(verified.payload(), 65_536));
|
||||
requireOnly(payload, "onlyReturnExisting", "contact", "termsOfServiceAgreed", "externalAccountBinding");
|
||||
boolean onlyExisting = bool(payload, "onlyReturnExisting", false);
|
||||
boolean terms = bool(payload, "termsOfServiceAgreed", false);
|
||||
List<String> contacts = textList(payload, "contact", 16, 512);
|
||||
Optional<byte[]> eabDocument = Optional.ofNullable(payload.fields().get("externalAccountBinding"))
|
||||
.map(StrictJson::encode);
|
||||
if (!rates.admitAccount(directory.directoryId())) return problem(429, "rateLimited");
|
||||
Optional<String> eab = service.verifyExternalAccountBinding(directory, verified.keyThumbprint(), eabDocument);
|
||||
boolean existing = service.accountByKey(directory.directoryId(), verified.keyThumbprint()).isPresent();
|
||||
AcmeState.Account account = service.createAccount(directory, verified.keyThumbprint(),
|
||||
verified.publicKey().getEncoded(), contacts, terms, eab, onlyExisting);
|
||||
return json(existing ? 200 : 201, accountJson(account, directory), directory.directoryId(),
|
||||
Map.of("Location", resourceUrl(directory, "account", account.accountId())));
|
||||
}
|
||||
|
||||
private Response newOrder(AcmeState.Directory directory, AcmeJwsVerifier.AccountKey account, byte[] raw) {
|
||||
PkiOperationValue.ObjectValue payload = object(StrictJson.parse(raw, 65_536));
|
||||
requireOnly(payload, "identifiers", "notBefore", "notAfter");
|
||||
List<AcmeState.Identifier> identifiers = identifiers(payload.fields().get("identifiers"));
|
||||
Optional<Instant> notBefore = optionalInstant(payload, "notBefore");
|
||||
Optional<Instant> notAfter = optionalInstant(payload, "notAfter");
|
||||
if (notBefore.isPresent() != notAfter.isPresent()) throw new AcmeJwsVerifier.AcmeProblem("malformed");
|
||||
if (service.pendingOrderCount(account.accountId(), configuration.maximumPendingOrdersPerAccount())
|
||||
>= configuration.maximumPendingOrdersPerAccount()) return problem(429, "rateLimited");
|
||||
if (!rates.admitOrder(account.accountId())) return problem(429, "rateLimited");
|
||||
AcmeState.Order order = service.createOrder(account.accountId(), account.recordCommitment(), directory,
|
||||
identifiers,
|
||||
notBefore, notAfter, Duration.ofHours(24));
|
||||
return json(201, orderJson(order, directory), directory.directoryId(),
|
||||
Map.of("Location", resourceUrl(directory, "order", order.orderId())));
|
||||
}
|
||||
|
||||
private Response account(AcmeJwsVerifier.AccountKey authenticated, String id, byte[] payload,
|
||||
AcmeState.Directory directory) {
|
||||
if (!authenticated.accountId().equals(id)) return problem(404, "accountDoesNotExist");
|
||||
PkiOperationValue.ObjectValue value = objectOrEmpty(payload);
|
||||
requireOnly(value, "status", "contact");
|
||||
if (value.fields().containsKey("status") && value.fields().containsKey("contact")) {
|
||||
throw new AcmeJwsVerifier.AcmeProblem("malformed");
|
||||
}
|
||||
AcmeState.Account account = service.account(id);
|
||||
if (value.fields().get("status") instanceof PkiOperationValue.Text status) {
|
||||
if (!"deactivated".equals(status.value())) throw new AcmeJwsVerifier.AcmeProblem("malformed");
|
||||
account = service.deactivateAccount(id, authenticated.recordCommitment());
|
||||
}
|
||||
if (value.fields().containsKey("contact")) {
|
||||
account = service.updateAccountContacts(id, authenticated.recordCommitment(),
|
||||
textList(value, "contact", 16, 512));
|
||||
}
|
||||
return json(200, accountJson(account, directory), directory.directoryId());
|
||||
}
|
||||
|
||||
private Response order(String accountId, String id, byte[] payload, AcmeState.Directory directory) {
|
||||
requirePostAsGet(payload);
|
||||
return json(200, orderJson(service.order(accountId, id), directory), directory.directoryId());
|
||||
}
|
||||
|
||||
private Response authorization(String accountId, String id, byte[] payload, AcmeState.Directory directory) {
|
||||
requirePostAsGet(payload);
|
||||
AcmeState.Authorization value = service.authorization(accountId, id);
|
||||
return json(200, authorizationJson(value, directory), directory.directoryId());
|
||||
}
|
||||
|
||||
private Response challenge(AcmeJwsVerifier.AccountKey account, String id, byte[] payload,
|
||||
AcmeState.Directory directory, ServerRuntime.CancellationToken cancellation,
|
||||
Instant requestDeadline) throws Exception {
|
||||
objectOrEmpty(payload);
|
||||
if (!rates.admitValidation(account.accountId())) return problem(429, "rateLimited");
|
||||
if (!validations.tryAdmit()) return problem(429, "rateLimited");
|
||||
Future<AcmeState.Challenge> future = null;
|
||||
try {
|
||||
future = validations.submit(() -> service.validateChallenge(
|
||||
account.accountId(), account.recordCommitment(), id, account.keyThumbprint(),
|
||||
requestDeadline, cancellation));
|
||||
AcmeState.Challenge value = future.get(configuration.execution().maximumDeadline().toMillis(),
|
||||
TimeUnit.MILLISECONDS);
|
||||
return json(200, challengeJson(value, directory), directory.directoryId());
|
||||
} catch (TimeoutException timeout) {
|
||||
if (future != null) {
|
||||
future.cancel(true);
|
||||
}
|
||||
return problem(504, "connection");
|
||||
} finally {
|
||||
validations.release();
|
||||
}
|
||||
}
|
||||
|
||||
private Response finalizeOrder(AcmeJwsVerifier.AccountKey account, String id, byte[] payload,
|
||||
AcmeState.Directory directory) {
|
||||
byte[] csr = AcmePayloads.requiredBase64Url(payload, "csr", configuration.maximumBodyBytes());
|
||||
if (!rates.tryAcquireFinalization()) {
|
||||
java.util.Arrays.fill(csr, (byte) 0);
|
||||
return problem(429, "rateLimited");
|
||||
}
|
||||
try {
|
||||
service.finalizeOrder(account.accountId(), account.recordCommitment(), id, csr);
|
||||
} finally {
|
||||
java.util.Arrays.fill(csr, (byte) 0);
|
||||
rates.releaseFinalization();
|
||||
}
|
||||
return json(200, orderJson(service.order(account.accountId(), id), directory), directory.directoryId());
|
||||
}
|
||||
|
||||
private Response certificate(String accountId, String id, byte[] payload, AcmeState.Directory directory,
|
||||
ServerRuntime.CancellationToken cancellation, Instant requestDeadline) {
|
||||
requirePostAsGet(payload);
|
||||
AcmeState.Order order = service.order(accountId, id);
|
||||
if (order.status() != AcmeState.OrderStatus.VALID || order.credentialId().isEmpty()) {
|
||||
return problem(403, "orderNotReady");
|
||||
}
|
||||
return new Response(200, PEM_CHAIN, new byte[0],
|
||||
Optional.of(output -> writePemChain(output, order, cancellation, requestDeadline)),
|
||||
directory.directoryId(), Map.of());
|
||||
}
|
||||
|
||||
private void writePemChain(OutputStream output, AcmeState.Order order,
|
||||
ServerRuntime.CancellationToken cancellation, Instant deadline) throws IOException {
|
||||
writePem(output, order.credentialId().orElseThrow(), cancellation, deadline);
|
||||
IssuerChainPath path = realm.session().repository().chainPath(order.issuancePathId())
|
||||
.orElseThrow(() -> new IllegalStateException("ACME issuance path unavailable"));
|
||||
if (!path.pathCommitment().equals(order.issuancePathCommitment())) {
|
||||
throw new IllegalStateException("ACME issuance path changed");
|
||||
}
|
||||
for (PkiId credentialId : path.orderedCredentialIds()) {
|
||||
writePem(output, credentialId, cancellation, deadline);
|
||||
}
|
||||
}
|
||||
|
||||
private void writePem(OutputStream output, PkiId credentialId,
|
||||
ServerRuntime.CancellationToken cancellation, Instant deadline) throws IOException {
|
||||
requireStreamActive(cancellation, deadline);
|
||||
output.write("-----BEGIN CERTIFICATE-----\n".getBytes(StandardCharsets.US_ASCII));
|
||||
try (PkiRepositoryContent content = realm.session().repository().openCredential(credentialId);
|
||||
InputStream input = content.openStream();
|
||||
OutputStream encoded = Base64.getMimeEncoder(64, new byte[] {'\n'}).wrap(new NonClosingOutput(output))) {
|
||||
byte[] buffer = new byte[TRANSFER_BUFFER];
|
||||
for (int count; (count = input.read(buffer)) >= 0;) {
|
||||
requireStreamActive(cancellation, deadline);
|
||||
if (count != 0) {
|
||||
encoded.write(buffer, 0, count);
|
||||
}
|
||||
}
|
||||
}
|
||||
output.write("\n-----END CERTIFICATE-----\n".getBytes(StandardCharsets.US_ASCII));
|
||||
}
|
||||
|
||||
private void requireStreamActive(ServerRuntime.CancellationToken cancellation, Instant deadline)
|
||||
throws IOException {
|
||||
if (cancellation.isCancelled() || !clock.instant().isBefore(deadline)) {
|
||||
throw new IOException("ACME certificate stream cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
private AcmeJwsVerifier.AccountKey accountKey(AcmeState.Directory directory, String kid) {
|
||||
String prefix = configuration.externalBaseUri().resolve("/acme/" + directory.alias() + "/account/").toASCIIString();
|
||||
if (!kid.startsWith(prefix)) throw new AcmeJwsVerifier.AcmeProblem("accountDoesNotExist");
|
||||
return service.accountKey(kid.substring(prefix.length()), kid);
|
||||
}
|
||||
|
||||
private void requireAccountDirectory(String accountId, AcmeState.Directory directory) {
|
||||
AcmeState.Account account = service.account(accountId);
|
||||
if (!account.directoryId().equals(directory.directoryId()) || account.status() != AcmeState.AccountStatus.VALID) {
|
||||
throw new AcmeJwsVerifier.AcmeProblem("accountDoesNotExist");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean authenticateTransport(HttpsExchange exchange, String requestId) {
|
||||
if (configuration.transportMode() == PkiServerConfiguration.AcmeTransportMode.DIRECT_TLS) return true;
|
||||
try {
|
||||
List<X509Certificate> chain = new ArrayList<>();
|
||||
for (Certificate certificate : exchange.getSSLSession().getPeerCertificates()) {
|
||||
if (!(certificate instanceof X509Certificate x509)) return false;
|
||||
chain.add(x509);
|
||||
}
|
||||
PkiServerAuthenticationResult result = proxyAuthenticator.authenticate(new PkiServerAuthenticationContext(
|
||||
chain, exchange.getSSLSession().getProtocol(), exchange.getSSLSession().getCipherSuite(),
|
||||
requestId, realm.configuration().realmId()));
|
||||
if (!(result instanceof PkiServerAuthenticationResult.Authenticated authenticated)
|
||||
|| !configuration.trustedProxyPrincipalIds().contains(authenticated.endClientPrincipalId())) return false;
|
||||
return realm.gateway().authorizeForwardedIdentity(authenticated.endClientPrincipalId(), requestId).allowed();
|
||||
} catch (SSLPeerUnverifiedException | RuntimeException failure) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void send(HttpExchange exchange, Response response, String requestId, String directoryId)
|
||||
throws IOException {
|
||||
String nonceDirectory = directoryId == null ? nonceDirectory(exchange.getRequestURI()) : directoryId;
|
||||
try { exchange.getResponseHeaders().set("Replay-Nonce", nonces.issue(nonceDirectory)); }
|
||||
catch (RuntimeException unavailable) { /* Capacity failure is already represented by the response. */ }
|
||||
exchange.getResponseHeaders().set(RequestIds.HEADER, requestId);
|
||||
exchange.getResponseHeaders().set("Cache-Control", "no-store");
|
||||
exchange.getResponseHeaders().set("Content-Type", response.contentType());
|
||||
response.headers().forEach((name, value) -> exchange.getResponseHeaders().set(name, value));
|
||||
boolean delivered = false;
|
||||
try {
|
||||
if (response.writer().isPresent()) {
|
||||
exchange.sendResponseHeaders(response.status(), 0);
|
||||
try (OutputStream output = exchange.getResponseBody()) { response.writer().orElseThrow().write(output); }
|
||||
} else {
|
||||
exchange.sendResponseHeaders(response.status(), response.body().length);
|
||||
try (OutputStream output = exchange.getResponseBody()) { output.write(response.body()); }
|
||||
}
|
||||
delivered = true;
|
||||
} finally {
|
||||
audit("ACME_REQUEST", "system", requestId,
|
||||
delivered ? "HTTP_" + response.status() : "DELIVERY_UNKNOWN");
|
||||
exchange.close();
|
||||
}
|
||||
}
|
||||
|
||||
private String nonceDirectory(URI requestUri) {
|
||||
String[] parts = requestUri.getPath().split("/", -1);
|
||||
if (parts.length >= 3 && "acme".equals(parts[1])) {
|
||||
try {
|
||||
return service.activeDirectory(parts[2]).directoryId();
|
||||
} catch (RuntimeException unavailable) {
|
||||
// Unknown directories receive a non-authoritative unusable nonce.
|
||||
}
|
||||
}
|
||||
return "directory:unknown";
|
||||
}
|
||||
|
||||
private void audit(String action, String actor, String requestId, String outcome) {
|
||||
realm.auditTransport(action, actor, Map.of("requestId", requestId, "outcome", outcome,
|
||||
"mode", configuration.transportMode().name()));
|
||||
}
|
||||
|
||||
private static Response json(int status, String json, String directoryId) {
|
||||
return json(status, json, directoryId, Map.of());
|
||||
}
|
||||
private static Response json(int status, String json, String directoryId, Map<String, String> headers) {
|
||||
return new Response(status, JSON, json.getBytes(StandardCharsets.UTF_8), Optional.empty(), directoryId,
|
||||
headers);
|
||||
}
|
||||
private static Response problem(int status, String type) {
|
||||
return new Response(status, PROBLEM, ("{\"type\":\"urn:ietf:params:acme:error:" + type
|
||||
+ "\",\"detail\":\"ACME request rejected\"}").getBytes(StandardCharsets.UTF_8),
|
||||
Optional.empty(), null, Map.of());
|
||||
}
|
||||
private static Response protocolFailure(Throwable failure) {
|
||||
if (failure instanceof AcmeJwsVerifier.AcmeProblem problem) {
|
||||
return problem(problemStatus(problem.type()), problem.type());
|
||||
}
|
||||
if (failure instanceof SecurityException || failure instanceof IllegalArgumentException) {
|
||||
return problem(403, "unauthorized");
|
||||
}
|
||||
if (failure instanceof zeroecho.pki.api.PkiException) {
|
||||
return problem(400, "badCSR");
|
||||
}
|
||||
if (failure instanceof IllegalStateException) {
|
||||
return problem(403, "orderNotReady");
|
||||
}
|
||||
return problem(500, "serverInternal");
|
||||
}
|
||||
private static int problemStatus(String type) {
|
||||
return switch (type) {
|
||||
case "unauthorized", "accountDoesNotExist", "orderNotReady", "userActionRequired" -> 403;
|
||||
case "rateLimited" -> 429;
|
||||
case "serverInternal" -> 500;
|
||||
default -> 400;
|
||||
};
|
||||
}
|
||||
private String resourceUrl(AcmeState.Directory directory, String type, String id) {
|
||||
return configuration.externalBaseUri().resolve("/acme/" + directory.alias() + "/" + type + "/" + id)
|
||||
.toASCIIString();
|
||||
}
|
||||
private String directoryJson(AcmeState.Directory value) {
|
||||
String base = configuration.externalBaseUri().resolve("/acme/" + value.alias() + "/").toASCIIString();
|
||||
return "{\"newNonce\":\"" + base + "new-nonce\",\"newAccount\":\"" + base
|
||||
+ "new-account\",\"newOrder\":\"" + base + "new-order\",\"revokeCert\":\""
|
||||
+ base + "revoke-cert\",\"keyChange\":\"" + base + "key-change\"}";
|
||||
}
|
||||
private String accountJson(AcmeState.Account value, AcmeState.Directory directory) {
|
||||
return "{\"status\":\"" + value.status().name().toLowerCase(java.util.Locale.ROOT) + "\"}";
|
||||
}
|
||||
private String orderJson(AcmeState.Order value, AcmeState.Directory directory) {
|
||||
StringBuilder out = new StringBuilder("{\"status\":\"").append(value.status().name().toLowerCase(java.util.Locale.ROOT))
|
||||
.append("\",\"identifiers\":[");
|
||||
for (int index = 0; index < value.identifiers().size(); index++) {
|
||||
if (index != 0) out.append(',');
|
||||
AcmeState.Identifier identifier = value.identifiers().get(index);
|
||||
out.append("{\"type\":\"dns\",\"value\":\"")
|
||||
.append(escape(identifier.presentation())).append("\"}");
|
||||
}
|
||||
out.append("],\"authorizations\":[");
|
||||
appendUrls(out, value.authorizationIds(), base(directory) + "authz/");
|
||||
out.append("],\"finalize\":\"").append(base(directory)).append("finalize/").append(value.orderId()).append('"');
|
||||
value.credentialId().ifPresent(ignored -> out.append(",\"certificate\":\"").append(base(directory))
|
||||
.append("certificate/").append(value.orderId()).append('"'));
|
||||
return out.append('}').toString();
|
||||
}
|
||||
private String authorizationJson(AcmeState.Authorization value, AcmeState.Directory directory) {
|
||||
StringBuilder out = new StringBuilder("{\"identifier\":{\"type\":\"dns\",\"value\":\"")
|
||||
.append(escape(value.identifier().value())).append("\"},\"status\":\"")
|
||||
.append(value.status().name().toLowerCase(java.util.Locale.ROOT));
|
||||
out.append('"');
|
||||
if (value.identifier().wildcard()) out.append(",\"wildcard\":true");
|
||||
out.append(",\"challenges\":[");
|
||||
appendUrls(out, value.challengeIds(), base(directory) + "challenge/");
|
||||
return out.append("]}").toString();
|
||||
}
|
||||
private String challengeJson(AcmeState.Challenge value, AcmeState.Directory directory) {
|
||||
return "{\"type\":\"" + challengeType(value.type()) + "\",\"url\":\"" + base(directory)
|
||||
+ "challenge/" + value.challengeId() + "\",\"status\":\""
|
||||
+ value.status().name().toLowerCase(java.util.Locale.ROOT) + "\",\"token\":\""
|
||||
+ value.token() + "\"}";
|
||||
}
|
||||
private String base(AcmeState.Directory value) {
|
||||
return configuration.externalBaseUri().resolve("/acme/" + value.alias() + "/").toASCIIString();
|
||||
}
|
||||
|
||||
private static void appendUrls(StringBuilder out, List<String> ids, String prefix) {
|
||||
for (int index = 0; index < ids.size(); index++) {
|
||||
if (index != 0) out.append(',');
|
||||
out.append('"').append(prefix).append(ids.get(index)).append('"');
|
||||
}
|
||||
}
|
||||
private static String challengeType(AcmeState.ChallengeType type) {
|
||||
return type == AcmeState.ChallengeType.HTTP_01 ? "http-01" : "dns-01";
|
||||
}
|
||||
private static RevocationReason revocationReason(int code) {
|
||||
return switch (code) {
|
||||
case 0 -> RevocationReason.UNSPECIFIED; case 1 -> RevocationReason.KEY_COMPROMISE;
|
||||
case 2 -> RevocationReason.CA_COMPROMISE; case 3 -> RevocationReason.AFFILIATION_CHANGED;
|
||||
case 4 -> RevocationReason.SUPERSEDED; case 5 -> RevocationReason.CESSATION_OF_OPERATION;
|
||||
case 9 -> RevocationReason.PRIVILEGE_WITHDRAWN; case 10 -> RevocationReason.AA_COMPROMISE;
|
||||
default -> throw new AcmeJwsVerifier.AcmeProblem("malformed");
|
||||
};
|
||||
}
|
||||
private static void requirePostAsGet(byte[] payload) {
|
||||
if (payload.length != 0) throw new AcmeJwsVerifier.AcmeProblem("malformed");
|
||||
}
|
||||
private static PkiOperationValue.ObjectValue objectOrEmpty(byte[] payload) {
|
||||
return payload.length == 0 ? new PkiOperationValue.ObjectValue(Map.of())
|
||||
: object(StrictJson.parse(payload, 65_536));
|
||||
}
|
||||
private static PkiOperationValue.ObjectValue object(PkiOperationValue value) {
|
||||
if (value instanceof PkiOperationValue.ObjectValue object) return object;
|
||||
throw new AcmeJwsVerifier.AcmeProblem("malformed");
|
||||
}
|
||||
private static void requireOnly(PkiOperationValue.ObjectValue value, String... fields) {
|
||||
if (!java.util.Set.of(fields).containsAll(value.fields().keySet())) {
|
||||
throw new AcmeJwsVerifier.AcmeProblem("malformed");
|
||||
}
|
||||
}
|
||||
private static boolean bool(PkiOperationValue.ObjectValue value, String field, boolean defaultValue) {
|
||||
PkiOperationValue item = value.fields().get(field);
|
||||
if (item == null) return defaultValue;
|
||||
if (item instanceof PkiOperationValue.BooleanValue flag) return flag.value();
|
||||
throw new AcmeJwsVerifier.AcmeProblem("malformed");
|
||||
}
|
||||
private static List<String> textList(PkiOperationValue.ObjectValue value, String field, int maximum,
|
||||
int maximumLength) {
|
||||
PkiOperationValue item = value.fields().get(field);
|
||||
if (item == null) return List.of();
|
||||
if (!(item instanceof PkiOperationValue.ListValue list) || list.values().size() > maximum) {
|
||||
throw new AcmeJwsVerifier.AcmeProblem("malformed");
|
||||
}
|
||||
List<String> result = new ArrayList<>();
|
||||
for (PkiOperationValue element : list.values()) {
|
||||
if (!(element instanceof PkiOperationValue.Text text) || text.value().length() > maximumLength) {
|
||||
throw new AcmeJwsVerifier.AcmeProblem("malformed");
|
||||
}
|
||||
result.add(text.value());
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
private static List<AcmeState.Identifier> identifiers(PkiOperationValue item) {
|
||||
if (!(item instanceof PkiOperationValue.ListValue list) || list.values().isEmpty() || list.values().size() > 64) {
|
||||
throw new AcmeJwsVerifier.AcmeProblem("rejectedIdentifier");
|
||||
}
|
||||
List<AcmeState.Identifier> result = new ArrayList<>();
|
||||
for (PkiOperationValue entry : list.values()) {
|
||||
PkiOperationValue.ObjectValue object = object(entry); requireOnly(object, "type", "value");
|
||||
if (!(object.fields().get("type") instanceof PkiOperationValue.Text type) || !"dns".equals(type.value())
|
||||
|| !(object.fields().get("value") instanceof PkiOperationValue.Text value)) {
|
||||
throw new AcmeJwsVerifier.AcmeProblem("rejectedIdentifier");
|
||||
}
|
||||
boolean wildcard = value.value().startsWith("*.");
|
||||
result.add(new AcmeState.Identifier(AcmeState.IdentifierType.DNS,
|
||||
wildcard ? value.value().substring(2) : value.value(), wildcard));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
private static Optional<Instant> optionalInstant(PkiOperationValue.ObjectValue value, String field) {
|
||||
PkiOperationValue item = value.fields().get(field);
|
||||
if (item == null) return Optional.empty();
|
||||
if (item instanceof PkiOperationValue.Text text) {
|
||||
try { return Optional.of(Instant.parse(text.value())); }
|
||||
catch (RuntimeException invalid) { throw new AcmeJwsVerifier.AcmeProblem("malformed"); }
|
||||
}
|
||||
throw new AcmeJwsVerifier.AcmeProblem("malformed");
|
||||
}
|
||||
private static byte[] readBody(InputStream input, int maximum) throws IOException {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream(Math.min(maximum, 16_384));
|
||||
byte[] buffer = new byte[8192]; int total = 0;
|
||||
for (int count; (count = input.read(buffer)) >= 0;) {
|
||||
if (count == 0) continue; total = Math.addExact(total, count);
|
||||
if (total > maximum) throw new IllegalArgumentException("ACME request body is too large");
|
||||
output.write(buffer, 0, count);
|
||||
}
|
||||
return output.toByteArray();
|
||||
}
|
||||
private void requireHeaders(Headers headers) {
|
||||
long count = 0;
|
||||
for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
|
||||
count += entry.getKey().length();
|
||||
for (String value : entry.getValue()) count += value.length();
|
||||
if (count > configuration.maximumHeaderBytes()) throw new IllegalArgumentException("ACME headers too large");
|
||||
}
|
||||
}
|
||||
private static boolean containsForwardedIdentity(Headers headers) {
|
||||
return headers.containsKey(ForwardedClientCertificateParser.RFC_CERTIFICATE_HEADER)
|
||||
|| headers.containsKey(ForwardedClientCertificateParser.RFC_CHAIN_HEADER)
|
||||
|| headers.containsKey(ForwardedClientCertificateParser.DIRECT_REJECTED_NGINX_HEADER);
|
||||
}
|
||||
private static String baseContentType(String value) {
|
||||
return value == null ? "" : value.split(";", 2)[0].trim();
|
||||
}
|
||||
private static String escape(String value) {
|
||||
return value.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
private record Response(int status, String contentType, byte[] body, Optional<BodyWriter> writer,
|
||||
String directoryId, Map<String, String> headers) {
|
||||
private Response {
|
||||
body = body.clone();
|
||||
writer = Objects.requireNonNull(writer, "writer");
|
||||
headers = Map.copyOf(headers);
|
||||
}
|
||||
@Override public byte[] body() { return body.clone(); }
|
||||
}
|
||||
@FunctionalInterface private interface BodyWriter { void write(OutputStream output) throws IOException; }
|
||||
private static final class NonClosingOutput extends java.io.FilterOutputStream {
|
||||
private NonClosingOutput(OutputStream output) { super(output); }
|
||||
@Override public void close() throws IOException { flush(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*******************************************************************************/
|
||||
package zeroecho.pki.server.http;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
import tools.jackson.core.JsonParser;
|
||||
import tools.jackson.core.JsonToken;
|
||||
import tools.jackson.core.ObjectReadContext;
|
||||
import tools.jackson.core.StreamReadConstraints;
|
||||
import tools.jackson.core.StreamReadFeature;
|
||||
import tools.jackson.core.json.JsonFactory;
|
||||
import tools.jackson.core.json.JsonFactoryBuilder;
|
||||
import tools.jackson.core.json.JsonReadFeature;
|
||||
import zeroecho.pki.server.acme.AcmeJwsVerifier;
|
||||
|
||||
/** Narrow large-scalar ACME endpoint payload decoder. */
|
||||
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.CyclomaticComplexity",
|
||||
"PMD.PreserveStackTrace", "PMD.AvoidCatchingGenericException" })
|
||||
/* default */ final class AcmePayloads {
|
||||
private AcmePayloads() { }
|
||||
|
||||
/* default */ static byte[] requiredBase64Url(byte[] document, String requiredField,
|
||||
int maximumDocumentBytes) {
|
||||
if (document == null || document.length == 0 || document.length > maximumDocumentBytes) {
|
||||
throw malformed();
|
||||
}
|
||||
StreamReadConstraints constraints = StreamReadConstraints.builder().maxNestingDepth(1)
|
||||
.maxDocumentLength(maximumDocumentBytes).maxTokenCount(4).maxNameLength(32)
|
||||
.maxStringLength(maximumDocumentBytes).build();
|
||||
JsonFactoryBuilder builder = JsonFactory.builder().streamReadConstraints(constraints)
|
||||
.enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION)
|
||||
.disable(StreamReadFeature.AUTO_CLOSE_SOURCE);
|
||||
for (JsonReadFeature feature : JsonReadFeature.values()) builder.disable(feature);
|
||||
JsonFactory factory = builder.build();
|
||||
try (JsonParser parser = factory.createParser(ObjectReadContext.empty(), document, 0, document.length)) {
|
||||
if (parser.nextToken() != JsonToken.START_OBJECT || parser.nextToken() != JsonToken.PROPERTY_NAME
|
||||
|| !requiredField.equals(parser.currentName()) || parser.nextToken() != JsonToken.VALUE_STRING) {
|
||||
throw malformed();
|
||||
}
|
||||
String encoded = parser.getString();
|
||||
if (parser.nextToken() != JsonToken.END_OBJECT || parser.nextToken() != null
|
||||
|| encoded.indexOf('=') >= 0 || !encoded.matches("[A-Za-z0-9_-]+")) {
|
||||
throw malformed();
|
||||
}
|
||||
byte[] decoded = Base64.getUrlDecoder().decode(encoded);
|
||||
if (decoded.length > maximumDocumentBytes
|
||||
|| !Base64.getUrlEncoder().withoutPadding().encodeToString(decoded).equals(encoded)) {
|
||||
throw malformed();
|
||||
}
|
||||
return decoded;
|
||||
} catch (AcmeJwsVerifier.AcmeProblem problem) {
|
||||
throw problem;
|
||||
} catch (RuntimeException failure) {
|
||||
throw malformed();
|
||||
}
|
||||
}
|
||||
|
||||
/* default */ static RevocationPayload revocation(byte[] document, int maximumDocumentBytes) {
|
||||
if (document == null || document.length == 0 || document.length > maximumDocumentBytes) throw malformed();
|
||||
StreamReadConstraints constraints = StreamReadConstraints.builder().maxNestingDepth(1)
|
||||
.maxDocumentLength(maximumDocumentBytes).maxTokenCount(7).maxNameLength(32)
|
||||
.maxStringLength(maximumDocumentBytes).maxNumberLength(3).build();
|
||||
JsonFactoryBuilder builder = JsonFactory.builder().streamReadConstraints(constraints)
|
||||
.enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION).disable(StreamReadFeature.AUTO_CLOSE_SOURCE);
|
||||
for (JsonReadFeature feature : JsonReadFeature.values()) builder.disable(feature);
|
||||
try (JsonParser parser = builder.build().createParser(ObjectReadContext.empty(), document, 0, document.length)) {
|
||||
if (parser.nextToken() != JsonToken.START_OBJECT) throw malformed();
|
||||
String certificate = null; int reason = 0;
|
||||
while (parser.nextToken() != JsonToken.END_OBJECT) {
|
||||
if (parser.currentToken() != JsonToken.PROPERTY_NAME) throw malformed();
|
||||
String name = parser.currentName(); JsonToken token = parser.nextToken();
|
||||
if ("certificate".equals(name) && token == JsonToken.VALUE_STRING && certificate == null) {
|
||||
certificate = parser.getString();
|
||||
} else if ("reason".equals(name) && token == JsonToken.VALUE_NUMBER_INT) {
|
||||
reason = parser.getIntValue();
|
||||
} else throw malformed();
|
||||
}
|
||||
if (parser.nextToken() != null || certificate == null || certificate.indexOf('=') >= 0
|
||||
|| !certificate.matches("[A-Za-z0-9_-]+")) throw malformed();
|
||||
byte[] decoded = Base64.getUrlDecoder().decode(certificate);
|
||||
if (decoded.length > maximumDocumentBytes
|
||||
|| !Base64.getUrlEncoder().withoutPadding().encodeToString(decoded).equals(certificate)) {
|
||||
throw malformed();
|
||||
}
|
||||
return new RevocationPayload(decoded, reason);
|
||||
} catch (AcmeJwsVerifier.AcmeProblem problem) {
|
||||
throw problem;
|
||||
} catch (RuntimeException failure) {
|
||||
throw malformed();
|
||||
}
|
||||
}
|
||||
|
||||
/* default */ record RevocationPayload(byte[] certificateDer, int reasonCode) {
|
||||
RevocationPayload { certificateDer = certificateDer.clone(); }
|
||||
@Override public byte[] certificateDer() { return certificateDer.clone(); }
|
||||
}
|
||||
|
||||
private static AcmeJwsVerifier.AcmeProblem malformed() {
|
||||
return new AcmeJwsVerifier.AcmeProblem("malformed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*******************************************************************************
|
||||
* 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.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLParameters;
|
||||
|
||||
import com.sun.net.httpserver.HttpsConfigurator;
|
||||
import com.sun.net.httpserver.HttpsParameters;
|
||||
import com.sun.net.httpserver.HttpsServer;
|
||||
|
||||
import zeroecho.pki.server.PkiServerConfiguration;
|
||||
import zeroecho.pki.server.ServerRealmContext;
|
||||
import zeroecho.pki.server.acme.AcmeNonceService;
|
||||
import zeroecho.pki.server.acme.AcmeProviders;
|
||||
import zeroecho.pki.server.acme.AcmeService;
|
||||
import zeroecho.pki.server.spi.AcmeChallengeProvider;
|
||||
import zeroecho.pki.server.spi.AcmeExternalAccountBindingProvider;
|
||||
import zeroecho.pki.spi.ProviderConfig;
|
||||
|
||||
/** Lifecycle owner for the isolated optional ACME HTTPS listener. */
|
||||
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.ControlStatementBraces" })
|
||||
public final class AcmeTransport implements AutoCloseable {
|
||||
private final HttpsServer listener;
|
||||
private final ServerRuntime protocolRuntime;
|
||||
private final ServerRuntime.ChallengeRuntime validationRuntime;
|
||||
|
||||
private AcmeTransport(HttpsServer listener, ServerRuntime protocolRuntime,
|
||||
ServerRuntime.ChallengeRuntime validationRuntime) {
|
||||
this.listener = listener;
|
||||
this.protocolRuntime = protocolRuntime;
|
||||
this.validationRuntime = validationRuntime;
|
||||
}
|
||||
|
||||
/** Starts one ACME listener over the lifecycle-owned realm and PKI session. */
|
||||
public static AcmeTransport start(PkiServerConfiguration.AcmeListener configuration,
|
||||
ServerRealmContext realm, Clock clock, SecureRandom random, ClassLoader loader,
|
||||
BooleanSupplier ready) {
|
||||
Objects.requireNonNull(configuration, "configuration");
|
||||
ServerRuntime protocol = null;
|
||||
ServerRuntime.ChallengeRuntime validation = null;
|
||||
HttpsServer listener = null;
|
||||
try {
|
||||
Map<String, AcmeChallengeProvider> providers = AcmeProviders.challenges(
|
||||
configuration.challengeProviders(), loader);
|
||||
Map<String, ProviderConfig> providerConfigurations = configuration.challengeProviders().stream()
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableMap(ProviderConfig::backendId, value -> value));
|
||||
Map<String, AcmeExternalAccountBindingProvider> eabProviders = AcmeProviders.eab(
|
||||
configuration.eabProviders(), loader);
|
||||
Map<String, ProviderConfig> eabConfigurations = configuration.eabProviders().stream()
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableMap(ProviderConfig::backendId, value -> value));
|
||||
AcmeService service = new AcmeService(realm, clock, random, providers, providerConfigurations,
|
||||
eabProviders, eabConfigurations);
|
||||
realm.gateway().installAcme(service);
|
||||
AcmeNonceService nonces = new AcmeNonceService(configuration.listenerId(),
|
||||
configuration.nonceLifetime(), configuration.maximumOutstandingNonces(), clock, random);
|
||||
protocol = new ServerRuntime(configuration.execution(), ServerRuntime.Lane.ACME);
|
||||
validation = new ServerRuntime.ChallengeRuntime(configuration.validationExecution());
|
||||
SSLContext tls = TlsProviders.create(configuration.tlsProvider(), loader);
|
||||
listener = HttpsServer.create(configuration.socketAddress(),
|
||||
configuration.execution().transportQueueCapacity());
|
||||
boolean proxy = configuration.transportMode()
|
||||
== PkiServerConfiguration.AcmeTransportMode.TRUSTED_REVERSE_PROXY;
|
||||
listener.setHttpsConfigurator(configurator(tls, proxy));
|
||||
listener.setExecutor(protocol.transportExecutor());
|
||||
listener.createContext("/", new AcmeHttpHandler(configuration, realm, service, nonces,
|
||||
protocol, validation, clock, new RequestIds(random), ready));
|
||||
listener.start();
|
||||
return new AcmeTransport(listener, protocol, validation);
|
||||
} catch (IOException failure) {
|
||||
closePartial(listener, validation, protocol);
|
||||
throw new IllegalStateException("ACME listener initialization failed", failure);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
closePartial(listener, validation, protocol);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return actual listener address, including an allocated ephemeral port */
|
||||
public InetSocketAddress address() { return listener.getAddress(); }
|
||||
|
||||
/** Rejects new protocol and validation work. */
|
||||
public void quiesce() { protocolRuntime.quiesce(); validationRuntime.quiesce(); }
|
||||
|
||||
/** Stops listener admission and all separately bounded ACME lanes. */
|
||||
public void shutdown(Duration graceful) {
|
||||
quiesce();
|
||||
listener.stop(Math.toIntExact(Math.min(Integer.MAX_VALUE, graceful.toSeconds())));
|
||||
validationRuntime.close();
|
||||
protocolRuntime.close();
|
||||
}
|
||||
|
||||
@Override public void close() { shutdown(Duration.ZERO); }
|
||||
|
||||
private static HttpsConfigurator configurator(SSLContext context, boolean proxy) {
|
||||
return new HttpsConfigurator(context) {
|
||||
@Override public void configure(HttpsParameters parameters) {
|
||||
SSLParameters secure = context.getDefaultSSLParameters();
|
||||
secure.setNeedClientAuth(proxy);
|
||||
secure.setWantClientAuth(false);
|
||||
parameters.setSSLParameters(secure);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static void closePartial(HttpsServer listener, ServerRuntime.ChallengeRuntime validation,
|
||||
ServerRuntime protocol) {
|
||||
if (listener != null) listener.stop(0);
|
||||
if (validation != null) validation.close();
|
||||
if (protocol != null) protocol.close();
|
||||
}
|
||||
}
|
||||
@@ -33,8 +33,8 @@
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.server.http;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -60,6 +60,8 @@ import zeroecho.pki.server.RepositoryAliasService;
|
||||
import zeroecho.pki.server.RoleTemplateCatalog;
|
||||
import zeroecho.pki.server.SecurityPrincipal;
|
||||
import zeroecho.pki.server.ServerControlOperation;
|
||||
import zeroecho.pki.server.acme.AcmeService;
|
||||
import zeroecho.pki.server.acme.AcmeState;
|
||||
|
||||
/** Closed strict HTTP decoding for operations exposed by the existing gateway. */
|
||||
@SuppressWarnings("PMD")
|
||||
@@ -391,6 +393,42 @@ final class HttpOperationCodec {
|
||||
RepositoryAliasService.Type.valueOf(fields.text("type")),
|
||||
fields.text("expectedCurrentCommitment"));
|
||||
}
|
||||
case ServerControlOperation.RegisterAcmeDirectory.NAME -> {
|
||||
fields.allowed(Set.of("alias", "authorityId", "profileId", "dnsNamespaces",
|
||||
"maximumValidityMillis", "publicKeyAlgorithms", "x509BindingPolicies",
|
||||
"challengeTypes", "challengeProviderIds", "eabProviderId", "eabRequired",
|
||||
"disclosurePolicy"));
|
||||
yield new ServerControlOperation.RegisterAcmeDirectory(new AcmeService.DirectoryRegistration(
|
||||
fields.text("alias"), fields.pkiId("authorityId"), fields.text("profileId"),
|
||||
fields.stringSet("dnsNamespaces"), Duration.ofMillis(fields.longValue("maximumValidityMillis")),
|
||||
fields.stringSet("publicKeyAlgorithms"), fields.stringSet("x509BindingPolicies"),
|
||||
fields.enumSet("challengeTypes", AcmeState.ChallengeType.class),
|
||||
fields.stringSet("challengeProviderIds"), fields.optionalText("eabProviderId"),
|
||||
fields.bool("eabRequired"), DisclosureService.Policy.valueOf(fields.text("disclosurePolicy"))));
|
||||
}
|
||||
case ServerControlOperation.InspectAcmeDirectory.NAME -> {
|
||||
fields.exact("directoryId"); yield new ServerControlOperation.InspectAcmeDirectory(
|
||||
fields.text("directoryId"));
|
||||
}
|
||||
case ServerControlOperation.ListAcmeDirectories.NAME -> {
|
||||
fields.exact("offset", "limit"); yield new ServerControlOperation.ListAcmeDirectories(
|
||||
fields.integer("offset"), fields.integer("limit"));
|
||||
}
|
||||
case ServerControlOperation.SetAcmeDirectoryActive.ACTIVATE,
|
||||
ServerControlOperation.SetAcmeDirectoryActive.DEACTIVATE -> {
|
||||
fields.exact("directoryId"); yield new ServerControlOperation.SetAcmeDirectoryActive(
|
||||
fields.text("directoryId"), id.equals(ServerControlOperation.SetAcmeDirectoryActive.ACTIVATE));
|
||||
}
|
||||
case ServerControlOperation.InspectAcmeAccount.NAME -> {
|
||||
fields.exact("accountId"); yield new ServerControlOperation.InspectAcmeAccount(fields.text("accountId"));
|
||||
}
|
||||
case ServerControlOperation.ListAcmeAccounts.NAME -> {
|
||||
fields.exact("offset", "limit"); yield new ServerControlOperation.ListAcmeAccounts(
|
||||
fields.integer("offset"), fields.integer("limit"));
|
||||
}
|
||||
case ServerControlOperation.DeactivateAcmeAccount.NAME -> {
|
||||
fields.exact("accountId"); yield new ServerControlOperation.DeactivateAcmeAccount(fields.text("accountId"));
|
||||
}
|
||||
default -> throw new SecurityException("Control operation is not exposed");
|
||||
};
|
||||
}
|
||||
@@ -409,6 +447,8 @@ final class HttpOperationCodec {
|
||||
value.authorityId());
|
||||
case ServerControlOperation.RemoveRepositoryAlias value -> authorityScope(realmId, authority,
|
||||
value.authorityId());
|
||||
case ServerControlOperation.RegisterAcmeDirectory value -> authorityScope(realmId, authority,
|
||||
value.registration().authorityId());
|
||||
default -> new Permission.Scope(realmId, authority, Optional.empty(), Optional.empty());
|
||||
};
|
||||
}
|
||||
@@ -530,6 +570,15 @@ final class HttpOperationCodec {
|
||||
}
|
||||
return Set.copyOf(result);
|
||||
}
|
||||
Set<String> stringSet(String name) {
|
||||
consumed.add(name); PkiOperationValue value = require(name);
|
||||
if (!(value instanceof PkiOperationValue.ListValue list)) throw type();
|
||||
Set<String> result = new LinkedHashSet<>();
|
||||
for (PkiOperationValue item : list.values()) {
|
||||
if (!(item instanceof PkiOperationValue.Text text) || !result.add(text.value())) throw type();
|
||||
}
|
||||
return Set.copyOf(result);
|
||||
}
|
||||
Fields object(String name) { consumed.add(name); return of(require(name)); }
|
||||
void complete() { if (!Objects.equals(consumed, fields.keySet()))
|
||||
throw new IllegalArgumentException("Request fields were not consumed"); }
|
||||
|
||||
@@ -56,6 +56,9 @@ final class ServerRuntime implements AutoCloseable {
|
||||
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 ACME_TRANSPORT_PREFIX = "zeroecho-pki-acme-https-";
|
||||
static final String ACME_PROTOCOL_PREFIX = "zeroecho-pki-acme-protocol-";
|
||||
static final String ACME_VALIDATION_PREFIX = "zeroecho-pki-acme-validation-";
|
||||
static final String SHUTDOWN_NAME = "zeroecho-pki-shutdown";
|
||||
|
||||
private final ThreadPoolExecutor transport;
|
||||
@@ -67,15 +70,21 @@ final class ServerRuntime implements AutoCloseable {
|
||||
private final AtomicBoolean accepting = new AtomicBoolean(true);
|
||||
|
||||
ServerRuntime(PkiServerConfiguration.Execution configuration) {
|
||||
this(configuration, false);
|
||||
this(configuration, Lane.ADMIN);
|
||||
}
|
||||
|
||||
ServerRuntime(PkiServerConfiguration.Execution configuration, boolean publicLane) {
|
||||
this(configuration, publicLane ? Lane.PUBLIC : Lane.ADMIN);
|
||||
}
|
||||
|
||||
ServerRuntime(PkiServerConfiguration.Execution configuration, Lane lane) {
|
||||
this.configuration = configuration;
|
||||
transport = pool(configuration.transportWorkers(), configuration.transportQueueCapacity(),
|
||||
new NamedThreadFactory(publicLane ? PUBLIC_TRANSPORT_PREFIX : TRANSPORT_PREFIX));
|
||||
new NamedThreadFactory(lane == Lane.PUBLIC ? PUBLIC_TRANSPORT_PREFIX
|
||||
: lane == Lane.ACME ? ACME_TRANSPORT_PREFIX : TRANSPORT_PREFIX));
|
||||
operations = pool(configuration.operationWorkers(), configuration.operationQueueCapacity(),
|
||||
new NamedThreadFactory(publicLane ? PUBLIC_STREAM_PREFIX : OPERATION_PREFIX));
|
||||
new NamedThreadFactory(lane == Lane.PUBLIC ? PUBLIC_STREAM_PREFIX
|
||||
: lane == Lane.ACME ? ACME_PROTOCOL_PREFIX : OPERATION_PREFIX));
|
||||
admitted = new Semaphore(configuration.maximumAdmittedRequests(), true);
|
||||
}
|
||||
|
||||
@@ -139,9 +148,11 @@ final class ServerRuntime implements AutoCloseable {
|
||||
transport.shutdown();
|
||||
operations.shutdown();
|
||||
boolean transportWorker = Thread.currentThread().getName().startsWith(TRANSPORT_PREFIX)
|
||||
|| Thread.currentThread().getName().startsWith(PUBLIC_TRANSPORT_PREFIX);
|
||||
|| Thread.currentThread().getName().startsWith(PUBLIC_TRANSPORT_PREFIX)
|
||||
|| Thread.currentThread().getName().startsWith(ACME_TRANSPORT_PREFIX);
|
||||
boolean operationWorker = Thread.currentThread().getName().startsWith(OPERATION_PREFIX)
|
||||
|| Thread.currentThread().getName().startsWith(PUBLIC_STREAM_PREFIX);
|
||||
|| Thread.currentThread().getName().startsWith(PUBLIC_STREAM_PREFIX)
|
||||
|| Thread.currentThread().getName().startsWith(ACME_PROTOCOL_PREFIX);
|
||||
if (!operationWorker) {
|
||||
await(operations, configuration.gracefulShutdown());
|
||||
}
|
||||
@@ -162,6 +173,42 @@ final class ServerRuntime implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
enum Lane { ADMIN, PUBLIC, ACME }
|
||||
|
||||
/** One separately admitted bounded challenge-validation execution lane. */
|
||||
static final class ChallengeRuntime implements AutoCloseable {
|
||||
private final ThreadPoolExecutor executor;
|
||||
private final Semaphore admitted;
|
||||
private final Duration graceful;
|
||||
private final Duration forced;
|
||||
private final AtomicBoolean accepting = new AtomicBoolean(true);
|
||||
private final Set<Future<?>> futures = ConcurrentHashMap.newKeySet();
|
||||
|
||||
ChallengeRuntime(PkiServerConfiguration.Execution configuration) {
|
||||
executor = pool(configuration.operationWorkers(), configuration.operationQueueCapacity(),
|
||||
new NamedThreadFactory(ACME_VALIDATION_PREFIX));
|
||||
admitted = new Semaphore(configuration.maximumAdmittedRequests(), true);
|
||||
graceful = configuration.gracefulShutdown(); forced = configuration.forcedShutdown();
|
||||
}
|
||||
|
||||
boolean tryAdmit() { return accepting.get() && admitted.tryAcquire(); }
|
||||
void release() { admitted.release(); }
|
||||
<T> Future<T> submit(java.util.concurrent.Callable<T> work) {
|
||||
if (!accepting.get()) throw new RejectedExecutionException("ACME validation is not accepting work");
|
||||
Future<T> future = executor.submit(work); futures.add(future); return future;
|
||||
}
|
||||
void quiesce() { accepting.set(false); }
|
||||
@Override public void close() {
|
||||
quiesce(); executor.shutdown();
|
||||
boolean worker = Thread.currentThread().getName().startsWith(ACME_VALIDATION_PREFIX);
|
||||
if (!worker) await(executor, graceful);
|
||||
if (!executor.isTerminated()) {
|
||||
futures.forEach(value -> value.cancel(true)); executor.shutdownNow();
|
||||
if (!worker) await(executor, forced);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ThreadPoolExecutor pool(int workers, int queueCapacity, ThreadFactory factory) {
|
||||
ThreadPoolExecutor result = new ThreadPoolExecutor(workers, workers, 0L, TimeUnit.MILLISECONDS,
|
||||
new ArrayBlockingQueue<>(queueCapacity), factory, new ThreadPoolExecutor.AbortPolicy());
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.server.spi;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import zeroecho.core.io.CancellationSignal;
|
||||
import zeroecho.pki.server.acme.AcmeState;
|
||||
import zeroecho.pki.spi.ProviderConfig;
|
||||
|
||||
/**
|
||||
* Explicit evidence-producing ACME challenge-provider SPI.
|
||||
*
|
||||
* <p>A provider receives no persistence or order-mutation authority. It may only
|
||||
* evaluate the exact immutable attempt and return a finite result. The ACME
|
||||
* service constructs and persists authoritative evidence after verifying every
|
||||
* returned binding.</p>
|
||||
*/
|
||||
@SuppressWarnings("PMD.ControlStatementBraces")
|
||||
public interface AcmeChallengeProvider {
|
||||
/** @return stable provider identity */
|
||||
String id();
|
||||
/** @return exact supported challenge types */
|
||||
Set<AcmeState.ChallengeType> challengeTypes();
|
||||
|
||||
/** Performs one bounded external validation attempt. */
|
||||
Result validate(Context context, ProviderConfig configuration, CancellationSignal cancellation);
|
||||
|
||||
/** Exact immutable challenge input; values are sensitive and must never be logged. */
|
||||
record Context(String directoryId, String authorizationId, AcmeState.Identifier identifier,
|
||||
AcmeState.ChallengeType challengeType, String token, String expectedKeyAuthorization,
|
||||
int attempt, Instant validationTime, Instant deadline) {
|
||||
/** Validates complete attempt binding. */
|
||||
public Context {
|
||||
requireId(directoryId); requireId(authorizationId); Objects.requireNonNull(identifier, "identifier");
|
||||
Objects.requireNonNull(challengeType, "challengeType");
|
||||
if (token == null || !token.matches("[A-Za-z0-9_-]{43,128}")) throw new IllegalArgumentException("Invalid ACME token");
|
||||
if (expectedKeyAuthorization == null || expectedKeyAuthorization.length() > 512) throw new IllegalArgumentException("Invalid key authorization");
|
||||
if (attempt <= 0) throw new IllegalArgumentException("Invalid ACME attempt");
|
||||
Objects.requireNonNull(validationTime, "validationTime"); Objects.requireNonNull(deadline, "deadline");
|
||||
if (!deadline.isAfter(validationTime)) throw new IllegalArgumentException("Invalid validation deadline");
|
||||
}
|
||||
}
|
||||
|
||||
/** Finite provider result without raw network, DNS, token or response data. */
|
||||
record Result(boolean valid, String classification, Instant observedAt, Instant expiresAt) {
|
||||
/** Validates the safe evidence draft. */
|
||||
public Result {
|
||||
if (classification == null || !classification.matches("[A-Z0-9_]{1,64}")) throw new IllegalArgumentException("Invalid ACME result classification");
|
||||
Objects.requireNonNull(observedAt, "observedAt"); Objects.requireNonNull(expiresAt, "expiresAt");
|
||||
if (!expiresAt.isAfter(observedAt)) throw new IllegalArgumentException("Invalid ACME evidence lifetime");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireId(String value) {
|
||||
if (value == null || !value.matches("[a-z0-9][a-z0-9._:-]{0,127}")) throw new IllegalArgumentException("Invalid ACME identity");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.server.spi;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.spi.ProviderConfig;
|
||||
|
||||
/**
|
||||
* Secret-confining external-account-binding verification SPI.
|
||||
* Implementations verify and consume nested EAB authority internally and never
|
||||
* return HMAC key material.
|
||||
*/
|
||||
@SuppressWarnings("PMD.ControlStatementBraces")
|
||||
public interface AcmeExternalAccountBindingProvider {
|
||||
/** @return stable explicitly configured provider identity */
|
||||
String id();
|
||||
|
||||
/** Verifies and, when configured, consumes one exact nested EAB JWS. */
|
||||
Binding verify(Request request, ProviderConfig configuration);
|
||||
|
||||
/** Exact bounded EAB verification request. */
|
||||
record Request(String directoryId, PkiId authorityId, String profileId,
|
||||
String accountKeyThumbprint, byte[] nestedJws, Instant now) {
|
||||
/** Defensively snapshots non-secret protocol material. */
|
||||
public Request {
|
||||
if (directoryId == null || directoryId.isBlank()) throw new IllegalArgumentException("Invalid directory identity");
|
||||
Objects.requireNonNull(authorityId, "authorityId");
|
||||
if (profileId == null || profileId.isBlank()) throw new IllegalArgumentException("Invalid profile identity");
|
||||
if (accountKeyThumbprint == null || !accountKeyThumbprint.matches("[0-9a-f]{64}")) throw new IllegalArgumentException("Invalid account commitment");
|
||||
nestedJws = Objects.requireNonNull(nestedJws, "nestedJws").clone();
|
||||
if (nestedJws.length == 0 || nestedJws.length > 65_536) throw new IllegalArgumentException("Invalid EAB document bound");
|
||||
Objects.requireNonNull(now, "now");
|
||||
}
|
||||
@Override public byte[] nestedJws() { return nestedJws.clone(); }
|
||||
}
|
||||
|
||||
/** Finite EAB policy output without secret material. */
|
||||
record Binding(String keyId, String policyCommitment, Set<String> dnsNamespaces,
|
||||
Instant expiresAt, boolean consumed) {
|
||||
/** Validates complete EAB policy binding. */
|
||||
public Binding {
|
||||
if (keyId == null || keyId.isBlank() || keyId.length() > 128) throw new IllegalArgumentException("Invalid EAB key identity");
|
||||
if (policyCommitment == null || !policyCommitment.matches("[0-9a-f]{64}")) throw new IllegalArgumentException("Invalid EAB policy commitment");
|
||||
dnsNamespaces = Set.copyOf(Objects.requireNonNull(dnsNamespaces, "dnsNamespaces"));
|
||||
if (dnsNamespaces.isEmpty() || dnsNamespaces.size() > 64) throw new IllegalArgumentException("Invalid EAB namespace policy");
|
||||
Objects.requireNonNull(expiresAt, "expiresAt");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user