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.
This commit is contained in:
2026-08-05 18:32:00 +02:00
parent b19edf17fd
commit 44cbb0a37d
38 changed files with 2831 additions and 107 deletions

View File

@@ -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}

View File

@@ -1,5 +1,5 @@
{
"version": 4,
"version": 5,
"serverName": "zeroecho-admin",
"realm": {
"realmId": "production",

137
docs/pki-server-ocsp.md Normal file
View File

@@ -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.

View File

@@ -1,5 +1,5 @@
{
"version": 4,
"version": 5,
"serverName": "zeroecho-admin",
"realm": {
"realmId": "production",

View File

@@ -1,5 +1,5 @@
{
"version": 4,
"version": 5,
"serverName": "zeroecho-admin-nginx",
"realm": {
"realmId": "production",

View File

@@ -1,5 +1,5 @@
{
"version": 4,
"version": 5,
"serverName": "zeroecho-admin-proxy",
"realm": {
"realmId": "production",

View File

@@ -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 {

View File

@@ -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<String> signatureBindingId, String signatureBindingCommitment,
OcspResponseService.ResponderId responderIdForm, Duration responseValidity, NoncePolicy noncePolicy,
int maximumNonceBytes, Set<OcspResponseService.CertIdHash> 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<String> signatureBindingId,
String signatureBindingCommitment, OcspResponseService.ResponderId responderIdForm,
Duration responseValidity, NoncePolicy noncePolicy, int maximumNonceBytes,
Set<OcspResponseService.CertIdHash> 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<OcspResponseService> 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<OcspResponseService> 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<Responder> 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<Responder> list(int offset, int limit) {
ServerControlStore.Page<ServerControlStore.ProtocolRecord> 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<Responder> aliases(String alias) {
List<Responder> matches = new ArrayList<>();
int offset = 0;
while (true) {
ServerControlStore.Page<Responder> 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<Responder> 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<String> signatureBindingId, String bindingCommitment,
OcspResponseService.ResponderId responderIdForm, Duration validity, NoncePolicy noncePolicy,
int maximumNonceBytes, Set<OcspResponseService.CertIdHash> 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<String> 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<OcspResponseService.CertIdHash> 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");
}
}
}

View File

@@ -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();
};
}
}

View File

@@ -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; }

View File

@@ -68,7 +68,7 @@ public record PkiServerConfiguration(int version, String serverName, ServerRealm
Optional<PublicListener> publicListener, Optional<AcmeListener> 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 {

View File

@@ -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();
}

View File

@@ -66,6 +66,7 @@ public final class ServerControlOperationExecutor {
private final OperationSecurityDescriptors descriptors;
private final Map<OperationSecurityDescriptors.ApprovalCategory, ApprovalService.Policy> approvalPolicies;
private final java.util.concurrent.atomic.AtomicReference<AcmeService> acme = new java.util.concurrent.atomic.AtomicReference<>();
private final java.util.concurrent.atomic.AtomicReference<OcspResponderService> 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<PkiOperationValue> actions = value.actions().stream().sorted(Comparator.comparingInt(Permission.Action::code))
.map(item -> (PkiOperationValue) text(item.name())).toList();

View File

@@ -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.
*
* <p>The ACME domain codec owns the payload schema. This record keeps the
* <p>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.</p>
*
* @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<String> ACME_NAMESPACES = Set.of(ACME_DIRECTORY, ACME_ACCOUNT, ACME_ORDER,
ACME_AUTHORIZATION, ACME_CHALLENGE, ACME_EVIDENCE);
private static final Set<String> 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<String> expectedCommitment) {
/** One compare-and-set mutation participating in an atomic protocol-state change. */
public record ProtocolMutation(ProtocolRecord record, Optional<String> 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> 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> 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<AcmeRecord> 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<ProtocolRecord> 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<AcmeMutation> requested) {
public synchronized void mutateProtocol(List<ProtocolMutation> requested) {
requireOpen();
List<AcmeMutation> mutations = List.copyOf(Objects.requireNonNull(requested, "requested"));
List<ProtocolMutation> 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<String> keys = new HashSet<>();
if (mutations.stream().anyMatch(item -> !keys.add(item.record().namespace() + '\n'
+ item.record().recordId()))) {
throw new IllegalArgumentException("Duplicate ACME transaction identity");
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<MetadataSnapshot.Record> 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));
}

View File

@@ -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");

View File

@@ -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 */

View File

@@ -56,7 +56,7 @@ public final class AcmeControlStore {
/** Creates one record after canonical sealing. */
public <T> T create(T unsealed) {
T sealed = seal(unsealed);
control.mutateAcme(List.of(new ServerControlStore.AcmeMutation(record(sealed), Optional.empty())));
control.mutateProtocol(List.of(new ServerControlStore.ProtocolMutation(record(sealed), Optional.empty())));
return sealed;
}
@@ -68,11 +68,11 @@ public final class AcmeControlStore {
.map(this::<AcmeState.Authorization>seal).toList();
List<AcmeState.Challenge> sealedChallenges = challenges.stream()
.map(this::<AcmeState.Challenge>seal).toList();
List<ServerControlStore.AcmeMutation> changes = new ArrayList<>();
changes.add(new ServerControlStore.AcmeMutation(record(sealedOrder), Optional.empty()));
sealedAuthorizations.forEach(value -> changes.add(new ServerControlStore.AcmeMutation(record(value), Optional.empty())));
sealedChallenges.forEach(value -> changes.add(new ServerControlStore.AcmeMutation(record(value), Optional.empty())));
control.mutateAcme(changes);
List<ServerControlStore.ProtocolMutation> 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<Replacement> replacements, List<?> creations) {
List<Object> sealed = replacements.stream().map(Replacement::next).map(this::sealObject).toList();
List<Object> created = creations.stream().map(this::sealObject).toList();
List<ServerControlStore.AcmeMutation> changes = new ArrayList<>();
List<ServerControlStore.ProtocolMutation> 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 <T> Optional<T> get(Class<T> 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 <T> ServerControlStore.Page<T> page(Class<T> type, int offset, int limit) {
ServerControlStore.Page<ServerControlStore.AcmeRecord> page =
control.acmeRecords(namespace(type), offset, limit);
ServerControlStore.Page<ServerControlStore.ProtocolRecord> page =
control.protocolRecords(namespace(type), offset, limit);
List<T> values = page.values().stream().map(value -> {
Object decoded = codec.decode(value.payload());
if (!type.isInstance(decoded) || !value.recordId().equals(identity(decoded))
@@ -251,8 +251,8 @@ public final class AcmeControlStore {
@SuppressWarnings("unchecked")
private <T> T seal(T value) { return (T) codec.seal(Objects.requireNonNull(value, "value")); }
private Object sealObject(Object value) { return codec.seal(Objects.requireNonNull(value, "value")); }
private ServerControlStore.AcmeRecord record(Object value) {
return new ServerControlStore.AcmeRecord(namespace(value.getClass()), identity(value),
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) {

View File

@@ -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());
};
}

View File

@@ -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<PkiId> 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<OcspResponseService.Response> 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<String, List<String>> 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<PkiServerAuthenticationContext> context = tlsContext(exchange, requestId, headers);
if (context.isEmpty() || authenticator.authenticatePublicProxyTransport(context.orElseThrow()).isEmpty()) {
throw new IllegalArgumentException("Proxy transport is unauthenticated");
}
}
private Optional<PkiServerAuthenticationContext> tlsContext(HttpExchange exchange, String requestId,
Map<String, List<String>> headers) {
if (!(exchange instanceof HttpsExchange https)) return Optional.empty();
try {
Certificate[] peers = https.getSSLSession().getPeerCertificates(); List<X509Certificate> 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<String> 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<String, List<String>> entry : headers.entrySet()) {
total = Math.addExact(total, entry.getKey().getBytes(StandardCharsets.UTF_8).length);
for (String value : entry.getValue()) {
total = Math.addExact(total, value.getBytes(StandardCharsets.UTF_8).length);
if (total > configuration.maximumHeaderBytes()) {
throw new IllegalArgumentException("OCSP request headers are oversized");
}
}
}
}
private static void requireAccept(Headers headers) {
List<String> 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<String> 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<byte[]> 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<String> 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<OcspResponderService.Responder> responder,
String classification) {
Map<String, String> 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<byte[]> 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; }
}

View File

@@ -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<OcspResponseService.CertId> requests, Optional<byte[]> nonce) {
Parsed { requests = List.copyOf(requests); nonce = nonce.map(byte[]::clone); }
@Override public Optional<byte[]> 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<OcspResponseService.CertIdHash> 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<OcspResponseService.CertId> 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<byte[]> 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");
}
}

View File

@@ -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();
}
}
}

View File

@@ -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 {

View File

@@ -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<X509Certificate> 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<X509Certificate> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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));

View File

@@ -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(); }
}
}

View File

@@ -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");
}

View File

@@ -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()));

View File

@@ -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()));
}
}

View File

@@ -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> 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<String> 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<Resolved> resolve(Command command, X509CertificateHolder issuer, RevocationView view)
throws Exception {
List<Resolved> 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> 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<Resolved> 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<byte[]> 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) { }
}

View File

@@ -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> credential(PkiId issuerId, BigInteger serial) {
requireOpen.run();
return store.getCredentialByIssuerAndSerial(Objects.requireNonNull(issuerId, "issuerId"),
Objects.requireNonNull(serial, "serial"));
}
@Override public Optional<StatusObject> statusObject(PkiId statusObjectId) {
requireOpen.run();
return store.getStatusObject(Objects.requireNonNull(statusObjectId, "statusObjectId"));

View File

@@ -110,6 +110,7 @@ final class DefaultPkiSession implements PkiSession {
private final Optional<CertificationRequestService> requests;
private final Optional<IssuanceService> issuance;
private final Optional<StatusObjectService> statusObjects;
private final Optional<OcspResponseService> ocsp;
private final Optional<PublicationService> publications;
private final Optional<PkiSigningBus> signingBus;
private final Optional<SignatureWorkflow> 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<OcspResponseService> ocsp() { requireOpen(); return ocsp; }
@Override
public Optional<PublicationService> publications() {
requireOpen();

View File

@@ -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<PkiId> responseChain,
String signatureAlgorithm, Optional<String> signatureBindingId,
ResponderId responderId, Instant producedAt, Instant thisUpdate, Instant nextUpdate,
Optional<byte[]> nonce, List<CertId> 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<byte[]> 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<String> signatureBindingId);
}

View File

@@ -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<IssuerChainPath> chainPath(PkiId pathId);
/** Returns an exact credential metadata record. */
Optional<Credential> credential(PkiId credentialId);
/** Returns an exact credential through the issuer-generation/serial index. */
default Optional<Credential> 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> statusObject(PkiId statusObjectId);
/** Opens validated immutable certificate content. */

View File

@@ -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<OcspResponseService> ocsp() { return Optional.empty(); }
/** @return configured publication service, or empty when no destination is enabled */
default Optional<PublicationService> publications() {
return Optional.empty();

View File

@@ -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<BigInteger> 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<Credential> getCredentialByIssuerAndSerial(PkiId issuerId, BigInteger serial) {
requireStoreUsable();
Objects.requireNonNull(issuerId, "issuerId");
requirePositiveSerial(serial);
MetadataKey key = serialIndexKey(issuerId, serial);
try (MetadataSnapshot snapshot = metadataStore.snapshot()) {
Optional<MetadataSnapshot.Record> 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<Path> records = Files.list(root)) {
java.util.Iterator<Path> 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<BigInteger> 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<Credential> 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<MetadataSnapshot.Record> 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<BigInteger> 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<BigInteger> 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<Credential> 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();

View File

@@ -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<RevocationRecord> 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;

View File

@@ -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<Credential> 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<Credential> 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.
*

View File

@@ -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<RevocationRecord> get(PkiId credentialId) throws IOException;
/** Releases the stable view. */
@Override void close() throws IOException;
}

View File

@@ -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());