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:
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user