From 44cbb0a37d4d86888e164ceae3d1301c55371d73 Mon Sep 17 00:00:00 2001 From: Leo Galambos Date: Wed, 5 Aug 2026 18:32:00 +0200 Subject: [PATCH] feat(pki-server): add OCSP responder and close server release Add durable multi-authority OCSP responders with strict request parsing, issuer-bound serial lookup, stable revocation views, signed responses, nonce policies and bounded protocol execution. Complete in-process and packaged OCSP validation and close the PKI server after the final architecture, security and release audit. --- docs/pki-server-acme.md | 2 +- docs/pki-server-loopback-example.json | 2 +- docs/pki-server-ocsp.md | 137 +++++ docs/pki-server-production-example.json | 2 +- ...ki-server-trusted-proxy-nginx-example.json | 2 +- ...-server-trusted-proxy-rfc9440-example.json | 2 +- pki-server/build.gradle | 9 + .../pki/server/OcspResponderService.java | 480 ++++++++++++++++++ .../server/OperationSecurityDescriptors.java | 22 +- .../java/zeroecho/pki/server/Permission.java | 4 +- .../pki/server/PkiServerConfiguration.java | 2 +- .../pki/server/ServerControlOperation.java | 32 +- .../ServerControlOperationExecutor.java | 31 ++ .../pki/server/ServerControlStore.java | 101 ++-- .../pki/server/ServerOperationGateway.java | 15 + .../pki/server/ServerRealmContext.java | 9 +- .../pki/server/acme/AcmeControlStore.java | 30 +- .../pki/server/http/HttpOperationCodec.java | 38 ++ .../pki/server/http/OcspHttpHandler.java | 333 ++++++++++++ .../pki/server/http/OcspRequestParser.java | 120 +++++ .../http/PublicRepositoryTransport.java | 22 +- .../pki/server/http/ServerRuntime.java | 12 +- .../zeroecho/pki/server/AcmeEndToEndTest.java | 231 ++++++++- .../pki/server/OcspResponderServiceTest.java | 236 +++++++++ .../ServerControlOperationExecutorTest.java | 6 +- .../server/ServerOperationGatewayTest.java | 2 +- .../server/http/OcspRequestParserTest.java | 154 ++++++ .../DefaultOcspResponseService.java | 318 ++++++++++++ .../pki/application/DefaultPkiRepository.java | 7 + .../pki/application/DefaultPkiSession.java | 10 + .../pki/application/OcspResponseService.java | 151 ++++++ .../pki/application/PkiRepository.java | 7 + .../zeroecho/pki/application/PkiSession.java | 18 + .../pki/impl/fs/FilesystemPkiStore.java | 208 +++++++- .../fs/FilesystemRevocationAuthority.java | 77 ++- .../java/zeroecho/pki/spi/store/PkiStore.java | 16 + .../pki/spi/store/RevocationView.java | 52 ++ .../pki/impl/fs/FilesystemPkiStoreTest.java | 38 +- 38 files changed, 2831 insertions(+), 107 deletions(-) create mode 100644 docs/pki-server-ocsp.md create mode 100644 pki-server/src/main/java/zeroecho/pki/server/OcspResponderService.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/http/OcspHttpHandler.java create mode 100644 pki-server/src/main/java/zeroecho/pki/server/http/OcspRequestParser.java create mode 100644 pki-server/src/test/java/zeroecho/pki/server/OcspResponderServiceTest.java create mode 100644 pki-server/src/test/java/zeroecho/pki/server/http/OcspRequestParserTest.java create mode 100644 pki/src/main/java/zeroecho/pki/application/DefaultOcspResponseService.java create mode 100644 pki/src/main/java/zeroecho/pki/application/OcspResponseService.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/store/RevocationView.java diff --git a/docs/pki-server-acme.md b/docs/pki-server-acme.md index a6a1a1c..8b0f443 100644 --- a/docs/pki-server-acme.md +++ b/docs/pki-server-acme.md @@ -8,7 +8,7 @@ An ACME directory is an immutable policy revision bound to one realm, logical au 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: +The server configuration schema is version 5. ACME is disabled explicitly with: ```json "acmeListener": {"enabled": false} diff --git a/docs/pki-server-loopback-example.json b/docs/pki-server-loopback-example.json index 4a1ba27..2f1cfe1 100644 --- a/docs/pki-server-loopback-example.json +++ b/docs/pki-server-loopback-example.json @@ -1,5 +1,5 @@ { - "version": 4, + "version": 5, "serverName": "zeroecho-admin", "realm": { "realmId": "production", diff --git a/docs/pki-server-ocsp.md b/docs/pki-server-ocsp.md new file mode 100644 index 0000000..2dc1b7f --- /dev/null +++ b/docs/pki-server-ocsp.md @@ -0,0 +1,137 @@ +# ZeroEcho OCSP responder + +ZeroEcho serves OCSP only on the separately bounded public listener at +`/ocsp/{responderAlias}`. Administrative operations remain under `/admin/v1`, +the public repository remains under `/public/v1`, and ACME remains on its own +listener. All four surfaces share one realm and one long-lived `PkiSession`, but +they do not share authorization or admission authority. + +## Responder authority + +An OCSP responder is a durable server-control record. Its alias binds exactly +one realm, logical authority, issuer generation, responder certificate, +`KeyRef`, explicit chain path, signature algorithm/binding commitment, nonce +policy, response-validity policy, accepted CertID hashes, and finite request +bounds. Activation revalidates those dependencies and freezes their +commitments. There is no first/last issuer selection, filename inference, +timestamp inference, runtime path building, or fallback signing algorithm. + +The unified administrative catalog exposes: + +- `ocsp.responder.register` +- `ocsp.responder.inspect` +- `ocsp.responder.list` +- `ocsp.responder.activate` +- `ocsp.responder.deactivate` + +Inspection requires `OCSP_RESPONDER_READ`; mutation requires the scoped +`OCSP_ADMINISTER` permission. Registration and activation are high-risk +operations and use the existing approval policy. Registration input does not +accept authoritative creation timestamps, lifecycle state, or record +commitments; the server creates those values. Ordinary results omit `KeyRef`, +certificate DER, and provider configuration. + +## Signing modes + +`ISSUER_SIGNED` is intended for explicitly approved internal deployments. The +configured credential and `KeyRef` must be the exact issuer-generation binding. +It should not be used to place an offline root key in an online service. + +`DELEGATED_RESPONDER` is preferred for public online operation. Before +activation ZeroEcho verifies the responder certificate, its exact issuer +relationship, validity, `id-kp-OCSPSigning` EKU, digital-signature key usage +when present, explicit chain path, signing binding, and configured key +capability. Private keys remain confined behind `KeyRef` and the existing +signing workflow; the HTTP layer never obtains a `PrivateKey`. + +Conceptual registration policies: + +```text +issuer-signed internal: + signingMode = ISSUER_SIGNED + responderCredentialId = exact issuer-generation credential + noncePolicy = REQUIRED + +delegated public: + signingMode = DELEGATED_RESPONDER + responderCredentialId = exact OCSP-signing credential + noncePolicy = REJECT + +cacheable public: + noncePolicy = REJECT + cacheLifetime <= responseValidity + +nonce echo: + noncePolicy = OPTIONAL_ECHO + nonce-bearing responses use no-store +``` + +## Requests and responses + +POST uses `Content-Type: application/ocsp-request`. GET uses an unpadded, +canonical URL-safe Base64 request path: + +```text +/ocsp/{responderAlias}/{base64url-request} +``` + +Query-string requests and alternate Base64 normalization are rejected. The +strict DER parser accepts unsigned requests only, consumes the complete input, +rejects duplicate or unknown critical extensions, and enforces configured byte +and entry bounds. Signed OCSP requests are rejected in this release; a request +signature is never treated as administrative or certificate-owner authority. + +Configured CertID hashing supports SHA-1 and SHA-256. SHA-1 is permitted only +for RFC-compatible issuer-name/key hashes. It is never enabled as a certificate +or OCSP response signature algorithm. The responder validates hashes against +its exact issuer-generation certificate and performs a disk-backed direct +issuer-generation-plus-serial lookup; it never searches another authority or +scans all credentials per request. + +One response captures one revocation revision and commitment. `good` means the +exact credential was issued by that issuer and is not revoked in that stable +ZeroEcho view; it is not a general statement of application validity. +`revoked` preserves the authoritative revocation time and supported reason. +`unknown` means exact issuance could not be established. Corrupt or +recovery-required authority returns a transient protocol/transport failure, not +`unknown`. + +Successful responses are strict signed DER with +`application/ocsp-response`, `nosniff`, `producedAt`, `thisUpdate`, and +`nextUpdate`. Nonce-free responses carry a strong ETag and bounded public cache +policy. An echoed nonce is returned exactly and makes the response non-cacheable. +No request DER, response DER, serial, issuer hash, nonce, certificate identity, +key reference, or parser/provider exception is logged or audited. + +## Deployment topology and capacity + +Direct mode permits anonymous OCSP over the public TLS listener. An optional +public client certificate does not change certificate status, and forwarded +identity headers are rejected. In trusted-reverse-proxy mode the backend proxy +hop still requires mTLS and the dedicated forwarding permission; proxy and +forwarded end-client identities do not authorize or select OCSP status. Source +IP, `Forwarded`, and `X-Forwarded-For` are never responder authority. + +OCSP has a separate bounded runtime lane even though it shares the public +transport listener. Parsing, lookup, signing, and response validation are +protected by finite request, queue, admission, concurrency, and deadline bounds. +OCSP saturation cannot consume administrative, public-stream, or ACME operation +permits. Shutdown quiesces admission, cancels remaining operations after the +finite grace period, and never retries an uncertain signature automatically. + +Example topologies use the existing strict public-listener configuration: + +```text +direct public listener: + public TLS identity + DIRECT_MTLS optional-client mapping policy + +trusted proxy public listener: + proxy-to-ZeroEcho mTLS + dedicated proxy principal + + FORWARD_AUTHENTICATED_CLIENT_IDENTITY +``` + +The current release is single-node. It has no distributed responder cache, +cluster coordination, or automatic status/checkpoint scheduling. Deployments +must route a responder alias to the node owning its realm and provision normal +external availability monitoring without treating that monitoring as PKI +authority. diff --git a/docs/pki-server-production-example.json b/docs/pki-server-production-example.json index 9093368..a7a5d0b 100644 --- a/docs/pki-server-production-example.json +++ b/docs/pki-server-production-example.json @@ -1,5 +1,5 @@ { - "version": 4, + "version": 5, "serverName": "zeroecho-admin", "realm": { "realmId": "production", diff --git a/docs/pki-server-trusted-proxy-nginx-example.json b/docs/pki-server-trusted-proxy-nginx-example.json index 3c0de17..3aaecd6 100644 --- a/docs/pki-server-trusted-proxy-nginx-example.json +++ b/docs/pki-server-trusted-proxy-nginx-example.json @@ -1,5 +1,5 @@ { - "version": 4, + "version": 5, "serverName": "zeroecho-admin-nginx", "realm": { "realmId": "production", diff --git a/docs/pki-server-trusted-proxy-rfc9440-example.json b/docs/pki-server-trusted-proxy-rfc9440-example.json index a5ed93b..3e6efdf 100644 --- a/docs/pki-server-trusted-proxy-rfc9440-example.json +++ b/docs/pki-server-trusted-proxy-rfc9440-example.json @@ -1,5 +1,5 @@ { - "version": 4, + "version": 5, "serverName": "zeroecho-admin-proxy", "realm": { "realmId": "production", diff --git a/pki-server/build.gradle b/pki-server/build.gradle index 4fb706d..5b88dda 100644 --- a/pki-server/build.gradle +++ b/pki-server/build.gradle @@ -6,12 +6,20 @@ plugins { group = 'org.egothor' +configurations { + mockitoAgent +} + dependencies { api project(':pki') implementation project(':lib') implementation platform('tools.jackson:jackson-bom:3.1.5') implementation 'tools.jackson.core:jackson-core' testImplementation 'org.bouncycastle:bcpkix-jdk18on:1.84' + testImplementation 'org.mockito:mockito-core:5.23.0' + mockitoAgent('org.mockito:mockito-core:5.23.0') { + transitive = false + } } application { @@ -21,6 +29,7 @@ application { tasks.named('test') { dependsOn tasks.named('installDist') + jvmArgs("-javaagent:${configurations.mockitoAgent.singleFile}") } jar { diff --git a/pki-server/src/main/java/zeroecho/pki/server/OcspResponderService.java b/pki-server/src/main/java/zeroecho/pki/server/OcspResponderService.java new file mode 100644 index 0000000..6576c8b --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/OcspResponderService.java @@ -0,0 +1,480 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.server; + +import java.io.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.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder; + +import zeroecho.pki.api.KeyRef; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.algorithm.X509AlgorithmBindingRegistry; +import zeroecho.pki.api.ca.IssuerChainPath; +import zeroecho.pki.api.ca.IssuerGeneration; +import zeroecho.pki.application.OcspResponseService; +import zeroecho.pki.application.PkiRepository; + +/** Durable exact responder-binding authority over the server control store. */ +@SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.ControlStatementBraces", + "PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.AvoidLiteralsInIfCondition", + "PMD.LinguisticNaming", "PMD.UseObjectForClearerAPI", "PMD.ExcessiveParameterList", + "PMD.PreserveStackTrace", "PMD.AvoidCatchingGenericException", "PMD.UseEnumCollections", + "PMD.ExceptionAsFlowControl" }) +public final class OcspResponderService { + /** Exact signing authority. */ + public enum SigningMode { + ISSUER_SIGNED(1), DELEGATED_RESPONDER(2); + private final int code; + SigningMode(int code) { this.code = code; } + /** Stable persistence code. */ public int code() { return code; } + /** Resolves an exact stable code. */ + public static SigningMode fromCode(int code) { + return switch (code) { case 1 -> ISSUER_SIGNED; case 2 -> DELEGATED_RESPONDER; + default -> throw new IllegalArgumentException("Unknown OCSP signing mode code"); }; + } + } + /** Closed nonce behavior. */ + public enum NoncePolicy { + REJECT(1), OPTIONAL_ECHO(2), REQUIRED(3); + private final int code; + NoncePolicy(int code) { this.code = code; } + /** Stable persistence code. */ public int code() { return code; } + /** Resolves an exact stable code. */ + public static NoncePolicy fromCode(int code) { + return switch (code) { case 1 -> REJECT; case 2 -> OPTIONAL_ECHO; case 3 -> REQUIRED; + default -> throw new IllegalArgumentException("Unknown OCSP nonce policy code"); }; + } + } + /** Durable activation state. */ + public enum State { + INACTIVE(1), ACTIVE(2); + private final int code; + State(int code) { this.code = code; } + /** Stable persistence code. */ public int code() { return code; } + /** Resolves an exact stable code. */ + public static State fromCode(int code) { + return switch (code) { case 1 -> INACTIVE; case 2 -> ACTIVE; + default -> throw new IllegalArgumentException("Unknown OCSP responder state code"); }; + } + } + + /** Closed administrator input without server-authoritative timestamps, state, or record commitments. */ + public record Registration(String responderId, String alias, PkiId authorityId, PkiId issuerId, + SigningMode signingMode, PkiId responderCredentialId, KeyRef signingKeyRef, PkiId chainPathId, + String signatureAlgorithm, Optional signatureBindingId, String signatureBindingCommitment, + OcspResponseService.ResponderId responderIdForm, Duration responseValidity, NoncePolicy noncePolicy, + int maximumNonceBytes, Set acceptedHashes, int maximumRequestBytes, + int maximumEntries, Duration cacheLifetime) { + /** Snapshots the typed finite registration input. */ + public Registration { + Permission.requireId(responderId, "OCSP responder"); Permission.requireId(alias, "OCSP alias"); + Objects.requireNonNull(authorityId); Objects.requireNonNull(issuerId); Objects.requireNonNull(signingMode); + Objects.requireNonNull(responderCredentialId); Objects.requireNonNull(signingKeyRef); + Objects.requireNonNull(chainPathId); Permission.requireBounded(signatureAlgorithm, 128, + "OCSP signature algorithm"); + signatureBindingId = Objects.requireNonNull(signatureBindingId); + requireDigest(signatureBindingCommitment); Objects.requireNonNull(responderIdForm); + Objects.requireNonNull(responseValidity); Objects.requireNonNull(noncePolicy); + acceptedHashes = Set.copyOf(Objects.requireNonNull(acceptedHashes)); + Objects.requireNonNull(cacheLifetime); + } + } + + /** Strict versioned responder binding. */ + public record Responder(String responderId, String alias, RealmId realmId, PkiId authorityId, + PkiId issuerId, SigningMode signingMode, PkiId responderCredentialId, KeyRef signingKeyRef, + PkiId chainPathId, String signatureAlgorithm, Optional signatureBindingId, + String signatureBindingCommitment, OcspResponseService.ResponderId responderIdForm, + Duration responseValidity, NoncePolicy noncePolicy, int maximumNonceBytes, + Set acceptedHashes, int maximumRequestBytes, + int maximumEntries, Duration cacheLifetime, State state, Instant createdAt, + String configurationCommitment) { + /** Validates all finite immutable dependencies and the record commitment. */ + public Responder { + Permission.requireId(responderId, "OCSP responder"); Permission.requireId(alias, "OCSP alias"); + Objects.requireNonNull(realmId); Objects.requireNonNull(authorityId); Objects.requireNonNull(issuerId); + Objects.requireNonNull(signingMode); Objects.requireNonNull(responderCredentialId); + Objects.requireNonNull(signingKeyRef); Objects.requireNonNull(chainPathId); + Permission.requireBounded(signatureAlgorithm, 128, "OCSP signature algorithm"); + signatureBindingId = Objects.requireNonNull(signatureBindingId); + requireDigest(signatureBindingCommitment); Objects.requireNonNull(responderIdForm); + positive(responseValidity, Duration.ofDays(7), "response validity"); Objects.requireNonNull(noncePolicy); + if (maximumNonceBytes < 8 || maximumNonceBytes > 4096 || maximumRequestBytes < 256 + || maximumRequestBytes > 1_048_576 || maximumEntries < 1 || maximumEntries > 4096) { + throw new IllegalArgumentException("OCSP responder bounds are invalid"); + } + acceptedHashes = Set.copyOf(Objects.requireNonNull(acceptedHashes)); + if (acceptedHashes.isEmpty()) throw new IllegalArgumentException("OCSP CertID hashes are empty"); + positive(cacheLifetime, responseValidity, "cache lifetime"); Objects.requireNonNull(state); + Objects.requireNonNull(createdAt); requireDigest(configurationCommitment); + } + } + + private final RealmId realmId; + private final ServerControlStore control; + private final PkiRepository repository; + private final X509AlgorithmBindingRegistry bindings; + private final Optional signing; + private final Clock clock; + + /** Binds responder control to one realm and one authoritative PKI repository. */ + public OcspResponderService(RealmId realmId, ServerControlStore control, PkiRepository repository, + X509AlgorithmBindingRegistry bindings, Optional signing, Clock clock) { + this.realmId = Objects.requireNonNull(realmId); this.control = Objects.requireNonNull(control); + this.repository = Objects.requireNonNull(repository); this.bindings = Objects.requireNonNull(bindings); + this.signing = Objects.requireNonNull(signing); + this.clock = Objects.requireNonNull(clock); + } + + /** Creates authoritative metadata and durably registers one inactive responder. */ + public Responder register(Registration supplied) { + Objects.requireNonNull(supplied, "supplied"); + return register(create(supplied.responderId(), supplied.alias(), supplied.authorityId(), supplied.issuerId(), + supplied.signingMode(), supplied.responderCredentialId(), supplied.signingKeyRef(), + supplied.chainPathId(), supplied.signatureAlgorithm(), supplied.signatureBindingId(), + supplied.signatureBindingCommitment(), supplied.responderIdForm(), supplied.responseValidity(), + supplied.noncePolicy(), supplied.maximumNonceBytes(), supplied.acceptedHashes(), + supplied.maximumRequestBytes(), supplied.maximumEntries(), supplied.cacheLifetime())); + } + + /** Canonically seals an already constructed internal draft. */ + /* default */ Responder register(Responder supplied) { + if (!supplied.realmId().equals(realmId) || supplied.state() != State.INACTIVE) { + throw new IllegalArgumentException("OCSP responder realm or initial state is invalid"); + } + if (!supplied.configurationCommitment().equals(commitment(supplied, true))) { + throw new IllegalArgumentException("OCSP responder commitment differs"); + } + if (!aliases(supplied.alias()).isEmpty()) { + throw new IllegalStateException("OCSP responder alias already exists"); + } + validateDependencies(supplied, true); + Responder sealed = seal(supplied, State.INACTIVE); + control.mutateProtocol(List.of(new ServerControlStore.ProtocolMutation(record(sealed), Optional.empty()))); + return sealed; + } + + /** Returns one exact responder. */ + public Responder require(String responderId) { + return control.protocolRecord(ServerControlStore.OCSP_RESPONDER, responderId) + .map(this::decode).orElseThrow(() -> new IllegalArgumentException("OCSP responder is unavailable")); + } + + /** Resolves one unique active public alias. */ + public Responder requireActiveAlias(String alias) { + Responder result = requireAlias(alias); + if (result.state() != State.ACTIVE) { + throw new IllegalStateException("OCSP responder is inactive"); + } + validateDependencies(result, false); return result; + } + + /** Resolves one unique durable alias regardless of activation state. */ + public Responder requireAlias(String alias) { + Permission.requireId(alias, "OCSP alias"); + List matches = aliases(alias); + if (matches.size() != 1) throw new IllegalArgumentException("OCSP responder alias is unavailable"); + return matches.getFirst(); + } + + /** Lists one bounded deterministic responder page. */ + public ServerControlStore.Page list(int offset, int limit) { + ServerControlStore.Page page = + control.protocolRecords(ServerControlStore.OCSP_RESPONDER, offset, limit); + return new ServerControlStore.Page<>(page.values().stream().map(this::decode).toList(), + page.nextOffset(), page.hasMore()); + } + + private List aliases(String alias) { + List matches = new ArrayList<>(); + int offset = 0; + while (true) { + ServerControlStore.Page page = list(offset, 256); + page.values().stream().filter(value -> value.alias().equals(alias)).forEach(matches::add); + if (!page.hasMore()) return List.copyOf(matches); + offset = page.nextOffset(); + } + } + + /** Atomically changes only responder activation after dependency revalidation. */ + public Responder setActive(String responderId, boolean active) { + Responder prior = require(responderId); validateDependencies(prior, true); + Responder next = seal(prior, active ? State.ACTIVE : State.INACTIVE); + control.mutateProtocol(List.of(new ServerControlStore.ProtocolMutation(record(next), + Optional.of(record(prior).commitment())))); + return next; + } + + /** Revalidates every durable binding during realm recovery. */ + public void validateAll() { + int offset = 0; + while (true) { + ServerControlStore.Page page = list(offset, 256); + page.values().forEach(value -> validateDependencies(value, true)); + if (!page.hasMore()) return; + offset = page.nextOffset(); + } + } + + /** Creates a canonical unsealed record for callers before registration. */ + public Responder create(String responderId, String alias, PkiId authorityId, PkiId issuerId, + SigningMode signingMode, PkiId responderCredentialId, KeyRef signingKeyRef, PkiId chainPathId, + String signatureAlgorithm, Optional signatureBindingId, String bindingCommitment, + OcspResponseService.ResponderId responderIdForm, Duration validity, NoncePolicy noncePolicy, + int maximumNonceBytes, Set hashes, int maximumRequestBytes, + int maximumEntries, Duration cacheLifetime) { + Responder draft = new Responder(responderId, alias, realmId, authorityId, issuerId, signingMode, + responderCredentialId, signingKeyRef, chainPathId, signatureAlgorithm, signatureBindingId, + bindingCommitment, responderIdForm, validity, noncePolicy, maximumNonceBytes, hashes, + maximumRequestBytes, maximumEntries, cacheLifetime, State.INACTIVE, clock.instant(), + "0".repeat(64)); + return new Responder(draft.responderId(), draft.alias(), draft.realmId(), draft.authorityId(), draft.issuerId(), + draft.signingMode(), draft.responderCredentialId(), draft.signingKeyRef(), draft.chainPathId(), + draft.signatureAlgorithm(), draft.signatureBindingId(), draft.signatureBindingCommitment(), + draft.responderIdForm(), draft.responseValidity(), draft.noncePolicy(), draft.maximumNonceBytes(), + draft.acceptedHashes(), draft.maximumRequestBytes(), draft.maximumEntries(), draft.cacheLifetime(), + draft.state(), draft.createdAt(), commitment(draft, true)); + } + + private Responder seal(Responder value, State state) { + Responder draft = new Responder(value.responderId(), value.alias(), value.realmId(), value.authorityId(), + value.issuerId(), value.signingMode(), value.responderCredentialId(), value.signingKeyRef(), + value.chainPathId(), value.signatureAlgorithm(), value.signatureBindingId(), + value.signatureBindingCommitment(), value.responderIdForm(), value.responseValidity(), + value.noncePolicy(), value.maximumNonceBytes(), value.acceptedHashes(), value.maximumRequestBytes(), + value.maximumEntries(), value.cacheLifetime(), state, value.createdAt(), value.configurationCommitment()); + return new Responder(draft.responderId(), draft.alias(), draft.realmId(), draft.authorityId(), draft.issuerId(), + draft.signingMode(), draft.responderCredentialId(), draft.signingKeyRef(), draft.chainPathId(), + draft.signatureAlgorithm(), draft.signatureBindingId(), draft.signatureBindingCommitment(), + draft.responderIdForm(), draft.responseValidity(), draft.noncePolicy(), draft.maximumNonceBytes(), + draft.acceptedHashes(), draft.maximumRequestBytes(), draft.maximumEntries(), draft.cacheLifetime(), + draft.state(), draft.createdAt(), commitment(draft, true)); + } + + private void validateDependencies(Responder value, boolean proveSigning) { + value.signatureBindingId().ifPresentOrElse( + bindingId -> bindings.require(bindingId, value.signatureBindingCommitment()), + () -> { + if (!bindings.commitment().equals(value.signatureBindingCommitment())) { + throw new IllegalStateException("OCSP algorithm registry commitment differs"); + } + }); + IssuerGeneration issuer = repository.issuer(value.issuerId()).orElseThrow(); + IssuerChainPath path = repository.chainPath(value.chainPathId()).orElseThrow(); + if (!issuer.authorityId().equals(value.authorityId()) || !path.authorityId().equals(value.authorityId()) + || !path.issuerId().equals(value.issuerId()) || !path.orderedCredentialIds().getFirst() + .equals(issuer.credentialId())) { + throw new IllegalStateException("OCSP responder issuer/path binding differs"); + } + try { + X509CertificateHolder issuerCertificate = certificate(issuer.credentialId()); + X509CertificateHolder responderCertificate = certificate(value.responderCredentialId()); + if (!responderCertificate.isValidOn(Date.from(clock.instant()))) { + throw new IllegalStateException("OCSP responder certificate is outside its validity interval"); + } + if (value.signingMode() == SigningMode.ISSUER_SIGNED) { + if (!value.responderCredentialId().equals(issuer.credentialId()) + || !value.signingKeyRef().equals(issuer.signingKeyRef())) { + throw new IllegalStateException("Issuer-signed responder binding differs"); + } + } else { + if (!responderCertificate.isSignatureValid(new JcaContentVerifierProviderBuilder() + .build(issuerCertificate)) || responderCertificate.getExtension(Extension.extendedKeyUsage) == null + || !org.bouncycastle.asn1.x509.ExtendedKeyUsage.fromExtensions( + responderCertificate.getExtensions()).hasKeyPurposeId( + org.bouncycastle.asn1.x509.KeyPurposeId.id_kp_OCSPSigning)) { + throw new IllegalStateException("Delegated OCSP responder certificate is unauthorized"); + } + Extension usage = responderCertificate.getExtension(Extension.keyUsage); + if (usage != null && !KeyUsage.fromExtensions(responderCertificate.getExtensions()) + .hasUsages(KeyUsage.digitalSignature)) { + throw new IllegalStateException("Delegated OCSP responder key usage is invalid"); + } + } + } catch (IllegalStateException failure) { + throw failure; + } catch (Exception failure) { + throw new IllegalStateException("OCSP responder certificate validation failed"); + } + if (proveSigning) { + try { + signing.orElseThrow(() -> new IllegalStateException("OCSP signing service is unavailable")) + .validateSigningBinding(value.responderCredentialId(), value.signingKeyRef(), + value.signatureAlgorithm(), value.signatureBindingId()); + } catch (IllegalStateException failure) { + throw failure; + } catch (RuntimeException failure) { + throw new IllegalStateException("OCSP signing capability proof failed"); + } + } + } + + private X509CertificateHolder certificate(PkiId credentialId) throws IOException { + try (zeroecho.pki.application.PkiRepositoryContent content = repository.openCredential(credentialId); + java.io.InputStream input = content.openStream()) { + byte[] encoded = input.readNBytes(1_048_577); + if (encoded.length == 1_048_577 || input.read() != -1) { + throw new IOException("OCSP responder certificate exceeds its finite bound"); + } + X509CertificateHolder certificate = new X509CertificateHolder(encoded); + if (!java.util.Arrays.equals(encoded, certificate.getEncoded())) { + throw new IOException("OCSP responder certificate is not canonical DER"); + } + return certificate; + } + } + + private ServerControlStore.ProtocolRecord record(Responder value) { + byte[] payload = encode(value); + return new ServerControlStore.ProtocolRecord(ServerControlStore.OCSP_RESPONDER, + value.responderId(), sha256(payload), payload); + } + + private Responder decode(ServerControlStore.ProtocolRecord stored) { + Responder value = decode(stored.payload()); + if (!stored.recordId().equals(value.responderId()) || !stored.commitment().equals(sha256(stored.payload()))) { + throw new IllegalStateException("OCSP responder record commitment differs"); + } + if (value.configurationCommitment().equals("0".repeat(64))) { + throw new IllegalStateException("OCSP responder commitment is unsealed"); + } + if (!value.configurationCommitment().equals(commitment(value, true))) { + throw new IllegalStateException("OCSP responder commitment differs"); + } + return value; + } + + private static String commitment(Responder value, boolean ignoreStored) { + return sha256(encode(value, ignoreStored)); + } + + private static byte[] encode(Responder value) { return encode(value, false); } + private static byte[] encode(Responder value, boolean ignoreCommitment) { + try (ByteArrayOutputStream bytes = new ByteArrayOutputStream(1024); + DataOutputStream output = new DataOutputStream(bytes)) { + output.writeInt(1); write(output, value.responderId()); write(output, value.alias()); + write(output, value.realmId().value()); write(output, value.authorityId().value()); + write(output, value.issuerId().value()); output.writeInt(value.signingMode().code()); + write(output, value.responderCredentialId().value()); write(output, value.signingKeyRef().value()); + write(output, value.chainPathId().value()); write(output, value.signatureAlgorithm()); + output.writeBoolean(value.signatureBindingId().isPresent()); + if (value.signatureBindingId().isPresent()) write(output, value.signatureBindingId().orElseThrow()); + write(output, value.signatureBindingCommitment()); output.writeInt(value.responderIdForm().code()); + output.writeLong(value.responseValidity().toSeconds()); output.writeInt(value.noncePolicy().code()); + output.writeInt(value.maximumNonceBytes()); output.writeInt(value.acceptedHashes().size()); + for (OcspResponseService.CertIdHash hash : value.acceptedHashes().stream().sorted().toList()) { + output.writeInt(hash.code()); + } + output.writeInt(value.maximumRequestBytes()); output.writeInt(value.maximumEntries()); + output.writeLong(value.cacheLifetime().toSeconds()); output.writeInt(value.state().code()); + output.writeLong(value.createdAt().toEpochMilli()); + write(output, ignoreCommitment ? "0".repeat(64) : value.configurationCommitment()); output.flush(); + return bytes.toByteArray(); + } catch (IOException impossible) { throw new IllegalStateException("OCSP encoding failed", impossible); } + } + + private static Responder decode(byte[] payload) { + if (payload.length == 0 || payload.length > 65_536) throw new IllegalStateException("OCSP record bound differs"); + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(payload))) { + if (input.readInt() != 1) throw new IOException("OCSP record schema is obsolete"); + String id = read(input); String alias = read(input); RealmId realm = new RealmId(read(input)); + PkiId authority = new PkiId(read(input)); PkiId issuer = new PkiId(read(input)); + SigningMode mode = SigningMode.fromCode(input.readInt()); PkiId credential = new PkiId(read(input)); + KeyRef key = new KeyRef(read(input)); PkiId path = new PkiId(read(input)); String algorithm = read(input); + Optional binding = input.readBoolean() ? Optional.of(read(input)) : Optional.empty(); + String bindingCommitment = read(input); + OcspResponseService.ResponderId form = OcspResponseService.ResponderId.fromCode(input.readInt()); + Duration validity = Duration.ofSeconds(input.readLong()); NoncePolicy nonce = NoncePolicy.fromCode(input.readInt()); + int nonceBytes = input.readInt(); int count = input.readInt(); + if (count < 1 || count > 2) throw new IOException("OCSP hash count differs"); + Set hashes = new java.util.HashSet<>(); + for (int index = 0; index < count; index++) { + hashes.add(OcspResponseService.CertIdHash.fromCode(input.readInt())); + } + int requestBytes = input.readInt(); int entries = input.readInt(); Duration cache = Duration.ofSeconds(input.readLong()); + State state = State.fromCode(input.readInt()); Instant created = Instant.ofEpochMilli(input.readLong()); + String commitment = read(input); if (input.read() != -1) throw new IOException("Trailing OCSP record data"); + return new Responder(id, alias, realm, authority, issuer, mode, credential, key, path, algorithm, + binding, bindingCommitment, form, validity, nonce, nonceBytes, hashes, requestBytes, + entries, cache, state, created, commitment); + } catch (IOException | RuntimeException failure) { + throw new IllegalStateException("OCSP responder record is invalid"); + } + } + + private static void write(DataOutputStream output, String value) throws IOException { + byte[] encoded = value.getBytes(StandardCharsets.UTF_8); + if (encoded.length > 16_384) throw new IOException("OCSP string bound differs"); + output.writeInt(encoded.length); output.write(encoded); + } + private static String read(DataInputStream input) throws IOException { + int length = input.readInt(); if (length < 0 || length > 16_384) throw new IOException("OCSP string bound differs"); + byte[] encoded = input.readNBytes(length); if (encoded.length != length) throw new IOException("OCSP record is truncated"); + String value = new String(encoded, StandardCharsets.UTF_8); + if (!java.util.Arrays.equals(encoded, value.getBytes(StandardCharsets.UTF_8))) throw new IOException("OCSP UTF-8 differs"); + return value; + } + private static String sha256(byte[] value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); } + catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException("SHA-256 unavailable", impossible); } + } + private static void requireDigest(String value) { + if (value == null || !value.matches("[0-9a-f]{64}")) throw new IllegalArgumentException("OCSP commitment 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("OCSP " + name + " is invalid"); + } + } +} 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 82df491..41b4e49 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/OperationSecurityDescriptors.java +++ b/pki-server/src/main/java/zeroecho/pki/server/OperationSecurityDescriptors.java @@ -164,7 +164,17 @@ public final class OperationSecurityDescriptors { 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))); + Permission.ResourceType.ACME_ACCOUNT, true, false), + control(ServerControlOperation.RegisterOcspResponder.NAME, Permission.Action.OCSP_ADMINISTER, + Permission.ResourceType.OCSP_RESPONDER, true, true), + control(ServerControlOperation.InspectOcspResponder.NAME, Permission.Action.OCSP_RESPONDER_READ, + Permission.ResourceType.OCSP_RESPONDER, false, false), + control(ServerControlOperation.ListOcspResponders.NAME, Permission.Action.OCSP_RESPONDER_READ, + Permission.ResourceType.OCSP_RESPONDER, false, false), + control(ServerControlOperation.SetOcspResponderActive.ACTIVATE, Permission.Action.OCSP_ADMINISTER, + Permission.ResourceType.OCSP_RESPONDER, true, true), + control(ServerControlOperation.SetOcspResponderActive.DEACTIVATE, Permission.Action.OCSP_ADMINISTER, + Permission.ResourceType.OCSP_RESPONDER, true, false))); } /** Creates a registry and rejects duplicate operation identities. */ @@ -420,6 +430,16 @@ public final class OperationSecurityDescriptors { 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()); + case ServerControlOperation.RegisterOcspResponder value -> "responder=" + + atom(value.registration().responderId()) + ";authority=" + + atom(value.registration().authorityId().value()) + ";issuer=" + + atom(value.registration().issuerId().value()) + ";binding=" + + value.registration().signatureBindingCommitment(); + case ServerControlOperation.InspectOcspResponder value -> "responder=" + atom(value.responderId()); + case ServerControlOperation.ListOcspResponders value -> "offset=" + value.offset() + + ";limit=" + value.limit(); + case ServerControlOperation.SetOcspResponderActive value -> "responder=" + + atom(value.responderId()) + ";active=" + value.active(); }; } } 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 7ece72e..e519384 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/Permission.java +++ b/pki-server/src/main/java/zeroecho/pki/server/Permission.java @@ -65,7 +65,7 @@ public final class Permission { CERTIFICATE_READ_PII(76), CERTIFICATE_DOWNLOAD(77), CERTIFICATE_PUBLICATION_CHANGE(78), CERTIFICATE_REVOKE(80), CERTIFICATE_HOLD(81), CERTIFICATE_RELEASE_HOLD(82), REVOCATION_HISTORY_READ(83), CRL_GENERATE(84), CRL_PUBLISH(85), CRL_DOWNLOAD(86), - OCSP_ADMINISTER(87), PUBLICATION_REGISTER(100), PUBLICATION_READ(101), + OCSP_ADMINISTER(87), OCSP_RESPONDER_READ(88), PUBLICATION_REGISTER(100), PUBLICATION_READ(101), PUBLICATION_PROCESS(102), PUBLICATION_RETRY(103), PUBLICATION_RECONCILE(104), 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), @@ -114,7 +114,7 @@ public final class Permission { 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), - ACME_DIRECTORY(38), ACME_ACCOUNT(39); + ACME_DIRECTORY(38), ACME_ACCOUNT(39), OCSP_RESPONDER(40); 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/PkiServerConfiguration.java b/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfiguration.java index 7afe750..8843730 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfiguration.java +++ b/pki-server/src/main/java/zeroecho/pki/server/PkiServerConfiguration.java @@ -68,7 +68,7 @@ public record PkiServerConfiguration(int version, String serverName, ServerRealm Optional publicListener, Optional acmeListener) { /** Current server configuration schema. */ - public static final int CURRENT_VERSION = 4; + public static final int CURRENT_VERSION = 5; /** Validates all security-sensitive fields before resource allocation. */ public PkiServerConfiguration { 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 1810a68..967b08c 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperation.java +++ b/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperation.java @@ -41,7 +41,7 @@ import zeroecho.pki.api.PkiId; import zeroecho.pki.server.acme.AcmeService; /** Closed transport-neutral server-control administration operation hierarchy. */ -@SuppressWarnings("PMD.ControlStatementBraces") +@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.ExcessivePublicCount" }) public sealed interface ServerControlOperation permits ServerControlOperation.RegisterPrincipal, ServerControlOperation.InspectPrincipal, ServerControlOperation.ListPrincipals, ServerControlOperation.SetPrincipalEnabled, ServerControlOperation.ListRoleTemplates, @@ -63,7 +63,9 @@ public sealed interface ServerControlOperation permits ServerControlOperation.Re ServerControlOperation.RegisterAcmeDirectory, ServerControlOperation.InspectAcmeDirectory, ServerControlOperation.ListAcmeDirectories, ServerControlOperation.SetAcmeDirectoryActive, ServerControlOperation.InspectAcmeAccount, ServerControlOperation.ListAcmeAccounts, - ServerControlOperation.DeactivateAcmeAccount { + ServerControlOperation.DeactivateAcmeAccount, ServerControlOperation.RegisterOcspResponder, + ServerControlOperation.InspectOcspResponder, ServerControlOperation.ListOcspResponders, + ServerControlOperation.SetOcspResponderActive { /** @return stable operation identity */ String name(); @@ -357,6 +359,32 @@ public sealed interface ServerControlOperation permits ServerControlOperation.Re @Override public String name() { return NAME; } } + /** Registers one exact inactive OCSP responder binding. */ + record RegisterOcspResponder(OcspResponderService.Registration registration) implements ServerControlOperation { + public static final String NAME = "ocsp.responder.register"; + public RegisterOcspResponder { Objects.requireNonNull(registration, "registration"); } + @Override public String name() { return NAME; } + } + /** Inspects one exact OCSP responder binding. */ + record InspectOcspResponder(String responderId) implements ServerControlOperation { + public static final String NAME = "ocsp.responder.inspect"; + public InspectOcspResponder { Permission.requireId(responderId, "OCSP responder"); } + @Override public String name() { return NAME; } + } + /** Lists a bounded OCSP responder page. */ + record ListOcspResponders(int offset, int limit) implements ServerControlOperation { + public static final String NAME = "ocsp.responder.list"; + public ListOcspResponders { page(offset, limit); } + @Override public String name() { return NAME; } + } + /** Activates or deactivates one exact responder. */ + record SetOcspResponderActive(String responderId, boolean active) implements ServerControlOperation { + public static final String ACTIVATE = "ocsp.responder.activate"; + public static final String DEACTIVATE = "ocsp.responder.deactivate"; + public SetOcspResponderActive { Permission.requireId(responderId, "OCSP responder"); } + @Override public String name() { return active ? ACTIVATE : DEACTIVATE; } + } + 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 301c6da..5480a2a 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationExecutor.java +++ b/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationExecutor.java @@ -66,6 +66,7 @@ public final class ServerControlOperationExecutor { private final OperationSecurityDescriptors descriptors; private final Map approvalPolicies; private final java.util.concurrent.atomic.AtomicReference acme = new java.util.concurrent.atomic.AtomicReference<>(); + private final java.util.concurrent.atomic.AtomicReference ocsp = new java.util.concurrent.atomic.AtomicReference<>(); /** Creates the one control dispatcher over existing durable authorities. */ public ServerControlOperationExecutor(RealmId realmId, AuthorityExposurePolicy exposure, @@ -117,6 +118,13 @@ public final class ServerControlOperationExecutor { } } + /** Installs the realm-owned durable OCSP responder capability exactly once. */ + public void installOcsp(OcspResponderService service) { + if (!ocsp.compareAndSet(null, Objects.requireNonNull(service, "service"))) { + throw new IllegalStateException("OCSP 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) { @@ -220,6 +228,14 @@ public final class ServerControlOperationExecutor { page(acme().accounts(value.offset(), value.limit()), ServerControlOperationExecutor::account)); case ServerControlOperation.DeactivateAcmeAccount value -> ordinary(operation, account(acme().deactivateAccount(value.accountId()))); + case ServerControlOperation.RegisterOcspResponder value -> ordinary(operation, + responder(ocsp().register(value.registration()))); + case ServerControlOperation.InspectOcspResponder value -> ordinary(operation, + responder(ocsp().require(value.responderId()))); + case ServerControlOperation.ListOcspResponders value -> ordinary(operation, + page(ocsp().list(value.offset(), value.limit()), ServerControlOperationExecutor::responder)); + case ServerControlOperation.SetOcspResponderActive value -> ordinary(operation, + responder(ocsp().setActive(value.responderId(), value.active()))); }; } @@ -227,6 +243,14 @@ public final class ServerControlOperationExecutor { return Optional.ofNullable(acme.get()).orElseThrow(() -> new IllegalStateException("ACME capability unavailable")); } + private OcspResponderService ocsp() { + return Optional.ofNullable(ocsp.get()).orElseThrow(() -> new IllegalStateException("OCSP capability unavailable")); + } + + /* default */ OcspResponderService.Responder ocspResponder(String responderId) { + return ocsp().require(responderId); + } + private RepositoryAliasService aliases() { return repositoryAliases.orElseThrow(() -> new IllegalStateException("Repository aliases are unavailable")); } @@ -390,6 +414,13 @@ public final class ServerControlOperationExecutor { "directoryRevision", integer(value.directoryRevision()), "status", text(value.status().name()), "createdAt", text(value.createdAt().toString()), "updatedAt", text(value.updatedAt().toString())); } + private static PkiOperationValue responder(OcspResponderService.Responder value) { + return object("responderId", text(value.responderId()), "alias", text(value.alias()), + "authorityId", text(value.authorityId().value()), "issuerId", text(value.issuerId().value()), + "signingMode", text(value.signingMode().name()), "responderCredentialId", + text(value.responderCredentialId().value()), "state", text(value.state().name()), + "configurationCommitment", text(value.configurationCommitment())); + } 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 96b7c32..81f2547 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/ServerControlStore.java +++ b/pki-server/src/main/java/zeroecho/pki/server/ServerControlStore.java @@ -103,9 +103,11 @@ public final class ServerControlStore implements AutoCloseable { 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"; + /** Stable namespace for durable OCSP responder bindings. */ + public static final String OCSP_RESPONDER = "io.zeroecho.server.ocsp-responder"; private static final int MAGIC = 0x5a455331; - private static final int SCHEMA = 4; + private static final int SCHEMA = 5; 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; @@ -118,39 +120,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; + private static final int KIND_PROTOCOL = 10; /** - * Strict opaque ACME payload framed by the server-control authority. + * Strict opaque protocol payload framed by the server-control authority. * - *

The ACME domain codec owns the payload schema. This record keeps the + *

The owning closed protocol 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 namespace one closed protocol namespace * @param recordId canonical domain identity * @param commitment SHA-256 commitment of the complete domain payload - * @param payload strict versioned ACME domain encoding + * @param payload strict versioned protocol-domain encoding */ - public record AcmeRecord(String namespace, String recordId, String commitment, byte[] payload) { + public record ProtocolRecord(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"); + public ProtocolRecord { + if (!PROTOCOL_NAMESPACES.contains(namespace)) { + throw new IllegalArgumentException("Unknown protocol control namespace"); } - Permission.requireId(recordId, "ACME record"); + Permission.requireId(recordId, "protocol 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"); + throw new IllegalArgumentException("Protocol 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); + private static final Set PROTOCOL_NAMESPACES = Set.of(ACME_DIRECTORY, ACME_ACCOUNT, ACME_ORDER, + ACME_AUTHORIZATION, ACME_CHALLENGE, ACME_EVIDENCE, OCSP_RESPONDER); /** * Durable realm-control identity and commitments. @@ -393,72 +395,73 @@ 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) { + /** One compare-and-set mutation participating in an atomic protocol-state change. */ + public record ProtocolMutation(ProtocolRecord record, Optional expectedCommitment) { /** Validates the immutable mutation request. */ - public AcmeMutation { + public ProtocolMutation { 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)); + /** Reads one exact protocol record from its closed namespace. */ + public synchronized Optional protocolRecord(String namespace, String recordId) { + requireProtocolNamespace(namespace); + return read(namespace, recordId, KIND_PROTOCOL, input -> readProtocol(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); + /** Returns one bounded deterministic page of protocol records. */ + public synchronized Page protocolRecords(String namespace, int offset, int limit) { + requireProtocolNamespace(namespace); + return scanPage(namespace, KIND_PROTOCOL, input -> readProtocol(input, namespace), offset, limit); } /** - * Atomically creates or compare-and-replaces a finite set of ACME records. + * Atomically creates or compare-and-replaces a finite set of protocol 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) { + public synchronized void mutateProtocol(List requested) { requireOpen(); - List mutations = List.copyOf(Objects.requireNonNull(requested, "requested")); + List mutations = List.copyOf(Objects.requireNonNull(requested, "requested")); if (mutations.isEmpty() || mutations.size() > 256) { - throw new IllegalArgumentException("ACME transaction size is invalid"); + throw new IllegalArgumentException("Protocol 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"); + throw new IllegalArgumentException("Duplicate protocol transaction identity"); } try (MetadataSnapshot snapshot = metadata.snapshot(); MetadataTransaction transaction = metadata.beginTransaction()) { - for (AcmeMutation mutation : mutations) { - AcmeRecord value = mutation.record(); + for (ProtocolMutation mutation : mutations) { + ProtocolRecord value = mutation.record(); MetadataKey metadataKey = key(value.namespace(), value.recordId()); Optional existing = snapshot.get(metadataKey); - byte[] encoded = encode(output -> writeAcme(output, value)); + byte[] encoded = encode(output -> writeProtocol(output, value)); RepeatableContent content = new ByteContent(encoded); if (mutation.expectedCommitment().isEmpty()) { - if (existing.isPresent()) throw new IllegalStateException("ACME record already exists"); + if (existing.isPresent()) throw new IllegalStateException("Protocol 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())); + .orElseThrow(() -> new IllegalStateException("Protocol record is unavailable")); + ProtocolRecord decoded = decode(current, KIND_PROTOCOL, + input -> readProtocol(input, value.namespace())); if (!mutation.expectedCommitment().orElseThrow().equals(decoded.commitment())) { - throw new IllegalStateException("ACME record commitment conflict"); + throw new IllegalStateException("Protocol 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"); + throw new IllegalStateException("Protocol metadata commit requires reconciliation"); } } catch (IOException failure) { - throw new IllegalStateException("ACME metadata mutation failed"); + throw new IllegalStateException("Protocol metadata mutation failed"); } } @@ -519,8 +522,8 @@ 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)); + for (String namespace : PROTOCOL_NAMESPACES) { + scan(namespace, KIND_PROTOCOL, input -> readProtocol(input, namespace)); } } @@ -723,14 +726,14 @@ 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(); + case ProtocolRecord 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 requireProtocolNamespace(String namespace) { + if (!PROTOCOL_NAMESPACES.contains(namespace)) { + throw new IllegalArgumentException("Unknown protocol control namespace"); } } @@ -764,15 +767,15 @@ 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()); + private static void writeProtocol(DataOutputStream out, ProtocolRecord value) throws IOException { + out.writeInt(KIND_PROTOCOL); 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 { + private static ProtocolRecord readProtocol(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), + return new ProtocolRecord(namespace, readString(in), readString(in), readBoundedBytes(in, MAXIMUM_RECORD_BYTES / 2)); } 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 20aedb4..1b468b8 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/ServerOperationGateway.java +++ b/pki-server/src/main/java/zeroecho/pki/server/ServerOperationGateway.java @@ -190,6 +190,8 @@ public final class ServerOperationGateway { /** Installs the optional configured ACME control capability once before listener readiness. */ public void installAcme(AcmeService service) { controlExecutor.installAcme(service); } + /** Installs the realm-owned OCSP administration capability before readiness. */ + public void installOcsp(OcspResponderService service) { controlExecutor.installOcsp(service); } /** Creates the pre-control-plane gateway surface for embedded source compatibility. */ public ServerOperationGateway(RealmId realmId, AuthorityExposurePolicy exposure, ServerControlStore control, @@ -538,6 +540,19 @@ public final class ServerOperationGateway { case ServerControlOperation.SetAcmeDirectoryActive value -> controlExecutor.acmeScope(value); case ServerControlOperation.InspectAcmeAccount value -> controlExecutor.acmeScope(value); case ServerControlOperation.DeactivateAcmeAccount value -> controlExecutor.acmeScope(value); + case ServerControlOperation.RegisterOcspResponder value -> new Permission.Scope(realmId, + Optional.of(value.registration().authorityId()), Optional.of(value.registration().issuerId()), + Optional.empty()); + case ServerControlOperation.InspectOcspResponder value -> { + OcspResponderService.Responder responder = controlExecutor.ocspResponder(value.responderId()); + yield new Permission.Scope(realmId, Optional.of(responder.authorityId()), + Optional.of(responder.issuerId()), Optional.empty()); + } + case ServerControlOperation.SetOcspResponderActive value -> { + OcspResponderService.Responder responder = controlExecutor.ocspResponder(value.responderId()); + yield new Permission.Scope(realmId, Optional.of(responder.authorityId()), + Optional.of(responder.issuerId()), Optional.empty()); + } 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 669503e..d5a5c54 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/ServerRealmContext.java +++ b/pki-server/src/main/java/zeroecho/pki/server/ServerRealmContext.java @@ -79,6 +79,7 @@ public final class ServerRealmContext implements AutoCloseable { private final DisclosureService disclosure; private final RepositoryAliasService repositoryAliases; private final AcmeControlStore acmeControl; + private final OcspResponderService ocspResponders; private final PublicRepositoryGateway publicRepository; private final AuditorViews auditorViews; private final ServerOperationGateway gateway; @@ -101,6 +102,9 @@ public final class ServerRealmContext implements AutoCloseable { this.repositoryAliases = repositoryAliases; this.acmeControl = new AcmeControlStore(control); this.acmeControl.validateAndRecover(clock); + this.ocspResponders = new OcspResponderService(configuration.realmId(), control, session.repository(), + session.algorithmBindings(), session.ocsp(), clock); + this.ocspResponders.validateAll(); this.publicRepository = new PublicRepositoryGateway(configuration.realmId(), configuration.authorityExposure(), session.repository(), control, roles, authorization, breakGlass, disclosure, repositoryAliases, this::requireOpen); @@ -112,6 +116,7 @@ public final class ServerRealmContext implements AutoCloseable { new OperationSecurityDescriptors(), session.operations(), session.resourceScopes(), configuration.approvalPolicies(), clock, auditSink, this::requireOpen); + this.gateway.installOcsp(ocspResponders); } /** @@ -148,7 +153,7 @@ public final class ServerRealmContext implements AutoCloseable { control.validateAll(); control.validateReferences(roles); audit = new SharedAuditSink(PkiBootstrap.openAudit(exact.pkiSessionConfiguration().audit())); - session = PkiSession.open(exact.pkiSessionConfiguration(), runtime.withAuditSink(audit)); + session = PkiSession.open(exact.pkiSessionConfiguration(), runtime.withAuditSink(audit), clock); validateExposure(exact.authorityExposure(), session); AuthorizationEngine authorization = new AuthorizationEngine(clock); ApprovalService approvals = new ApprovalService(control, clock, audit); @@ -186,6 +191,8 @@ public final class ServerRealmContext implements AutoCloseable { 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 durable exact OCSP responder bindings */ + public OcspResponderService ocspResponders() { requireOpen(); return ocspResponders; } /** @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 index d74fcc0..d5ae7fd 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/acme/AcmeControlStore.java +++ b/pki-server/src/main/java/zeroecho/pki/server/acme/AcmeControlStore.java @@ -56,7 +56,7 @@ public final class AcmeControlStore { /** 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()))); + control.mutateProtocol(List.of(new ServerControlStore.ProtocolMutation(record(sealed), Optional.empty()))); return sealed; } @@ -68,11 +68,11 @@ public final class AcmeControlStore { .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); + List changes = new ArrayList<>(); + changes.add(new ServerControlStore.ProtocolMutation(record(sealedOrder), Optional.empty())); + sealedAuthorizations.forEach(value -> changes.add(new ServerControlStore.ProtocolMutation(record(value), Optional.empty()))); + sealedChallenges.forEach(value -> changes.add(new ServerControlStore.ProtocolMutation(record(value), Optional.empty()))); + control.mutateProtocol(changes); return new Graph(sealedOrder, sealedAuthorizations, sealedChallenges); } @@ -85,13 +85,13 @@ public final class AcmeControlStore { 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<>(); + 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)))); + changes.add(new ServerControlStore.ProtocolMutation(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); + created.forEach(value -> changes.add(new ServerControlStore.ProtocolMutation(record(value), Optional.empty()))); + control.mutateProtocol(changes); return new Transition(sealed, created); } /** Results of one atomic ACME graph transition. */ @@ -114,7 +114,7 @@ public final class AcmeControlStore { /** 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 -> { + return control.protocolRecord(namespace, recordId).map(value -> { Object decoded = codec.decode(value.payload()); if (!type.isInstance(decoded) || !recordId.equals(identity(decoded)) || !value.commitment().equals(commitment(decoded))) { @@ -126,8 +126,8 @@ public final class AcmeControlStore { /** 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); + ServerControlStore.Page page = + control.protocolRecords(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)) @@ -251,8 +251,8 @@ public final class AcmeControlStore { @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), + private ServerControlStore.ProtocolRecord record(Object value) { + return new ServerControlStore.ProtocolRecord(namespace(value.getClass()), identity(value), commitment(value), codec.encode(value)); } private static String commitment(Object value) { 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 1d61a97..bff6b89 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 @@ -51,10 +51,12 @@ import zeroecho.pki.api.status.StatusObjectType; import zeroecho.pki.application.PkiOperation; import zeroecho.pki.application.PkiOperationValue; import zeroecho.pki.server.OperationSecurityDescriptors; +import zeroecho.pki.application.OcspResponseService; import zeroecho.pki.server.AdministrativeOperation; import zeroecho.pki.server.ApprovalService; import zeroecho.pki.server.DisclosureService; import zeroecho.pki.server.Permission; +import zeroecho.pki.server.OcspResponderService; import zeroecho.pki.server.RealmId; import zeroecho.pki.server.RepositoryAliasService; import zeroecho.pki.server.RoleTemplateCatalog; @@ -429,6 +431,39 @@ final class HttpOperationCodec { case ServerControlOperation.DeactivateAcmeAccount.NAME -> { fields.exact("accountId"); yield new ServerControlOperation.DeactivateAcmeAccount(fields.text("accountId")); } + case ServerControlOperation.RegisterOcspResponder.NAME -> { + fields.exact("responderId", "alias", "authorityId", "issuerId", "signingMode", + "responderCredentialId", "signingKeyRef", "chainPathId", "signatureAlgorithm", + "signatureBindingId", "signatureBindingCommitment", "responderIdForm", + "responseValidityMillis", "noncePolicy", "maximumNonceBytes", "acceptedHashes", + "maximumRequestBytes", "maximumEntries", "cacheLifetimeMillis"); + yield new ServerControlOperation.RegisterOcspResponder(new OcspResponderService.Registration( + fields.text("responderId"), fields.text("alias"), + fields.pkiId("authorityId"), fields.pkiId("issuerId"), + OcspResponderService.SigningMode.valueOf(fields.text("signingMode")), + fields.pkiId("responderCredentialId"), new KeyRef(fields.text("signingKeyRef")), + fields.pkiId("chainPathId"), fields.text("signatureAlgorithm"), + fields.optionalText("signatureBindingId"), fields.text("signatureBindingCommitment"), + OcspResponseService.ResponderId.valueOf(fields.text("responderIdForm")), + Duration.ofMillis(fields.longValue("responseValidityMillis")), + OcspResponderService.NoncePolicy.valueOf(fields.text("noncePolicy")), + fields.integer("maximumNonceBytes"), + fields.enumSet("acceptedHashes", OcspResponseService.CertIdHash.class), + fields.integer("maximumRequestBytes"), fields.integer("maximumEntries"), + Duration.ofMillis(fields.longValue("cacheLifetimeMillis")))); + } + case ServerControlOperation.InspectOcspResponder.NAME -> { + fields.exact("responderId"); yield new ServerControlOperation.InspectOcspResponder(fields.text("responderId")); + } + case ServerControlOperation.ListOcspResponders.NAME -> { + fields.exact("offset", "limit"); yield new ServerControlOperation.ListOcspResponders( + fields.integer("offset"), fields.integer("limit")); + } + case ServerControlOperation.SetOcspResponderActive.ACTIVATE, + ServerControlOperation.SetOcspResponderActive.DEACTIVATE -> { + fields.exact("responderId"); yield new ServerControlOperation.SetOcspResponderActive( + fields.text("responderId"), id.equals(ServerControlOperation.SetOcspResponderActive.ACTIVATE)); + } default -> throw new SecurityException("Control operation is not exposed"); }; } @@ -449,6 +484,9 @@ final class HttpOperationCodec { value.authorityId()); case ServerControlOperation.RegisterAcmeDirectory value -> authorityScope(realmId, authority, value.registration().authorityId()); + case ServerControlOperation.RegisterOcspResponder value -> new Permission.Scope(realmId, + Optional.of(value.registration().authorityId()), Optional.of(value.registration().issuerId()), + Optional.empty()); default -> new Permission.Scope(realmId, authority, Optional.empty(), Optional.empty()); }; } diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/OcspHttpHandler.java b/pki-server/src/main/java/zeroecho/pki/server/http/OcspHttpHandler.java new file mode 100644 index 0000000..9190169 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/http/OcspHttpHandler.java @@ -0,0 +1,333 @@ +/******************************************************************************* + * 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.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.time.Clock; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.BooleanSupplier; + +import javax.net.ssl.SSLPeerUnverifiedException; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpsExchange; + +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.ca.IssuerChainPath; +import zeroecho.pki.api.ca.IssuerGeneration; +import zeroecho.pki.application.OcspResponseService; +import zeroecho.pki.application.PkiRepositoryContent; +import zeroecho.pki.server.AdministrativeAuthenticationMode; +import zeroecho.pki.server.OcspResponderService; +import zeroecho.pki.server.PkiServerConfiguration; +import zeroecho.pki.server.ServerRealmContext; +import zeroecho.pki.server.spi.PkiServerAuthenticationContext; + +/** Strict anonymous OCSP protocol adapter on the public listener only. */ +@SuppressWarnings("PMD") +final class OcspHttpHandler implements HttpHandler { + private static final String MEDIA_REQUEST = "application/ocsp-request"; + private static final String MEDIA_RESPONSE = "application/ocsp-response"; + private final PkiServerConfiguration.PublicListener configuration; + private final ServerRealmContext realm; + private final AdministrativeAuthenticator authenticator; + private final ServerRuntime runtime; + private final Clock clock; + private final RequestIds requestIds; + private final BooleanSupplier ready; + + OcspHttpHandler(PkiServerConfiguration.PublicListener configuration, ServerRealmContext realm, + AdministrativeAuthenticator authenticator, ServerRuntime runtime, Clock clock, + RequestIds requestIds, BooleanSupplier ready) { + this.configuration = java.util.Objects.requireNonNull(configuration); this.realm = java.util.Objects.requireNonNull(realm); + this.authenticator = java.util.Objects.requireNonNull(authenticator); this.runtime = java.util.Objects.requireNonNull(runtime); + this.clock = java.util.Objects.requireNonNull(clock); this.requestIds = java.util.Objects.requireNonNull(requestIds); + this.ready = java.util.Objects.requireNonNull(ready); + } + + @Override public void handle(HttpExchange exchange) throws IOException { + String requestId = "unavailable-request"; boolean admitted = false; + OcspResponderService.Responder auditedResponder = null; + try { + requestId = requestIds.resolve(exchange.getRequestHeaders().get(RequestIds.HEADER)); + if (!ready.getAsBoolean()) { transportFailure(exchange, 503); return; } + requireHeadersBounded(exchange.getRequestHeaders()); + validateTransport(exchange, requestId); + Route route = route(exchange); + OcspResponderService.Responder responder = activeResponder(route.alias()); + auditedResponder = responder; + audit(requestId, Optional.of(responder), "ACCEPTED"); + if (!runtime.tryAdmit()) { audit(requestId, Optional.of(responder), "OVERLOAD"); transportFailure(exchange, 429); return; } + admitted = true; + int maximumRequestBytes = Math.min(configuration.maximumBodyBytes(), responder.maximumRequestBytes()); + byte[] request = route.encoded().orElseGet(() -> read(exchange, maximumRequestBytes)); + if (request.length > maximumRequestBytes) { transportFailure(exchange, 413); return; } + OcspRequestParser.Parsed parsed = OcspRequestParser.parse(request, responder.maximumEntries(), + responder.acceptedHashes(), responder.maximumNonceBytes()); + validateNonce(responder.noncePolicy(), parsed.nonce()); + OcspResponseService.Response response = execute(responder, parsed); + send(exchange, responder, response, parsed.nonce().isPresent(), requestId); + audit(requestId, Optional.of(responder), "RESPONDED_GOOD_" + response.goodCount() + + "_REVOKED_" + response.revokedCount() + "_UNKNOWN_" + response.unknownCount()); + } catch (UnknownAlias unavailable) { audit(requestId, Optional.empty(), "UNKNOWN_RESPONDER"); transportFailure(exchange, 404); + } catch (InactiveResponder inactive) { audit(requestId, Optional.empty(), "INACTIVE_RESPONDER"); transportFailure(exchange, 503); + } catch (MethodFailure method) { audit(requestId, Optional.ofNullable(auditedResponder), "METHOD_REJECTED"); transportFailure(exchange, 405); + } catch (MediaFailure media) { audit(requestId, Optional.ofNullable(auditedResponder), "MEDIA_REJECTED"); transportFailure(exchange, 406); + } catch (IllegalArgumentException malformed) { audit(requestId, Optional.ofNullable(auditedResponder), "MALFORMED"); protocolFailure(exchange, org.bouncycastle.cert.ocsp.OCSPRespBuilder.MALFORMED_REQUEST); + } catch (RejectedExecutionException overload) { audit(requestId, Optional.ofNullable(auditedResponder), "OVERLOAD"); transportFailure(exchange, 429); + } catch (TimeoutException deadline) { audit(requestId, Optional.ofNullable(auditedResponder), "DEADLINE"); transportFailure(exchange, 504); + } catch (RuntimeException failure) { audit(requestId, Optional.ofNullable(auditedResponder), "UNAVAILABLE"); transportFailure(exchange, 503); + } finally { if (admitted) runtime.releaseAdmission(); exchange.close(); } + } + + private OcspResponderService.Responder activeResponder(String alias) { + try { + OcspResponderService.Responder responder = realm.ocspResponders().requireAlias(alias); + if (responder.state() != OcspResponderService.State.ACTIVE) throw new InactiveResponder(); + return realm.ocspResponders().requireActiveAlias(alias); + } catch (IllegalArgumentException unavailable) { + throw new UnknownAlias(); + } + } + + private OcspResponseService.Response execute(OcspResponderService.Responder responder, + OcspRequestParser.Parsed parsed) throws TimeoutException { + IssuerGeneration issuer = realm.session().repository().issuer(responder.issuerId()).orElseThrow(); + IssuerChainPath path = realm.session().repository().chainPath(responder.chainPathId()).orElseThrow(); + List chain = new ArrayList<>(); + if (responder.signingMode() == OcspResponderService.SigningMode.DELEGATED_RESPONDER) { + chain.add(responder.responderCredentialId()); + } + chain.addAll(path.orderedCredentialIds()); + Instant produced = producedAt(responder, parsed.nonce().isPresent()); + OcspResponseService.Command command = new OcspResponseService.Command(responder.authorityId(), + responder.issuerId(), issuer.credentialId(), responder.responderCredentialId(), + responder.signingKeyRef(), chain, responder.signatureAlgorithm(), responder.signatureBindingId(), + responder.responderIdForm(), produced, produced, produced.plus(responder.responseValidity()), + parsed.nonce(), parsed.requests()); + ServerRuntime.Submitted submitted = runtime.submit(cancellation -> { + cancellation.throwIfCancelled(); + return realm.session().ocsp().orElseThrow().respond(command); + }); + try { + return submitted.future().get(configuration.maximumStreamDuration().toMillis(), TimeUnit.MILLISECONDS); + } catch (InterruptedException failure) { + submitted.cancellation().cancel(); submitted.future().cancel(true); Thread.currentThread().interrupt(); + throw new TimeoutException("OCSP operation interrupted"); + } catch (ExecutionException failure) { + if (failure.getCause() instanceof RuntimeException runtimeFailure) throw runtimeFailure; + throw new IllegalStateException("OCSP operation failed"); + } finally { submitted.finish(); } + } + + private Instant producedAt(OcspResponderService.Responder responder, boolean nonce) { + Instant now = clock.instant().truncatedTo(ChronoUnit.SECONDS); + if (nonce) { + return now; + } + long seconds = Math.max(1L, responder.cacheLifetime().toSeconds()); + long bucket = Math.multiplyExact(Math.floorDiv(now.getEpochSecond(), seconds), seconds); + Instant produced = Instant.ofEpochSecond(bucket); + try (PkiRepositoryContent content = realm.session().repository() + .openCredential(responder.responderCredentialId()); InputStream input = content.openStream()) { + byte[] encoded = input.readNBytes(1_048_577); + if (encoded.length > 1_048_576 || input.read() != -1) { + throw new IllegalStateException("OCSP responder certificate exceeds its finite bound"); + } + Instant notBefore = new org.bouncycastle.cert.X509CertificateHolder(encoded) + .getNotBefore().toInstant(); + return produced.isBefore(notBefore) ? notBefore : produced; + } catch (IOException failure) { + throw new IllegalStateException("OCSP responder certificate is unavailable"); + } + } + + private void validateTransport(HttpExchange exchange, String requestId) { + Map> headers = Map.copyOf(exchange.getRequestHeaders()); + boolean forwarded = ForwardedClientCertificateParser.containsForwardedIdentity(headers); + if (configuration.authentication().mode() == AdministrativeAuthenticationMode.DIRECT_MTLS) { + if (forwarded) throw new IllegalArgumentException("Forwarded identity is prohibited"); + return; + } + Optional context = tlsContext(exchange, requestId, headers); + if (context.isEmpty() || authenticator.authenticatePublicProxyTransport(context.orElseThrow()).isEmpty()) { + throw new IllegalArgumentException("Proxy transport is unauthenticated"); + } + } + + private Optional tlsContext(HttpExchange exchange, String requestId, + Map> headers) { + if (!(exchange instanceof HttpsExchange https)) return Optional.empty(); + try { + Certificate[] peers = https.getSSLSession().getPeerCertificates(); List chain = new ArrayList<>(); + for (Certificate peer : peers) { if (!(peer instanceof X509Certificate certificate)) return Optional.empty(); chain.add(certificate); } + return Optional.of(new PkiServerAuthenticationContext(chain, https.getSSLSession().getProtocol(), + https.getSSLSession().getCipherSuite(), requestId, realm.configuration().realmId(), headers)); + } catch (SSLPeerUnverifiedException failure) { return Optional.empty(); } + } + + private Route route(HttpExchange exchange) { + String path = exchange.getRequestURI().getRawPath(); + if (exchange.getRequestURI().getRawQuery() != null || path.indexOf('%') >= 0) throw new IllegalArgumentException(); + String[] part = path.split("/", -1); + if (part.length != 3 && part.length != 4 || !"".equals(part[0]) || !"ocsp".equals(part[1]) + || !part[2].matches("[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")) throw new UnknownAlias(); + if (part.length == 3) { + if (!"POST".equals(exchange.getRequestMethod())) throw new MethodFailure(); + requireAccept(exchange.getRequestHeaders()); + List type = exchange.getRequestHeaders().get("Content-Type"); + if (type == null || type.size() != 1 || !MEDIA_REQUEST.equalsIgnoreCase(type.getFirst())) throw new IllegalArgumentException(); + requireBodyFraming(exchange.getRequestHeaders()); + return new Route(part[2], Optional.empty()); + } + if (!"GET".equals(exchange.getRequestMethod())) throw new MethodFailure(); + requireAccept(exchange.getRequestHeaders()); + return new Route(part[2], Optional.of(OcspRequestParser.decodeGet(part[3], configuration.maximumBodyBytes()))); + } + + private void requireHeadersBounded(Headers headers) { + int total = 0; + for (Map.Entry> entry : headers.entrySet()) { + total = Math.addExact(total, entry.getKey().getBytes(StandardCharsets.UTF_8).length); + for (String value : entry.getValue()) { + total = Math.addExact(total, value.getBytes(StandardCharsets.UTF_8).length); + if (total > configuration.maximumHeaderBytes()) { + throw new IllegalArgumentException("OCSP request headers are oversized"); + } + } + } + } + + private static void requireAccept(Headers headers) { + List accept = headers.get("Accept"); + if (accept != null && (accept.size() != 1 || !("*/*".equals(accept.getFirst()) + || MEDIA_RESPONSE.equalsIgnoreCase(accept.getFirst())))) { + throw new MediaFailure(); + } + } + + private static void requireBodyFraming(Headers headers) { + if (headers.containsKey("Transfer-Encoding")) throw new IllegalArgumentException("OCSP framing is invalid"); + List length = headers.get("Content-Length"); + if (length != null) { + if (length.size() != 1 || !length.getFirst().matches("[0-9]{1,10}")) { + throw new IllegalArgumentException("OCSP content length is invalid"); + } + } + } + + private static byte[] read(HttpExchange exchange, int maximum) { + try (InputStream input = exchange.getRequestBody(); ByteArrayOutputStream output = new ByteArrayOutputStream(Math.min(maximum, 16_384))) { + byte[] buffer = new byte[8192]; int total = 0; + while (true) { int count = input.read(buffer); if (count < 0) break; if (count == 0) throw new IOException("No progress"); + total = Math.addExact(total, count); if (total > maximum) throw new IllegalArgumentException(); output.write(buffer, 0, count); } + return output.toByteArray(); + } catch (IOException failure) { throw new IllegalArgumentException("OCSP request body is invalid"); } + } + + private static void validateNonce(OcspResponderService.NoncePolicy policy, Optional nonce) { + if (policy == OcspResponderService.NoncePolicy.REJECT && nonce.isPresent() + || policy == OcspResponderService.NoncePolicy.REQUIRED && nonce.isEmpty()) throw new IllegalArgumentException(); + } + + private void send(HttpExchange exchange, OcspResponderService.Responder responder, + OcspResponseService.Response response, boolean nonce, String requestId) throws IOException { + Headers headers = exchange.getResponseHeaders(); headers.set("Content-Type", MEDIA_RESPONSE); + headers.set("X-Content-Type-Options", "nosniff"); headers.set(RequestIds.HEADER, requestId); + if (nonce) { headers.set("Cache-Control", "no-store"); } + else { + long seconds = Math.min(responder.cacheLifetime().toSeconds(), responder.responseValidity().toSeconds()); + String validator = etag(response.der()); + headers.set("Cache-Control", "public, max-age=" + seconds); + headers.set("Expires", java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME.format( + java.time.ZonedDateTime.ofInstant(clock.instant().plusSeconds(seconds), java.time.ZoneOffset.UTC))); + headers.set("ETag", validator); + List conditional = exchange.getRequestHeaders().get("If-None-Match"); + if (conditional != null && conditional.size() == 1 && validator.equals(conditional.getFirst())) { + exchange.sendResponseHeaders(304, -1); + return; + } + } + exchange.sendResponseHeaders(200, response.der().length); + try (OutputStream output = exchange.getResponseBody()) { output.write(response.der()); } + } + + private static void protocolFailure(HttpExchange exchange, int status) throws IOException { + byte[] body; + try { body = new org.bouncycastle.cert.ocsp.OCSPRespBuilder().build(status, null).getEncoded(); } + catch (org.bouncycastle.cert.ocsp.OCSPException impossible) { throw new IOException("OCSP failure encoding failed"); } + exchange.getResponseHeaders().set("Content-Type", MEDIA_RESPONSE); exchange.getResponseHeaders().set("Cache-Control", "no-store"); + exchange.sendResponseHeaders(200, body.length); try (OutputStream output = exchange.getResponseBody()) { output.write(body); } + } + private static void transportFailure(HttpExchange exchange, int status) throws IOException { + exchange.getResponseHeaders().set("Cache-Control", "no-store"); exchange.getResponseHeaders().set("X-Content-Type-Options", "nosniff"); + exchange.sendResponseHeaders(status, -1); + } + private void audit(String requestId, Optional responder, + String classification) { + Map details = new java.util.LinkedHashMap<>(); + details.put("request", requestId); details.put("classification", classification); + responder.ifPresent(value -> details.put("responder", value.responderId())); + realm.auditTransport("OCSP_REQUEST", "anonymous", Map.copyOf(details)); + } + private static String etag(byte[] value) { + try { return '"' + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)) + '"'; } + catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException("SHA-256 unavailable", impossible); } + } + private record Route(String alias, Optional encoded) { Route { encoded = encoded.map(byte[]::clone); } } + private static final class UnknownAlias extends RuntimeException { private static final long serialVersionUID = 1L; } + private static final class InactiveResponder extends RuntimeException { private static final long serialVersionUID = 1L; } + private static final class MethodFailure extends RuntimeException { private static final long serialVersionUID = 1L; } + private static final class MediaFailure extends RuntimeException { private static final long serialVersionUID = 1L; } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/OcspRequestParser.java b/pki-server/src/main/java/zeroecho/pki/server/http/OcspRequestParser.java new file mode 100644 index 0000000..810f15e --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/http/OcspRequestParser.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.http; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import org.bouncycastle.asn1.ASN1OctetString; +import org.bouncycastle.asn1.DERNull; +import org.bouncycastle.asn1.nist.NISTObjectIdentifiers; +import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers; +import org.bouncycastle.asn1.oiw.OIWObjectIdentifiers; +import org.bouncycastle.cert.ocsp.CertificateID; +import org.bouncycastle.cert.ocsp.OCSPReq; +import org.bouncycastle.cert.ocsp.Req; + +import zeroecho.pki.application.OcspResponseService; + +/** Narrow canonical DER and unpadded Base64url OCSP request decoder. */ +@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.ExceptionAsFlowControl", "PMD.PreserveStackTrace", + "PMD.AvoidCatchingGenericException", "PMD.CyclomaticComplexity" }) +final class OcspRequestParser { + /* default */ record Parsed(List requests, Optional nonce) { + Parsed { requests = List.copyOf(requests); nonce = nonce.map(byte[]::clone); } + @Override public Optional nonce() { return nonce.map(byte[]::clone); } + } + + private OcspRequestParser() { } + + /* default */ static byte[] decodeGet(String encoded, int maximumBytes) { + if (encoded.isEmpty() || encoded.length() > Math.addExact(maximumBytes * 2, 8) + || !encoded.matches("[A-Za-z0-9_-]+") || encoded.indexOf('=') >= 0) throw malformed(); + try { + byte[] decoded = Base64.getUrlDecoder().decode(encoded); + if (decoded.length > maximumBytes || !Base64.getUrlEncoder().withoutPadding() + .encodeToString(decoded).equals(encoded)) throw malformed(); + return decoded; + } catch (IllegalArgumentException failure) { throw malformed(); } + } + + /* default */ static Parsed parse(byte[] der, int maximumEntries, + Set acceptedHashes, int maximumNonceBytes) { + try { + OCSPReq request = new OCSPReq(der); + if (!Arrays.equals(der, request.getEncoded()) || request.isSigned()) throw malformed(); + Req[] entries = request.getRequestList(); + if (entries.length == 0 || entries.length > maximumEntries) throw malformed(); + List result = new ArrayList<>(entries.length); + for (Req entry : entries) { + CertificateID id = entry.getCertID(); + org.bouncycastle.asn1.x509.AlgorithmIdentifier algorithm = id.toASN1Primitive().getHashAlgorithm(); + OcspResponseService.CertIdHash hash; + if (OIWObjectIdentifiers.idSHA1.equals(id.getHashAlgOID()) + && DERNull.INSTANCE.equals(algorithm.getParameters())) { + hash = OcspResponseService.CertIdHash.SHA1; + } else if (NISTObjectIdentifiers.id_sha256.equals(id.getHashAlgOID()) + && algorithm.getParameters() == null) { + hash = OcspResponseService.CertIdHash.SHA256; + } + else throw malformed(); + if (!acceptedHashes.contains(hash)) throw malformed(); + result.add(new OcspResponseService.CertId(hash, id.getIssuerNameHash(), + id.getIssuerKeyHash(), id.getSerialNumber())); + } + List extensionIds = request.getExtensionOIDs(); + if (extensionIds.size() > 1 || extensionIds.stream().anyMatch( + oid -> !OCSPObjectIdentifiers.id_pkix_ocsp_nonce.equals(oid))) throw malformed(); + Optional nonce = Optional.empty(); + if (!extensionIds.isEmpty()) { + org.bouncycastle.asn1.x509.Extension extension = request.getExtension( + OCSPObjectIdentifiers.id_pkix_ocsp_nonce); + if (extension == null || extension.isCritical()) throw malformed(); + byte[] value = ASN1OctetString.getInstance(extension.getParsedValue()).getOctets(); + if (value.length == 0 || value.length > maximumNonceBytes) throw malformed(); + nonce = Optional.of(value); + } + return new Parsed(result, nonce); + } catch (IOException | RuntimeException failure) { throw malformed(); } + } + + private static IllegalArgumentException malformed() { + return new IllegalArgumentException("OCSP request is malformed"); + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/PublicRepositoryTransport.java b/pki-server/src/main/java/zeroecho/pki/server/http/PublicRepositoryTransport.java index da8c0fa..a795fab 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/http/PublicRepositoryTransport.java +++ b/pki-server/src/main/java/zeroecho/pki/server/http/PublicRepositoryTransport.java @@ -57,10 +57,12 @@ import zeroecho.pki.server.ServerRealmContext; public final class PublicRepositoryTransport implements AutoCloseable { private final HttpServer listener; private final ServerRuntime runtime; + private final ServerRuntime ocspRuntime; - private PublicRepositoryTransport(HttpServer listener, ServerRuntime runtime) { + private PublicRepositoryTransport(HttpServer listener, ServerRuntime runtime, ServerRuntime ocspRuntime) { this.listener = listener; this.runtime = runtime; + this.ocspRuntime = ocspRuntime; } /** Starts one separately bounded public listener over the shared realm. */ @@ -72,9 +74,11 @@ public final class PublicRepositoryTransport implements AutoCloseable { Objects.requireNonNull(realm, "realm"); Objects.requireNonNull(authenticator, "authenticator"); ServerRuntime runtime = null; + ServerRuntime ocspRuntime = null; HttpServer listener = null; try { runtime = new ServerRuntime(configuration.execution(), true); + ocspRuntime = new ServerRuntime(configuration.execution(), ServerRuntime.Lane.OCSP); if (configuration.tlsProvider().isPresent()) { SSLContext context = TlsProviders.create(configuration.tlsProvider().orElseThrow(), loader); HttpsServer secure = HttpsServer.create(configuration.socketAddress(), @@ -88,15 +92,17 @@ public final class PublicRepositoryTransport implements AutoCloseable { configuration.execution().transportQueueCapacity()); } listener.setExecutor(runtime.transportExecutor()); + listener.createContext("/ocsp/", new OcspHttpHandler(configuration, realm, authenticator, + ocspRuntime, clock, new RequestIds(random), ready)); listener.createContext("/", new PublicRepositoryHttpHandler(configuration, realm, authenticator, runtime, clock, new RequestIds(random), ready)); listener.start(); - return new PublicRepositoryTransport(listener, runtime); + return new PublicRepositoryTransport(listener, runtime, ocspRuntime); } catch (IOException failure) { - closePartial(listener, runtime); + closePartial(listener, runtime, ocspRuntime); throw new IllegalStateException("Public repository listener initialization failed", failure); } catch (RuntimeException | Error failure) { - closePartial(listener, runtime); + closePartial(listener, runtime, ocspRuntime); throw failure; } } @@ -105,7 +111,7 @@ public final class PublicRepositoryTransport implements AutoCloseable { public InetSocketAddress address() { return listener.getAddress(); } /** Prevents new public stream admission. */ - public void quiesce() { runtime.quiesce(); } + public void quiesce() { runtime.quiesce(); ocspRuntime.quiesce(); } /** Stops the listener and its independent bounded resources. */ public void shutdown(Duration graceful) { @@ -113,6 +119,7 @@ public final class PublicRepositoryTransport implements AutoCloseable { int seconds = Math.toIntExact(Math.min(Integer.MAX_VALUE, Objects.requireNonNull(graceful, "graceful").toSeconds())); listener.stop(seconds); + ocspRuntime.close(); runtime.close(); } @@ -132,12 +139,15 @@ public final class PublicRepositoryTransport implements AutoCloseable { }; } - private static void closePartial(HttpServer listener, ServerRuntime runtime) { + private static void closePartial(HttpServer listener, ServerRuntime runtime, ServerRuntime ocspRuntime) { if (listener != null) { listener.stop(0); } if (runtime != null) { runtime.close(); } + if (ocspRuntime != null) { + ocspRuntime.close(); + } } } 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 df5c176..51a43d7 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 @@ -59,6 +59,8 @@ final class ServerRuntime implements AutoCloseable { 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 OCSP_TRANSPORT_PREFIX = "zeroecho-pki-ocsp-https-"; + static final String OCSP_OPERATION_PREFIX = "zeroecho-pki-ocsp-response-"; static final String SHUTDOWN_NAME = "zeroecho-pki-shutdown"; private final ThreadPoolExecutor transport; @@ -81,10 +83,12 @@ final class ServerRuntime implements AutoCloseable { this.configuration = configuration; transport = pool(configuration.transportWorkers(), configuration.transportQueueCapacity(), new NamedThreadFactory(lane == Lane.PUBLIC ? PUBLIC_TRANSPORT_PREFIX - : lane == Lane.ACME ? ACME_TRANSPORT_PREFIX : TRANSPORT_PREFIX)); + : lane == Lane.ACME ? ACME_TRANSPORT_PREFIX + : lane == Lane.OCSP ? OCSP_TRANSPORT_PREFIX : TRANSPORT_PREFIX)); operations = pool(configuration.operationWorkers(), configuration.operationQueueCapacity(), new NamedThreadFactory(lane == Lane.PUBLIC ? PUBLIC_STREAM_PREFIX - : lane == Lane.ACME ? ACME_PROTOCOL_PREFIX : OPERATION_PREFIX)); + : lane == Lane.ACME ? ACME_PROTOCOL_PREFIX + : lane == Lane.OCSP ? OCSP_OPERATION_PREFIX : OPERATION_PREFIX)); admitted = new Semaphore(configuration.maximumAdmittedRequests(), true); } @@ -150,9 +154,11 @@ final class ServerRuntime implements AutoCloseable { boolean transportWorker = Thread.currentThread().getName().startsWith(TRANSPORT_PREFIX) || Thread.currentThread().getName().startsWith(PUBLIC_TRANSPORT_PREFIX) || Thread.currentThread().getName().startsWith(ACME_TRANSPORT_PREFIX); + transportWorker = transportWorker || Thread.currentThread().getName().startsWith(OCSP_TRANSPORT_PREFIX); boolean operationWorker = Thread.currentThread().getName().startsWith(OPERATION_PREFIX) || Thread.currentThread().getName().startsWith(PUBLIC_STREAM_PREFIX) || Thread.currentThread().getName().startsWith(ACME_PROTOCOL_PREFIX); + operationWorker = operationWorker || Thread.currentThread().getName().startsWith(OCSP_OPERATION_PREFIX); if (!operationWorker) { await(operations, configuration.gracefulShutdown()); } @@ -173,7 +179,7 @@ final class ServerRuntime implements AutoCloseable { } } - enum Lane { ADMIN, PUBLIC, ACME } + enum Lane { ADMIN, PUBLIC, ACME, OCSP } /** One separately admitted bounded challenge-validation execution lane. */ static final class ChallengeRuntime implements AutoCloseable { diff --git a/pki-server/src/test/java/zeroecho/pki/server/AcmeEndToEndTest.java b/pki-server/src/test/java/zeroecho/pki/server/AcmeEndToEndTest.java index 52e1fb0..27c5e4f 100644 --- a/pki-server/src/test/java/zeroecho/pki/server/AcmeEndToEndTest.java +++ b/pki-server/src/test/java/zeroecho/pki/server/AcmeEndToEndTest.java @@ -35,10 +35,12 @@ package zeroecho.pki.server; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.math.BigInteger; import java.net.InetAddress; import java.net.ServerSocket; import java.net.Socket; @@ -65,12 +67,23 @@ import java.util.HexFormat; import java.security.MessageDigest; import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; +import org.bouncycastle.asn1.DEROctetString; +import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers; import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.Extensions; import org.bouncycastle.asn1.x509.ExtensionsGenerator; import org.bouncycastle.asn1.x509.GeneralName; import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder; +import org.bouncycastle.cert.ocsp.BasicOCSPResp; +import org.bouncycastle.cert.ocsp.CertificateID; +import org.bouncycastle.cert.ocsp.OCSPReqBuilder; +import org.bouncycastle.cert.ocsp.OCSPResp; +import org.bouncycastle.cert.ocsp.RevokedStatus; import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder; +import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -112,12 +125,13 @@ class AcmeEndToEndTest { System.out.println("completesRealHttpsAccountIssuanceRolloverRestartAndRevocation"); HttpServerTestSupport.Fixture tls = HttpServerTestSupport.tls(); int acmePort = reservePort(); + int publicPort = 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); + Fixture fixture = fixture(tls, acmePort, publicPort, challengePort, rootKey); seed(fixture); URI directoryUri = URI.create("https://localhost:" + acmePort + "/acme/" + DIRECTORY_ALIAS @@ -181,6 +195,10 @@ class AcmeEndToEndTest { .toList()); chain.get(0).verify(chain.get(1).getPublicKey()); leafDer = chain.get(0).getEncoded(); + assertOcsp(wire, publicPort, chain.get(1), chain.get(0), false); + assertOcspNonce(wire, publicPort, chain.get(1), chain.get(0)); + assertOcspUnknownAndMulti(wire, publicPort, chain.get(1), chain.get(0)); + assertOcspNoncePolicies(wire, publicPort, chain.get(1), chain.get(0)); assertEquals(200, client.rollover(keyChange, replacementKey).status()); assertEquals(200, client.postAsGet(accountUri).status()); @@ -201,6 +219,8 @@ class AcmeEndToEndTest { String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(leafDer); assertEquals(200, replacement.kid(revoke, "{\"certificate\":\"" + encoded + "\",\"reason\":1}").status()); + List chain = certificates(replacement.postAsGet(certificateUri).bodyText()); + assertOcsp(wire, publicPort, chain.get(1), chain.get(0), true); assertFalse(replacement.kid(revoke, "{\"certificate\":\"" + encoded + "\",\"reason\":1}").status() == 200); System.out.println("...rollover-restart-revocation=true"); @@ -218,9 +238,10 @@ class AcmeEndToEndTest { HttpServerTestSupport.PackagedTls tls = HttpServerTestSupport.packagedTls(root.resolve("tls")); int adminPort = reservePort(); int acmePort = reservePort(); + int publicPort = reservePort(); int challengePort = reservePort(); KeyPair rootKey = rsa((byte) 41); - Fixture fixture = packagedFixture(root, tls, adminPort, acmePort, challengePort, rootKey); + Fixture fixture = packagedFixture(root, tls, adminPort, acmePort, publicPort, challengePort, rootKey); seed(fixture, false); Path configuration = root.resolve("server.json"); java.nio.file.Files.writeString(configuration, packagedJson(fixture.configuration(), tls), @@ -271,10 +292,12 @@ class AcmeEndToEndTest { URI certificate = URI.create(AcmeTestClient.text(finalized, "certificate")); List chain = certificates(client.postAsGet(certificate).bodyText()); assertEquals(2, chain.size()); + assertOcsp(wire, publicPort, chain.get(1), chain.get(0), false); 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()); + assertOcsp(wire, publicPort, chain.get(1), chain.get(0), true); } System.out.println("...installed-launcher-flow=true"); } finally { @@ -304,8 +327,8 @@ class AcmeEndToEndTest { } } - private Fixture fixture(HttpServerTestSupport.Fixture tls, int acmePort, int challengePort, KeyPair rootKey) - throws Exception { + private Fixture fixture(HttpServerTestSupport.Fixture tls, int acmePort, int publicPort, 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 @@ -341,8 +364,13 @@ class AcmeEndToEndTest { 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.PublicListener publicListener = new PkiServerConfiguration.PublicListener( + InetAddress.getByName("127.0.0.1"), publicPort, + Optional.of(new ProviderConfig("test-tls", Map.of())), false, base.authentication(), + 16_384, 65_536, lane, Duration.ofSeconds(20), Duration.ofMinutes(5), + Duration.ofSeconds(30), true); PkiServerConfiguration configuration = new PkiServerConfiguration(base.version(), base.serverName(), realm, - base.listener(), base.authentication(), base.execution(), base.runtime(), Optional.empty(), + base.listener(), base.authentication(), base.execution(), base.runtime(), Optional.of(publicListener), Optional.of(acme)); PkiSessionRuntimeDependencies dependencies = PkiSessionRuntimeDependencies.withKeyringUnlockProvider( () -> new KeyringPassword(KEYRING_PASSWORD.clone())); @@ -365,6 +393,24 @@ class AcmeEndToEndTest { 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())); + zeroecho.pki.api.ca.CaRecord authorityRecord = context.session().repository().authority(authority) + .orElseThrow(); + zeroecho.pki.api.ca.IssuerGeneration issuer = context.session().repository() + .issuer(authorityRecord.currentIssuanceIssuerId()).orElseThrow(); + OcspResponderService.Responder responder = context.ocspResponders().create("ocsp-e2e-root", "root", + authority, issuer.issuerId(), OcspResponderService.SigningMode.ISSUER_SIGNED, + issuer.credentialId(), issuer.signingKeyRef(), authorityRecord.issuanceChainPathId(), + "SHA256withRSA", Optional.empty(), context.session().algorithmBindings().commitment(), + zeroecho.pki.application.OcspResponseService.ResponderId.BY_KEY, Duration.ofMinutes(5), + OcspResponderService.NoncePolicy.OPTIONAL_ECHO, 64, + Set.of(zeroecho.pki.application.OcspResponseService.CertIdHash.SHA1, + zeroecho.pki.application.OcspResponseService.CertIdHash.SHA256), + 16_384, 8, Duration.ofMinutes(1)); + context.ocspResponders().setActive(context.ocspResponders().register(responder).responderId(), true); + registerNonceResponder(context, responder, "ocsp-e2e-reject", "reject", + OcspResponderService.NoncePolicy.REJECT); + registerNonceResponder(context, responder, "ocsp-e2e-required", "required", + OcspResponderService.NoncePolicy.REQUIRED); ActiveCertificateProfile active = context.session().profiles().requireActiveProfile("server-tls"); AcmeService service = new AcmeService(context, ServerTestSupport.CLOCK, HttpServerTestSupport.random(), Map.of(Http01ChallengeProvider.ID, new Http01ChallengeProvider()), @@ -389,13 +435,26 @@ class AcmeEndToEndTest { } } + private static void registerNonceResponder(ServerRealmContext context, + OcspResponderService.Responder template, String responderId, String alias, + OcspResponderService.NoncePolicy noncePolicy) { + OcspResponderService.Responder responder = context.ocspResponders().create(responderId, alias, + template.authorityId(), template.issuerId(), template.signingMode(), + template.responderCredentialId(), template.signingKeyRef(), template.chainPathId(), + template.signatureAlgorithm(), template.signatureBindingId(), + template.signatureBindingCommitment(), template.responderIdForm(), template.responseValidity(), + noncePolicy, template.maximumNonceBytes(), template.acceptedHashes(), + template.maximumRequestBytes(), template.maximumEntries(), template.cacheLifetime()); + context.ocspResponders().setActive(context.ocspResponders().register(responder).responderId(), true); + } + 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 { + int publicPort, 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)) { @@ -439,11 +498,16 @@ class AcmeEndToEndTest { 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, + PkiServerConfiguration.PublicListener publicListener = new PkiServerConfiguration.PublicListener( + InetAddress.getByName("127.0.0.1"), publicPort, Optional.of(tlsProvider), false, + authentication, 16_384, 65_536, lane, Duration.ofSeconds(20), Duration.ofMinutes(5), + Duration.ofSeconds(30), true); + PkiServerConfiguration configuration = new PkiServerConfiguration(PkiServerConfiguration.CURRENT_VERSION, + "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)); + Optional.of(publicListener), Optional.of(acme)); return new Fixture(configuration, PkiSessionRuntimeDependencies.withKeyringUnlockProvider( () -> new KeyringPassword(KEYRING_PASSWORD.clone())), http01); } @@ -464,7 +528,14 @@ class AcmeEndToEndTest { String workflow = providerJson(signing.workflow()); String store = providerJson(realm.pkiSessionConfiguration().store()); String audit = providerJson(realm.pkiSessionConfiguration().audit()); - return "{\"version\":4,\"serverName\":\"packaged-acme\",\"realm\":{" + String authenticationJson = "{\"mode\":\"DIRECT_MTLS\",\"directClientMappings\":[{" + + "\"mappingId\":\"packaged-admin\",\"principalId\":\"administrator\"," + + "\"certificateSha256\":\"" + + configuration.authentication().directClientMappings().get(0).certificateSha256().orElseThrow() + + "\"}]}"; + PkiServerConfiguration.PublicListener publicListener = configuration.publicListener().orElseThrow(); + return "{\"version\":" + PkiServerConfiguration.CURRENT_VERSION + + ",\"serverName\":\"packaged-acme\",\"realm\":{" + "\"realmId\":\"production\",\"displayName\":\"Packaged ACME\"," + "\"authorityExposure\":{\"mode\":\"ALL_REALM_AUTHORITIES\",\"authorityIds\":[]," + "\"creationPermitted\":true},\"authorizationCommitment\":\"" + realm.authorizationCommitment() @@ -485,11 +556,14 @@ class AcmeEndToEndTest { + "\"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}," + + "\"maximumBodyBytes\":1048576},\"authentication\":" + authenticationJson + + ",\"execution\":" + executionJson() + ",\"publicListener\":{\"enabled\":true," + + "\"address\":\"127.0.0.1\",\"port\":" + publicListener.port() + + ",\"tlsProvider\":" + tlsJson + ",\"allowPlaintextLoopback\":false," + + "\"authentication\":" + authenticationJson + + ",\"maximumHeaderBytes\":16384,\"maximumBodyBytes\":65536,\"execution\":" + executionJson() + + ",\"maximumStreamDurationMillis\":20000,\"publicImmutableCacheMillis\":300000," + + "\"publicAliasCacheMillis\":30000,\"authorityListExposed\":true}," + "\"acmeListener\":{\"enabled\":true,\"listenerId\":\"packaged-acme\"," + "\"address\":\"127.0.0.1\",\"port\":" + configuration.acmeListener().orElseThrow().port() + ",\"tlsProvider\":" + tlsJson + ",\"transportMode\":\"DIRECT_TLS\"," @@ -576,6 +650,135 @@ class AcmeEndToEndTest { return List.copyOf(result); } + private static void assertOcsp(HttpClient client, int publicPort, X509Certificate issuer, + X509Certificate certificate, boolean revoked) throws Exception { + JcaX509CertificateHolder issuerHolder = new JcaX509CertificateHolder(issuer); + org.bouncycastle.operator.DigestCalculator digest = new JcaDigestCalculatorProviderBuilder().build().get( + new org.bouncycastle.asn1.x509.AlgorithmIdentifier( + org.bouncycastle.asn1.nist.NISTObjectIdentifiers.id_sha256)); + OCSPReqBuilder builder = new OCSPReqBuilder(); + builder.addRequest(new CertificateID(digest, issuerHolder, certificate.getSerialNumber())); + byte[] request = builder.build().getEncoded(); + URI endpoint = URI.create("https://localhost:" + publicPort + "/ocsp/root"); + java.net.http.HttpRequest wireRequest; + if (revoked) { + String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(request); + wireRequest = java.net.http.HttpRequest.newBuilder(URI.create(endpoint + "/" + encoded)) + .header("Accept", "application/ocsp-response").GET().build(); + } else { + wireRequest = java.net.http.HttpRequest.newBuilder(endpoint) + .header("Accept", "application/ocsp-response") + .header("Content-Type", "application/ocsp-request") + .POST(java.net.http.HttpRequest.BodyPublishers.ofByteArray(request)).build(); + } + java.net.http.HttpResponse response = client.send(wireRequest, + java.net.http.HttpResponse.BodyHandlers.ofByteArray()); + assertEquals(200, response.statusCode()); + assertEquals("application/ocsp-response", response.headers().firstValue("Content-Type").orElseThrow()); + String etag = response.headers().firstValue("ETag").orElseThrow(); + OCSPResp parsed = new OCSPResp(response.body()); + assertEquals(0, parsed.getStatus()); + BasicOCSPResp basic = assertInstanceOf(BasicOCSPResp.class, parsed.getResponseObject()); + assertEquals(1, basic.getResponses().length); + assertTrue(basic.isSignatureValid(new JcaContentVerifierProviderBuilder().build(issuerHolder))); + assertTrue(basic.getResponses()[0].getThisUpdate() != null); + assertTrue(basic.getResponses()[0].getNextUpdate() != null); + if (revoked) { + assertInstanceOf(RevokedStatus.class, basic.getResponses()[0].getCertStatus()); + } else { + assertEquals(null, basic.getResponses()[0].getCertStatus()); + String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(request); + java.net.http.HttpResponse conditional = client.send(java.net.http.HttpRequest.newBuilder( + URI.create(endpoint + "/" + encoded)).header("Accept", "application/ocsp-response") + .header("If-None-Match", etag).GET().build(), + java.net.http.HttpResponse.BodyHandlers.ofByteArray()); + assertEquals(304, conditional.statusCode()); + assertEquals(0, conditional.body().length); + } + } + + private static void assertOcspNonce(HttpClient client, int publicPort, X509Certificate issuer, + X509Certificate certificate) throws Exception { + JcaX509CertificateHolder issuerHolder = new JcaX509CertificateHolder(issuer); + org.bouncycastle.operator.DigestCalculator digest = new JcaDigestCalculatorProviderBuilder().build() + .get(CertificateID.HASH_SHA1); + byte[] nonce = new byte[] { 9, 8, 7, 6, 5, 4, 3, 2 }; + OCSPReqBuilder builder = new OCSPReqBuilder(); + builder.addRequest(new CertificateID(digest, issuerHolder, certificate.getSerialNumber())); + builder.setRequestExtensions(new Extensions(new Extension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce, + false, new DEROctetString(new DEROctetString(nonce).getEncoded())))); + java.net.http.HttpResponse response = client.send(java.net.http.HttpRequest.newBuilder( + URI.create("https://localhost:" + publicPort + "/ocsp/root")) + .header("Accept", "application/ocsp-response") + .header("Content-Type", "application/ocsp-request") + .POST(java.net.http.HttpRequest.BodyPublishers.ofByteArray(builder.build().getEncoded())).build(), + java.net.http.HttpResponse.BodyHandlers.ofByteArray()); + assertEquals(200, response.statusCode()); + assertEquals("no-store", response.headers().firstValue("Cache-Control").orElseThrow()); + assertTrue(response.headers().firstValue("ETag").isEmpty()); + BasicOCSPResp basic = assertInstanceOf(BasicOCSPResp.class, + new OCSPResp(response.body()).getResponseObject()); + Extension echoed = basic.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce); + assertTrue(echoed != null); + assertEquals(java.util.HexFormat.of().formatHex(nonce), java.util.HexFormat.of().formatHex( + org.bouncycastle.asn1.ASN1OctetString.getInstance(echoed.getParsedValue()).getOctets())); + } + + private static void assertOcspUnknownAndMulti(HttpClient client, int publicPort, X509Certificate issuer, + X509Certificate certificate) throws Exception { + JcaX509CertificateHolder issuerHolder = new JcaX509CertificateHolder(issuer); + org.bouncycastle.operator.DigestCalculator digest = new JcaDigestCalculatorProviderBuilder().build() + .get(CertificateID.HASH_SHA1); + OCSPReqBuilder builder = new OCSPReqBuilder(); + builder.addRequest(new CertificateID(digest, issuerHolder, certificate.getSerialNumber())); + digest = new JcaDigestCalculatorProviderBuilder().build().get(CertificateID.HASH_SHA1); + builder.addRequest(new CertificateID(digest, issuerHolder, certificate.getSerialNumber().add(BigInteger.ONE))); + java.net.http.HttpResponse response = client.send(java.net.http.HttpRequest.newBuilder( + URI.create("https://localhost:" + publicPort + "/ocsp/root")) + .header("Accept", "application/ocsp-response").header("Content-Type", "application/ocsp-request") + .POST(java.net.http.HttpRequest.BodyPublishers.ofByteArray(builder.build().getEncoded())).build(), + java.net.http.HttpResponse.BodyHandlers.ofByteArray()); + BasicOCSPResp basic = assertInstanceOf(BasicOCSPResp.class, + new OCSPResp(response.body()).getResponseObject()); + assertEquals(2, basic.getResponses().length); + assertEquals(null, basic.getResponses()[0].getCertStatus()); + assertInstanceOf(org.bouncycastle.cert.ocsp.UnknownStatus.class, + basic.getResponses()[1].getCertStatus()); + } + + private static void assertOcspNoncePolicies(HttpClient client, int publicPort, X509Certificate issuer, + X509Certificate certificate) throws Exception { + JcaX509CertificateHolder issuerHolder = new JcaX509CertificateHolder(issuer); + byte[] nonce = new byte[] { 3, 1, 4, 1, 5, 9, 2, 6 }; + OCSPReqBuilder nonceBuilder = new OCSPReqBuilder(); + nonceBuilder.addRequest(new CertificateID(new JcaDigestCalculatorProviderBuilder().build() + .get(CertificateID.HASH_SHA1), issuerHolder, certificate.getSerialNumber())); + nonceBuilder.setRequestExtensions(new Extensions(new Extension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce, + false, new DEROctetString(new DEROctetString(nonce).getEncoded())))); + byte[] withNonce = nonceBuilder.build().getEncoded(); + OCSPReqBuilder plainBuilder = new OCSPReqBuilder(); + plainBuilder.addRequest(new CertificateID(new JcaDigestCalculatorProviderBuilder().build() + .get(CertificateID.HASH_SHA1), issuerHolder, certificate.getSerialNumber())); + byte[] withoutNonce = plainBuilder.build().getEncoded(); + assertEquals(org.bouncycastle.cert.ocsp.OCSPRespBuilder.MALFORMED_REQUEST, + postOcsp(client, publicPort, "reject", withNonce).getStatus()); + assertEquals(org.bouncycastle.cert.ocsp.OCSPRespBuilder.MALFORMED_REQUEST, + postOcsp(client, publicPort, "required", withoutNonce).getStatus()); + assertEquals(org.bouncycastle.cert.ocsp.OCSPRespBuilder.SUCCESSFUL, + postOcsp(client, publicPort, "required", withNonce).getStatus()); + } + + private static OCSPResp postOcsp(HttpClient client, int publicPort, String alias, byte[] request) + throws Exception { + java.net.http.HttpResponse response = client.send(java.net.http.HttpRequest.newBuilder( + URI.create("https://localhost:" + publicPort + "/ocsp/" + alias)) + .header("Accept", "application/ocsp-response").header("Content-Type", "application/ocsp-request") + .POST(java.net.http.HttpRequest.BodyPublishers.ofByteArray(request)).build(), + java.net.http.HttpResponse.BodyHandlers.ofByteArray()); + assertEquals(200, response.statusCode()); + return new OCSPResp(response.body()); + } + private static KeyPair rsa(byte seed) throws Exception { KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); generator.initialize(2048, random(seed)); diff --git a/pki-server/src/test/java/zeroecho/pki/server/OcspResponderServiceTest.java b/pki-server/src/test/java/zeroecho/pki/server/OcspResponderServiceTest.java new file mode 100644 index 0000000..46ad553 --- /dev/null +++ b/pki-server/src/test/java/zeroecho/pki/server/OcspResponderServiceTest.java @@ -0,0 +1,236 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.server; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import zeroecho.pki.api.KeyRef; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.algorithm.X509AlgorithmBindingRegistry; +import zeroecho.pki.api.ca.IssuerChainPath; +import zeroecho.pki.api.ca.IssuerGeneration; +import zeroecho.pki.api.ca.IssuerGenerationState; +import zeroecho.pki.application.OcspResponseService; +import zeroecho.pki.application.PkiRepository; +import zeroecho.pki.application.PkiRepositoryContent; +import zeroecho.pki.impl.fs.PosixTransactionalMetadataStore; + +/** Durable responder binding, activation, recovery and dependency-failure coverage. */ +class OcspResponderServiceTest { + @TempDir Path temporaryDirectory; + + @Test + void persistsExactIssuerBindingAndFailsOnChangedRegistryCommitment() throws Exception { + System.out.println("persistsExactIssuerBindingAndFailsOnChangedRegistryCommitment"); + PkiId credentialId = new PkiId("credential:ocsp-root"); + PkiId issuerId = IssuerGeneration.idFor(ServerTestSupport.AUTHORITY, credentialId); + KeyRef keyRef = new KeyRef("kref:v1:keyring:ocsp-root"); + IssuerGeneration issuer = new IssuerGeneration(issuerId, ServerTestSupport.AUTHORITY, credentialId, + keyRef, IssuerGenerationState.ACTIVE, ServerTestSupport.DIGEST, ServerTestSupport.DIGEST); + IssuerChainPath path = IssuerChainPath.create(ServerTestSupport.AUTHORITY, issuerId, + java.util.List.of(credentialId)); + PkiRepository repository = mock(PkiRepository.class); + when(repository.issuer(issuerId)).thenReturn(Optional.of(issuer)); + when(repository.chainPath(path.pathId())).thenReturn(Optional.of(path)); + byte[] certificate = certificate(); + when(repository.openCredential(credentialId)).thenAnswer(ignored -> content(certificate)); + X509AlgorithmBindingRegistry bindings = mock(X509AlgorithmBindingRegistry.class); + when(bindings.commitment()).thenReturn(ServerTestSupport.DIGEST); + OcspResponseService signing = mock(OcspResponseService.class); + + Path log; + String responderId; + try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(temporaryDirectory)) { + log = opened.log(); + OcspResponderService service = new OcspResponderService(ServerTestSupport.REALM, opened.store(), + repository, bindings, Optional.of(signing), ServerTestSupport.CLOCK); + OcspResponderService.Registration registration = new OcspResponderService.Registration( + "ocsp-root", "root", ServerTestSupport.AUTHORITY, + issuerId, OcspResponderService.SigningMode.ISSUER_SIGNED, credentialId, keyRef, path.pathId(), + "SHA256withRSA", Optional.empty(), ServerTestSupport.DIGEST, + OcspResponseService.ResponderId.BY_KEY, Duration.ofMinutes(5), + OcspResponderService.NoncePolicy.OPTIONAL_ECHO, 64, + Set.of(OcspResponseService.CertIdHash.SHA1, OcspResponseService.CertIdHash.SHA256), + 16_384, 8, Duration.ofMinutes(1)); + responderId = service.register(registration).responderId(); + assertThrows(IllegalStateException.class, () -> service.requireActiveAlias("root")); + OcspResponderService.Registration duplicateAlias = new OcspResponderService.Registration( + "ocsp-root-duplicate", "root", ServerTestSupport.AUTHORITY, issuerId, + OcspResponderService.SigningMode.ISSUER_SIGNED, credentialId, keyRef, path.pathId(), + "SHA256withRSA", Optional.empty(), ServerTestSupport.DIGEST, + OcspResponseService.ResponderId.BY_KEY, Duration.ofMinutes(5), + OcspResponderService.NoncePolicy.REJECT, 64, + Set.of(OcspResponseService.CertIdHash.SHA1), 16_384, 8, Duration.ofMinutes(1)); + assertThrows(IllegalStateException.class, () -> service.register(duplicateAlias)); + assertEquals(OcspResponderService.State.ACTIVE, service.setActive(responderId, true).state()); + assertEquals(responderId, service.requireActiveAlias("root").responderId()); + } + + try (ServerControlStore reopened = new ServerControlStore(PosixTransactionalMetadataStore.open(log, + OptionalLong.of(1_048_576)))) { + OcspResponderService recovered = new OcspResponderService(ServerTestSupport.REALM, reopened, + repository, bindings, Optional.of(signing), ServerTestSupport.CLOCK); + recovered.validateAll(); + assertEquals(responderId, recovered.requireActiveAlias("root").responderId()); + verify(signing, atLeast(3)).validateSigningBinding(credentialId, keyRef, + "SHA256withRSA", Optional.empty()); + when(bindings.commitment()).thenReturn("f".repeat(64)); + assertThrows(IllegalStateException.class, recovered::validateAll); + } + System.out.println("...durable-recovery=true changed-binding-rejected=true"); + System.out.println("persistsExactIssuerBindingAndFailsOnChangedRegistryCommitment...ok"); + } + + @Test + void validatesDelegatedResponderIssuerEkuUsageAndSigningCapability() throws Exception { + System.out.println("validatesDelegatedResponderIssuerEkuUsageAndSigningCapability"); + CertificatePair certificates = delegatedCertificates(); + PkiId issuerCredential = new PkiId("credential:delegated-issuer"); + PkiId responderCredential = new PkiId("credential:delegated-responder"); + PkiId issuerId = IssuerGeneration.idFor(ServerTestSupport.AUTHORITY, issuerCredential); + IssuerGeneration issuer = new IssuerGeneration(issuerId, ServerTestSupport.AUTHORITY, issuerCredential, + new KeyRef("kref:v1:keyring:delegated-issuer"), IssuerGenerationState.ACTIVE, + ServerTestSupport.DIGEST, ServerTestSupport.DIGEST); + IssuerChainPath path = IssuerChainPath.create(ServerTestSupport.AUTHORITY, issuerId, + java.util.List.of(issuerCredential)); + PkiRepository repository = mock(PkiRepository.class); + when(repository.issuer(issuerId)).thenReturn(Optional.of(issuer)); + when(repository.chainPath(path.pathId())).thenReturn(Optional.of(path)); + when(repository.openCredential(issuerCredential)).thenAnswer(ignored -> content(certificates.issuer())); + when(repository.openCredential(responderCredential)).thenAnswer( + ignored -> content(certificates.responder())); + X509AlgorithmBindingRegistry bindings = mock(X509AlgorithmBindingRegistry.class); + when(bindings.commitment()).thenReturn(ServerTestSupport.DIGEST); + OcspResponseService signing = mock(OcspResponseService.class); + KeyRef responderKey = new KeyRef("kref:v1:keyring:delegated-responder"); + Path delegatedDirectory = temporaryDirectory.resolve("delegated"); + Files.createDirectory(delegatedDirectory); + try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(delegatedDirectory)) { + OcspResponderService service = new OcspResponderService(ServerTestSupport.REALM, opened.store(), + repository, bindings, Optional.of(signing), ServerTestSupport.CLOCK); + OcspResponderService.Registration registration = new OcspResponderService.Registration( + "ocsp-delegated", "delegated", ServerTestSupport.AUTHORITY, issuerId, + OcspResponderService.SigningMode.DELEGATED_RESPONDER, responderCredential, responderKey, + path.pathId(), "SHA256withRSA", Optional.empty(), ServerTestSupport.DIGEST, + OcspResponseService.ResponderId.BY_NAME, Duration.ofMinutes(5), + OcspResponderService.NoncePolicy.REJECT, 64, + Set.of(OcspResponseService.CertIdHash.SHA256), 16_384, 8, Duration.ofMinutes(1)); + OcspResponderService.Responder registered = service.register(registration); + assertEquals(OcspResponderService.SigningMode.DELEGATED_RESPONDER, registered.signingMode()); + verify(signing).validateSigningBinding(responderCredential, responderKey, + "SHA256withRSA", Optional.empty()); + } + System.out.println("...delegated-eku-and-key-usage=true"); + System.out.println("validatesDelegatedResponderIssuerEkuUsageAndSigningCapability...ok"); + } + + private static PkiRepositoryContent content(byte[] encoded) throws Exception { + PkiRepositoryContent content = mock(PkiRepositoryContent.class); + when(content.openStream()).thenAnswer(ignored -> new ByteArrayInputStream(encoded)); + return content; + } + + private static byte[] certificate() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + KeyPair keys = generator.generateKeyPair(); + X500Name name = new X500Name("CN=OCSP Responder Root"); + X509v3CertificateBuilder builder = new X509v3CertificateBuilder(name, BigInteger.ONE, + Date.from(Instant.parse("2025-01-01T00:00:00Z")), + Date.from(Instant.parse("2030-01-01T00:00:00Z")), name, + org.bouncycastle.asn1.x509.SubjectPublicKeyInfo.getInstance(keys.getPublic().getEncoded())); + X509CertificateHolder certificate = builder.build( + new JcaContentSignerBuilder("SHA256withRSA").build(keys.getPrivate())); + return certificate.getEncoded(); + } + + private static CertificatePair delegatedCertificates() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + KeyPair issuerKeys = generator.generateKeyPair(); + KeyPair responderKeys = generator.generateKeyPair(); + X500Name issuerName = new X500Name("CN=Delegated OCSP Issuer"); + X509v3CertificateBuilder issuerBuilder = new X509v3CertificateBuilder(issuerName, BigInteger.ONE, + Date.from(Instant.parse("2025-01-01T00:00:00Z")), + Date.from(Instant.parse("2030-01-01T00:00:00Z")), issuerName, + org.bouncycastle.asn1.x509.SubjectPublicKeyInfo.getInstance(issuerKeys.getPublic().getEncoded())); + X509CertificateHolder issuer = issuerBuilder.build( + new JcaContentSignerBuilder("SHA256withRSA").build(issuerKeys.getPrivate())); + X509v3CertificateBuilder responderBuilder = new X509v3CertificateBuilder(issuerName, BigInteger.TWO, + Date.from(Instant.parse("2025-01-01T00:00:00Z")), + Date.from(Instant.parse("2030-01-01T00:00:00Z")), new X500Name("CN=Delegated OCSP Responder"), + org.bouncycastle.asn1.x509.SubjectPublicKeyInfo.getInstance(responderKeys.getPublic().getEncoded())); + responderBuilder.addExtension(Extension.extendedKeyUsage, false, + new ExtendedKeyUsage(KeyPurposeId.id_kp_OCSPSigning)); + responderBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature)); + X509CertificateHolder responder = responderBuilder.build( + new JcaContentSignerBuilder("SHA256withRSA").build(issuerKeys.getPrivate())); + return new CertificatePair(issuer.getEncoded(), responder.getEncoded()); + } + + private record CertificatePair(byte[] issuer, byte[] responder) { + private CertificatePair { issuer = issuer.clone(); responder = responder.clone(); } + @Override public byte[] issuer() { return issuer.clone(); } + @Override public byte[] responder() { return responder.clone(); } + } +} 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 92d33c0..cd3f2c6 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(62, catalog.descriptors().size()); + assertEquals(67, catalog.descriptors().size()); assertEquals(16, catalog.descriptors().values().stream() .filter(value -> value.family() == OperationSecurityDescriptors.Family.PKI_OPERATION).count()); - assertEquals(46, catalog.descriptors().values().stream() + assertEquals(51, 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=46"); + System.out.println("...control-operations=51"); 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 d7db912..a12143f 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(62, descriptors.descriptors().size()); + assertEquals(67, 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/http/OcspRequestParserTest.java b/pki-server/src/test/java/zeroecho/pki/server/http/OcspRequestParserTest.java new file mode 100644 index 0000000..d6b4822 --- /dev/null +++ b/pki-server/src/test/java/zeroecho/pki/server/http/OcspRequestParserTest.java @@ -0,0 +1,154 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.server.http; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; +import java.util.Set; + +import org.bouncycastle.asn1.DEROctetString; +import org.bouncycastle.asn1.nist.NISTObjectIdentifiers; +import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.AlgorithmIdentifier; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.Extensions; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.ocsp.CertificateID; +import org.bouncycastle.cert.ocsp.OCSPReqBuilder; +import org.bouncycastle.operator.DigestCalculator; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; +import org.junit.jupiter.api.Test; + +import zeroecho.pki.application.OcspResponseService; + +/** Strict OCSP DER and GET request framing coverage. */ +class OcspRequestParserTest { + @Test + void parsesCanonicalSha1Sha256AndExactNonce() throws Exception { + System.out.println("parsesCanonicalSha1Sha256AndExactNonce"); + X509CertificateHolder issuer = certificate(); + DigestCalculator sha1 = new JcaDigestCalculatorProviderBuilder().build().get(CertificateID.HASH_SHA1); + DigestCalculator sha256 = new JcaDigestCalculatorProviderBuilder().build().get( + new AlgorithmIdentifier(NISTObjectIdentifiers.id_sha256)); + OCSPReqBuilder builder = new OCSPReqBuilder(); + builder.addRequest(new CertificateID(sha1, issuer, BigInteger.valueOf(17))); + builder.addRequest(new CertificateID(sha256, issuer, BigInteger.valueOf(18))); + byte[] nonce = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }; + builder.setRequestExtensions(new Extensions(new Extension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce, + false, new DEROctetString(new DEROctetString(nonce).getEncoded())))); + byte[] encoded = builder.build().getEncoded(); + + OcspRequestParser.Parsed parsed = OcspRequestParser.parse(encoded, 2, + Set.of(OcspResponseService.CertIdHash.SHA1, OcspResponseService.CertIdHash.SHA256), 32); + assertEquals(2, parsed.requests().size()); + assertEquals(OcspResponseService.CertIdHash.SHA1, parsed.requests().get(0).hash()); + assertEquals(OcspResponseService.CertIdHash.SHA256, parsed.requests().get(1).hash()); + assertArrayEquals(nonce, parsed.nonce().orElseThrow()); + String get = Base64.getUrlEncoder().withoutPadding().encodeToString(encoded); + assertArrayEquals(encoded, OcspRequestParser.decodeGet(get, encoded.length)); + System.out.println("...entries=2 nonce=echoable"); + System.out.println("parsesCanonicalSha1Sha256AndExactNonce...ok"); + } + + @Test + void rejectsAlternateGetEncodingBoundsAndUnknownCriticalExtension() throws Exception { + System.out.println("rejectsAlternateGetEncodingBoundsAndUnknownCriticalExtension"); + X509CertificateHolder issuer = certificate(); + DigestCalculator digest = new JcaDigestCalculatorProviderBuilder().build().get(CertificateID.HASH_SHA1); + OCSPReqBuilder builder = new OCSPReqBuilder(); + builder.addRequest(new CertificateID(digest, issuer, BigInteger.ONE)); + builder.setRequestExtensions(new Extensions(new Extension( + new org.bouncycastle.asn1.ASN1ObjectIdentifier("1.3.6.1.4.1.55555.1"), true, + new DEROctetString(new byte[] { 5 })))); + byte[] critical = builder.build().getEncoded(); + assertThrows(IllegalArgumentException.class, () -> OcspRequestParser.parse(critical, 1, + Set.of(OcspResponseService.CertIdHash.SHA1), 32)); + assertThrows(IllegalArgumentException.class, () -> OcspRequestParser.parse(critical, 0, + Set.of(OcspResponseService.CertIdHash.SHA1), 32)); + String canonical = Base64.getUrlEncoder().withoutPadding().encodeToString(critical); + assertThrows(IllegalArgumentException.class, () -> OcspRequestParser.decodeGet(canonical + "=", 4096)); + assertThrows(IllegalArgumentException.class, () -> OcspRequestParser.decodeGet(canonical, 1)); + assertThrows(IllegalArgumentException.class, () -> OcspRequestParser.parse( + java.util.Arrays.copyOf(critical, critical.length + 1), 1, + Set.of(OcspResponseService.CertIdHash.SHA1), 32)); + System.out.println("...strict-get-and-der=true"); + System.out.println("rejectsAlternateGetEncodingBoundsAndUnknownCriticalExtension...ok"); + } + + @Test + void rejectsSignedRequestsWithoutTreatingSignaturesAsAuthority() throws Exception { + System.out.println("rejectsSignedRequestsWithoutTreatingSignaturesAsAuthority"); + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + KeyPair keys = generator.generateKeyPair(); + X509CertificateHolder issuer = certificate(keys); + DigestCalculator digest = new JcaDigestCalculatorProviderBuilder().build().get(CertificateID.HASH_SHA1); + OCSPReqBuilder builder = new OCSPReqBuilder(); + builder.addRequest(new CertificateID(digest, issuer, BigInteger.ONE)); + builder.setRequestorName(new GeneralName(issuer.getSubject())); + byte[] signed = builder.build(new JcaContentSignerBuilder("SHA256withRSA").build(keys.getPrivate()), + new X509CertificateHolder[] { issuer }).getEncoded(); + assertThrows(IllegalArgumentException.class, () -> OcspRequestParser.parse(signed, 1, + Set.of(OcspResponseService.CertIdHash.SHA1), 32)); + System.out.println("...signed-request-authority=false"); + System.out.println("rejectsSignedRequestsWithoutTreatingSignaturesAsAuthority...ok"); + } + + private static X509CertificateHolder certificate() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + return certificate(generator.generateKeyPair()); + } + + private static X509CertificateHolder certificate(KeyPair keys) throws Exception { + X500Name name = new X500Name("CN=OCSP Parser Issuer"); + X509v3CertificateBuilder builder = new X509v3CertificateBuilder(name, BigInteger.ONE, + Date.from(Instant.parse("2025-01-01T00:00:00Z")), + Date.from(Instant.parse("2030-01-01T00:00:00Z")), name, + org.bouncycastle.asn1.x509.SubjectPublicKeyInfo.getInstance(keys.getPublic().getEncoded())); + return builder.build(new JcaContentSignerBuilder("SHA256withRSA").build(keys.getPrivate())); + } +} diff --git a/pki/src/main/java/zeroecho/pki/application/DefaultOcspResponseService.java b/pki/src/main/java/zeroecho/pki/application/DefaultOcspResponseService.java new file mode 100644 index 0000000..de84ee4 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/application/DefaultOcspResponseService.java @@ -0,0 +1,318 @@ +/******************************************************************************* + * 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.application; + +import java.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.bouncycastle.asn1.DEROctetString; +import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers; +import org.bouncycastle.asn1.x509.CRLReason; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.Extensions; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.ocsp.BasicOCSPResp; +import org.bouncycastle.cert.ocsp.BasicOCSPRespBuilder; +import org.bouncycastle.cert.ocsp.CertificateID; +import org.bouncycastle.cert.ocsp.CertificateStatus; +import org.bouncycastle.cert.ocsp.OCSPResp; +import org.bouncycastle.cert.ocsp.OCSPRespBuilder; +import org.bouncycastle.cert.ocsp.SingleResp; +import org.bouncycastle.cert.ocsp.RevokedStatus; +import org.bouncycastle.cert.ocsp.RespID; +import org.bouncycastle.cert.ocsp.UnknownStatus; +import org.bouncycastle.operator.DigestCalculator; +import org.bouncycastle.operator.DigestCalculatorProvider; +import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; + +import zeroecho.core.spec.AlgorithmIdentity; +import zeroecho.pki.api.PkiException; +import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.revocation.RevocationReason; +import zeroecho.pki.api.revocation.RevocationState; +import zeroecho.pki.impl.core.async.PkiSigningBus; +import zeroecho.pki.impl.framework.x509.bc.PkiBusContentSigner; +import zeroecho.pki.spi.store.PkiStore; +import zeroecho.pki.spi.store.RevocationView; + +/** Store-backed, signing-bus-confined OCSP response application service. */ +@SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops", "PMD.CyclomaticComplexity", + "PMD.PreserveStackTrace", "PMD.ExceptionAsFlowControl", "PMD.SignatureDeclareThrowsException", + "PMD.NPathComplexity", "PMD.ControlStatementBraces", "PMD.UseVarargs" }) +final class DefaultOcspResponseService implements OcspResponseService { + private final PkiStore store; + private final PkiSigningBus bus; + private final Duration signingTtl; + private final Runnable requireOpen; + + /* default */ DefaultOcspResponseService(PkiStore store, PkiSigningBus bus, Duration signingTtl, + Runnable requireOpen) { + this.store = Objects.requireNonNull(store, "store"); + this.bus = Objects.requireNonNull(bus, "bus"); + this.signingTtl = Objects.requireNonNull(signingTtl, "signingTtl"); + this.requireOpen = Objects.requireNonNull(requireOpen, "requireOpen"); + } + + @Override public Response respond(Command command) { + requireOpen.run(); + Objects.requireNonNull(command, "command"); + try { + X509CertificateHolder issuer = certificate(command.issuerCredentialId()); + X509CertificateHolder responder = certificate(command.responderCredentialId()); + validateAuthority(command, issuer, responder); + List resolved; + long revision; + String commitment; + try (RevocationView view = store.openRevocationView()) { + revision = view.revision(); + commitment = view.commitment(); + resolved = resolve(command, issuer, view); + } + int good = Math.toIntExact(resolved.stream().filter(value -> value.status() == CertificateStatus.GOOD) + .count()); + int revoked = Math.toIntExact(resolved.stream().filter(value -> value.status() instanceof RevokedStatus) + .count()); + int unknown = Math.subtractExact(resolved.size(), Math.addExact(good, revoked)); + return new Response(sign(command, responder, resolved), revision, commitment, good, revoked, unknown); + } catch (Exception failure) { + throw new PkiException("OCSP response generation failed: code=OCSP_RESPONSE_FAILED"); + } + } + + @Override public void validateSigningBinding(zeroecho.pki.api.PkiId responderCredentialId, + zeroecho.pki.api.KeyRef signingKeyRef, String signatureAlgorithm, + Optional signatureBindingId) { + requireOpen.run(); + Objects.requireNonNull(responderCredentialId, "responderCredentialId"); + Objects.requireNonNull(signingKeyRef, "signingKeyRef"); + Objects.requireNonNull(signatureBindingId, "signatureBindingId"); + byte[] challenge = "ZeroEcho OCSP signing binding v1".getBytes(java.nio.charset.StandardCharsets.US_ASCII); + try { + X509CertificateHolder responder = certificate(responderCredentialId); + AlgorithmIdentity identity = bus.authority().resolveIdentity(signatureAlgorithm); + PkiBusContentSigner signer = signatureBindingId.isPresent() + ? new PkiBusContentSigner(bus, signingKeyRef, identity, signatureBindingId.orElseThrow(), signingTtl) + : new PkiBusContentSigner(bus, signingKeyRef, identity, signingTtl); + try (java.io.OutputStream output = signer.getOutputStream()) { output.write(challenge); } + org.bouncycastle.operator.ContentVerifier verifier = + new org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder().build(responder) + .get(signer.getAlgorithmIdentifier()); + try (java.io.OutputStream output = verifier.getOutputStream()) { output.write(challenge); } + if (!verifier.verify(signer.getSignature())) { + throw new IOException("OCSP signing binding differs"); + } + } catch (Exception failure) { + throw new PkiException("OCSP signing binding validation failed: code=OCSP_SIGNING_BINDING_FAILED"); + } + } + + private List resolve(Command command, X509CertificateHolder issuer, RevocationView view) + throws Exception { + List result = new ArrayList<>(command.requests().size()); + DigestCalculatorProvider digests = new JcaDigestCalculatorProviderBuilder().build(); + for (CertId request : command.requests()) { + DigestCalculator digest = digests.get(CertificateID.HASH_SHA1); + if (request.hash() == CertIdHash.SHA256) { + digest = digests.get(new org.bouncycastle.asn1.x509.AlgorithmIdentifier( + org.bouncycastle.asn1.nist.NISTObjectIdentifiers.id_sha256)); + } + CertificateID id = new CertificateID(digest, issuer, request.serial()); + if (!MessageDigest.isEqual(id.getIssuerNameHash(), request.issuerNameHash()) + || !MessageDigest.isEqual(id.getIssuerKeyHash(), request.issuerKeyHash())) { + result.add(new Resolved(id, new UnknownStatus())); + continue; + } + Credential credential = store.getCredentialByIssuerAndSerial(command.issuerId(), request.serial()) + .orElse(null); + if (credential == null) { + result.add(new Resolved(id, new UnknownStatus())); + continue; + } + CertificateStatus status = view.get(credential.credentialId()) + .map(record -> status(record.transition().state(), record.transition().time(), + record.transition().permanentReason().orElse(RevocationReason.UNSPECIFIED))) + .orElse(CertificateStatus.GOOD); + result.add(new Resolved(id, status)); + } + return List.copyOf(result); + } + + private byte[] sign(Command command, X509CertificateHolder responder, List resolved) + throws Exception { + DigestCalculatorProvider digests = new JcaDigestCalculatorProviderBuilder().build(); + BasicOCSPRespBuilder builder = command.responderId() == ResponderId.BY_NAME + ? new BasicOCSPRespBuilder(new RespID(responder.getSubject())) + : new BasicOCSPRespBuilder(new RespID(responder.getSubjectPublicKeyInfo(), + digests.get(CertificateID.HASH_SHA1))); + for (Resolved item : resolved) { + builder.addResponse(item.id(), item.status(), Date.from(command.thisUpdate()), + Date.from(command.nextUpdate()), null); + } + if (command.nonce().isPresent()) { + builder.setResponseExtensions(new Extensions(new Extension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce, + false, new DEROctetString(new DEROctetString(command.nonce().orElseThrow()).getEncoded())))); + } + AlgorithmIdentity identity = bus.authority().resolveIdentity(command.signatureAlgorithm()); + PkiBusContentSigner signer = command.signatureBindingId().isPresent() + ? new PkiBusContentSigner(bus, command.signingKeyRef(), identity, + command.signatureBindingId().orElseThrow(), signingTtl) + : new PkiBusContentSigner(bus, command.signingKeyRef(), identity, signingTtl); + X509CertificateHolder[] chain = new X509CertificateHolder[command.responseChain().size()]; + for (int index = 0; index < chain.length; index++) chain[index] = certificate(command.responseChain().get(index)); + BasicOCSPResp basic = builder.build(signer, chain, Date.from(command.producedAt())); + OCSPResp outer = new OCSPRespBuilder().build(OCSPRespBuilder.SUCCESSFUL, basic); + byte[] encoded = outer.getEncoded(); + OCSPResp decodedOuter = new OCSPResp(encoded); + BasicOCSPResp decoded = (BasicOCSPResp) decodedOuter.getResponseObject(); + if (!Arrays.equals(encoded, decodedOuter.getEncoded()) + || decodedOuter.getStatus() != OCSPRespBuilder.SUCCESSFUL + || decoded == null || !decoded.getProducedAt().equals(Date.from(command.producedAt())) + || !decoded.getResponderId().equals(basic.getResponderId()) + || !decoded.getSignatureAlgorithmID().equals(signer.getAlgorithmIdentifier()) + || !decoded.isSignatureValid(new org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder() + .build(responder)) + || !matchesResponses(decoded.getResponses(), resolved, command) + || !matchesCertificates(decoded.getCerts(), chain) + || !matchesNonce(decoded, command.nonce())) { + throw new IOException("Generated OCSP response validation failed"); + } + return encoded; + } + + private static boolean matchesResponses(SingleResp[] decoded, List expected, Command command) { + if (decoded.length != expected.size()) return false; + for (int index = 0; index < decoded.length; index++) { + SingleResp actual = decoded[index]; Resolved wanted = expected.get(index); + if (!actual.getCertID().equals(wanted.id()) + || !actual.getThisUpdate().equals(Date.from(command.thisUpdate())) + || !actual.getNextUpdate().equals(Date.from(command.nextUpdate())) + || !sameStatus(actual.getCertStatus(), wanted.status())) return false; + } + return true; + } + + private static boolean sameStatus(CertificateStatus actual, CertificateStatus expected) { + if (actual == CertificateStatus.GOOD || expected == CertificateStatus.GOOD) { + return actual == CertificateStatus.GOOD && expected == CertificateStatus.GOOD; + } + if (actual instanceof UnknownStatus && expected instanceof UnknownStatus) return true; + if (actual instanceof RevokedStatus left && expected instanceof RevokedStatus right) { + return left.getRevocationTime().equals(right.getRevocationTime()) + && left.hasRevocationReason() == right.hasRevocationReason() + && (!left.hasRevocationReason() || left.getRevocationReason() == right.getRevocationReason()); + } + return false; + } + + private static boolean matchesCertificates(X509CertificateHolder[] decoded, X509CertificateHolder[] expected) + throws IOException { + if (decoded.length != expected.length) return false; + for (int index = 0; index < decoded.length; index++) { + if (!Arrays.equals(decoded[index].getEncoded(), expected[index].getEncoded())) return false; + } + return true; + } + + private static boolean matchesNonce(BasicOCSPResp decoded, Optional expected) { + Extension extension = decoded.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce); + if (expected.isEmpty()) return extension == null; + if (extension == null || extension.isCritical()) return false; + byte[] actual = org.bouncycastle.asn1.ASN1OctetString.getInstance(extension.getParsedValue()).getOctets(); + return MessageDigest.isEqual(actual, expected.orElseThrow()); + } + + private void validateAuthority(Command command, X509CertificateHolder issuer, + X509CertificateHolder responder) { + zeroecho.pki.api.ca.IssuerGeneration generation = store.getIssuerGeneration(command.issuerId()) + .orElseThrow(() -> new IllegalArgumentException("OCSP issuer is unavailable")); + if (!generation.authorityId().equals(command.authorityId()) + || !generation.credentialId().equals(command.issuerCredentialId()) + || !command.signingKeyRef().equals(generation.signingKeyRef()) + && command.responderCredentialId().equals(command.issuerCredentialId())) { + throw new IllegalArgumentException("OCSP responder authority mismatch"); + } + if (issuer.getSerialNumber().signum() <= 0 || responder.getSerialNumber().signum() <= 0) { + throw new IllegalArgumentException("OCSP responder certificate is invalid"); + } + if (!responder.isValidOn(Date.from(command.producedAt())) + || command.nextUpdate().isAfter(responder.getNotAfter().toInstant())) { + throw new IllegalArgumentException("OCSP response exceeds responder signing validity"); + } + } + + private X509CertificateHolder certificate(zeroecho.pki.api.PkiId credentialId) throws IOException { + try (PkiRepositoryContent content = new DefaultPkiRepository(store, requireOpen) + .openCredential(credentialId); InputStream input = content.openStream()) { + byte[] encoded = input.readNBytes(1_048_577); + if (encoded.length > 1_048_576 || input.read() != -1) { + throw new IOException("OCSP certificate exceeds its finite bound"); + } + return new X509CertificateHolder(encoded); + } + } + + private static CertificateStatus status(RevocationState state, java.time.Instant time, + RevocationReason reason) { + return switch (state) { + case CLEAR -> CertificateStatus.GOOD; + case HELD -> new RevokedStatus(Date.from(time), CRLReason.certificateHold); + case PERMANENTLY_REVOKED -> new RevokedStatus(Date.from(time), reason(reason)); + }; + } + + private static int reason(RevocationReason reason) { + return switch (reason) { + case KEY_COMPROMISE -> CRLReason.keyCompromise; + case CA_COMPROMISE -> CRLReason.cACompromise; + case AFFILIATION_CHANGED -> CRLReason.affiliationChanged; + case SUPERSEDED -> CRLReason.superseded; + case CESSATION_OF_OPERATION -> CRLReason.cessationOfOperation; + case CERTIFICATE_HOLD -> CRLReason.certificateHold; + case REMOVE_FROM_CRL -> CRLReason.removeFromCRL; + case PRIVILEGE_WITHDRAWN -> CRLReason.privilegeWithdrawn; + case AA_COMPROMISE -> CRLReason.aACompromise; + case UNSPECIFIED -> CRLReason.unspecified; + }; + } + + private record Resolved(CertificateID id, CertificateStatus status) { } +} diff --git a/pki/src/main/java/zeroecho/pki/application/DefaultPkiRepository.java b/pki/src/main/java/zeroecho/pki/application/DefaultPkiRepository.java index b7e10fa..30fadfb 100644 --- a/pki/src/main/java/zeroecho/pki/application/DefaultPkiRepository.java +++ b/pki/src/main/java/zeroecho/pki/application/DefaultPkiRepository.java @@ -41,6 +41,7 @@ import java.util.HexFormat; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.math.BigInteger; import org.bouncycastle.cert.X509CRLHolder; import org.bouncycastle.cert.X509CertificateHolder; @@ -99,6 +100,12 @@ final class DefaultPkiRepository implements PkiRepository { return store.getCredential(Objects.requireNonNull(credentialId, "credentialId")); } + @Override public Optional credential(PkiId issuerId, BigInteger serial) { + requireOpen.run(); + return store.getCredentialByIssuerAndSerial(Objects.requireNonNull(issuerId, "issuerId"), + Objects.requireNonNull(serial, "serial")); + } + @Override public Optional statusObject(PkiId statusObjectId) { requireOpen.run(); return store.getStatusObject(Objects.requireNonNull(statusObjectId, "statusObjectId")); diff --git a/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java b/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java index 79e6953..33e60f0 100644 --- a/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java +++ b/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java @@ -110,6 +110,7 @@ final class DefaultPkiSession implements PkiSession { private final Optional requests; private final Optional issuance; private final Optional statusObjects; + private final Optional ocsp; private final Optional publications; private final Optional signingBus; private final Optional signatureWorkflow; @@ -130,6 +131,8 @@ final class DefaultPkiSession implements PkiSession { this.requests = graph.requests(); this.issuance = graph.issuance(); this.statusObjects = graph.statusObjects(); + this.ocsp = graph.signingBus().map(bus -> new DefaultOcspResponseService(store, bus, + configuration.signing().orElseThrow().signingTtl(), this::requireOpen)); this.publications = graph.publications(); this.signingBus = graph.signingBus(); this.signatureWorkflow = graph.signatureWorkflow(); @@ -155,6 +158,11 @@ final class DefaultPkiSession implements PkiSession { return open(configuration, dependencies, Clock.systemUTC(), ProductionBootstrap.INSTANCE); } + /* default */ static PkiSession open(PkiSessionConfiguration configuration, + PkiSessionRuntimeDependencies dependencies, Clock clock) { + return open(configuration, dependencies, clock, ProductionBootstrap.INSTANCE); + } + /* default */ static PkiSession open(PkiSessionConfiguration configuration, Clock clock, Bootstrap bootstrap) { return open(configuration, PkiSessionRuntimeDependencies.none(), clock, bootstrap); } @@ -239,6 +247,8 @@ final class DefaultPkiSession implements PkiSession { return statusObjects; } + @Override public Optional ocsp() { requireOpen(); return ocsp; } + @Override public Optional publications() { requireOpen(); diff --git a/pki/src/main/java/zeroecho/pki/application/OcspResponseService.java b/pki/src/main/java/zeroecho/pki/application/OcspResponseService.java new file mode 100644 index 0000000..e210479 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/application/OcspResponseService.java @@ -0,0 +1,151 @@ +/******************************************************************************* + * 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.application; + +import java.math.BigInteger; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import zeroecho.pki.api.KeyRef; +import zeroecho.pki.api.PkiId; + +/** Transport-neutral strict OCSP response generation through confined signing. */ +@SuppressWarnings("PMD.ControlStatementBraces") +public interface OcspResponseService { + /** Supported RFC CertID digest identities. */ + enum CertIdHash { + SHA1(1), SHA256(2); + + private final int code; + + CertIdHash(int code) { this.code = code; } + + /** Stable persistence code. */ + public int code() { return code; } + + /** Resolves an exact stable persistence code. */ + public static CertIdHash fromCode(int code) { + return switch (code) { case 1 -> SHA1; case 2 -> SHA256; + default -> throw new IllegalArgumentException("Unknown OCSP CertID hash code"); }; + } + } + /** Exact responder identifier representation. */ + enum ResponderId { + BY_NAME(1), BY_KEY(2); + + private final int code; + + ResponderId(int code) { this.code = code; } + + /** Stable persistence code. */ + public int code() { return code; } + + /** Resolves an exact stable persistence code. */ + public static ResponderId fromCode(int code) { + return switch (code) { case 1 -> BY_NAME; case 2 -> BY_KEY; + default -> throw new IllegalArgumentException("Unknown OCSP responder ID code"); }; + } + } + + /** One strictly parsed CertID. */ + record CertId(CertIdHash hash, byte[] issuerNameHash, byte[] issuerKeyHash, BigInteger serial) { + /** Defensively owns all request values. */ + public CertId { + Objects.requireNonNull(hash, "hash"); + issuerNameHash = Objects.requireNonNull(issuerNameHash, "issuerNameHash").clone(); + issuerKeyHash = Objects.requireNonNull(issuerKeyHash, "issuerKeyHash").clone(); + Objects.requireNonNull(serial, "serial"); + int length = hash == CertIdHash.SHA1 ? 20 : 32; + if (issuerNameHash.length != length || issuerKeyHash.length != length + || serial.signum() <= 0 || serial.bitLength() > 160) { + throw new IllegalArgumentException("OCSP CertID is invalid"); + } + } + @Override public byte[] issuerNameHash() { return issuerNameHash.clone(); } + @Override public byte[] issuerKeyHash() { return issuerKeyHash.clone(); } + } + + /** Exact already-authorized responder generation command. */ + record Command(PkiId authorityId, PkiId issuerId, PkiId issuerCredentialId, + PkiId responderCredentialId, KeyRef signingKeyRef, List responseChain, + String signatureAlgorithm, Optional signatureBindingId, + ResponderId responderId, Instant producedAt, Instant thisUpdate, Instant nextUpdate, + Optional nonce, List requests) { + /** Validates finite exact responder inputs. */ + public Command { + Objects.requireNonNull(authorityId, "authorityId"); Objects.requireNonNull(issuerId, "issuerId"); + Objects.requireNonNull(issuerCredentialId, "issuerCredentialId"); + Objects.requireNonNull(responderCredentialId, "responderCredentialId"); + Objects.requireNonNull(signingKeyRef, "signingKeyRef"); + responseChain = List.copyOf(Objects.requireNonNull(responseChain, "responseChain")); + if (responseChain.isEmpty() || responseChain.size() > 32) throw new IllegalArgumentException("OCSP chain is invalid"); + if (signatureAlgorithm == null || signatureAlgorithm.isBlank()) throw new IllegalArgumentException("OCSP algorithm is invalid"); + signatureBindingId = Objects.requireNonNull(signatureBindingId, "signatureBindingId"); + Objects.requireNonNull(responderId, "responderId"); Objects.requireNonNull(producedAt, "producedAt"); + Objects.requireNonNull(thisUpdate, "thisUpdate"); Objects.requireNonNull(nextUpdate, "nextUpdate"); + if (thisUpdate.isAfter(producedAt) || !nextUpdate.isAfter(thisUpdate)) throw new IllegalArgumentException("OCSP times are invalid"); + nonce = Objects.requireNonNull(nonce, "nonce").map(byte[]::clone); + requests = List.copyOf(Objects.requireNonNull(requests, "requests")); + if (requests.isEmpty()) throw new IllegalArgumentException("OCSP request is empty"); + } + @Override public Optional nonce() { return nonce.map(byte[]::clone); } + } + + /** Signed canonical response and stable revocation provenance. */ + record Response(byte[] der, long revocationRevision, String revocationCommitment, + int goodCount, int revokedCount, int unknownCount) { + /** Defensively owns response DER. */ + public Response { + der = Objects.requireNonNull(der, "der").clone(); + if (revocationRevision < 0 || revocationCommitment == null + || !revocationCommitment.matches("[0-9a-f]{64}") || goodCount < 0 || revokedCount < 0 + || unknownCount < 0 || Math.addExact(Math.addExact(goodCount, revokedCount), unknownCount) == 0) { + throw new IllegalArgumentException("OCSP provenance is invalid"); + } + } + @Override public byte[] der() { return der.clone(); } + } + + /** Resolves one stable view and signs exactly one response. */ + Response respond(Command command); + + /** + * Proves that one configured confined key capability signs as the exact + * responder certificate before a durable responder is activated. + */ + void validateSigningBinding(PkiId responderCredentialId, KeyRef signingKeyRef, + String signatureAlgorithm, Optional signatureBindingId); +} diff --git a/pki/src/main/java/zeroecho/pki/application/PkiRepository.java b/pki/src/main/java/zeroecho/pki/application/PkiRepository.java index 92014ac..7cc7c0a 100644 --- a/pki/src/main/java/zeroecho/pki/application/PkiRepository.java +++ b/pki/src/main/java/zeroecho/pki/application/PkiRepository.java @@ -35,6 +35,7 @@ package zeroecho.pki.application; import java.util.List; import java.util.Optional; +import java.math.BigInteger; import zeroecho.pki.api.PkiId; import zeroecho.pki.api.ca.CaRecord; @@ -55,6 +56,12 @@ public interface PkiRepository { Optional chainPath(PkiId pathId); /** Returns an exact credential metadata record. */ Optional credential(PkiId credentialId); + /** Returns an exact credential through the issuer-generation/serial index. */ + default Optional credential(PkiId issuerId, BigInteger serial) { + java.util.Objects.requireNonNull(issuerId, "issuerId"); + java.util.Objects.requireNonNull(serial, "serial"); + return Optional.empty(); + } /** Returns an exact status-object metadata record. */ Optional statusObject(PkiId statusObjectId); /** Opens validated immutable certificate content. */ diff --git a/pki/src/main/java/zeroecho/pki/application/PkiSession.java b/pki/src/main/java/zeroecho/pki/application/PkiSession.java index c2b1c29..7a5b1ce 100644 --- a/pki/src/main/java/zeroecho/pki/application/PkiSession.java +++ b/pki/src/main/java/zeroecho/pki/application/PkiSession.java @@ -33,6 +33,7 @@ ******************************************************************************/ package zeroecho.pki.application; +import java.time.Clock; import java.util.Optional; import zeroecho.pki.api.CaService; @@ -77,6 +78,20 @@ public interface PkiSession extends AutoCloseable { return DefaultPkiSession.open(configuration, dependencies); } + /** + * Opens a production session with explicit process-local capabilities and a + * lifecycle clock shared by every time-dependent backend service. + * + * @param configuration validated immutable provider configuration + * @param dependencies process-local key-access capabilities + * @param clock authoritative session clock + * @return opened lifecycle-owned session + */ + static PkiSession open(PkiSessionConfiguration configuration, PkiSessionRuntimeDependencies dependencies, + Clock clock) { + return DefaultPkiSession.open(configuration, dependencies, clock); + } + /** @return profile lifecycle service owned by this session */ ProfileService profiles(); @@ -103,6 +118,9 @@ public interface PkiSession extends AutoCloseable { return Optional.empty(); } + /** @return confined OCSP responder service, or empty when signing is unavailable */ + default Optional ocsp() { return Optional.empty(); } + /** @return configured publication service, or empty when no destination is enabled */ default Optional publications() { return Optional.empty(); 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 b9e96a2..c93e070 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java @@ -35,8 +35,12 @@ package zeroecho.pki.impl.fs; import java.io.Closeable; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; import java.io.IOException; import java.io.InputStream; +import java.math.BigInteger; import java.nio.ByteBuffer; import java.nio.channels.FileChannel; import java.nio.channels.FileLock; @@ -108,6 +112,7 @@ import zeroecho.pki.api.content.DurableContentReference; import zeroecho.pki.api.content.DurableContentOwner; import zeroecho.pki.impl.ProfileLifecycleFailure; import zeroecho.pki.impl.ProfileLifecycleFailure.Code; +import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialFramework; import zeroecho.pki.impl.core.async.PkiSigningBus; import zeroecho.pki.spi.store.PkiStore; import zeroecho.core.io.CancellationSignal; @@ -122,6 +127,7 @@ import zeroecho.pki.spi.store.StagedContentStore; import zeroecho.pki.spi.store.SignWorkflowStore; import zeroecho.pki.spi.store.TemporaryUniqueIndex; import zeroecho.pki.spi.store.RevocationSnapshot; +import zeroecho.pki.spi.store.RevocationView; import zeroecho.pki.spi.store.RevocationHistory; /** @@ -175,7 +181,9 @@ import zeroecho.pki.spi.store.RevocationHistory; */ @SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity", "PMD.TooManyMethods", "PMD.ExcessivePublicCount", "PMD.UseTryWithResources", "PMD.ExceptionAsFlowControl", - "PMD.PreserveStackTrace", "PMD.NcssCount", "PMD.AvoidDuplicateLiterals" }) + "PMD.PreserveStackTrace", "PMD.NcssCount", "PMD.AvoidDuplicateLiterals", + "PMD.ControlStatementBraces", "PMD.CollapsibleIfStatements", "PMD.AvoidDeeplyNestedIfStmts", + "PMD.AvoidLiteralsInIfCondition" }) public final class FilesystemPkiStore implements PkiStore, Closeable { private static final Logger LOG = Logger.getLogger(FilesystemPkiStore.class.getName()); @@ -186,6 +194,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { private static final String STATUS_RECORD_NAMESPACE = "io.zeroecho.pki.status-object-record"; private static final String STATUS_OWNER_NAMESPACE = "io.zeroecho.pki.status-object-owner"; private static final String PUBLICATION_RECORD_NAMESPACE = "io.zeroecho.pki.publication-record"; + private static final String CREDENTIAL_SERIAL_INDEX_NAMESPACE = "io.zeroecho.pki.credential-serial-index"; private static final int CURRENT_SIGN_RECORD_VERSION = 2; private static final int SIGN_OWNER_VALUE_VERSION = 1; private static final int STATUS_OWNER_VALUE_VERSION = 1; @@ -294,6 +303,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { this.signingTimeWatermark = new AtomicLong(loadSigningTimeWatermark()); this.historySeq = new AtomicLong(0L); recoverStagedContent(); + rebuildCredentialSerialIndex(); recoverPublicationRecords(); boolean snapshotRestore = requireSnapshotBoundary(); openedRevocations = FilesystemRevocationAuthority.open( @@ -637,6 +647,10 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { writeOnce(paths.issuerGenerationPath(generation.issuerId()), FsCodec.encode(FsCodec.ISSUER_GENERATION, generation), "ISSUER_GENERATION", FsUtil.safeId(generation.issuerId())); + x509Serial(credential).ifPresent(serial -> { + requireSerialAvailable(generation.issuerId(), serial, generation.credentialId()); + ensureSerialIndex(generation.issuerId(), serial, generation.credentialId()); + }); } @Override @@ -687,7 +701,11 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { public void putCredential(final Credential credential) { requireStoreUsable(); Objects.requireNonNull(credential, "credential"); + Optional serial = indexableSerial(credential); + serial.ifPresent(value -> requireSerialAvailable(credential.issuerRef().issuerId(), value, credential)); credentialContentTransactions.put(credential); + serial.ifPresent(value -> ensureSerialIndex(credential.issuerRef().issuerId(), value, + credential.credentialId())); } @Override @@ -706,6 +724,184 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { } } + @Override + public Optional getCredentialByIssuerAndSerial(PkiId issuerId, BigInteger serial) { + requireStoreUsable(); + Objects.requireNonNull(issuerId, "issuerId"); + requirePositiveSerial(serial); + MetadataKey key = serialIndexKey(issuerId, serial); + try (MetadataSnapshot snapshot = metadataStore.snapshot()) { + Optional stored = snapshot.get(key); + if (stored.isEmpty()) return Optional.empty(); + PkiId credentialId; + try (MetadataSnapshot.Record record = stored.orElseThrow()) { + credentialId = decodeSerialIndex(record, issuerId, serial); + } + Credential credential = getCredential(credentialId) + .orElseThrow(() -> new IllegalStateException("Credential serial index target is missing")); + if (!credential.issuerRef().issuerId().equals(issuerId) + || !x509Serial(credential).filter(serial::equals).isPresent()) { + throw new IllegalStateException("Credential serial index authority mismatch"); + } + return Optional.of(credential); + } catch (IOException failure) { + throw new IllegalStateException("Credential serial index is invalid"); + } + } + + private void rebuildCredentialSerialIndex() throws IOException { + Path root = paths.root().resolve("credentials").resolve("by-id"); + if (!Files.isDirectory(root)) return; + 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 serial = indexableSerial(credential); + if (serial.isPresent()) { + requireSerialAvailable(credential.issuerRef().issuerId(), serial.orElseThrow(), credential); + ensureSerialIndex(credential.issuerRef().issuerId(), serial.orElseThrow(), + credential.credentialId()); + } + } + } + } + + private void requireSerialAvailable(PkiId issuerId, BigInteger serial, PkiId credentialId) { + Credential candidate = getCredential(credentialId).orElse(null); + requireSerialAvailable(issuerId, serial, candidate); + } + + private void requireSerialAvailable(PkiId issuerId, BigInteger serial, Credential candidate) { + Optional existing = getCredentialByIssuerAndSerial(issuerId, serial); + if (existing.isPresent() && (candidate == null + || !existing.orElseThrow().credentialId().equals(candidate.credentialId()))) { + if (candidate == null || !sameImmutableContent(existing.orElseThrow(), candidate)) { + throw new IllegalStateException("Duplicate issuer-generation certificate serial"); + } + } + } + + private void ensureSerialIndex(PkiId issuerId, BigInteger serial, PkiId credentialId) { + MetadataKey key = serialIndexKey(issuerId, serial); + try (MetadataSnapshot snapshot = metadataStore.snapshot()) { + Optional stored = snapshot.get(key); + if (stored.isPresent()) { + try (MetadataSnapshot.Record record = stored.orElseThrow()) { + PkiId indexedId = decodeSerialIndex(record, issuerId, serial); + if (!indexedId.equals(credentialId)) { + Credential indexed = getCredential(indexedId).orElseThrow( + () -> new IllegalStateException("Credential serial index target is missing")); + Credential candidate = getCredential(credentialId).orElseThrow( + () -> new IllegalStateException("Credential serial candidate is missing")); + if (!sameImmutableContent(indexed, candidate)) { + throw new IllegalStateException("Duplicate issuer-generation certificate serial"); + } + } + } + return; + } + } catch (IOException failure) { + throw new IllegalStateException("Credential serial index read failed"); + } + try (MetadataTransaction transaction = metadataStore.beginTransaction()) { + transaction.create(key, byteContent(encodeSerialIndex(issuerId, serial, credentialId)), + CancellationSignal.NONE); + MetadataCommitResult result = transaction.commit(); + if (result.outcome() == MetadataCommitResult.Outcome.COMMITTED) return; + if (result.outcome() == MetadataCommitResult.Outcome.UNKNOWN) { + durabilityUncertain.set(true); + throw new PkiException("Store durability unconfirmed: code=STORE_DURABILITY_UNCONFIRMED"); + } + } catch (IOException failure) { + throw new IllegalStateException("Credential serial index persistence failed"); + } + requireSerialAvailable(issuerId, serial, credentialId); + } + + private static boolean sameImmutableContent(Credential left, Credential right) { + return left.content().storeId().equals(right.content().storeId()) + && left.content().contentId().equals(right.content().contentId()) + && left.content().sha256().equals(right.content().sha256()) + && left.content().length() == right.content().length(); + } + + private static Optional x509Serial(Credential credential) { + if (!BcX509CredentialFramework.FORMAT_ID.equals(credential.formatId())) return Optional.empty(); + try { + BigInteger serial = new BigInteger(credential.serialOrUniqueId()); + if (serial.signum() <= 0 || serial.bitLength() > 160 + || !serial.toString().equals(credential.serialOrUniqueId())) return Optional.empty(); + return Optional.of(serial); + } catch (NumberFormatException failure) { + return Optional.empty(); + } + } + + private Optional indexableSerial(Credential credential) { + if (credential.issuerRef().issuerId().value().startsWith("issuer-unresolved:") + || getIssuerGeneration(credential.issuerRef().issuerId()).isEmpty()) { + return Optional.empty(); + } + return x509Serial(credential); + } + + private static void requirePositiveSerial(BigInteger serial) { + Objects.requireNonNull(serial, "serial"); + if (serial.signum() <= 0 || serial.bitLength() > 160) { + throw new IllegalArgumentException("X.509 serial must be a positive value of at most 20 octets"); + } + } + + private static MetadataKey serialIndexKey(PkiId issuerId, BigInteger serial) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] issuer = issuerId.value().getBytes(StandardCharsets.UTF_8); + digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(issuer.length).array()); + digest.update(issuer); + digest.update(serial.toByteArray()); + return new MetadataKey(CREDENTIAL_SERIAL_INDEX_NAMESPACE, + HexFormat.of().formatHex(digest.digest())); + } catch (java.security.NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 unavailable", impossible); + } + } + + private static byte[] encodeSerialIndex(PkiId issuerId, BigInteger serial, PkiId credentialId) { + try (ByteArrayOutputStream bytes = new ByteArrayOutputStream(256); + DataOutputStream output = new DataOutputStream(bytes)) { + output.writeInt(1); + output.writeUTF(issuerId.value()); + output.writeUTF(serial.toString()); + output.writeUTF(credentialId.value()); + output.flush(); + return bytes.toByteArray(); + } catch (IOException impossible) { + throw new IllegalStateException("Credential serial index encoding failed", impossible); + } + } + + private static PkiId decodeSerialIndex(MetadataSnapshot.Record record, PkiId issuerId, + BigInteger serial) throws IOException { + byte[] encoded = readMetadataValue(record); + if (encoded.length > 16_384) throw new IOException("Credential serial index exceeds its bound"); + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded))) { + if (input.readInt() != 1) throw new IOException("Credential serial index version is obsolete"); + PkiId storedIssuer = new PkiId(input.readUTF()); + String storedSerial = input.readUTF(); + PkiId credentialId = new PkiId(input.readUTF()); + if (input.read() != -1 || !storedIssuer.equals(issuerId) + || !serial.toString().equals(storedSerial)) { + throw new IOException("Credential serial index authority mismatch"); + } + return credentialId; + } catch (IllegalArgumentException failure) { + throw new IOException("Credential serial index is malformed", failure); + } + } + @Override public Optional getCredentialByIssuanceIntent(IssuanceIntent intent) { requireStoreUsable(); @@ -941,6 +1137,16 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { } } + @Override + public RevocationView openRevocationView() { + requireStoreUsable(); + try { + return revocations.view(); + } catch (IOException failure) { + throw corruptRevocationState(); + } + } + @Override public void putStatusObject(final StatusObject object) { requireStoreUsable(); diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationAuthority.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationAuthority.java index ca362bb..1868547 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationAuthority.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationAuthority.java @@ -1,6 +1,35 @@ /******************************************************************************* * 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.impl.fs; @@ -24,10 +53,11 @@ import zeroecho.pki.api.revocation.RevocationTransition; import zeroecho.pki.spi.store.MetadataStoreId; import zeroecho.pki.spi.store.RevocationHistory; import zeroecho.pki.spi.store.RevocationSnapshot; +import zeroecho.pki.spi.store.RevocationView; /** Store-owned coordination of the authoritative log and its derived state. */ @SuppressWarnings({ "PMD.CloseResource", "PMD.UseTryWithResources", "PMD.AvoidSynchronizedAtMethodLevel", - "PMD.UnusedAssignment" }) + "PMD.UnusedAssignment", "PMD.ControlStatementBraces" }) final class FilesystemRevocationAuthority implements AutoCloseable { private static final FilesystemRevocationCurrentIndex.Configuration INDEX_CONFIGURATION = @@ -183,6 +213,21 @@ final class FilesystemRevocationAuthority implements AutoCloseable { } } + /* default */ RevocationView view() throws IOException { + lifecycle.writeLock().lock(); + try { + requireOperational(); + ensureIndex(); + FilesystemRevocationLog.RecoveryTarget head = log.recoveryTarget(); + if (index.coveredGlobalRevision() != head.globalRevision()) recoverIndex(head); + DirectView view = new DirectView(this, head); + view.acquire(); + return view; + } finally { + lifecycle.writeLock().unlock(); + } + } + /* default */ FilesystemRevocationLog.RecoveryTarget head() throws IOException { lifecycle.readLock().lock(); try { @@ -389,6 +434,36 @@ final class FilesystemRevocationAuthority implements AutoCloseable { void closeFromOwner() throws IOException; } + /** Read-lock-backed direct view; callers close it before external signing. */ + private static final class DirectView implements RevocationView { + private final FilesystemRevocationAuthority owner; + private final FilesystemRevocationLog.RecoveryTarget head; + private boolean closed; + + private DirectView(FilesystemRevocationAuthority owner, + FilesystemRevocationLog.RecoveryTarget head) { + this.owner = owner; + this.head = head; + } + + private void acquire() { owner.lifecycle.readLock().lock(); } + @Override public long revision() { requireOpen(); return head.globalRevision(); } + @Override public String commitment() { requireOpen(); return head.globalCommitment().value(); } + @Override public Optional get(PkiId credentialId) throws IOException { + requireOpen(); + return owner.index.lookup(Objects.requireNonNull(credentialId, "credentialId")) + .map(FilesystemRevocationAuthority::record); + } + @Override public void close() { + if (closed) return; + closed = true; + owner.lifecycle.readLock().unlock(); + } + private void requireOpen() { + if (closed) throw new IllegalStateException("Revocation view is closed"); + } + } + /** Immutable checkpoint-backed current-state view. */ private static final class StableView implements RevocationSnapshot, OwnedResource { private final FilesystemRevocationAuthority owner; 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 be3f822..3a9dcfa 100644 --- a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java +++ b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java @@ -34,6 +34,7 @@ package zeroecho.pki.spi.store; import java.time.Instant; +import java.math.BigInteger; import java.util.List; import java.util.Optional; @@ -194,6 +195,18 @@ public interface PkiStore extends SignWorkflowStore, AutoCloseable { */ Optional getCredential(PkiId credentialId); + /** + * Resolves one exact X.509 credential through the derived issuer-generation + * and canonical positive serial index. Implementations must validate the + * returned authoritative credential and must not scan the credential + * population for a lookup. + * + * @param issuerId exact issuer-generation identity + * @param serial canonical positive X.509 serial + * @return matching authoritative credential, when issued + */ + Optional getCredentialByIssuerAndSerial(PkiId issuerId, BigInteger serial); + /** * Resolves the exact credential atomically persisted with one issuance intent. * Implementations must reject duplicate matches and mismatched command @@ -270,6 +283,9 @@ public interface PkiStore extends SignWorkflowStore, AutoCloseable { */ RevocationSnapshot openRevocationSnapshot(); + /** Opens a stable direct-lookup current-state view for finite protocol work. */ + RevocationView openRevocationView(); + /** * Persists a status object. * diff --git a/pki/src/main/java/zeroecho/pki/spi/store/RevocationView.java b/pki/src/main/java/zeroecho/pki/spi/store/RevocationView.java new file mode 100644 index 0000000..8aa3cab --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/store/RevocationView.java @@ -0,0 +1,52 @@ +/******************************************************************************* + * 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.spi.store; + +import java.io.IOException; +import java.util.Optional; + +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.revocation.RevocationRecord; + +/** Stable direct-lookup view of one authoritative revocation log revision. */ +public interface RevocationView extends AutoCloseable { + /** @return captured global revision */ + long revision(); + /** @return captured global commitment */ + String commitment(); + /** Returns one current state without scanning the revocation population. */ + Optional get(PkiId credentialId) throws IOException; + /** Releases the stable view. */ + @Override void close() throws IOException; +} diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java index f920b09..d48960f 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java @@ -128,6 +128,7 @@ import zeroecho.pki.api.publication.PublicationTargetType; import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.revocation.RevocationCommand; import zeroecho.pki.api.revocation.RevocationRecord; +import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialFramework; import zeroecho.pki.api.revocation.RevocationReason; import zeroecho.pki.api.status.StatusObject; import zeroecho.pki.api.status.StatusObjectType; @@ -624,6 +625,39 @@ public final class FilesystemPkiStoreTest { System.out.println("caReferencesRequireValidStandaloneCredentialsAndPreserveOrder...ok"); } + @Test + void issuerSerialIndexSeparatesEqualSerialsAndRecoversOnRestart() throws Exception { + System.out.println("issuerSerialIndexSeparatesEqualSerialsAndRecoversOnRestart"); + Path root = tmp.resolve("store-issuer-serial-index"); + PkiId firstIssuer; + PkiId secondIssuer; + PkiId firstCredential; + PkiId secondCredential; + try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) { + CaRecord first = TestObjects.minimalCaRecord(store, "ca-index-one", CaState.ACTIVE); + CaRecord second = TestObjects.minimalCaRecord(store, "ca-index-two", CaState.ACTIVE); + store.putCa(first); + store.putCa(second); + firstIssuer = first.currentIssuanceIssuerId(); + secondIssuer = second.currentIssuanceIssuerId(); + firstCredential = store.getIssuerGeneration(firstIssuer).orElseThrow().credentialId(); + secondCredential = store.getIssuerGeneration(secondIssuer).orElseThrow().credentialId(); + assertEquals(firstCredential, store.getCredentialByIssuerAndSerial(firstIssuer, BigInteger.ONE) + .orElseThrow().credentialId()); + assertEquals(secondCredential, store.getCredentialByIssuerAndSerial(secondIssuer, BigInteger.ONE) + .orElseThrow().credentialId()); + assertTrue(store.getCredentialByIssuerAndSerial(firstIssuer, BigInteger.TWO).isEmpty()); + } + try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) { + assertEquals(firstCredential, reopened.getCredentialByIssuerAndSerial(firstIssuer, BigInteger.ONE) + .orElseThrow().credentialId()); + assertEquals(secondCredential, reopened.getCredentialByIssuerAndSerial(secondIssuer, BigInteger.ONE) + .orElseThrow().credentialId()); + } + System.out.println("...same-serial-separated=true"); + System.out.println("issuerSerialIndexSeparatesEqualSerialsAndRecoversOnRestart...ok"); + } + @Test void oldStoreVersionIsRejected() throws Exception { System.out.println("oldStoreVersionIsRejected"); @@ -1336,9 +1370,9 @@ public final class FilesystemPkiStoreTest { .build(keyPair.getPrivate())).getEncoded(); zeroecho.pki.api.content.DurableContentReference content = zeroecho.pki.testkit.PkiTestRuntime.fixtureReference(store.stagedContent(), Encoding.DER, der); - return new Credential(credentialId, new FormatId("fmt-x509"), + return new Credential(credentialId, BcX509CredentialFramework.FORMAT_ID, new IssuerRef(authorityId, issuerId, pathId), subject, - new Validity(notBefore, notAfter), "CA-" + authorityId.value(), + new Validity(notBefore, notAfter), "1", new PkiId("pk-" + authorityId.value()), new CaProfileBinding(new CertificateProfileRef("profile-ca", 1, new byte[32])), CredentialStatus.ISSUED, content, emptyAttributes());