From b19edf17fd2a699157ca57f5913647fff4a75ac2 Mon Sep 17 00:00:00 2001 From: Leo Galambos Date: Wed, 5 Aug 2026 18:16:00 +0200 Subject: [PATCH] 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. --- docs/pki-server-acme.md | 73 ++ docs/pki-server-admin-https.md | 2 +- docs/pki-server-loopback-example.json | 3 +- docs/pki-server-production-example.json | 3 +- ...ki-server-trusted-proxy-nginx-example.json | 3 +- ...-server-trusted-proxy-rfc9440-example.json | 3 +- pki-server/build.gradle | 4 + .../pki/server/DisclosureService.java | 54 +- .../server/OperationSecurityDescriptors.java | 42 +- .../java/zeroecho/pki/server/Permission.java | 6 +- .../zeroecho/pki/server/PkiHttpsServer.java | 44 +- .../pki/server/PkiServerConfiguration.java | 142 +++- .../server/PkiServerConfigurationCodec.java | 47 +- .../pki/server/ServerControlOperation.java | 51 +- .../ServerControlOperationExecutor.java | 60 ++ .../pki/server/ServerControlStore.java | 145 +++- .../pki/server/ServerOperationGateway.java | 14 +- .../pki/server/ServerRealmContext.java | 6 + .../pki/server/acme/AcmeControlStore.java | 283 +++++++ .../pki/server/acme/AcmeJwsVerifier.java | 314 +++++++ .../pki/server/acme/AcmeNonceService.java | 152 ++++ .../pki/server/acme/AcmeProviders.java | 86 ++ .../pki/server/acme/AcmeRateAdmission.java | 104 +++ .../zeroecho/pki/server/acme/AcmeService.java | 790 ++++++++++++++++++ .../zeroecho/pki/server/acme/AcmeState.java | 305 +++++++ .../pki/server/acme/AcmeStateCodec.java | 311 +++++++ .../server/acme/Http01ChallengeProvider.java | 174 ++++ .../acme/JndiDns01ChallengeProvider.java | 174 ++++ .../pki/server/acme/MessageDigestSupport.java | 48 ++ .../pki/server/http/AcmeHttpHandler.java | 666 +++++++++++++++ .../pki/server/http/AcmePayloads.java | 133 +++ .../pki/server/http/AcmeTransport.java | 152 ++++ .../pki/server/http/HttpOperationCodec.java | 51 +- .../pki/server/http/ServerRuntime.java | 57 +- .../pki/server/spi/AcmeChallengeProvider.java | 91 ++ .../AcmeExternalAccountBindingProvider.java | 84 ++ ...oecho.pki.server.spi.AcmeChallengeProvider | 2 + .../server/security/role-templates-v1.json | 2 +- .../zeroecho/pki/server/AcmeEndToEndTest.java | 659 +++++++++++++++ .../pki/server/HttpServerTestSupport.java | 32 + .../pki/server/PkiHttpsServerTest.java | 35 + .../ServerControlOperationExecutorTest.java | 6 +- .../server/ServerOperationGatewayTest.java | 2 +- .../server/acme/AcmeProviderSecurityTest.java | 120 +++ .../acme/AcmeSecurityPrimitivesTest.java | 184 ++++ .../pki/server/acme/TestAcmeEabProvider.java | 100 +++ .../pki/server/http/AcmeTestClient.java | 262 ++++++ .../HttpServerControlOperationCodecTest.java | 19 + ...ver.spi.AcmeExternalAccountBindingProvider | 1 + .../pki/api/issuance/IssuanceIntent.java | 81 ++ .../api/issuance/IssueEndEntityCommand.java | 12 +- .../pki/impl/core/DefaultIssuanceService.java | 54 +- .../pki/impl/core/async/PkiSigningBus.java | 24 +- .../pki/impl/fs/FilesystemPkiStore.java | 41 +- .../java/zeroecho/pki/spi/store/PkiStore.java | 11 + .../pki/e2e/H7EndEntityAcceptanceE2eTest.java | 2 +- .../java/zeroecho/pki/e2e/PkiCoreE2eTest.java | 13 +- .../impl/core/H7ProfileEnforcementTest.java | 2 +- 58 files changed, 6295 insertions(+), 46 deletions(-) create mode 100644 docs/pki-server-acme.md create mode 100644 pki-server/src/main/java/zeroecho/pki/server/acme/AcmeControlStore.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/acme/AcmeJwsVerifier.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/acme/AcmeNonceService.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/acme/AcmeProviders.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/acme/AcmeRateAdmission.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/acme/AcmeService.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/acme/AcmeState.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/acme/AcmeStateCodec.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/acme/Http01ChallengeProvider.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/acme/JndiDns01ChallengeProvider.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/acme/MessageDigestSupport.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/http/AcmeHttpHandler.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/http/AcmePayloads.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/http/AcmeTransport.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/spi/AcmeChallengeProvider.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/spi/AcmeExternalAccountBindingProvider.java create mode 100644 pki-server/src/main/resources/META-INF/services/zeroecho.pki.server.spi.AcmeChallengeProvider create mode 100644 pki-server/src/test/java/zeroecho/pki/server/AcmeEndToEndTest.java create mode 100644 pki-server/src/test/java/zeroecho/pki/server/acme/AcmeProviderSecurityTest.java create mode 100644 pki-server/src/test/java/zeroecho/pki/server/acme/AcmeSecurityPrimitivesTest.java create mode 100644 pki-server/src/test/java/zeroecho/pki/server/acme/TestAcmeEabProvider.java create mode 100644 pki-server/src/test/java/zeroecho/pki/server/http/AcmeTestClient.java create mode 100644 pki-server/src/test/resources/META-INF/services/zeroecho.pki.server.spi.AcmeExternalAccountBindingProvider create mode 100644 pki/src/main/java/zeroecho/pki/api/issuance/IssuanceIntent.java diff --git a/docs/pki-server-acme.md b/docs/pki-server-acme.md new file mode 100644 index 0000000..a6a1a1c --- /dev/null +++ b/docs/pki-server-acme.md @@ -0,0 +1,73 @@ +# ACME automated certificate management + +ZeroEcho exposes ACME on an optional listener that is independent of the administrative and public-repository listeners. All three listeners share one realm and one long-lived `PkiSession`, but they have separate routes, workers, queues, and admission limits. The ACME listener serves only `/acme/{directoryAlias}/...`; it never exposes `/admin/v1` or `/public/v1`. + +An ACME directory is an immutable policy revision bound to one realm, logical authority, active end-entity profile, explicit current issuer generation, and explicit issuance chain path. It also freezes DNS namespaces, key and X.509 binding policy, validity, challenge providers, EAB policy, disclosure policy, and a policy commitment. A client cannot select another authority, profile, issuer, path, binding, or disclosure state in an order. + +## Listener and topology + +Direct deployments use server-authenticated TLS. A client TLS certificate is not ACME account authority; ACME identity is the account key authenticated by JWS. Trusted-reverse-proxy deployments require the established mutually authenticated proxy-to-ZeroEcho TLS hop and a dedicated enabled proxy principal with `FORWARD_AUTHENTICATED_CLIENT_IDENTITY`. Forwarded administrative identity is not used as an ACME account. Source addresses and `Forwarded` or `X-Forwarded-*` headers never authorize ACME. + +The server configuration schema is version 4. ACME is disabled explicitly with: + +```json +"acmeListener": {"enabled": false} +``` + +An enabled direct listener uses the following safe structural example (provider properties are deployment references, never embedded secrets): + +```json +{ + "enabled": true, + "listenerId": "acme-internal", + "address": "127.0.0.1", + "port": 8445, + "tlsProvider": {"id": "jsse-pkcs12", "properties": {"keyStore": "tls/acme.p12", "keyStorePasswordEnvironment": "ZEROECHO_ACME_TLS_PASSWORD"}}, + "transportMode": "DIRECT_TLS", + "externalBaseUri": "https://acme.example.invalid", + "maximumHeaderBytes": 32768, + "maximumBodyBytes": 1048576, + "execution": {"transportWorkers": 4, "transportQueueCapacity": 32, "operationWorkers": 4, "operationQueueCapacity": 32, "maximumAdmittedRequests": 32, "defaultDeadlineMillis": 30000, "maximumDeadlineMillis": 120000, "gracefulShutdownMillis": 30000, "forcedShutdownMillis": 10000}, + "validationExecution": {"transportWorkers": 2, "transportQueueCapacity": 8, "operationWorkers": 2, "operationQueueCapacity": 8, "maximumAdmittedRequests": 8, "defaultDeadlineMillis": 30000, "maximumDeadlineMillis": 120000, "gracefulShutdownMillis": 30000, "forcedShutdownMillis": 10000}, + "nonceLifetimeMillis": 300000, + "maximumOutstandingNonces": 4096, + "maximumAccountsPresented": 100, + "maximumOrdersPresented": 100, + "admissionWindowMillis": 60000, + "maximumNewAccountsPerWindow": 100, + "maximumNewOrdersPerAccountWindow": 100, + "maximumPendingOrdersPerAccount": 50, + "maximumChallengeValidationsPerAccountWindow": 100, + "maximumConcurrentFinalizations": 4, + "challengeProviders": [], + "eabProviders": [] +} +``` + +Trusted-proxy mode changes `transportMode` to `TRUSTED_REVERSE_PROXY` and additionally configures exact cryptographic proxy-certificate mappings plus the matching `trustedProxyPrincipalIds`. The TLS provider must require and validate the proxy certificate. There is no plaintext proxy mode. + +## Directory examples + +Directory registration is performed through the unified administrative operation catalog, followed by an approved `acme.directory.activate` when configured policy requires approval. + +- An internal DNS directory restricts `dnsNamespaces` to the enterprise suffix, enables only `DNS_01`, and configures `zeroecho.dns-01.jndi.v1` with an explicit numeric DNS provider URL. +- A public HTTP directory enables `HTTP_01` through `zeroecho.http-01.v1`; private and internal validation targets remain rejected unless the directory provider configuration explicitly permits them. +- A wildcard directory enables `DNS_01`; wildcard identifiers cannot use HTTP-01. +- An EAB-required enterprise directory sets `eabRequired` and selects one explicitly enabled EAB provider. EAB secrets remain inside the provider boundary and are never persisted in ACME records. +- A trusted-proxy directory uses the same directory policy as a direct deployment; only listener transport composition differs. + +## Protocol and validation + +Replay nonces contain 256 bits of randomness, are listener/directory bound, one-time, finite-lived, and process-local. JWS accepts only flattened, canonical, unpadded Base64url ES256 with exact URL binding and exactly one of `jwk` or `kid`. The effective external URL comes from configuration, not an untrusted host or forwarding header. + +HTTP-01 probes the canonical challenge URL on port 80, pins policy-validated resolution results, bounds response bytes and deadlines, and never forwards credentials. The optional `targetPort` provider setting exists only for explicitly private test or internal validation environments: a nonstandard port is rejected unless `allowPrivate` is also explicitly enabled. Production Internet-facing directories should omit `targetPort` and therefore use port 80. DNS-01 uses absolute `_acme-challenge` queries, exact TXT digest matching, explicit DNS resolver configuration, and bounded optional CNAME delegation. Challenge success becomes authoritative only after provider-produced evidence is durably bound to the exact authorization. There is no administrative force-valid operation. + +## Issuance, chains, disclosure, and revocation + +Finalization requires every authorization to be valid and unexpired, strict canonical CSR/PoP validation, and exact equality between canonical CSR identifiers and order identifiers. Issuance uses the directory's frozen authority/profile policy plus the authority's explicit current issuer and explicit issuance chain path. A durable issuance intent prevents duplicate certificate creation after an uncertain response. + +The authenticated certificate resource returns deterministic PEM in the order `issued leaf -> selected issuer/path certificates`. It performs no runtime path guessing. This resource is account-authorized and does not bypass the separate Public Repository disclosure decision. The directory controls the resulting leaf disclosure policy and never makes certificates publicly searchable. + +Revocation delegates to the existing `RevocationService`, is limited to the account's issued certificate and supported ACME reason semantics, and never edits revocation records directly. Account key rollover uses strict nested JWS and atomically replaces the account key; the previous key stops authorizing after commit. + +Contacts and identifiers are sensitive. Audit events contain safe IDs and classifications only—never JWS signatures, JWKs, contacts, tokens, key authorizations, DNS values, HTTP responses, CSR DER, certificate subject/SAN, EAB material, paths, or provider failures. diff --git a/docs/pki-server-admin-https.md b/docs/pki-server-admin-https.md index ac5b03e..30baef0 100644 --- a/docs/pki-server-admin-https.md +++ b/docs/pki-server-admin-https.md @@ -76,7 +76,7 @@ GET /admin/v1/operations/{operationId} POST /admin/v1/operations/{operationId} ``` -No ACME or public certificate repository routes are part of this server. Administrative POST bodies are strict UTF-8 JSON containing version, optional authority and approval identities, an optional shortening deadline, and the closed argument object for one typed operation. Batch execution, file paths, raw DER, polymorphic class metadata, unknown keys, duplicate keys, and trailing JSON are rejected. +No ACME or public certificate repository routes are registered on the administrative listener. Optional ACME and public repository listeners are independently configured and bounded; see [pki-server-acme.md](pki-server-acme.md) and [pki-server-public-repository.md](pki-server-public-repository.md). Administrative POST bodies are strict UTF-8 JSON containing version, optional authority and approval identities, an optional shortening deadline, and the closed argument object for one typed operation. Batch execution, file paths, raw DER, polymorphic class metadata, unknown keys, duplicate keys, and trailing JSON are rejected. Responses are deterministic version-one JSON. They carry only safe typed results or stable failure codes. `RECOVERY_REQUIRED` and `EXTERNAL_OUTCOME_UNKNOWN` remain distinct; deadlines never claim rollback and never trigger automatic retry. diff --git a/docs/pki-server-loopback-example.json b/docs/pki-server-loopback-example.json index ffdb177..4a1ba27 100644 --- a/docs/pki-server-loopback-example.json +++ b/docs/pki-server-loopback-example.json @@ -1,5 +1,5 @@ { - "version": 3, + "version": 4, "serverName": "zeroecho-admin", "realm": { "realmId": "production", @@ -114,5 +114,6 @@ "publicAliasCacheMillis": 300000, "authorityListExposed": true }, + "acmeListener": {"enabled": false}, "runtime": {} } diff --git a/docs/pki-server-production-example.json b/docs/pki-server-production-example.json index f8b8aac..9093368 100644 --- a/docs/pki-server-production-example.json +++ b/docs/pki-server-production-example.json @@ -1,5 +1,5 @@ { - "version": 3, + "version": 4, "serverName": "zeroecho-admin", "realm": { "realmId": "production", @@ -75,5 +75,6 @@ "forcedShutdownMillis": 10000 }, "publicListener": {"enabled": false}, + "acmeListener": {"enabled": false}, "runtime": {} } diff --git a/docs/pki-server-trusted-proxy-nginx-example.json b/docs/pki-server-trusted-proxy-nginx-example.json index 67549ea..3c0de17 100644 --- a/docs/pki-server-trusted-proxy-nginx-example.json +++ b/docs/pki-server-trusted-proxy-nginx-example.json @@ -1,5 +1,5 @@ { - "version": 3, + "version": 4, "serverName": "zeroecho-admin-nginx", "realm": { "realmId": "production", @@ -41,5 +41,6 @@ }, "execution": {"transportWorkers":8,"transportQueueCapacity":64,"operationWorkers":4,"operationQueueCapacity":32,"maximumAdmittedRequests":96,"defaultDeadlineMillis":30000,"maximumDeadlineMillis":120000,"gracefulShutdownMillis":30000,"forcedShutdownMillis":10000}, "publicListener": {"enabled":false}, + "acmeListener": {"enabled":false}, "runtime": {} } diff --git a/docs/pki-server-trusted-proxy-rfc9440-example.json b/docs/pki-server-trusted-proxy-rfc9440-example.json index e4f3399..a5ed93b 100644 --- a/docs/pki-server-trusted-proxy-rfc9440-example.json +++ b/docs/pki-server-trusted-proxy-rfc9440-example.json @@ -1,5 +1,5 @@ { - "version": 3, + "version": 4, "serverName": "zeroecho-admin-proxy", "realm": { "realmId": "production", @@ -67,5 +67,6 @@ "publicAliasCacheMillis":300000, "authorityListExposed":true }, + "acmeListener": {"enabled":false}, "runtime": {} } diff --git a/pki-server/build.gradle b/pki-server/build.gradle index cde2645..4fb706d 100644 --- a/pki-server/build.gradle +++ b/pki-server/build.gradle @@ -19,6 +19,10 @@ application { applicationName = 'zeroecho-pki-server' } +tasks.named('test') { + dependsOn tasks.named('installDist') +} + jar { manifest { attributes( diff --git a/pki-server/src/main/java/zeroecho/pki/server/DisclosureService.java b/pki-server/src/main/java/zeroecho/pki/server/DisclosureService.java index 60d4a53..c22884e 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/DisclosureService.java +++ b/pki-server/src/main/java/zeroecho/pki/server/DisclosureService.java @@ -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 ownerPrincipalId, - String policyCommitment, Instant updatedAt) { + Optional 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 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 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 principal, boolean ownerRelationship, diff --git a/pki-server/src/main/java/zeroecho/pki/server/OperationSecurityDescriptors.java b/pki-server/src/main/java/zeroecho/pki/server/OperationSecurityDescriptors.java index f28ec92..82df491 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/OperationSecurityDescriptors.java +++ b/pki-server/src/main/java/zeroecho/pki/server/OperationSecurityDescriptors.java @@ -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()); }; } } diff --git a/pki-server/src/main/java/zeroecho/pki/server/Permission.java b/pki-server/src/main/java/zeroecho/pki/server/Permission.java index 07f1a94..7ece72e 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/Permission.java +++ b/pki-server/src/main/java/zeroecho/pki/server/Permission.java @@ -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; } diff --git a/pki-server/src/main/java/zeroecho/pki/server/PkiHttpsServer.java b/pki-server/src/main/java/zeroecho/pki/server/PkiHttpsServer.java index 2107089..66e1182 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/PkiHttpsServer.java +++ b/pki-server/src/main/java/zeroecho/pki/server/PkiHttpsServer.java @@ -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 publicAuthenticator; private final Optional publicTransport; + private final Optional acmeTransport; private final AtomicReference state; private PkiHttpsServer(PkiServerConfiguration configuration, ServerRealmContext realm, PkiServerAuthenticator authenticator, PkiHttpsTransport transport, Optional publicAuthenticator, Optional publicTransport, + Optional acmeTransport, AtomicReference 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 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, Throwable primary) { + primary = close(acmeTransport, primary); primary = close(publicTransport, primary); primary = close(publicAuthenticator, primary); primary = close(transport, primary); diff --git a/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfiguration.java b/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfiguration.java index d92031a..7afe750 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfiguration.java +++ b/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfiguration.java @@ -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) { + Optional publicListener, Optional 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 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) { + 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 proxyTransportMappings, + Set 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 challengeProviders, List 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 providers(List source, String name) { + List 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() { } + } } diff --git a/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfigurationCodec.java b/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfigurationCodec.java index 8280e64..a5577a2 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfigurationCodec.java +++ b/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfigurationCodec.java @@ -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 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 proxy = mode + == PkiServerConfiguration.AcmeTransportMode.TRUSTED_REVERSE_PROXY + ? mappings(value.list("proxyTransportMappings")) : List.of(); + Set 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 providers(List source) { + List 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( diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperation.java b/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperation.java index caaa77b..1810a68 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperation.java +++ b/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperation.java @@ -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(); } diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationExecutor.java b/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationExecutor.java index 0f85edf..301c6da 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationExecutor.java +++ b/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationExecutor.java @@ -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 repositoryAliases; private final OperationSecurityDescriptors descriptors; private final Map approvalPolicies; + private final java.util.concurrent.atomic.AtomicReference 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 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 actions = value.actions().stream().sorted(Comparator.comparingInt(Permission.Action::code)) .map(item -> (PkiOperationValue) text(item.name())).toList(); diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerControlStore.java b/pki-server/src/main/java/zeroecho/pki/server/ServerControlStore.java index 9500a42..96b7c32 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/ServerControlStore.java +++ b/pki-server/src/main/java/zeroecho/pki/server/ServerControlStore.java @@ -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. + * + *

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.

+ * + * @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 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 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(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 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 requested) { + requireOpen(); + List mutations = List.copyOf(Objects.requireNonNull(requested, "requested")); + if (mutations.isEmpty() || mutations.size() > 256) { + throw new IllegalArgumentException("ACME transaction size is invalid"); + } + Set 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 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 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; } diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerOperationGateway.java b/pki-server/src/main/java/zeroecho/pki/server/ServerOperationGateway.java index 9059280..20aedb4 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/ServerOperationGateway.java +++ b/pki-server/src/main/java/zeroecho/pki/server/ServerOperationGateway.java @@ -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"); diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerRealmContext.java b/pki-server/src/main/java/zeroecho/pki/server/ServerRealmContext.java index b256ac2..669503e 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/ServerRealmContext.java +++ b/pki-server/src/main/java/zeroecho/pki/server/ServerRealmContext.java @@ -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 */ diff --git a/pki-server/src/main/java/zeroecho/pki/server/acme/AcmeControlStore.java b/pki-server/src/main/java/zeroecho/pki/server/acme/AcmeControlStore.java new file mode 100644 index 0000000..d74fcc0 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/acme/AcmeControlStore.java @@ -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 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 authorizations, + List challenges) { + AcmeState.Order sealedOrder = seal(order); + List sealedAuthorizations = authorizations.stream() + .map(this::seal).toList(); + List sealedChallenges = challenges.stream() + .map(this::seal).toList(); + List 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 replace(List replacements) { + return transition(replacements, List.of()).replaced(); + } + + /** Atomically creates immutable records and replaces their related owners. */ + public Transition transition(List replacements, List creations) { + List sealed = replacements.stream().map(Replacement::next).map(this::sealObject).toList(); + List created = creations.stream().map(this::sealObject).toList(); + List 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 replaced, List 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 Optional get(Class 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 ServerControlStore.Page page(Class type, int offset, int limit) { + ServerControlStore.Page page = + control.acmeRecords(namespace(type), offset, limit); + List 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 void visit(Class type, java.util.function.Consumer consumer) { + int offset = 0; + do { + ServerControlStore.Page 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 authorizations, + List challenges) { + /** Defensively snapshots the graph. */ + public Graph { + Objects.requireNonNull(order, "order"); authorizations = List.copyOf(authorizations); + challenges = List.copyOf(challenges); + } + } + + @SuppressWarnings("unchecked") + private 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"); + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/acme/AcmeJwsVerifier.java b/pki-server/src/main/java/zeroecho/pki/server/acme/AcmeJwsVerifier.java new file mode 100644 index 0000000..a9c9247 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/acme/AcmeJwsVerifier.java @@ -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 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 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 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 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 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 PROTECTED = java.util.Set.of("alg", "nonce", "url", "jwk", "kid"); + private static final java.util.Set JWK_PROTECTED = java.util.Set.of("alg", "nonce", "url", "jwk"); + private static final java.util.Set KID_PROTECTED = java.util.Set.of("alg", "nonce", "url", "kid"); + private static final java.util.Set INNER_PROTECTED = java.util.Set.of("alg", "url", "jwk"); + private static final java.util.Set INNER_PAYLOAD = java.util.Set.of("account", "oldKey"); + private static final java.util.Set JWK_FIELDS = java.util.Set.of("kty", "crv", "x", "y"); + private SetHolder() { } + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/acme/AcmeNonceService.java b/pki-server/src/main/java/zeroecho/pki/server/acme/AcmeNonceService.java new file mode 100644 index 0000000..a2dc706 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/acme/AcmeNonceService.java @@ -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. + * + *

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.

+ */ +@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 byCommitment = new HashMap<>(); + private final NavigableMap> 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 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"); + } + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/acme/AcmeProviders.java b/pki-server/src/main/java/zeroecho/pki/server/acme/AcmeProviders.java new file mode 100644 index 0000000..8e91d70 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/acme/AcmeProviders.java @@ -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 challenges(List 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 eab(List enabled, + ClassLoader loader) { + return resolve(enabled, ServiceLoader.load(AcmeExternalAccountBindingProvider.class, loader).stream() + .map(ServiceLoader.Provider::get).toList(), AcmeExternalAccountBindingProvider::id); + } + + private static Map resolve(List enabled, List discovered, + java.util.function.Function identity) { + Objects.requireNonNull(loaderMarker(enabled), "enabled"); + Map 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 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 loaderMarker(List enabled) { + return List.copyOf(Objects.requireNonNull(enabled, "enabled")); + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/acme/AcmeRateAdmission.java b/pki-server/src/main/java/zeroecho/pki/server/acme/AcmeRateAdmission.java new file mode 100644 index 0000000..b76e8d5 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/acme/AcmeRateAdmission.java @@ -0,0 +1,104 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + *******************************************************************************/ +package zeroecho.pki.server.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 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) { } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/acme/AcmeService.java b/pki-server/src/main/java/zeroecho/pki/server/acme/AcmeService.java new file mode 100644 index 0000000..6ce512a --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/acme/AcmeService.java @@ -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. + * + *

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.

+ */ +@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 providers; + private final Map providerConfigurations; + private final Map eabProviders; + private final Map eabProviderConfigurations; + + /** Creates a realm-bound ACME service from explicitly enabled providers. */ + public AcmeService(ServerRealmContext realm, Clock clock, SecureRandom random, + Map providers, + Map providerConfigurations, + Map eabProviders, + Map 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 providers, + Map providerConfigurations) { + this(realm, clock, random, providers, providerConfigurations, Map.of(), Map.of()); + } + + private void validateRecoveredState() { + int offset = 0; + do { + ServerControlStore.Page 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 dnsNamespaces, Duration maximumValidity, Set publicKeyAlgorithms, + Set x509BindingPolicies, Set challengeTypes, + Set challengeProviderIds, Optional 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 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 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 contacts, boolean termsAgreed, + Optional eabPolicyCommitment, boolean onlyReturnExisting) { + requireActiveExact(directory); + Optional 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 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 verifyExternalAccountBinding(AcmeState.Directory directory, String keyThumbprint, + Optional 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 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 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 requested, Optional notBefore, Optional notAfter, + Duration orderLifetime) { + AcmeState.Account account = requireAuthenticatedAccount(accountId, expectedAccountCommitment); + requireAccountDirectory(account, directory); + requireActiveExact(directory); List identifiers = canonicalIdentifiers(requested, directory); + Instant now = clock.instant(); Instant expires = now.plus(orderLifetime); + List authorizations = new ArrayList<>(); + List challenges = new ArrayList<>(); + for (AcmeState.Identifier identifier : identifiers) { + String authorizationId = id("authorization"); List 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 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 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 ids() { return Set.of(); } + @Override public Optional get(AttributeId id) { Objects.requireNonNull(id); return Optional.empty(); } + @Override public List 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 = 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 types, Set 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 canonicalIdentifiers(List requested, + AcmeState.Directory directory) { + List 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 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 expected) { + Set 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 directoriesForAlias(String alias) { + List result = new ArrayList<>(); int offset = 0; + do { + ServerControlStore.Page 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 accountByThumbprint(String directoryId, String thumbprint) { + int offset = 0; + do { + ServerControlStore.Page page = store.page(AcmeState.Account.class, offset, 256); + Optional 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 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 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); + } + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/acme/AcmeState.java b/pki-server/src/main/java/zeroecho/pki/server/acme/AcmeState.java new file mode 100644 index 0000000..571de36 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/acme/AcmeState.java @@ -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. + * + *

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.

+ */ +@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 identifierTypes, Set dnsNamespaces, + Duration maximumValidity, Set publicKeyAlgorithms, Set x509BindingPolicies, + Set challengeTypes, Set challengeProviderIds, + Optional 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 contacts, boolean termsAgreed, + Optional 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 identifiers, Optional notBefore, Optional notAfter, + OrderStatus status, List authorizationIds, Optional 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 challengeIds, + Instant expiresAt, Optional 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 failureCode, Optional 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 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 Set nonEmpty(Set source, String name) { + Set result = Set.copyOf(Objects.requireNonNull(source, name)); + if (result.isEmpty() || result.size() > 64) throw new IllegalArgumentException(name + " is invalid"); + return result; + } + private static Set nonEmptyStrings(Set source, String name) { + Set result = nonEmpty(source, name); + result.forEach(value -> { if (value.isBlank() || value.length() > 256) throw new IllegalArgumentException(name + " is invalid"); }); + return result; + } + private static List distinctIdentifiers(List source) { + List 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 distinctIds(List source, int maximum, String name) { + List 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; + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/acme/AcmeStateCodec.java b/pki-server/src/main/java/zeroecho/pki/server/acme/AcmeStateCodec.java new file mode 100644 index 0000000..eaad553 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/acme/AcmeStateCodec.java @@ -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 types = enumSet(in, AcmeState.IdentifierType.class); + Set namespaces = strings(in); Duration validity = Duration.ofSeconds(in.readLong()); + Set algorithms = strings(in), bindings = strings(in); + Set challenges = enumSet(in, AcmeState.ChallengeType.class); + Set providers = strings(in); Optional 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 identifiers = identifiers(in); Optional from = optionalInstant(in), to = optionalInstant(in); + AcmeState.OrderStatus status = AcmeState.OrderStatus.valueOf(string(in)); List authorizations = stringsList(in); + Optional 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 values) throws IOException { + out.writeInt(values.size()); for (AcmeState.Identifier value : values) identifier(out, value); + } + private static List identifiers(DataInputStream in) throws IOException { + int count = count(in); List result = new ArrayList<>(count); + for (int index = 0; index < count; index++) result.add(identifier(in)); return List.copyOf(result); + } + private static > void enums(DataOutputStream out, Set values) throws IOException { + strings(out, values.stream().map(Enum::name).collect(java.util.stream.Collectors.toSet())); + } + private static > Set enumSet(DataInputStream in, Class type) throws IOException { + Set 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 values) throws IOException { + List sorted = values.stream().sorted().toList(); stringsList(out, sorted); + } + private static Set strings(DataInputStream in) throws IOException { + List values = stringsList(in); Set result = new HashSet<>(values); + if (result.size() != values.size()) throw invalid(); return Set.copyOf(result); + } + private static void stringsList(DataOutputStream out, List values) throws IOException { + if (values.size() > MAX_COLLECTION) throw invalid(); out.writeInt(values.size()); for (String value : values) string(out, value); + } + private static List stringsList(DataInputStream in) throws IOException { + int count = count(in); List 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 value) throws IOException { out.writeBoolean(value.isPresent()); if (value.isPresent()) string(out, value.orElseThrow()); } + private static Optional 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 value) throws IOException { out.writeBoolean(value.isPresent()); if (value.isPresent()) instant(out, value.orElseThrow()); } + private static Optional 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"); } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/acme/Http01ChallengeProvider.java b/pki-server/src/main/java/zeroecho/pki/server/acme/Http01ChallengeProvider.java new file mode 100644 index 0000000..d44ae1e --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/acme/Http01ChallengeProvider.java @@ -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 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 required = Set.of("allowPrivate", "timeoutMillis", "maximumBodyBytes", "maximumAddresses"); + Set 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) { } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/acme/JndiDns01ChallengeProvider.java b/pki-server/src/main/java/zeroecho/pki/server/acme/JndiDns01ChallengeProvider.java new file mode 100644 index 0000000..31bc666 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/acme/JndiDns01ChallengeProvider.java @@ -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 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 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 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 txt = values(attributes.get("TXT"), settings.maximumRecords(), settings.maximumRecordBytes(), true); + List cname = values(attributes.get("CNAME"), 1, 253, false); + return new Lookup(txt, cname); + } finally { + context.close(); + } + } + + private static List values(Attribute attribute, int maximum, int maximumBytes, boolean txt) throws Exception { + if (attribute == null) return List.of(); List 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 txt, List cname) { } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/acme/MessageDigestSupport.java b/pki-server/src/main/java/zeroecho/pki/server/acme/MessageDigestSupport.java new file mode 100644 index 0000000..ece4391 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/acme/MessageDigestSupport.java @@ -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); } + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/AcmeHttpHandler.java b/pki-server/src/main/java/zeroecho/pki/server/http/AcmeHttpHandler.java new file mode 100644 index 0000000..ac8e11b --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/http/AcmeHttpHandler.java @@ -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 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 contacts = textList(payload, "contact", 16, 512); + Optional eabDocument = Optional.ofNullable(payload.fields().get("externalAccountBinding")) + .map(StrictJson::encode); + if (!rates.admitAccount(directory.directoryId())) return problem(429, "rateLimited"); + Optional 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 identifiers = identifiers(payload.fields().get("identifiers")); + Optional notBefore = optionalInstant(payload, "notBefore"); + Optional 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 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 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 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 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 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 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 identifiers(PkiOperationValue item) { + if (!(item instanceof PkiOperationValue.ListValue list) || list.values().isEmpty() || list.values().size() > 64) { + throw new AcmeJwsVerifier.AcmeProblem("rejectedIdentifier"); + } + List 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 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> 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 writer, + String directoryId, Map 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(); } + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/AcmePayloads.java b/pki-server/src/main/java/zeroecho/pki/server/http/AcmePayloads.java new file mode 100644 index 0000000..7d0f07a --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/http/AcmePayloads.java @@ -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"); + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/AcmeTransport.java b/pki-server/src/main/java/zeroecho/pki/server/http/AcmeTransport.java new file mode 100644 index 0000000..2f68e94 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/http/AcmeTransport.java @@ -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 providers = AcmeProviders.challenges( + configuration.challengeProviders(), loader); + Map providerConfigurations = configuration.challengeProviders().stream() + .collect(java.util.stream.Collectors.toUnmodifiableMap(ProviderConfig::backendId, value -> value)); + Map eabProviders = AcmeProviders.eab( + configuration.eabProviders(), loader); + Map 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(); + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/HttpOperationCodec.java b/pki-server/src/main/java/zeroecho/pki/server/http/HttpOperationCodec.java index 4b1fac8..1d61a97 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/http/HttpOperationCodec.java +++ b/pki-server/src/main/java/zeroecho/pki/server/http/HttpOperationCodec.java @@ -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 stringSet(String name) { + consumed.add(name); PkiOperationValue value = require(name); + if (!(value instanceof PkiOperationValue.ListValue list)) throw type(); + Set 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"); } diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/ServerRuntime.java b/pki-server/src/main/java/zeroecho/pki/server/http/ServerRuntime.java index 1632d00..df5c176 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/http/ServerRuntime.java +++ b/pki-server/src/main/java/zeroecho/pki/server/http/ServerRuntime.java @@ -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> 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(); } + Future submit(java.util.concurrent.Callable work) { + if (!accepting.get()) throw new RejectedExecutionException("ACME validation is not accepting work"); + Future 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()); diff --git a/pki-server/src/main/java/zeroecho/pki/server/spi/AcmeChallengeProvider.java b/pki-server/src/main/java/zeroecho/pki/server/spi/AcmeChallengeProvider.java new file mode 100644 index 0000000..114ae86 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/spi/AcmeChallengeProvider.java @@ -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. + * + *

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.

+ */ +@SuppressWarnings("PMD.ControlStatementBraces") +public interface AcmeChallengeProvider { + /** @return stable provider identity */ + String id(); + /** @return exact supported challenge types */ + Set 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"); + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/spi/AcmeExternalAccountBindingProvider.java b/pki-server/src/main/java/zeroecho/pki/server/spi/AcmeExternalAccountBindingProvider.java new file mode 100644 index 0000000..aa2af57 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/spi/AcmeExternalAccountBindingProvider.java @@ -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 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"); + } + } +} diff --git a/pki-server/src/main/resources/META-INF/services/zeroecho.pki.server.spi.AcmeChallengeProvider b/pki-server/src/main/resources/META-INF/services/zeroecho.pki.server.spi.AcmeChallengeProvider new file mode 100644 index 0000000..9411ef3 --- /dev/null +++ b/pki-server/src/main/resources/META-INF/services/zeroecho.pki.server.spi.AcmeChallengeProvider @@ -0,0 +1,2 @@ +zeroecho.pki.server.acme.Http01ChallengeProvider +zeroecho.pki.server.acme.JndiDns01ChallengeProvider diff --git a/pki-server/src/main/resources/zeroecho/pki/server/security/role-templates-v1.json b/pki-server/src/main/resources/zeroecho/pki/server/security/role-templates-v1.json index 1ef0396..d7122bd 100644 --- a/pki-server/src/main/resources/zeroecho/pki/server/security/role-templates-v1.json +++ b/pki-server/src/main/resources/zeroecho/pki/server/security/role-templates-v1.json @@ -14,7 +14,7 @@ {"id":"recovery-officer","version":1,"actions":["RESTORE_EXECUTE"]}, {"id":"auditor","version":1,"actions":["AUDIT_READ_REDACTED","AUDIT_INTEGRITY_VERIFY","REALM_READ","AUTHORITY_LIST","AUTHORITY_READ","PROFILE_READ","CERTIFICATE_READ_METADATA","REVOCATION_HISTORY_READ","PUBLICATION_READ","DISCLOSURE_READ"]}, {"id":"privileged-auditor","version":1,"actions":["AUDIT_READ_REDACTED","AUDIT_READ_FULL","AUDIT_READ_PII","AUDIT_EXPORT","AUDIT_INTEGRITY_VERIFY","CERTIFICATE_READ_METADATA","CERTIFICATE_READ_CONTENT","CERTIFICATE_READ_PII","DISCLOSURE_READ"]}, - {"id":"acme-administrator","version":1,"actions":["REALM_READ","AUTHORITY_READ","PROFILE_READ","POLICY_READ"]}, + {"id":"acme-administrator","version":1,"actions":["REALM_READ","AUTHORITY_READ","PROFILE_READ","POLICY_READ","ACME_DIRECTORY_READ","ACME_DIRECTORY_MANAGE","ACME_ACCOUNT_READ","ACME_ACCOUNT_MANAGE"]}, {"id":"public-principal","version":1,"actions":["REALM_READ","CA_CHAIN_DOWNLOAD","CRL_DOWNLOAD"]} ] } diff --git a/pki-server/src/test/java/zeroecho/pki/server/AcmeEndToEndTest.java b/pki-server/src/test/java/zeroecho/pki/server/AcmeEndToEndTest.java new file mode 100644 index 0000000..52e1fb0 --- /dev/null +++ b/pki-server/src/test/java/zeroecho/pki/server/AcmeEndToEndTest.java @@ -0,0 +1,659 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + *******************************************************************************/ +package zeroecho.pki.server; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URI; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.SecureRandom; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.HexFormat; +import java.security.MessageDigest; + +import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.ExtensionsGenerator; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import zeroecho.core.storage.KeyringPassword; +import zeroecho.core.storage.KeyringStore; +import zeroecho.pki.api.KeyRef; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.SubjectRef; +import zeroecho.pki.api.ca.CaCreateCommand; +import zeroecho.pki.api.profile.ActiveCertificateProfile; +import zeroecho.pki.api.profile.BuiltInCertificateProfileCatalog; +import zeroecho.pki.api.profile.BuiltInCertificateProfileTemplate; +import zeroecho.pki.application.PkiSessionConfiguration; +import zeroecho.pki.application.PkiSessionRuntimeDependencies; +import zeroecho.pki.impl.framework.x509.bc.SimpleAttributeSet; +import zeroecho.pki.server.acme.AcmeService; +import zeroecho.pki.server.acme.AcmeState; +import zeroecho.pki.server.acme.Http01ChallengeProvider; +import zeroecho.pki.server.acme.TestAcmeEabProvider; +import zeroecho.pki.server.http.AcmeTestClient; +import zeroecho.pki.server.http.TestTlsProvider; +import zeroecho.pki.spi.ProviderConfig; + +/** Complete wire-level ACME account-to-revocation integration. */ +@SuppressWarnings("PMD") +class AcmeEndToEndTest { + private static final char[] KEYRING_PASSWORD = "acme-end-to-end-password".toCharArray(); + private static final String DIRECTORY_ALIAS = "internal"; + private static final String EAB_DIRECTORY_ALIAS = "eab"; + private static final String IDENTIFIER = "localhost"; + private static final byte[] EAB_SECRET = "zeroecho-test-eab-secret-32-bytes" + .getBytes(StandardCharsets.US_ASCII); + + @TempDir Path temporaryDirectory; + + @Test + void completesRealHttpsAccountIssuanceRolloverRestartAndRevocation() throws Exception { + System.out.println("completesRealHttpsAccountIssuanceRolloverRestartAndRevocation"); + HttpServerTestSupport.Fixture tls = HttpServerTestSupport.tls(); + int acmePort = reservePort(); + int challengePort = reservePort(); + KeyPair rootKey = rsa((byte) 21); + KeyPair leafKey = rsa((byte) 22); + KeyPair accountKey = ec((byte) 23); + KeyPair replacementKey = ec((byte) 24); + Fixture fixture = fixture(tls, acmePort, challengePort, rootKey); + seed(fixture); + + URI directoryUri = URI.create("https://localhost:" + acmePort + "/acme/" + DIRECTORY_ALIAS + + "/directory"); + HttpClient wire = HttpClient.newBuilder().sslContext(tls.anonymousContext()) + .connectTimeout(Duration.ofSeconds(5)).build(); + String accountKid; + URI accountUri; + URI orderUri; + URI certificateUri; + byte[] leafDer; + try (PkiHttpsServer server = start(fixture); + AcmeTestClient client = new AcmeTestClient(wire, directoryUri, accountKey)) { + verifyEabOverHttp(wire, acmePort, ec((byte) 25)); + AcmeTestClient.Response directory = client.directory(); + assertEquals(200, directory.status()); + URI nonceUri = URI.create(AcmeTestClient.text(directory, "newNonce")); + URI newAccount = URI.create(AcmeTestClient.text(directory, "newAccount")); + URI newOrder = URI.create(AcmeTestClient.text(directory, "newOrder")); + URI keyChange = URI.create(AcmeTestClient.text(directory, "keyChange")); + client.nonce(nonceUri); + AcmeTestClient.Response account = client.createAccount(newAccount, + "{\"termsOfServiceAgreed\":true}"); + assertEquals(201, account.status()); + accountKid = client.accountKid(); + accountUri = URI.create(accountKid); + + AcmeTestClient.Response order = client.kid(newOrder, + "{\"identifiers\":[{\"type\":\"dns\",\"value\":\"localhost\"}]}"); + assertEquals(201, order.status()); + orderUri = URI.create(order.header("Location").orElseThrow()); + URI authorizationUri = URI.create(AcmeTestClient.textList(order, "authorizations").get(0)); + URI finalizeUri = URI.create(AcmeTestClient.text(order, "finalize")); + AcmeTestClient.Response authorization = client.postAsGet(authorizationUri); + assertEquals("pending", AcmeTestClient.text(authorization, "status")); + URI challengeUri = AcmeTestClient.challenge(authorization).url(); + AcmeTestClient.Response challenge = client.postAsGet(challengeUri); + String token = AcmeTestClient.text(challenge, "token"); + + try (Http01Responder responder = new Http01Responder(challengePort, token, + client.keyAuthorization(token))) { + responder.start(); + AcmeTestClient.Response validated = client.kid(challengeUri, "{}"); + assertEquals(200, validated.status()); + assertEquals("valid", AcmeTestClient.text(validated, "status")); + responder.await(); + } + AcmeTestClient.Response ready = client.postAsGet(orderUri); + assertEquals("ready", AcmeTestClient.text(ready, "status")); + String csr = Base64.getUrlEncoder().withoutPadding().encodeToString(csr(leafKey)); + AcmeTestClient.Response finalized = client.kid(finalizeUri, "{\"csr\":\"" + csr + "\"}"); + assertEquals(200, finalized.status()); + assertEquals("valid", AcmeTestClient.text(finalized, "status")); + certificateUri = URI.create(AcmeTestClient.text(finalized, "certificate")); + AcmeTestClient.Response certificate = client.postAsGet(certificateUri); + assertEquals(200, certificate.status()); + List chain = certificates(certificate.bodyText()); + assertEquals(2, chain.size()); + assertEquals(List.of(IDENTIFIER), chain.get(0).getSubjectAlternativeNames().stream() + .filter(value -> Integer.valueOf(2).equals(value.get(0))).map(value -> value.get(1).toString()) + .toList()); + chain.get(0).verify(chain.get(1).getPublicKey()); + leafDer = chain.get(0).getEncoded(); + + assertEquals(200, client.rollover(keyChange, replacementKey).status()); + assertEquals(200, client.postAsGet(accountUri).status()); + System.out.println("...https-jws-http01-issuance=true"); + } + + try (PkiHttpsServer restarted = start(fixture); + AcmeTestClient replacement = new AcmeTestClient(wire, directoryUri, replacementKey); + AcmeTestClient old = new AcmeTestClient(wire, directoryUri, accountKey)) { + URI nonceUri = URI.create(AcmeTestClient.text(replacement.directory(), "newNonce")); + replacement.nonce(nonceUri); replacement.useAccount(accountKid); + assertEquals(200, replacement.postAsGet(accountUri).status()); + old.directory(); old.nonce(nonceUri); old.useAccount(accountKid); + assertEquals(403, old.postAsGet(accountUri).status()); + assertEquals(200, replacement.postAsGet(orderUri).status()); + assertEquals(200, replacement.postAsGet(certificateUri).status()); + URI revoke = URI.create(AcmeTestClient.text(replacement.directory(), "revokeCert")); + String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(leafDer); + assertEquals(200, replacement.kid(revoke, + "{\"certificate\":\"" + encoded + "\",\"reason\":1}").status()); + assertFalse(replacement.kid(revoke, + "{\"certificate\":\"" + encoded + "\",\"reason\":1}").status() == 200); + System.out.println("...rollover-restart-revocation=true"); + } + System.out.println("...ok"); + } + + @Test + void completesInstalledDistributionAccountToRevocationWorkflow() throws Exception { + System.out.println("completesInstalledDistributionAccountToRevocationWorkflow"); + Path root = temporaryDirectory.resolve("packaged"); + java.nio.file.Files.createDirectories(root); + java.nio.file.Files.setPosixFilePermissions(root, java.nio.file.attribute.PosixFilePermissions + .fromString("rwx------")); + HttpServerTestSupport.PackagedTls tls = HttpServerTestSupport.packagedTls(root.resolve("tls")); + int adminPort = reservePort(); + int acmePort = reservePort(); + int challengePort = reservePort(); + KeyPair rootKey = rsa((byte) 41); + Fixture fixture = packagedFixture(root, tls, adminPort, acmePort, challengePort, rootKey); + seed(fixture, false); + Path configuration = root.resolve("server.json"); + java.nio.file.Files.writeString(configuration, packagedJson(fixture.configuration(), tls), + StandardCharsets.UTF_8); + Path launcher = Path.of(System.getProperty("user.dir"), "build", "install", "zeroecho-pki-server", "bin", + "zeroecho-pki-server").toAbsolutePath(); + Path output = root.resolve("server-output.log"); + ProcessBuilder builder = new ProcessBuilder(launcher.toString(), "--config", configuration.toString()); + builder.environment().put("ZEROECHO_TEST_TLS_PASSWORD", new String(HttpServerTestSupport.PASSWORD)); + builder.environment().put("ZEROECHO_TEST_KEYRING_PASSWORD", new String(KEYRING_PASSWORD)); + builder.redirectErrorStream(true).redirectOutput(output.toFile()); + Process process = builder.start(); + URI directoryUri = URI.create("https://localhost:" + acmePort + "/acme/" + DIRECTORY_ALIAS + + "/directory"); + HttpClient wire = HttpClient.newBuilder().sslContext(tls.anonymousContext()) + .connectTimeout(Duration.ofMillis(500)).build(); + try { + awaitDirectory(wire, directoryUri, process, output); + KeyPair accountKey = ec((byte) 42); + KeyPair replacement = ec((byte) 43); + KeyPair leaf = rsa((byte) 44); + try (AcmeTestClient client = new AcmeTestClient(wire, directoryUri, accountKey)) { + AcmeTestClient.Response directory = client.directory(); + URI nonce = URI.create(AcmeTestClient.text(directory, "newNonce")); + URI account = URI.create(AcmeTestClient.text(directory, "newAccount")); + URI orderEndpoint = URI.create(AcmeTestClient.text(directory, "newOrder")); + URI rollover = URI.create(AcmeTestClient.text(directory, "keyChange")); + URI revoke = URI.create(AcmeTestClient.text(directory, "revokeCert")); + client.nonce(nonce); + assertEquals(201, client.createAccount(account, "{}").status()); + AcmeTestClient.Response order = client.kid(orderEndpoint, + "{\"identifiers\":[{\"type\":\"dns\",\"value\":\"localhost\"}]}"); + URI authorization = URI.create(AcmeTestClient.textList(order, "authorizations").get(0)); + URI finalizeOrder = URI.create(AcmeTestClient.text(order, "finalize")); + URI orderUri = URI.create(order.header("Location").orElseThrow()); + URI challenge = AcmeTestClient.challenge(client.postAsGet(authorization)).url(); + String token = AcmeTestClient.text(client.postAsGet(challenge), "token"); + try (Http01Responder responder = new Http01Responder(challengePort, token, + client.keyAuthorization(token))) { + responder.start(); + assertEquals("valid", AcmeTestClient.text(client.kid(challenge, "{}"), "status")); + responder.await(); + } + assertEquals("ready", AcmeTestClient.text(client.postAsGet(orderUri), "status")); + String encodedCsr = Base64.getUrlEncoder().withoutPadding().encodeToString(csr(leaf)); + AcmeTestClient.Response finalized = client.kid(finalizeOrder, + "{\"csr\":\"" + encodedCsr + "\"}"); + URI certificate = URI.create(AcmeTestClient.text(finalized, "certificate")); + List chain = certificates(client.postAsGet(certificate).bodyText()); + assertEquals(2, chain.size()); + assertEquals(200, client.rollover(rollover, replacement).status()); + String der = Base64.getUrlEncoder().withoutPadding().encodeToString(chain.get(0).getEncoded()); + assertEquals(200, client.kid(revoke, + "{\"certificate\":\"" + der + "\",\"reason\":1}").status()); + } + System.out.println("...installed-launcher-flow=true"); + } finally { + process.destroy(); + if (!process.waitFor(10, TimeUnit.SECONDS)) { + process.destroyForcibly(); + assertTrue(process.waitFor(5, TimeUnit.SECONDS)); + } + } + System.out.println("...ok"); + } + + private static void verifyEabOverHttp(HttpClient wire, int acmePort, KeyPair key) throws Exception { + URI directoryUri = URI.create("https://localhost:" + acmePort + "/acme/" + EAB_DIRECTORY_ALIAS + + "/directory"); + try (AcmeTestClient client = new AcmeTestClient(wire, directoryUri, key)) { + AcmeTestClient.Response directory = client.directory(); + URI nonce = URI.create(AcmeTestClient.text(directory, "newNonce")); + URI account = URI.create(AcmeTestClient.text(directory, "newAccount")); + client.nonce(nonce); + assertEquals(403, client.jwk(account, "{}").status()); + String binding = client.externalAccountBinding(account, "test-key", EAB_SECRET); + AcmeTestClient.Response created = client.jwk(account, + "{\"externalAccountBinding\":" + binding + "}"); + assertEquals(201, created.status(), created.bodyText()); + System.out.println("...http-eab-required=true"); + } + } + + private Fixture fixture(HttpServerTestSupport.Fixture tls, int acmePort, int challengePort, KeyPair rootKey) + throws Exception { + Path root = temporaryDirectory.resolve("server"); + PkiServerConfiguration base = HttpServerTestSupport.configuration(root, tls.clientCertificate()); + java.nio.file.Files.setPosixFilePermissions(root, java.nio.file.attribute.PosixFilePermissions + .fromString("rwx------")); + Path keyring = root.resolve("signing-keyring.zek"); + try (KeyringPassword password = new KeyringPassword(KEYRING_PASSWORD.clone()); + KeyringStore store = KeyringStore.create(keyring, password)) { + store.putPrivate("root.prv", "RSA", rootKey.getPrivate()); + store.putPublic("root.pub", "RSA", rootKey.getPublic()); + } + ProviderConfig workflow = new ProviderConfig("zeroecho-lib", Map.of( + "keyringPath", keyring.toString(), "operationRoot", root.resolve("signing-operations").toString(), + "keyRefPrefix", "acme-test:", "requireComponentSuffix", "true")); + PkiSessionConfiguration.SigningConfiguration signing = new PkiSessionConfiguration.SigningConfiguration( + workflow, new ProviderConfig("x509-bc", Map.of()), root.resolve("signing-bus.log").toString(), + "SHA256withRSA", Duration.ofSeconds(10), Optional.empty()); + PkiSessionConfiguration session = new PkiSessionConfiguration(3, + base.realm().pkiSessionConfiguration().store(), base.realm().pkiSessionConfiguration().audit(), + Optional.of(signing), List.of(), List.of()); + ServerRealmConfiguration realm = new ServerRealmConfiguration(base.realm().realmId(), + base.realm().displayName(), session, base.realm().authorityExposure(), + base.realm().authorizationCommitment(), base.realm().approvalCommitment(), + base.realm().disclosureCommitment(), base.realm().disclosureDefaults(), + base.realm().controlLogPath(), base.realm().controlStoreId(), base.realm().approvalPolicies()); + PkiServerConfiguration.Execution lane = new PkiServerConfiguration.Execution(2, 4, 2, 4, 4, + Duration.ofSeconds(10), Duration.ofSeconds(20), Duration.ofSeconds(2), Duration.ofSeconds(2)); + ProviderConfig http01 = new ProviderConfig(Http01ChallengeProvider.ID, Map.of("allowPrivate", "true", + "timeoutMillis", "5000", "maximumBodyBytes", "4096", "maximumAddresses", "4", + "targetPort", Integer.toString(challengePort))); + PkiServerConfiguration.AcmeListener acme = new PkiServerConfiguration.AcmeListener("acme-e2e", + InetAddress.getByName("127.0.0.1"), acmePort, new ProviderConfig("test-tls", Map.of()), + PkiServerConfiguration.AcmeTransportMode.DIRECT_TLS, List.of(), Set.of(), + URI.create("https://localhost:" + acmePort), 16_384, 1_048_576, lane, lane, + Duration.ofMinutes(5), 256, 32, 32, Duration.ofMinutes(1), 32, 32, 16, 32, 2, + List.of(http01), List.of(new ProviderConfig(TestAcmeEabProvider.ID, Map.of()))); + PkiServerConfiguration configuration = new PkiServerConfiguration(base.version(), base.serverName(), realm, + base.listener(), base.authentication(), base.execution(), base.runtime(), Optional.empty(), + Optional.of(acme)); + PkiSessionRuntimeDependencies dependencies = PkiSessionRuntimeDependencies.withKeyringUnlockProvider( + () -> new KeyringPassword(KEYRING_PASSWORD.clone())); + return new Fixture(configuration, dependencies, http01); + } + + private void seed(Fixture fixture) throws Exception { + seed(fixture, true); + } + + private void seed(Fixture fixture, boolean includeEab) throws Exception { + try (ServerRealmContext context = ServerRealmContext.open(fixture.configuration().realm(), + fixture.dependencies(), ServerTestSupport.CLOCK, HttpServerTestSupport.random())) { + BuiltInCertificateProfileTemplate root = template("root-ca"); + BuiltInCertificateProfileTemplate leaf = template("server-tls"); + context.session().profiles().importBuiltIn(root); + context.session().profiles().activateProfile("root-ca", root.definition().profileVersion()); + context.session().profiles().importBuiltIn(leaf); + context.session().profiles().activateProfile("server-tls", leaf.definition().profileVersion()); + PkiId authority = context.session().authorities().orElseThrow().createRoot(new CaCreateCommand( + root.definition().formatId(), new SubjectRef("CN=ZeroEcho ACME E2E Root"), "root-ca", + Optional.of(new KeyRef("acme-test:root.prv")), new SimpleAttributeSet())); + ActiveCertificateProfile active = context.session().profiles().requireActiveProfile("server-tls"); + AcmeService service = new AcmeService(context, ServerTestSupport.CLOCK, HttpServerTestSupport.random(), + Map.of(Http01ChallengeProvider.ID, new Http01ChallengeProvider()), + Map.of(Http01ChallengeProvider.ID, fixture.http01()), + Map.of(TestAcmeEabProvider.ID, new TestAcmeEabProvider()), + Map.of(TestAcmeEabProvider.ID, new ProviderConfig(TestAcmeEabProvider.ID, Map.of()))); + AcmeState.Directory directory = service.registerDirectory(new AcmeService.DirectoryRegistration( + DIRECTORY_ALIAS, authority, "server-tls", Set.of(IDENTIFIER), Duration.ofDays(30), + active.definition().leafPolicy().allowedSubjectKeyAlgorithmIds(), Set.of("STANDARD_ONLY"), + Set.of(AcmeState.ChallengeType.HTTP_01), Set.of(Http01ChallengeProvider.ID), Optional.empty(), + false, DisclosureService.Policy.OWNER_ONLY)); + service.activateDirectory(directory.directoryId()); + if (includeEab) { + AcmeState.Directory eab = service.registerDirectory(new AcmeService.DirectoryRegistration( + EAB_DIRECTORY_ALIAS, authority, "server-tls", Set.of(IDENTIFIER), Duration.ofDays(30), + active.definition().leafPolicy().allowedSubjectKeyAlgorithmIds(), Set.of("STANDARD_ONLY"), + Set.of(AcmeState.ChallengeType.HTTP_01), Set.of(Http01ChallengeProvider.ID), + Optional.of(TestAcmeEabProvider.ID), true, DisclosureService.Policy.OWNER_ONLY)); + service.activateDirectory(eab.directoryId()); + } + assertEquals(authority, context.session().repository().authority(authority).orElseThrow().caId()); + } + } + + private PkiHttpsServer start(Fixture fixture) throws Exception { + return PkiHttpsServer.start(fixture.configuration(), fixture.dependencies(), ServerTestSupport.CLOCK, + HttpServerTestSupport.random(), TestTlsProvider.class.getClassLoader()); + } + + private Fixture packagedFixture(Path root, HttpServerTestSupport.PackagedTls tls, int adminPort, int acmePort, + int challengePort, KeyPair rootKey) throws Exception { + Path keyring = root.resolve("signing-keyring.zek"); + try (KeyringPassword password = new KeyringPassword(KEYRING_PASSWORD.clone()); + KeyringStore store = KeyringStore.create(keyring, password)) { + store.putPrivate("root.prv", "RSA", rootKey.getPrivate()); + store.putPublic("root.pub", "RSA", rootKey.getPublic()); + } + ProviderConfig workflow = new ProviderConfig("zeroecho-lib", Map.of("keyringPath", keyring.toString(), + "operationRoot", root.resolve("signing-operations").toString(), "keyRefPrefix", "acme-test:", + "requireComponentSuffix", "true")); + PkiSessionConfiguration.SigningConfiguration signing = new PkiSessionConfiguration.SigningConfiguration( + workflow, new ProviderConfig("x509-bc", Map.of()), root.resolve("signing-bus.log").toString(), + "SHA256withRSA", Duration.ofSeconds(10), Optional.of("ZEROECHO_TEST_KEYRING_PASSWORD")); + PkiSessionConfiguration session = new PkiSessionConfiguration(3, + new ProviderConfig("fs", Map.of("root", root.resolve("pki").toString())), + new ProviderConfig("memory", Map.of("size", "256")), Optional.of(signing), List.of(), List.of()); + ApprovalService.Policy approval = new ApprovalService.Policy("high-risk", 1, Set.of("approver"), Set.of(), + true, Duration.ofHours(1), true); + ServerRealmConfiguration realm = new ServerRealmConfiguration(ServerTestSupport.REALM, "Packaged ACME", + session, new AuthorityExposurePolicy(AuthorityExposurePolicy.Mode.ALL_REALM_AUTHORITIES, Set.of(), + true), ServerTestSupport.DIGEST, approval.commitment(), ServerTestSupport.DIGEST, + DisclosureService.Defaults.recommended(), root.resolve("control.log"), + new zeroecho.pki.spi.store.MetadataStoreId("1234567890abcdef1234567890abcdef"), + Map.of(OperationSecurityDescriptors.ApprovalCategory.HIGH_RISK, approval)); + String certificateDigest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(tls.clientCertificate().getEncoded())); + PkiServerConfiguration.Authentication authentication = new PkiServerConfiguration.Authentication( + AdministrativeAuthenticationMode.DIRECT_MTLS, + List.of(new PkiServerConfiguration.ClientCertificateMapping("packaged-admin", "administrator", + Optional.of(certificateDigest), Optional.empty(), Optional.empty())), + List.of(), List.of(), Set.of(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), + 0, 0); + ProviderConfig tlsProvider = tlsProvider(tls); + PkiServerConfiguration.Execution lane = new PkiServerConfiguration.Execution(2, 4, 2, 4, 4, + Duration.ofSeconds(10), Duration.ofSeconds(20), Duration.ofSeconds(2), Duration.ofSeconds(2)); + ProviderConfig http01 = new ProviderConfig(Http01ChallengeProvider.ID, Map.of("allowPrivate", "true", + "timeoutMillis", "5000", "maximumBodyBytes", "4096", "maximumAddresses", "4", "targetPort", + Integer.toString(challengePort))); + PkiServerConfiguration.AcmeListener acme = new PkiServerConfiguration.AcmeListener("packaged-acme", + InetAddress.getByName("127.0.0.1"), acmePort, tlsProvider, + PkiServerConfiguration.AcmeTransportMode.DIRECT_TLS, List.of(), Set.of(), + URI.create("https://localhost:" + acmePort), 16_384, 1_048_576, lane, lane, + Duration.ofMinutes(5), 256, 32, 32, Duration.ofMinutes(1), 32, 32, 16, 32, 2, + List.of(http01), List.of()); + PkiServerConfiguration configuration = new PkiServerConfiguration(4, "packaged-acme", realm, + new PkiServerConfiguration.Listener(InetAddress.getByName("127.0.0.1"), adminPort, tlsProvider, + true, 16_384, 1_048_576), authentication, lane, + new PkiServerConfiguration.RuntimeCapabilities(Optional.of("ZEROECHO_TEST_KEYRING_PASSWORD")), + Optional.empty(), Optional.of(acme)); + return new Fixture(configuration, PkiSessionRuntimeDependencies.withKeyringUnlockProvider( + () -> new KeyringPassword(KEYRING_PASSWORD.clone())), http01); + } + + private static ProviderConfig tlsProvider(HttpServerTestSupport.PackagedTls tls) { + return new ProviderConfig("jsse-pkcs12", Map.of("keyStore", tls.keyStore().toString(), + "keyStorePasswordEnvironment", "ZEROECHO_TEST_TLS_PASSWORD", "trustStore", + tls.trustStore().toString(), "trustStorePasswordEnvironment", "ZEROECHO_TEST_TLS_PASSWORD")); + } + + private static String packagedJson(PkiServerConfiguration configuration, + HttpServerTestSupport.PackagedTls tls) { + ServerRealmConfiguration realm = configuration.realm(); + PkiSessionConfiguration.SigningConfiguration signing = realm.pkiSessionConfiguration().signing() + .orElseThrow(); + ProviderConfig http = configuration.acmeListener().orElseThrow().challengeProviders().get(0); + String tlsJson = providerJson(tlsProvider(tls)); + String workflow = providerJson(signing.workflow()); + String store = providerJson(realm.pkiSessionConfiguration().store()); + String audit = providerJson(realm.pkiSessionConfiguration().audit()); + return "{\"version\":4,\"serverName\":\"packaged-acme\",\"realm\":{" + + "\"realmId\":\"production\",\"displayName\":\"Packaged ACME\"," + + "\"authorityExposure\":{\"mode\":\"ALL_REALM_AUTHORITIES\",\"authorityIds\":[]," + + "\"creationPermitted\":true},\"authorizationCommitment\":\"" + realm.authorizationCommitment() + + "\",\"approvalCommitment\":\"" + realm.approvalCommitment() + + "\",\"disclosureCommitment\":\"" + realm.disclosureCommitment() + "\"," + + "\"disclosureDefaults\":{\"rootCa\":\"PUBLIC\",\"intermediateCa\":\"PUBLIC\"," + + "\"caChain\":\"PUBLIC\",\"crl\":\"PUBLIC\",\"leaf\":\"OWNER_ONLY\"," + + "\"sensitiveLeaf\":\"RESTRICTED\"},\"controlLog\":\"" + json(realm.controlLogPath().toString()) + + "\",\"controlStoreId\":\"" + realm.controlStoreId().value() + "\"," + + "\"approvalPolicy\":{\"policyId\":\"high-risk\",\"threshold\":1," + + "\"eligibleApprovers\":[\"approver\"],\"requiredRoleTemplateIds\":[]," + + "\"requesterSeparation\":true,\"lifetimeMillis\":3600000,\"justificationRequired\":true}," + + "\"pkiSession\":{\"version\":3,\"store\":" + store + ",\"audit\":" + audit + + ",\"signing\":{\"workflow\":" + workflow + ",\"framework\":" + + providerJson(signing.framework()) + ",\"busPath\":\"" + json(signing.busPath()) + + "\",\"signatureAlgorithm\":\"SHA256withRSA\",\"signingTtlMillis\":10000," + + "\"unlockEnvironmentVariable\":\"ZEROECHO_TEST_KEYRING_PASSWORD\"}," + + "\"publishers\":[],\"bindingProviders\":[]}},\"listener\":{\"address\":\"127.0.0.1\"," + + "\"port\":" + configuration.listener().port() + ",\"tlsProvider\":" + tlsJson + + ",\"clientCertificateRequired\":true,\"maximumHeaderBytes\":16384," + + "\"maximumBodyBytes\":1048576},\"authentication\":{\"mode\":\"DIRECT_MTLS\"," + + "\"directClientMappings\":[{\"mappingId\":\"packaged-admin\"," + + "\"principalId\":\"administrator\",\"certificateSha256\":\"" + + configuration.authentication().directClientMappings().get(0).certificateSha256().orElseThrow() + + "\"}]},\"execution\":" + executionJson() + ",\"publicListener\":{\"enabled\":false}," + + "\"acmeListener\":{\"enabled\":true,\"listenerId\":\"packaged-acme\"," + + "\"address\":\"127.0.0.1\",\"port\":" + configuration.acmeListener().orElseThrow().port() + + ",\"tlsProvider\":" + tlsJson + ",\"transportMode\":\"DIRECT_TLS\"," + + "\"externalBaseUri\":\"" + configuration.acmeListener().orElseThrow().externalBaseUri() + + "\",\"maximumHeaderBytes\":16384,\"maximumBodyBytes\":1048576,\"execution\":" + + executionJson() + ",\"validationExecution\":" + executionJson() + + ",\"nonceLifetimeMillis\":300000,\"maximumOutstandingNonces\":256," + + "\"maximumAccountsPresented\":32,\"maximumOrdersPresented\":32," + + "\"admissionWindowMillis\":60000,\"maximumNewAccountsPerWindow\":32," + + "\"maximumNewOrdersPerAccountWindow\":32,\"maximumPendingOrdersPerAccount\":16," + + "\"maximumChallengeValidationsPerAccountWindow\":32,\"maximumConcurrentFinalizations\":2," + + "\"challengeProviders\":[" + providerJson(http) + "],\"eabProviders\":[]}," + + "\"runtime\":{\"keyUnlockEnvironmentVariable\":\"ZEROECHO_TEST_KEYRING_PASSWORD\"}}"; + } + + private static String executionJson() { + return "{\"transportWorkers\":2,\"transportQueueCapacity\":4,\"operationWorkers\":2," + + "\"operationQueueCapacity\":4,\"maximumAdmittedRequests\":4," + + "\"defaultDeadlineMillis\":10000,\"maximumDeadlineMillis\":20000," + + "\"gracefulShutdownMillis\":2000,\"forcedShutdownMillis\":2000}"; + } + + private static String providerJson(ProviderConfig provider) { + StringBuilder out = new StringBuilder("{\"id\":\"").append(provider.backendId()) + .append("\",\"properties\":{"); + int index = 0; + for (Map.Entry property : new java.util.TreeMap<>(provider.properties()).entrySet()) { + if (index++ != 0) out.append(','); + out.append('\"').append(json(property.getKey())).append("\":\"") + .append(json(property.getValue())).append('\"'); + } + return out.append("}}").toString(); + } + + private static String json(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } + + private static void awaitDirectory(HttpClient client, URI directory, Process process, Path output) + throws Exception { + long deadline = System.nanoTime() + Duration.ofSeconds(20).toNanos(); + Throwable last = null; + while (System.nanoTime() < deadline && process.isAlive()) { + try { + java.net.http.HttpResponse response = client.send(java.net.http.HttpRequest.newBuilder(directory) + .timeout(Duration.ofSeconds(1)).GET().build(), java.net.http.HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() == 200) return; + } catch (java.io.IOException unavailable) { + last = unavailable; + } + } + String diagnostics = java.nio.file.Files.exists(output) ? java.nio.file.Files.readString(output) : ""; + throw new AssertionError("Packaged ACME listener did not become ready: " + diagnostics, last); + } + + private static BuiltInCertificateProfileTemplate template(String id) { + return BuiltInCertificateProfileCatalog.load(AcmeEndToEndTest.class.getClassLoader()).stream() + .filter(value -> id.equals(value.definition().profileId())).findFirst().orElseThrow(); + } + + private static byte[] csr(KeyPair key) throws Exception { + ExtensionsGenerator extensions = new ExtensionsGenerator(); + extensions.addExtension(Extension.subjectAlternativeName, false, + new GeneralNames(new GeneralName(GeneralName.dNSName, IDENTIFIER))); + JcaPKCS10CertificationRequestBuilder builder = new JcaPKCS10CertificationRequestBuilder( + new X500Name("CN=" + IDENTIFIER), key.getPublic()); + builder.addAttribute(PKCSObjectIdentifiers.pkcs_9_at_extensionRequest, extensions.generate()); + return builder.build(new JcaContentSignerBuilder("SHA256withRSA").build(key.getPrivate())).getEncoded(); + } + + private static List certificates(String pem) throws Exception { + List result = new ArrayList<>(); + String begin = "-----BEGIN CERTIFICATE-----"; + String end = "-----END CERTIFICATE-----"; + int offset = 0; + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + while ((offset = pem.indexOf(begin, offset)) >= 0) { + int last = pem.indexOf(end, offset); + String encoded = pem.substring(offset + begin.length(), last).replaceAll("\\s", ""); + result.add((X509Certificate) factory.generateCertificate( + new ByteArrayInputStream(Base64.getDecoder().decode(encoded)))); + offset = last + end.length(); + } + return List.copyOf(result); + } + + private static KeyPair rsa(byte seed) throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048, random(seed)); + return generator.generateKeyPair(); + } + + private static KeyPair ec(byte seed) throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new java.security.spec.ECGenParameterSpec("secp256r1"), random(seed)); + return generator.generateKeyPair(); + } + + private static SecureRandom random(byte seed) throws Exception { + SecureRandom result = SecureRandom.getInstance("SHA1PRNG"); + result.setSeed(new byte[] { 2, 4, 6, 8, seed }); + return result; + } + + private static int reservePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))) { + return socket.getLocalPort(); + } + } + + private record Fixture(PkiServerConfiguration configuration, PkiSessionRuntimeDependencies dependencies, + ProviderConfig http01) { } + + private static final class Http01Responder implements AutoCloseable { + private final String token; + private final String body; + private final ServerSocket listener; + private final CountDownLatch completed = new CountDownLatch(1); + private final AtomicReference failure = new AtomicReference<>(); + private Thread thread; + + Http01Responder(int port, String token, String body) throws Exception { + this.token = token; + this.body = body; + listener = new ServerSocket(port, 1, InetAddress.getByName("127.0.0.1")); + } + + void start() { + thread = new Thread(this::serve, "zeroecho-acme-http01-fixture"); + thread.start(); + } + + void await() throws Exception { + assertTrue(completed.await(10, TimeUnit.SECONDS)); + if (failure.get() != null) throw new AssertionError("HTTP-01 responder failed", failure.get()); + } + + private void serve() { + try (Socket connection = listener.accept()) { + connection.setSoTimeout(5_000); + ByteArrayOutputStream request = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + while (!request.toString(StandardCharsets.US_ASCII).contains("\r\n\r\n")) { + int count = connection.getInputStream().read(buffer); + if (count < 0 || request.size() + count > 16_384) throw new IllegalStateException("bad request"); + request.write(buffer, 0, count); + } + assertTrue(request.toString(StandardCharsets.US_ASCII) + .startsWith("GET /.well-known/acme-challenge/" + token + " HTTP/1.1\r\n")); + byte[] content = body.getBytes(StandardCharsets.US_ASCII); + connection.getOutputStream().write(("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: " + + content.length + "\r\nConnection: close\r\n\r\n").getBytes(StandardCharsets.US_ASCII)); + connection.getOutputStream().write(content); + connection.getOutputStream().flush(); + } catch (Throwable problem) { + failure.set(problem); + } finally { + completed.countDown(); + } + } + + @Override public void close() throws Exception { + listener.close(); + if (thread != null) thread.join(5_000); + } + } +} diff --git a/pki-server/src/test/java/zeroecho/pki/server/HttpServerTestSupport.java b/pki-server/src/test/java/zeroecho/pki/server/HttpServerTestSupport.java index 889eb7d..e7e9654 100644 --- a/pki-server/src/test/java/zeroecho/pki/server/HttpServerTestSupport.java +++ b/pki-server/src/test/java/zeroecho/pki/server/HttpServerTestSupport.java @@ -104,6 +104,35 @@ public final class HttpServerTestSupport { return new Fixture(client, clientContext, anonymousContext, proxy, proxyContext, forwarded, root); } + static PackagedTls packagedTls(java.nio.file.Path directory) throws Exception { + java.nio.file.Files.createDirectories(directory); + KeyPair rootKey = keyPair((byte) 31); + X509Certificate root = certificate(rootKey, rootKey, "CN=Packaged Test Root", "CN=Packaged Test Root", + BigInteger.valueOf(31), true, false); + KeyPair serverKey = keyPair((byte) 32); + X509Certificate server = certificate(serverKey, rootKey, "CN=localhost", "CN=Packaged Test Root", + BigInteger.valueOf(32), false, true); + KeyPair clientKey = keyPair((byte) 33); + X509Certificate client = certificate(clientKey, rootKey, "CN=packaged-admin", "CN=Packaged Test Root", + BigInteger.valueOf(33), false, false); + java.nio.file.Path keys = directory.resolve("server.p12"); + java.nio.file.Path trust = directory.resolve("trust.p12"); + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(null, PASSWORD); + keyStore.setKeyEntry("server", serverKey.getPrivate(), PASSWORD, + new java.security.cert.Certificate[] { server, root }); + try (java.io.OutputStream output = java.nio.file.Files.newOutputStream(keys)) { + keyStore.store(output, PASSWORD); + } + KeyStore trustStore = KeyStore.getInstance("PKCS12"); + trustStore.load(null, PASSWORD); + trustStore.setCertificateEntry("root", root); + try (java.io.OutputStream output = java.nio.file.Files.newOutputStream(trust)) { + trustStore.store(output, PASSWORD); + } + return new PackagedTls(keys, trust, context(clientKey, client, root, false), client); + } + public static PkiServerConfiguration configuration(java.nio.file.Path directory, X509Certificate client) throws Exception { return configuration(directory, directAuthentication(client)); @@ -247,4 +276,7 @@ public final class HttpServerTestSupport { public record Fixture(X509Certificate clientCertificate, SSLContext clientContext, SSLContext anonymousContext, X509Certificate proxyCertificate, SSLContext proxyContext, X509Certificate forwardedCertificate, X509Certificate rootCertificate) { } + + record PackagedTls(java.nio.file.Path keyStore, java.nio.file.Path trustStore, SSLContext anonymousContext, + X509Certificate clientCertificate) { } } diff --git a/pki-server/src/test/java/zeroecho/pki/server/PkiHttpsServerTest.java b/pki-server/src/test/java/zeroecho/pki/server/PkiHttpsServerTest.java index 7f33de0..e706820 100644 --- a/pki-server/src/test/java/zeroecho/pki/server/PkiHttpsServerTest.java +++ b/pki-server/src/test/java/zeroecho/pki/server/PkiHttpsServerTest.java @@ -363,6 +363,41 @@ class PkiHttpsServerTest { System.out.println("...ok"); } + @Test + void acmeListenerIsLifecycleOwnedAndRouteIsolated() throws Exception { + System.out.println("acmeListenerIsLifecycleOwnedAndRouteIsolated"); + HttpServerTestSupport.Fixture tls = HttpServerTestSupport.tls(); + PkiServerConfiguration base = HttpServerTestSupport.configuration( + temporaryDirectory.resolve("acme-isolation"), tls.clientCertificate()); + PkiServerConfiguration.Execution lane = new PkiServerConfiguration.Execution(1, 2, 1, 2, 2, + Duration.ofSeconds(5), Duration.ofSeconds(5), Duration.ofSeconds(1), Duration.ofSeconds(1)); + PkiServerConfiguration.AcmeListener acme = new PkiServerConfiguration.AcmeListener("acme-test", + java.net.InetAddress.getLoopbackAddress(), 0, + new zeroecho.pki.spi.ProviderConfig("test-tls", Map.of()), + PkiServerConfiguration.AcmeTransportMode.DIRECT_TLS, List.of(), Set.of(), + URI.create("https://acme.example.test"), 16_384, 1_048_576, lane, lane, + Duration.ofMinutes(5), 64, 16, 16, Duration.ofMinutes(1), 16, 16, 8, 16, 1, + List.of(), List.of()); + PkiServerConfiguration configuration = new PkiServerConfiguration(base.version(), base.serverName(), + base.realm(), base.listener(), base.authentication(), base.execution(), base.runtime(), + Optional.empty(), Optional.of(acme)); + seed(configuration); + try (PkiHttpsServer server = PkiHttpsServer.start(configuration, PkiSessionRuntimeDependencies.none(), + ServerTestSupport.CLOCK, HttpServerTestSupport.random(), TestTlsProvider.class.getClassLoader())) { + HttpClient anonymous = HttpClient.newBuilder().sslContext(tls.anonymousContext()).build(); + HttpClient administrator = HttpClient.newBuilder().sslContext(tls.clientContext()).build(); + URI acmeAdmin = URI.create("https://localhost:" + server.acmeAddress().orElseThrow().getPort() + + "/admin/v1/realm"); + URI adminAcme = URI.create("https://localhost:" + server.address().getPort() + + "/acme/internal/directory"); + assertEquals(404, get(anonymous, acmeAdmin).statusCode()); + assertEquals(404, get(administrator, adminAcme).statusCode()); + assertTrue(server.realm() == server.realm()); + System.out.println("...acme-port=" + server.acmeAddress().orElseThrow().getPort()); + } + System.out.println("...ok"); + } + @Test void authenticatesTrustedProxyWithRfc9440ForwardedClient() throws Exception { System.out.println("authenticatesTrustedProxyWithRfc9440ForwardedClient"); diff --git a/pki-server/src/test/java/zeroecho/pki/server/ServerControlOperationExecutorTest.java b/pki-server/src/test/java/zeroecho/pki/server/ServerControlOperationExecutorTest.java index 62eadac..92d33c0 100644 --- a/pki-server/src/test/java/zeroecho/pki/server/ServerControlOperationExecutorTest.java +++ b/pki-server/src/test/java/zeroecho/pki/server/ServerControlOperationExecutorTest.java @@ -63,15 +63,15 @@ class ServerControlOperationExecutorTest { void catalogContainsOneExplicitPairOfOperationFamilies() { System.out.println("catalogContainsOneExplicitPairOfOperationFamilies"); OperationSecurityDescriptors catalog = new OperationSecurityDescriptors(); - assertEquals(54, catalog.descriptors().size()); + assertEquals(62, catalog.descriptors().size()); assertEquals(16, catalog.descriptors().values().stream() .filter(value -> value.family() == OperationSecurityDescriptors.Family.PKI_OPERATION).count()); - assertEquals(38, catalog.descriptors().values().stream() + assertEquals(46, catalog.descriptors().values().stream() .filter(value -> value.family() == OperationSecurityDescriptors.Family.SERVER_CONTROL_OPERATION) .count()); assertEquals(OperationSecurityDescriptors.Family.SERVER_CONTROL_OPERATION, catalog.require(ServerControlOperation.IssueCapability.NAME).family()); - System.out.println("...control-operations=34"); + System.out.println("...control-operations=46"); System.out.println("...ok"); } diff --git a/pki-server/src/test/java/zeroecho/pki/server/ServerOperationGatewayTest.java b/pki-server/src/test/java/zeroecho/pki/server/ServerOperationGatewayTest.java index 33c4b1d..d7db912 100644 --- a/pki-server/src/test/java/zeroecho/pki/server/ServerOperationGatewayTest.java +++ b/pki-server/src/test/java/zeroecho/pki/server/ServerOperationGatewayTest.java @@ -184,7 +184,7 @@ class ServerOperationGatewayTest { void descriptorRegistryRejectsUnknownAndDuplicateOperations() { System.out.println("descriptorRegistryRejectsUnknownAndDuplicateOperations"); OperationSecurityDescriptors descriptors = new OperationSecurityDescriptors(); - assertEquals(54, descriptors.descriptors().size()); + assertEquals(62, descriptors.descriptors().size()); assertEquals(16, descriptors.descriptors().values().stream() .filter(value -> value.family() == OperationSecurityDescriptors.Family.PKI_OPERATION).count()); assertThrows(SecurityException.class, () -> descriptors.require(new PkiOperation.ValidateConfiguration())); diff --git a/pki-server/src/test/java/zeroecho/pki/server/acme/AcmeProviderSecurityTest.java b/pki-server/src/test/java/zeroecho/pki/server/acme/AcmeProviderSecurityTest.java new file mode 100644 index 0000000..001067e --- /dev/null +++ b/pki-server/src/test/java/zeroecho/pki/server/acme/AcmeProviderSecurityTest.java @@ -0,0 +1,120 @@ +/******************************************************************************* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.time.Instant; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import zeroecho.core.io.CancellationSignal; +import zeroecho.pki.server.spi.AcmeChallengeProvider; +import zeroecho.pki.server.spi.AcmeExternalAccountBindingProvider; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.spi.ProviderConfig; + +/** Production challenge-provider policy and explicit-composition coverage. */ +class AcmeProviderSecurityTest { + private static final Instant NOW = Instant.parse("2026-08-05T08:00:00Z"); + + @Test + void providersRequireExplicitStableIdentity() { + System.out.println("providersRequireExplicitStableIdentity"); + Map none = AcmeProviders.challenges(List.of(), getClass().getClassLoader()); + assertTrue(none.isEmpty()); + Map selected = AcmeProviders.challenges(List.of( + new ProviderConfig(Http01ChallengeProvider.ID, httpProperties(false))), getClass().getClassLoader()); + assertEquals(Set.of(Http01ChallengeProvider.ID), selected.keySet()); + assertThrows(IllegalArgumentException.class, () -> AcmeProviders.challenges(List.of( + new ProviderConfig("missing-provider", Map.of())), getClass().getClassLoader())); + assertThrows(UnsupportedOperationException.class, () -> selected.clear()); + System.out.println("...provider=" + selected.keySet().iterator().next()); + System.out.println("...ok"); + } + + @Test + void httpProviderRejectsInternalTargetBeforeNetworkAccess() { + System.out.println("httpProviderRejectsInternalTargetBeforeNetworkAccess"); + AcmeChallengeProvider.Result result = new Http01ChallengeProvider().validate(context("localhost"), + new ProviderConfig(Http01ChallengeProvider.ID, httpProperties(false)), CancellationSignal.NONE); + assertFalse(result.valid()); + assertEquals("TARGET_NETWORK_REJECTED", result.classification()); + System.out.println("...classification=" + result.classification()); + System.out.println("...ok"); + } + + @Test + void dnsProviderRequiresExplicitNumericResolver() { + System.out.println("dnsProviderRequiresExplicitNumericResolver"); + ProviderConfig invalid = new ProviderConfig(JndiDns01ChallengeProvider.ID, Map.of( + "providerUrl", "dns://resolver.example.test", "timeoutMillis", "1000", + "maximumRecords", "8", "maximumRecordBytes", "256", "maximumCnameDepth", "2")); + assertThrows(IllegalArgumentException.class, () -> new JndiDns01ChallengeProvider().validate( + context("example.test"), invalid, CancellationSignal.NONE)); + System.out.println("...search-suffix=disabled-by-absolute-name"); + System.out.println("...ok"); + } + + @Test + void eabProviderIsClasspathInactiveUntilExplicitlySelected() { + System.out.println("eabProviderIsClasspathInactiveUntilExplicitlySelected"); + assertTrue(AcmeProviders.eab(List.of(), getClass().getClassLoader()).isEmpty()); + Map selected = AcmeProviders.eab(List.of( + new ProviderConfig(TestAcmeEabProvider.ID, Map.of())), getClass().getClassLoader()); + assertEquals(Set.of(TestAcmeEabProvider.ID), selected.keySet()); + System.out.println("...eab-provider=" + TestAcmeEabProvider.ID); + System.out.println("...ok"); + } + + private static AcmeChallengeProvider.Context context(String name) { + return new AcmeChallengeProvider.Context("directory:test:r1", "authorization:test", + new AcmeState.Identifier(AcmeState.IdentifierType.DNS, name, false), + AcmeState.ChallengeType.HTTP_01, "A".repeat(43), "A".repeat(43) + "." + "B".repeat(43), + 1, NOW, NOW.plus(Duration.ofSeconds(5))); + } + + private static Map httpProperties(boolean allowPrivate) { + return Map.of("allowPrivate", Boolean.toString(allowPrivate), "timeoutMillis", "1000", + "maximumBodyBytes", "4096", "maximumAddresses", "4"); + } +} diff --git a/pki-server/src/test/java/zeroecho/pki/server/acme/AcmeSecurityPrimitivesTest.java b/pki-server/src/test/java/zeroecho/pki/server/acme/AcmeSecurityPrimitivesTest.java new file mode 100644 index 0000000..256db73 --- /dev/null +++ b/pki-server/src/test/java/zeroecho/pki/server/acme/AcmeSecurityPrimitivesTest.java @@ -0,0 +1,184 @@ +/******************************************************************************* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigInteger; +import java.net.InetAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.SecureRandom; +import java.security.Signature; +import java.security.interfaces.ECPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import zeroecho.pki.server.PkiServerConfiguration; +import zeroecho.pki.spi.ProviderConfig; + +/** Strict replay, JOSE and bounded-admission coverage. */ +class AcmeSecurityPrimitivesTest { + private static final Instant NOW = Instant.parse("2026-08-05T08:00:00Z"); + private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC); + private static final String DIRECTORY = "directory:internal:r1"; + private static final URI URL = URI.create("https://acme.example.test/acme/internal/new-account"); + + @Test + void nonceIsDirectoryBoundOneTimeAndBounded() { + System.out.println("nonceIsDirectoryBoundOneTimeAndBounded"); + AcmeNonceService service = new AcmeNonceService("listener", Duration.ofMinutes(5), 16, + CLOCK, deterministicRandom()); + String first = service.issue(DIRECTORY); + assertFalse(service.consume(first, "directory:other:r1")); + assertTrue(service.consume(first, DIRECTORY)); + assertFalse(service.consume(first, DIRECTORY)); + for (int index = 0; index < 16; index++) service.issue(DIRECTORY); + assertThrows(IllegalStateException.class, () -> service.issue(DIRECTORY)); + System.out.println("...nonce-length=" + first.length()); + System.out.println("...ok"); + } + + @Test + void es256JwsRequiresCanonicalUrlNonceAndJwkMode() throws Exception { + System.out.println("es256JwsRequiresCanonicalUrlNonceAndJwkMode"); + KeyPair key = accountKey(); + AcmeNonceService nonces = new AcmeNonceService("listener", Duration.ofMinutes(5), 16, + CLOCK, deterministicRandom()); + String nonce = nonces.issue(DIRECTORY); + byte[] document = jws(key, nonce, URL, "{}".getBytes(StandardCharsets.UTF_8)); + AcmeJwsVerifier.Verified verified = new AcmeJwsVerifier().verify(document, 65_536, URL, DIRECTORY, + AcmeJwsVerifier.KeyMode.JWK, nonces, ignored -> { throw new AssertionError(); }); + assertEquals(64, verified.keyThumbprint().length()); + assertThrows(AcmeJwsVerifier.AcmeProblem.class, () -> new AcmeJwsVerifier().verify(document, + 65_536, URL, DIRECTORY, AcmeJwsVerifier.KeyMode.JWK, nonces, + ignored -> { throw new AssertionError(); })); + String second = nonces.issue(DIRECTORY); + byte[] wrongUrl = jws(key, second, URI.create("https://wrong.example/acme/internal/new-account"), + "{}".getBytes(StandardCharsets.UTF_8)); + assertThrows(AcmeJwsVerifier.AcmeProblem.class, () -> new AcmeJwsVerifier().verify(wrongUrl, + 65_536, URL, DIRECTORY, AcmeJwsVerifier.KeyMode.JWK, nonces, + ignored -> { throw new AssertionError(); })); + System.out.println("...algorithm=ES256"); + System.out.println("...ok"); + } + + @Test + void accountCodecAndAdmissionRemainFiniteAndDeterministic() throws Exception { + System.out.println("accountCodecAndAdmissionRemainFiniteAndDeterministic"); + KeyPair key = accountKey(); + AcmeState.Account source = new AcmeState.Account("account:test", DIRECTORY, 1, "1".repeat(64), + "2".repeat(64), key.getPublic().getEncoded(), AcmeState.AccountStatus.VALID, + List.of("mailto:operator@example.test"), true, Optional.empty(), NOW, NOW, "0".repeat(64)); + AcmeStateCodec codec = new AcmeStateCodec(); + AcmeState.Account sealed = (AcmeState.Account) codec.seal(source); + AcmeState.Account decoded = (AcmeState.Account) codec.decode(codec.encode(sealed)); + assertEquals(sealed.accountId(), decoded.accountId()); + assertEquals(sealed.commitment(), decoded.commitment()); + assertArrayEquals(sealed.publicKeySpki(), decoded.publicKeySpki()); + PkiServerConfiguration.AcmeListener configuration = listener(); + AcmeRateAdmission admission = new AcmeRateAdmission(configuration, CLOCK); + assertTrue(admission.admitOrder("account:test")); + assertFalse(admission.admitOrder("account:test")); + assertTrue(admission.tryAcquireFinalization()); + assertFalse(admission.tryAcquireFinalization()); + admission.releaseFinalization(); + System.out.println("...record-commitment=" + sealed.commitment().substring(0, 12)); + System.out.println("...ok"); + } + + private static PkiServerConfiguration.AcmeListener listener() throws Exception { + PkiServerConfiguration.Execution execution = new PkiServerConfiguration.Execution(1, 1, 1, 1, 1, + Duration.ofSeconds(5), Duration.ofSeconds(5), Duration.ofSeconds(1), Duration.ofSeconds(1)); + return new PkiServerConfiguration.AcmeListener("listener", InetAddress.getLoopbackAddress(), 0, + new ProviderConfig("tls-test", Map.of()), PkiServerConfiguration.AcmeTransportMode.DIRECT_TLS, + List.of(), Set.of(), URI.create("https://acme.example.test"), 4096, 1_048_576, + execution, execution, Duration.ofMinutes(5), 16, 16, 16, Duration.ofMinutes(1), + 2, 1, 2, 2, 1, List.of(), List.of()); + } + + private static byte[] jws(KeyPair key, String nonce, URI url, byte[] payload) throws Exception { + ECPublicKey publicKey = (ECPublicKey) key.getPublic(); + String x = coordinate(publicKey.getW().getAffineX()); + String y = coordinate(publicKey.getW().getAffineY()); + String protectedJson = "{\"alg\":\"ES256\",\"nonce\":\"" + nonce + "\",\"url\":\"" + + url + "\",\"jwk\":{\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"" + x + + "\",\"y\":\"" + y + "\"}}"; + String protectedValue = encode(protectedJson.getBytes(StandardCharsets.UTF_8)); + String payloadValue = encode(payload); + Signature signer = Signature.getInstance("SHA256withECDSAinP1363Format"); + signer.initSign(key.getPrivate(), deterministicRandom()); + signer.update((protectedValue + "." + payloadValue).getBytes(StandardCharsets.US_ASCII)); + return ("{\"protected\":\"" + protectedValue + "\",\"payload\":\"" + payloadValue + + "\",\"signature\":\"" + encode(signer.sign()) + "\"}").getBytes(StandardCharsets.UTF_8); + } + + private static KeyPair accountKey() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new ECGenParameterSpec("secp256r1"), deterministicRandom()); + return generator.generateKeyPair(); + } + + private static String coordinate(BigInteger value) { + byte[] source = value.toByteArray(); byte[] exact = new byte[32]; + int length = Math.min(source.length, exact.length); + System.arraycopy(source, source.length - length, exact, exact.length - length, length); + return encode(exact); + } + private static String encode(byte[] value) { return Base64.getUrlEncoder().withoutPadding().encodeToString(value); } + private static SecureRandom deterministicRandom() { + try { + SecureRandom value = SecureRandom.getInstance("SHA1PRNG"); + value.setSeed(new byte[] {1, 3, 3, 7}); return value; + } catch (java.security.NoSuchAlgorithmException impossible) { + throw new IllegalStateException(impossible); + } + } +} diff --git a/pki-server/src/test/java/zeroecho/pki/server/acme/TestAcmeEabProvider.java b/pki-server/src/test/java/zeroecho/pki/server/acme/TestAcmeEabProvider.java new file mode 100644 index 0000000..ef46516 --- /dev/null +++ b/pki-server/src/test/java/zeroecho/pki/server/acme/TestAcmeEabProvider.java @@ -0,0 +1,100 @@ +/******************************************************************************* + * 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.time.Duration; +import java.util.Base64; +import java.util.HexFormat; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import zeroecho.pki.server.spi.AcmeExternalAccountBindingProvider; +import zeroecho.pki.spi.ProviderConfig; + +/** Deterministic secret-free test proof of explicit EAB provider composition. */ +public final class TestAcmeEabProvider implements AcmeExternalAccountBindingProvider { + /** Stable test provider identity. */ + public static final String ID = "test.eab.v1"; + private static final byte[] SECRET = "zeroecho-test-eab-secret-32-bytes" + .getBytes(StandardCharsets.US_ASCII); + private static final Pattern FLATTENED = Pattern.compile("\\{\\\"protected\\\":\\\"([A-Za-z0-9_-]+)\\\"," + + "\\\"payload\\\":\\\"([A-Za-z0-9_-]+)\\\",\\\"signature\\\":\\\"([A-Za-z0-9_-]+)\\\"}"); + private static final Pattern JWK = Pattern.compile("\\{\\\"kty\\\":\\\"EC\\\",\\\"crv\\\":\\\"P-256\\\"," + + "\\\"x\\\":\\\"([A-Za-z0-9_-]{43})\\\",\\\"y\\\":\\\"([A-Za-z0-9_-]{43})\\\"}"); + + @Override public String id() { return ID; } + + @Override public Binding verify(Request request, ProviderConfig configuration) { + if (!ID.equals(configuration.backendId()) || !configuration.properties().isEmpty()) { + throw new SecurityException("Test EAB rejected"); + } + try { + Matcher match = FLATTENED.matcher(new String(request.nestedJws(), StandardCharsets.US_ASCII)); + if (!match.matches()) throw new SecurityException("Test EAB rejected"); + Base64.Decoder decoder = Base64.getUrlDecoder(); + String protectedValue = new String(decoder.decode(match.group(1)), StandardCharsets.UTF_8); + String payload = new String(decoder.decode(match.group(2)), StandardCharsets.UTF_8); + Matcher jwk = JWK.matcher(payload); + if (!jwk.matches()) throw new SecurityException("Test EAB rejected"); + String canonical = "{\"crv\":\"P-256\",\"kty\":\"EC\",\"x\":\"" + jwk.group(1) + + "\",\"y\":\"" + jwk.group(2) + "\"}"; + if (!protectedValue.contains("\"alg\":\"HS256\"") + || !protectedValue.contains("\"kid\":\"test-key\"") + || !protectedValue.contains("/acme/eab/new-account\"") + || !HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(canonical.getBytes(StandardCharsets.US_ASCII))) + .equals(request.accountKeyThumbprint())) { + throw new SecurityException("Test EAB rejected"); + } + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(SECRET, "HmacSHA256")); + byte[] expected = mac.doFinal((match.group(1) + "." + match.group(2)) + .getBytes(StandardCharsets.US_ASCII)); + byte[] actual = decoder.decode(match.group(3)); + if (!MessageDigest.isEqual(expected, actual)) throw new SecurityException("Test EAB rejected"); + return new Binding("test-key", "b".repeat(64), Set.of("localhost"), + request.now().plus(Duration.ofMinutes(5)), true); + } catch (SecurityException failure) { + throw failure; + } catch (Exception failure) { + throw new SecurityException("Test EAB rejected"); + } + } +} diff --git a/pki-server/src/test/java/zeroecho/pki/server/http/AcmeTestClient.java b/pki-server/src/test/java/zeroecho/pki/server/http/AcmeTestClient.java new file mode 100644 index 0000000..715ba92 --- /dev/null +++ b/pki-server/src/test/java/zeroecho/pki/server/http/AcmeTestClient.java @@ -0,0 +1,262 @@ +/******************************************************************************* + * 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.math.BigInteger; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.MessageDigest; +import java.security.Signature; +import java.security.interfaces.ECPublicKey; +import java.time.Duration; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import zeroecho.pki.application.PkiOperationValue; + +/** Narrow wire-level ACME test client exercising the real HTTPS/JWS boundary. */ +public final class AcmeTestClient implements AutoCloseable { + private static final String JOSE = "application/jose+json"; + private static final int MAXIMUM_RESPONSE = 2_097_152; + private final HttpClient client; + private final URI directoryUri; + private KeyPair accountKey; + private String accountKid; + private String nonce; + + /** Creates a client for one exact ACME directory URL and ES256 account key. */ + public AcmeTestClient(HttpClient client, URI directoryUri, KeyPair accountKey) { + this.client = Objects.requireNonNull(client, "client"); + this.directoryUri = Objects.requireNonNull(directoryUri, "directoryUri"); + this.accountKey = Objects.requireNonNull(accountKey, "accountKey"); + } + + /** Fetches the directory object and captures its response nonce. */ + public Response directory() throws Exception { return send(HttpRequest.newBuilder(directoryUri).GET().build()); } + + /** Obtains a fresh replay nonce using the advertised endpoint. */ + public Response nonce(URI endpoint) throws Exception { + return send(HttpRequest.newBuilder(endpoint).method("HEAD", HttpRequest.BodyPublishers.noBody()).build()); + } + + /** Creates an account using JWK authentication and retains its exact KID. */ + public Response createAccount(URI endpoint, String payload) throws Exception { + Response response = jwk(endpoint, payload); + accountKid = response.header("Location").orElseThrow(); + return response; + } + + /** Sends one JWK-authenticated ACME request. */ + public Response jwk(URI endpoint, String payload) throws Exception { + return post(endpoint, flattened(endpoint, payload.getBytes(StandardCharsets.UTF_8), accountKey, + Optional.of(jwk(accountKey)), Optional.empty(), true)); + } + + /** Sends one KID-authenticated ACME request. */ + public Response kid(URI endpoint, String payload) throws Exception { + if (accountKid == null) throw new IllegalStateException("ACME account has not been created"); + return post(endpoint, flattened(endpoint, payload.getBytes(StandardCharsets.UTF_8), accountKey, + Optional.empty(), Optional.of(accountKid), true)); + } + + /** Performs strict nested-JWS account-key rollover and adopts the replacement key. */ + public Response rollover(URI endpoint, KeyPair replacement) throws Exception { + String innerClaims = "{\"account\":\"" + accountKid + "\",\"oldKey\":" + jwk(accountKey) + "}"; + byte[] inner = flattened(endpoint, innerClaims.getBytes(StandardCharsets.UTF_8), replacement, + Optional.of(jwk(replacement)), Optional.empty(), false); + Response response = post(endpoint, flattened(endpoint, inner, accountKey, Optional.empty(), + Optional.of(accountKid), true)); + if (response.status() >= 200 && response.status() < 300) accountKey = replacement; + return response; + } + + /** Sends an authenticated POST-as-GET request. */ + public Response postAsGet(URI endpoint) throws Exception { return kid(endpoint, ""); } + + /** Returns the current exact account KID. */ + public String accountKid() { return accountKid; } + + /** Adopts an existing account URL for restart and superseded-key checks. */ + public void useAccount(String kid) { + if (kid == null || !kid.startsWith("https://")) { + throw new IllegalArgumentException("ACME account KID is invalid"); + } + accountKid = kid; + } + + /** Returns the current replay nonce, if one has been received. */ + public Optional replayNonce() { return Optional.ofNullable(nonce); } + + /** Returns the RFC 8555 key authorization for one challenge token. */ + public String keyAuthorization(String token) throws Exception { + String value = jwk(accountKey); + PkiOperationValue.ObjectValue parsed = (PkiOperationValue.ObjectValue) StrictJson.parse( + value.getBytes(StandardCharsets.UTF_8), 4_096); + String canonical = "{\"crv\":\"P-256\",\"kty\":\"EC\",\"x\":\"" + + ((PkiOperationValue.Text) parsed.fields().get("x")).value() + "\",\"y\":\"" + + ((PkiOperationValue.Text) parsed.fields().get("y")).value() + "\"}"; + return token + "." + encode(MessageDigest.getInstance("SHA-256") + .digest(canonical.getBytes(StandardCharsets.US_ASCII))); + } + + /** Constructs the strict nested HS256 EAB JWS used by the deterministic test provider. */ + public String externalAccountBinding(URI newAccount, String keyId, byte[] secret) throws Exception { + String protectedValue = encode(("{\"alg\":\"HS256\",\"kid\":\"" + keyId + + "\",\"url\":\"" + newAccount.toASCIIString() + "\"}").getBytes(StandardCharsets.UTF_8)); + String payload = encode(jwk(accountKey).getBytes(StandardCharsets.UTF_8)); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.clone(), "HmacSHA256")); + String signature = encode(mac.doFinal((protectedValue + "." + payload) + .getBytes(StandardCharsets.US_ASCII))); + return "{\"protected\":\"" + protectedValue + "\",\"payload\":\"" + payload + + "\",\"signature\":\"" + signature + "\"}"; + } + + /** Extracts a required text property from a strict JSON response. */ + public static String text(Response response, String field) { + PkiOperationValue value = response.json().fields().get(field); + if (value instanceof PkiOperationValue.Text text) return text.value(); + throw new IllegalArgumentException("Missing ACME response field: " + field); + } + + /** Extracts a required list of text values from a strict JSON response. */ + public static List textList(Response response, String field) { + PkiOperationValue value = response.json().fields().get(field); + if (!(value instanceof PkiOperationValue.ListValue list)) { + throw new IllegalArgumentException("Missing ACME response list: " + field); + } + return list.values().stream().map(item -> { + if (item instanceof PkiOperationValue.Text text) return text.value(); + throw new IllegalArgumentException("Invalid ACME response list: " + field); + }).toList(); + } + + /** Extracts one challenge object from an authorization response. */ + public static Challenge challenge(Response response) { + PkiOperationValue value = response.json().fields().get("challenges"); + if (!(value instanceof PkiOperationValue.ListValue list) || list.values().size() != 1 + || !(list.values().get(0) instanceof PkiOperationValue.Text url)) { + throw new IllegalArgumentException("ACME challenge response is invalid"); + } + return new Challenge(URI.create(url.value())); + } + + private Response post(URI endpoint, byte[] body) throws Exception { + HttpRequest request = HttpRequest.newBuilder(endpoint).timeout(Duration.ofSeconds(20)) + .header("Content-Type", JOSE).POST(HttpRequest.BodyPublishers.ofByteArray(body)).build(); + return send(request); + } + + private Response send(HttpRequest request) throws Exception { + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofByteArray()); + if (response.body().length > MAXIMUM_RESPONSE) throw new IllegalStateException("ACME response is oversized"); + response.headers().firstValue("Replay-Nonce").ifPresent(value -> nonce = value); + return new Response(response.statusCode(), response.headers().map(), response.body()); + } + + private byte[] flattened(URI endpoint, byte[] payload, KeyPair key, Optional protectedJwk, + Optional kid, boolean includeNonce) throws Exception { + if (protectedJwk.isPresent() == kid.isPresent()) throw new IllegalArgumentException("JWK/KID mode is invalid"); + StringBuilder header = new StringBuilder("{\"alg\":\"ES256\""); + if (includeNonce) { + if (nonce == null) throw new IllegalStateException("ACME nonce is unavailable"); + header.append(",\"nonce\":\"").append(nonce).append('"'); + } + header.append(",\"url\":\"").append(endpoint.toASCIIString()).append('"'); + protectedJwk.ifPresent(value -> header.append(",\"jwk\":").append(value)); + kid.ifPresent(value -> header.append(",\"kid\":\"").append(value).append('"')); + header.append('}'); + String encodedHeader = encode(header.toString().getBytes(StandardCharsets.UTF_8)); + String encodedPayload = encode(payload); + Signature signer = Signature.getInstance("SHA256withECDSAinP1363Format"); + signer.initSign(key.getPrivate()); + signer.update((encodedHeader + "." + encodedPayload).getBytes(StandardCharsets.US_ASCII)); + String document = "{\"protected\":\"" + encodedHeader + "\",\"payload\":\"" + + encodedPayload + "\",\"signature\":\"" + encode(signer.sign()) + "\"}"; + return document.getBytes(StandardCharsets.UTF_8); + } + + private static String jwk(KeyPair keyPair) { + ECPublicKey key = (ECPublicKey) keyPair.getPublic(); + return "{\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"" + + coordinate(key.getW().getAffineX()) + "\",\"y\":\"" + + coordinate(key.getW().getAffineY()) + "\"}"; + } + + private static String coordinate(BigInteger value) { + byte[] source = value.toByteArray(); byte[] exact = new byte[32]; + int length = Math.min(source.length, exact.length); + System.arraycopy(source, source.length - length, exact, exact.length - length, length); + return encode(exact); + } + + private static String encode(byte[] value) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(value); + } + + @Override public void close() { nonce = null; accountKid = null; } + + /** Immutable bounded HTTP response captured by the test client. */ + public record Response(int status, Map> headers, byte[] body) { + /** Defensively snapshots response data. */ + public Response { headers = Map.copyOf(headers); body = body.clone(); } + @Override public byte[] body() { return body.clone(); } + /** Returns one header without relying on implementation-specific casing. */ + public Optional header(String name) { + return headers.entrySet().stream().filter(entry -> entry.getKey().equalsIgnoreCase(name)) + .flatMap(entry -> entry.getValue().stream()).findFirst(); + } + /** Decodes a strict JSON object response. */ + public PkiOperationValue.ObjectValue json() { + PkiOperationValue value = StrictJson.parse(body, MAXIMUM_RESPONSE); + if (value instanceof PkiOperationValue.ObjectValue object) return object; + throw new IllegalArgumentException("ACME response is not an object"); + } + /** Returns the UTF-8 response body for safe assertions. */ + public String bodyText() { return new String(body, StandardCharsets.UTF_8); } + } + + /** One challenge URL selected from an authorization resource. */ + public record Challenge(URI url) { } +} diff --git a/pki-server/src/test/java/zeroecho/pki/server/http/HttpServerControlOperationCodecTest.java b/pki-server/src/test/java/zeroecho/pki/server/http/HttpServerControlOperationCodecTest.java index 02385ad..8676ee5 100644 --- a/pki-server/src/test/java/zeroecho/pki/server/http/HttpServerControlOperationCodecTest.java +++ b/pki-server/src/test/java/zeroecho/pki/server/http/HttpServerControlOperationCodecTest.java @@ -91,6 +91,25 @@ class HttpServerControlOperationCodecTest { System.out.println("...ok"); } + @Test + void decodesDirectoryRegistrationWithClosedPolicy() { + System.out.println("decodesDirectoryRegistrationWithClosedPolicy"); + String json = "{\"version\":1,\"authorityId\":\"ca:internal\",\"arguments\":{" + + "\"alias\":\"internal\",\"authorityId\":\"ca:internal\",\"profileId\":\"tls\"," + + "\"dnsNamespaces\":[\"example.test\"],\"maximumValidityMillis\":86400000," + + "\"publicKeyAlgorithms\":[\"ECDSA\"],\"x509BindingPolicies\":[\"STANDARD_ONLY\"]," + + "\"challengeTypes\":[\"DNS_01\"],\"challengeProviderIds\":[\"zeroecho.dns-01.jndi.v1\"]," + + "\"eabRequired\":false,\"disclosurePolicy\":\"OWNER_ONLY\"}}"; + HttpOperationCodec.Decoded decoded = decode(ServerControlOperation.RegisterAcmeDirectory.NAME, json); + ServerControlOperation.RegisterAcmeDirectory operation = assertInstanceOf( + ServerControlOperation.RegisterAcmeDirectory.class, + ((AdministrativeOperation.Control) decoded.operation()).operation()); + assertEquals("internal", operation.registration().alias()); + assertEquals("tls", operation.registration().profileId()); + System.out.println("...directory-policy=closed"); + System.out.println("...ok"); + } + private static HttpOperationCodec.Decoded decode(String operation, String json) { return HttpOperationCodec.decode(operation, json.getBytes(StandardCharsets.UTF_8), 16_384, REALM, DESCRIPTORS::require); diff --git a/pki-server/src/test/resources/META-INF/services/zeroecho.pki.server.spi.AcmeExternalAccountBindingProvider b/pki-server/src/test/resources/META-INF/services/zeroecho.pki.server.spi.AcmeExternalAccountBindingProvider new file mode 100644 index 0000000..ccbbc0c --- /dev/null +++ b/pki-server/src/test/resources/META-INF/services/zeroecho.pki.server.spi.AcmeExternalAccountBindingProvider @@ -0,0 +1 @@ +zeroecho.pki.server.acme.TestAcmeEabProvider diff --git a/pki/src/main/java/zeroecho/pki/api/issuance/IssuanceIntent.java b/pki/src/main/java/zeroecho/pki/api/issuance/IssuanceIntent.java new file mode 100644 index 0000000..3065fb9 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/issuance/IssuanceIntent.java @@ -0,0 +1,81 @@ +/******************************************************************************* + * 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.api.issuance; + +import java.util.Objects; + +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.attr.AttributeId; +import zeroecho.pki.api.profile.CertificateProfileRef; + +/** + * Durable idempotency and frozen-selection authority for one issuance request. + * + *

The identifier is supplied by a trusted orchestration layer such as ACME. + * It is not a credential, secret, bearer token, or operation permission. Reuse is + * valid only when the complete command commitment and frozen PKI selection match.

+ * + * @param issuanceId stable caller correlation identity + * @param commandCommitment SHA-256 commitment of canonical safe issuance input + * @param expectedProfile exact activated profile reference + * @param expectedIssuerId exact issuer generation + * @param expectedChainPathId exact issuance chain path + * @param expectedChainPathCommitment frozen chain-path commitment + */ +public record IssuanceIntent(String issuanceId, String commandCommitment, + CertificateProfileRef expectedProfile, PkiId expectedIssuerId, + PkiId expectedChainPathId, String expectedChainPathCommitment) { + /** Stable internal metadata attribute carrying the non-secret issuance identity. */ + public static final AttributeId ID_ATTRIBUTE = new AttributeId("urn:zeroecho:pki:issuance-id:v1"); + /** Stable internal metadata attribute carrying the canonical command commitment. */ + public static final AttributeId COMMITMENT_ATTRIBUTE = + new AttributeId("urn:zeroecho:pki:issuance-command-commitment:v1"); + /** Validates the transport-neutral frozen issuance authority. */ + public IssuanceIntent { + if (issuanceId == null || !issuanceId.matches("[a-z0-9][a-z0-9._:-]{0,127}")) { + throw new IllegalArgumentException("Issuance identity is invalid"); + } + requireDigest(commandCommitment); + Objects.requireNonNull(expectedProfile, "expectedProfile"); + Objects.requireNonNull(expectedIssuerId, "expectedIssuerId"); + Objects.requireNonNull(expectedChainPathId, "expectedChainPathId"); + requireDigest(expectedChainPathCommitment); + } + + private static void requireDigest(String value) { + if (value == null || !value.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException("Issuance commitment is invalid"); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/api/issuance/IssueEndEntityCommand.java b/pki/src/main/java/zeroecho/pki/api/issuance/IssueEndEntityCommand.java index 1c82b42..1bb932b 100644 --- a/pki/src/main/java/zeroecho/pki/api/issuance/IssueEndEntityCommand.java +++ b/pki/src/main/java/zeroecho/pki/api/issuance/IssueEndEntityCommand.java @@ -48,9 +48,16 @@ import zeroecho.pki.api.request.ParsedCertificationRequest; * @param profileId profile id governing issuance * @param validityOverride optional requested validity override * (policy-validated) + * @param issuanceIntent optional durable correlation and frozen selection */ public record IssueEndEntityCommand(PkiId issuerCaId, ParsedCertificationRequest request, String profileId, - Optional validityOverride) { + Optional validityOverride, Optional issuanceIntent) { + + /** Creates an ordinary non-correlated administrative issuance command. */ + public IssueEndEntityCommand(PkiId issuerCaId, ParsedCertificationRequest request, String profileId, + Optional validityOverride) { + this(issuerCaId, request, profileId, validityOverride, Optional.empty()); + } /** * Creates an issuance command. @@ -71,5 +78,8 @@ public record IssueEndEntityCommand(PkiId issuerCaId, ParsedCertificationRequest if (validityOverride == null) { throw new IllegalArgumentException("validityOverride must not be null"); } + if (issuanceIntent == null) { + throw new IllegalArgumentException("issuanceIntent must not be null"); + } } } diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java index a33c527..553d1fd 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java @@ -54,6 +54,7 @@ import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiId; import zeroecho.pki.api.ProfileService; import zeroecho.pki.api.attr.AttributeValue; +import zeroecho.pki.api.attr.AttributeSet; import zeroecho.pki.api.audit.AuditEvent; import zeroecho.pki.api.audit.Principal; import zeroecho.pki.api.audit.Purpose; @@ -67,6 +68,7 @@ import zeroecho.pki.api.credential.EffectiveCredentialStatus; import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver; import zeroecho.pki.api.issuance.BundleCommand; import zeroecho.pki.api.issuance.IssueEndEntityCommand; +import zeroecho.pki.api.issuance.IssuanceIntent; import zeroecho.pki.api.issuance.ReissueCommand; import zeroecho.pki.api.issuance.RenewCommand; import zeroecho.pki.api.issuance.ReplaceCommand; @@ -86,6 +88,7 @@ import zeroecho.pki.spi.audit.AuditSink; import zeroecho.pki.spi.framework.CredentialFramework; import zeroecho.pki.spi.framework.CredentialIssuerBackend; import zeroecho.pki.spi.store.PkiStore; +import zeroecho.pki.impl.core.attr.SimpleAttributeSet; /** * Default implementation of {@link IssuanceService}. @@ -142,7 +145,7 @@ import zeroecho.pki.spi.store.PkiStore; *

*/ // PMD cannot infer that retaining boundary causes would violate the redaction contract. -@SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.PreserveStackTrace" }) +@SuppressWarnings({ "PMD.AvoidSynchronizedAtMethodLevel", "PMD.CyclomaticComplexity", "PMD.PreserveStackTrace" }) public final class DefaultIssuanceService implements IssuanceService { /** @@ -227,8 +230,13 @@ public final class DefaultIssuanceService implements IssuanceService { * persistence of the validated leaf fails */ @Override - public CredentialBundle issueEndEntity(IssueEndEntityCommand command) { + public synchronized CredentialBundle issueEndEntity(IssueEndEntityCommand command) { Objects.requireNonNull(command, "command"); + Optional committed = command.issuanceIntent() + .flatMap(store::getCredentialByIssuanceIntent); + if (committed.isPresent()) { + return committedBundle(command, committed.orElseThrow()); + } CaRecord issuer = store.getCa(command.issuerCaId()).orElseThrow(() -> new PkiException("Issuer CA not found")); if (issuer.state() != CaState.ACTIVE) { @@ -242,6 +250,11 @@ public final class DefaultIssuanceService implements IssuanceService { throw rejection(candidate.request(), "PROFILE_NOT_ACTIVE"); } CertificateProfile profile = active.profile(); + command.issuanceIntent().ifPresent(intent -> { + if (!intent.expectedProfile().equals(active.reference())) { + throw new PkiException("Frozen issuance profile is unavailable"); + } + }); if (!command.profileId().equals(active.reference().profileId())) { throw rejection(candidate.request(), "PROFILE_ID_MISMATCH"); } @@ -255,6 +268,7 @@ public final class DefaultIssuanceService implements IssuanceService { CredentialUse.END_ENTITY_ISSUER, statusEvaluation)); zeroecho.pki.api.ca.IssuerGeneration generation = IssuerAuthorities.current(store, issuer); zeroecho.pki.api.ca.IssuerChainPath issuancePath = IssuerAuthorities.issuancePath(store, issuer); + command.issuanceIntent().ifPresent(intent -> requireFrozenSelection(intent, generation, issuancePath)); ValidatedCertificateRequest validated; try { validated = CertificateProfileValidator.validate(candidate, profile, active.reference(), issuerCred, @@ -274,10 +288,46 @@ public final class DefaultIssuanceService implements IssuanceService { requireIssuedCredentialMatches(validated, issuerCred, serial, bundle, candidate.request()); Credential exactCredential = IssuerAuthorities.withIssuer(bundle.credential(), new zeroecho.pki.api.IssuerRef(issuer.caId(), generation.issuerId(), issuancePath.pathId())); + if (command.issuanceIntent().isPresent()) { + IssuanceIntent intent = command.issuanceIntent().orElseThrow(); + AttributeSet attributes = SimpleAttributeSet.builder().putAll(exactCredential.attributes()) + .put(IssuanceIntent.ID_ATTRIBUTE, new AttributeValue.StringValue(intent.issuanceId())) + .put(IssuanceIntent.COMMITMENT_ATTRIBUTE, + new AttributeValue.StringValue(intent.commandCommitment())).build(); + exactCredential = new Credential(exactCredential.credentialId(), exactCredential.formatId(), + exactCredential.issuerRef(), exactCredential.subjectRef(), exactCredential.validity(), + exactCredential.serialOrUniqueId(), exactCredential.publicKeyId(), + exactCredential.profileBinding(), exactCredential.status(), exactCredential.content(), attributes); + } store.putCredential(exactCredential); return new CredentialBundle(exactCredential, pathContent(issuancePath)); } + private CredentialBundle committedBundle(IssueEndEntityCommand command, Credential credential) { + IssuanceIntent intent = command.issuanceIntent().orElseThrow(); + if (!credential.issuerRef().caId().equals(command.issuerCaId()) + || !credential.issuerRef().issuerId().equals(intent.expectedIssuerId()) + || !credential.issuerRef().chainPathId().equals(intent.expectedChainPathId())) { + throw new PkiException("Committed issuance selection mismatch"); + } + zeroecho.pki.api.ca.IssuerChainPath path = store.getIssuerChainPath(intent.expectedChainPathId()) + .orElseThrow(() -> new PkiException("Committed issuance chain path is unavailable")); + if (!path.pathCommitment().equals(intent.expectedChainPathCommitment())) { + throw new PkiException("Committed issuance chain commitment mismatch"); + } + return new CredentialBundle(CredentialSnapshots.copy(credential), pathContent(path)); + } + + private static void requireFrozenSelection(IssuanceIntent intent, + zeroecho.pki.api.ca.IssuerGeneration generation, + zeroecho.pki.api.ca.IssuerChainPath path) { + if (!generation.issuerId().equals(intent.expectedIssuerId()) + || !path.pathId().equals(intent.expectedChainPathId()) + || !path.pathCommitment().equals(intent.expectedChainPathCommitment())) { + throw new PkiException("Frozen issuance selection is no longer active"); + } + } + /** * Selects the issuer credential that should be used for issuance in the given * format. diff --git a/pki/src/main/java/zeroecho/pki/impl/core/async/PkiSigningBus.java b/pki/src/main/java/zeroecho/pki/impl/core/async/PkiSigningBus.java index 93b76be..aea52a6 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/async/PkiSigningBus.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/async/PkiSigningBus.java @@ -62,6 +62,7 @@ import zeroecho.pki.api.audit.Principal; import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy; import zeroecho.pki.api.orch.SigningSubmissionId; import zeroecho.pki.api.orch.WorkflowStateRecord; +import zeroecho.pki.impl.framework.x509.X509AlgorithmResolver; import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot; import zeroecho.pki.impl.framework.x509.X509ExecutionPlan; import zeroecho.pki.spi.crypto.SignatureWorkflow; @@ -332,10 +333,25 @@ public final class PkiSigningBus implements AutoCloseable { if (supportedAlgorithms.isEmpty()) { throw new IllegalArgumentException("Signature workflow must declare a signing identity"); } - for (String algorithm : supportedAlgorithms) { - X509ExecutionPlan plan = authority.planSigning(algorithm, - workflowImplementationId(workflow), SignatureWorkflow.class); - authority.authorize(plan, workflow, AlgorithmExecutionCapability.Direction.SIGN); + int x509Capable = 0; + for (String algorithm : supportedAlgorithms.stream().sorted().toList()) { + try { + X509ExecutionPlan plan = authority.planSigning(algorithm, + workflowImplementationId(workflow), SignatureWorkflow.class); + authority.authorize(plan, workflow, AlgorithmExecutionCapability.Direction.SIGN); + x509Capable++; + } catch (X509AlgorithmResolver.ResolutionException unavailable) { + if (unavailable.failure() != X509AlgorithmResolver.Failure.NO_BINDING) { + throw unavailable; + } + } catch (IllegalArgumentException unavailable) { + if (!"Unknown algorithm identity".equals(unavailable.getMessage())) { + throw unavailable; + } + } + } + if (x509Capable == 0) { + throw new IllegalArgumentException("Signature workflow has no X.509-capable signing identity"); } } diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java index 2eb9433..b9e96a2 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java @@ -84,6 +84,8 @@ import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.ca.IssuerChainPath; import zeroecho.pki.api.ca.IssuerGeneration; import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.attr.AttributeValue; +import zeroecho.pki.api.issuance.IssuanceIntent; import zeroecho.pki.api.orch.SigningSubmissionId; import zeroecho.pki.api.orch.WorkflowStateRecord; import zeroecho.pki.api.policy.PolicyTrace; @@ -173,7 +175,7 @@ import zeroecho.pki.spi.store.RevocationHistory; */ @SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity", "PMD.TooManyMethods", "PMD.ExcessivePublicCount", "PMD.UseTryWithResources", "PMD.ExceptionAsFlowControl", - "PMD.PreserveStackTrace", "PMD.NcssCount" }) + "PMD.PreserveStackTrace", "PMD.NcssCount", "PMD.AvoidDuplicateLiterals" }) public final class FilesystemPkiStore implements PkiStore, Closeable { private static final Logger LOG = Logger.getLogger(FilesystemPkiStore.class.getName()); @@ -704,6 +706,43 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { } } + @Override + public Optional getCredentialByIssuanceIntent(IssuanceIntent intent) { + requireStoreUsable(); + Objects.requireNonNull(intent, "intent"); + Path root = paths.root().resolve("credentials").resolve("by-id"); + if (!Files.isDirectory(root)) { + return Optional.empty(); + } + Credential match = null; + try (Stream records = Files.list(root)) { + java.util.Iterator iterator = records + .filter(path -> path.getFileName().toString().endsWith(".bin")) + .sorted(Comparator.comparing(path -> path.getFileName().toString())).iterator(); + while (iterator.hasNext()) { + Credential credential = FsCodec.decode(FsCodec.CREDENTIAL, + FsOperations.readAll(iterator.next()), stagedContent); + Optional issuance = credential.attributes().get(IssuanceIntent.ID_ATTRIBUTE); + if (issuance.orElse(null) instanceof AttributeValue.StringValue value + && intent.issuanceId().equals(value.value())) { + Optional commitment = credential.attributes() + .get(IssuanceIntent.COMMITMENT_ATTRIBUTE); + if (!(commitment.orElse(null) instanceof AttributeValue.StringValue command) + || !intent.commandCommitment().equals(command.value())) { + throw new IllegalStateException("Issuance identity was reused with different input"); + } + if (match != null) { + throw new IllegalStateException("Duplicate issuance identity"); + } + match = credentialContentTransactions.validateLoaded(credential.credentialId(), credential); + } + } + return Optional.ofNullable(match); + } catch (IOException failure) { + throw new IllegalStateException("Issuance correlation read failed", failure); + } + } + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") private CaRecord validateCaCredentialReferences(CaRecord record) { for (PkiId issuerId : record.issuerIds()) { diff --git a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java index 9426848..be3f822 100644 --- a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java +++ b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java @@ -42,6 +42,7 @@ import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.ca.IssuerChainPath; import zeroecho.pki.api.ca.IssuerGeneration; import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.issuance.IssuanceIntent; import zeroecho.pki.api.orch.WorkflowStateRecord; import zeroecho.pki.api.policy.PolicyTrace; import zeroecho.pki.api.profile.ActiveCertificateProfile; @@ -193,6 +194,16 @@ public interface PkiStore extends SignWorkflowStore, AutoCloseable { */ Optional getCredential(PkiId credentialId); + /** + * Resolves the exact credential atomically persisted with one issuance intent. + * Implementations must reject duplicate matches and mismatched command + * commitments rather than selecting by list order. + * + * @param intent exact trusted issuance identity and commitment + * @return the previously committed credential, when present + */ + Optional getCredentialByIssuanceIntent(IssuanceIntent intent); + /** * Persists a parsed certification request. * diff --git a/pki/src/test/java/zeroecho/pki/e2e/H7EndEntityAcceptanceE2eTest.java b/pki/src/test/java/zeroecho/pki/e2e/H7EndEntityAcceptanceE2eTest.java index 905e361..731946f 100644 --- a/pki/src/test/java/zeroecho/pki/e2e/H7EndEntityAcceptanceE2eTest.java +++ b/pki/src/test/java/zeroecho/pki/e2e/H7EndEntityAcceptanceE2eTest.java @@ -278,7 +278,7 @@ final class H7EndEntityAcceptanceE2eTest { assertTrue(serial.signum() > 0); assertTrue(serial.toByteArray().length <= 20); } - assertEquals(List.of("issuerCaId", "request", "profileId", "validityOverride"), + assertEquals(List.of("issuerCaId", "request", "profileId", "validityOverride", "issuanceIntent"), Arrays.stream(IssueEndEntityCommand.class.getRecordComponents()).map(component -> component.getName()) .toList()); diff --git a/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java b/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java index 706b2a5..cc10101 100644 --- a/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java +++ b/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java @@ -87,6 +87,7 @@ import zeroecho.pki.api.credential.EffectiveCredentialStatus; import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver; import zeroecho.pki.api.issuance.BundleCommand; import zeroecho.pki.api.issuance.IssueEndEntityCommand; +import zeroecho.pki.api.issuance.IssuanceIntent; import zeroecho.pki.api.issuance.VerificationPolicy; import zeroecho.pki.api.request.CertificationRequest; import zeroecho.pki.api.request.ParsedCertificationRequest; @@ -157,7 +158,17 @@ public final class PkiCoreE2eTest { runtime.caService().selectIssuancePath(rootCaId, root.currentIssuanceIssuerId(), root.issuanceChainPathId(), "restore explicit test selection"); - issuance.issueEndEntity(new IssueEndEntityCommand(rootCaId, leafRequest, "default", Optional.empty())); + CaRecord selected = runtime.caService().getCa(rootCaId); + zeroecho.pki.api.ca.IssuerChainPath path = runtime.store() + .getIssuerChainPath(selected.issuanceChainPathId()).orElseThrow(); + IssuanceIntent intent = new IssuanceIntent("issuance:matrix-leaf", "a".repeat(64), + runtime.profileService().requireActiveProfile("default").reference(), + selected.currentIssuanceIssuerId(), selected.issuanceChainPathId(), path.pathCommitment()); + IssueEndEntityCommand command = new IssueEndEntityCommand(rootCaId, leafRequest, "default", + Optional.empty(), Optional.of(intent)); + CredentialBundle first = issuance.issueEndEntity(command); + CredentialBundle repeated = issuance.issueEndEntity(command); + assertEquals(first.credential().credentialId(), repeated.credential().credentialId()); assertEquals(List.of(usable.credentialId()), List.copyOf(resolved)); assertEquals(1, backend.endEntityCalls.get()); } diff --git a/pki/src/test/java/zeroecho/pki/impl/core/H7ProfileEnforcementTest.java b/pki/src/test/java/zeroecho/pki/impl/core/H7ProfileEnforcementTest.java index 85576c0..14c6041 100644 --- a/pki/src/test/java/zeroecho/pki/impl/core/H7ProfileEnforcementTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/core/H7ProfileEnforcementTest.java @@ -345,7 +345,7 @@ final class H7ProfileEnforcementTest { .issueEndEntity(new IssueEndEntityCommand(caId, request, "requested", Optional.empty()))); assertEquals(signs, runtime.submittedSignCount()); - assertEquals(List.of("issuerCaId", "request", "profileId", "validityOverride"), + assertEquals(List.of("issuerCaId", "request", "profileId", "validityOverride", "issuanceIntent"), java.util.Arrays.stream(IssueEndEntityCommand.class.getRecordComponents()) .map(component -> component.getName()).toList()); }