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 {
|
||||
|
||||
@@ -35,10 +35,12 @@ package zeroecho.pki.server;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.net.InetAddress;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
@@ -65,12 +67,23 @@ import java.util.HexFormat;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
|
||||
import org.bouncycastle.asn1.DEROctetString;
|
||||
import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers;
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.asn1.x509.Extension;
|
||||
import org.bouncycastle.asn1.x509.Extensions;
|
||||
import org.bouncycastle.asn1.x509.ExtensionsGenerator;
|
||||
import org.bouncycastle.asn1.x509.GeneralName;
|
||||
import org.bouncycastle.asn1.x509.GeneralNames;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder;
|
||||
import org.bouncycastle.cert.ocsp.BasicOCSPResp;
|
||||
import org.bouncycastle.cert.ocsp.CertificateID;
|
||||
import org.bouncycastle.cert.ocsp.OCSPReqBuilder;
|
||||
import org.bouncycastle.cert.ocsp.OCSPResp;
|
||||
import org.bouncycastle.cert.ocsp.RevokedStatus;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder;
|
||||
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;
|
||||
import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
@@ -112,12 +125,13 @@ class AcmeEndToEndTest {
|
||||
System.out.println("completesRealHttpsAccountIssuanceRolloverRestartAndRevocation");
|
||||
HttpServerTestSupport.Fixture tls = HttpServerTestSupport.tls();
|
||||
int acmePort = reservePort();
|
||||
int publicPort = reservePort();
|
||||
int challengePort = reservePort();
|
||||
KeyPair rootKey = rsa((byte) 21);
|
||||
KeyPair leafKey = rsa((byte) 22);
|
||||
KeyPair accountKey = ec((byte) 23);
|
||||
KeyPair replacementKey = ec((byte) 24);
|
||||
Fixture fixture = fixture(tls, acmePort, challengePort, rootKey);
|
||||
Fixture fixture = fixture(tls, acmePort, publicPort, challengePort, rootKey);
|
||||
seed(fixture);
|
||||
|
||||
URI directoryUri = URI.create("https://localhost:" + acmePort + "/acme/" + DIRECTORY_ALIAS
|
||||
@@ -181,6 +195,10 @@ class AcmeEndToEndTest {
|
||||
.toList());
|
||||
chain.get(0).verify(chain.get(1).getPublicKey());
|
||||
leafDer = chain.get(0).getEncoded();
|
||||
assertOcsp(wire, publicPort, chain.get(1), chain.get(0), false);
|
||||
assertOcspNonce(wire, publicPort, chain.get(1), chain.get(0));
|
||||
assertOcspUnknownAndMulti(wire, publicPort, chain.get(1), chain.get(0));
|
||||
assertOcspNoncePolicies(wire, publicPort, chain.get(1), chain.get(0));
|
||||
|
||||
assertEquals(200, client.rollover(keyChange, replacementKey).status());
|
||||
assertEquals(200, client.postAsGet(accountUri).status());
|
||||
@@ -201,6 +219,8 @@ class AcmeEndToEndTest {
|
||||
String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(leafDer);
|
||||
assertEquals(200, replacement.kid(revoke,
|
||||
"{\"certificate\":\"" + encoded + "\",\"reason\":1}").status());
|
||||
List<X509Certificate> chain = certificates(replacement.postAsGet(certificateUri).bodyText());
|
||||
assertOcsp(wire, publicPort, chain.get(1), chain.get(0), true);
|
||||
assertFalse(replacement.kid(revoke,
|
||||
"{\"certificate\":\"" + encoded + "\",\"reason\":1}").status() == 200);
|
||||
System.out.println("...rollover-restart-revocation=true");
|
||||
@@ -218,9 +238,10 @@ class AcmeEndToEndTest {
|
||||
HttpServerTestSupport.PackagedTls tls = HttpServerTestSupport.packagedTls(root.resolve("tls"));
|
||||
int adminPort = reservePort();
|
||||
int acmePort = reservePort();
|
||||
int publicPort = reservePort();
|
||||
int challengePort = reservePort();
|
||||
KeyPair rootKey = rsa((byte) 41);
|
||||
Fixture fixture = packagedFixture(root, tls, adminPort, acmePort, challengePort, rootKey);
|
||||
Fixture fixture = packagedFixture(root, tls, adminPort, acmePort, publicPort, challengePort, rootKey);
|
||||
seed(fixture, false);
|
||||
Path configuration = root.resolve("server.json");
|
||||
java.nio.file.Files.writeString(configuration, packagedJson(fixture.configuration(), tls),
|
||||
@@ -271,10 +292,12 @@ class AcmeEndToEndTest {
|
||||
URI certificate = URI.create(AcmeTestClient.text(finalized, "certificate"));
|
||||
List<X509Certificate> chain = certificates(client.postAsGet(certificate).bodyText());
|
||||
assertEquals(2, chain.size());
|
||||
assertOcsp(wire, publicPort, chain.get(1), chain.get(0), false);
|
||||
assertEquals(200, client.rollover(rollover, replacement).status());
|
||||
String der = Base64.getUrlEncoder().withoutPadding().encodeToString(chain.get(0).getEncoded());
|
||||
assertEquals(200, client.kid(revoke,
|
||||
"{\"certificate\":\"" + der + "\",\"reason\":1}").status());
|
||||
assertOcsp(wire, publicPort, chain.get(1), chain.get(0), true);
|
||||
}
|
||||
System.out.println("...installed-launcher-flow=true");
|
||||
} finally {
|
||||
@@ -304,8 +327,8 @@ class AcmeEndToEndTest {
|
||||
}
|
||||
}
|
||||
|
||||
private Fixture fixture(HttpServerTestSupport.Fixture tls, int acmePort, int challengePort, KeyPair rootKey)
|
||||
throws Exception {
|
||||
private Fixture fixture(HttpServerTestSupport.Fixture tls, int acmePort, int publicPort, int challengePort,
|
||||
KeyPair rootKey) throws Exception {
|
||||
Path root = temporaryDirectory.resolve("server");
|
||||
PkiServerConfiguration base = HttpServerTestSupport.configuration(root, tls.clientCertificate());
|
||||
java.nio.file.Files.setPosixFilePermissions(root, java.nio.file.attribute.PosixFilePermissions
|
||||
@@ -341,8 +364,13 @@ class AcmeEndToEndTest {
|
||||
URI.create("https://localhost:" + acmePort), 16_384, 1_048_576, lane, lane,
|
||||
Duration.ofMinutes(5), 256, 32, 32, Duration.ofMinutes(1), 32, 32, 16, 32, 2,
|
||||
List.of(http01), List.of(new ProviderConfig(TestAcmeEabProvider.ID, Map.of())));
|
||||
PkiServerConfiguration.PublicListener publicListener = new PkiServerConfiguration.PublicListener(
|
||||
InetAddress.getByName("127.0.0.1"), publicPort,
|
||||
Optional.of(new ProviderConfig("test-tls", Map.of())), false, base.authentication(),
|
||||
16_384, 65_536, lane, Duration.ofSeconds(20), Duration.ofMinutes(5),
|
||||
Duration.ofSeconds(30), true);
|
||||
PkiServerConfiguration configuration = new PkiServerConfiguration(base.version(), base.serverName(), realm,
|
||||
base.listener(), base.authentication(), base.execution(), base.runtime(), Optional.empty(),
|
||||
base.listener(), base.authentication(), base.execution(), base.runtime(), Optional.of(publicListener),
|
||||
Optional.of(acme));
|
||||
PkiSessionRuntimeDependencies dependencies = PkiSessionRuntimeDependencies.withKeyringUnlockProvider(
|
||||
() -> new KeyringPassword(KEYRING_PASSWORD.clone()));
|
||||
@@ -365,6 +393,24 @@ class AcmeEndToEndTest {
|
||||
PkiId authority = context.session().authorities().orElseThrow().createRoot(new CaCreateCommand(
|
||||
root.definition().formatId(), new SubjectRef("CN=ZeroEcho ACME E2E Root"), "root-ca",
|
||||
Optional.of(new KeyRef("acme-test:root.prv")), new SimpleAttributeSet()));
|
||||
zeroecho.pki.api.ca.CaRecord authorityRecord = context.session().repository().authority(authority)
|
||||
.orElseThrow();
|
||||
zeroecho.pki.api.ca.IssuerGeneration issuer = context.session().repository()
|
||||
.issuer(authorityRecord.currentIssuanceIssuerId()).orElseThrow();
|
||||
OcspResponderService.Responder responder = context.ocspResponders().create("ocsp-e2e-root", "root",
|
||||
authority, issuer.issuerId(), OcspResponderService.SigningMode.ISSUER_SIGNED,
|
||||
issuer.credentialId(), issuer.signingKeyRef(), authorityRecord.issuanceChainPathId(),
|
||||
"SHA256withRSA", Optional.empty(), context.session().algorithmBindings().commitment(),
|
||||
zeroecho.pki.application.OcspResponseService.ResponderId.BY_KEY, Duration.ofMinutes(5),
|
||||
OcspResponderService.NoncePolicy.OPTIONAL_ECHO, 64,
|
||||
Set.of(zeroecho.pki.application.OcspResponseService.CertIdHash.SHA1,
|
||||
zeroecho.pki.application.OcspResponseService.CertIdHash.SHA256),
|
||||
16_384, 8, Duration.ofMinutes(1));
|
||||
context.ocspResponders().setActive(context.ocspResponders().register(responder).responderId(), true);
|
||||
registerNonceResponder(context, responder, "ocsp-e2e-reject", "reject",
|
||||
OcspResponderService.NoncePolicy.REJECT);
|
||||
registerNonceResponder(context, responder, "ocsp-e2e-required", "required",
|
||||
OcspResponderService.NoncePolicy.REQUIRED);
|
||||
ActiveCertificateProfile active = context.session().profiles().requireActiveProfile("server-tls");
|
||||
AcmeService service = new AcmeService(context, ServerTestSupport.CLOCK, HttpServerTestSupport.random(),
|
||||
Map.of(Http01ChallengeProvider.ID, new Http01ChallengeProvider()),
|
||||
@@ -389,13 +435,26 @@ class AcmeEndToEndTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static void registerNonceResponder(ServerRealmContext context,
|
||||
OcspResponderService.Responder template, String responderId, String alias,
|
||||
OcspResponderService.NoncePolicy noncePolicy) {
|
||||
OcspResponderService.Responder responder = context.ocspResponders().create(responderId, alias,
|
||||
template.authorityId(), template.issuerId(), template.signingMode(),
|
||||
template.responderCredentialId(), template.signingKeyRef(), template.chainPathId(),
|
||||
template.signatureAlgorithm(), template.signatureBindingId(),
|
||||
template.signatureBindingCommitment(), template.responderIdForm(), template.responseValidity(),
|
||||
noncePolicy, template.maximumNonceBytes(), template.acceptedHashes(),
|
||||
template.maximumRequestBytes(), template.maximumEntries(), template.cacheLifetime());
|
||||
context.ocspResponders().setActive(context.ocspResponders().register(responder).responderId(), true);
|
||||
}
|
||||
|
||||
private PkiHttpsServer start(Fixture fixture) throws Exception {
|
||||
return PkiHttpsServer.start(fixture.configuration(), fixture.dependencies(), ServerTestSupport.CLOCK,
|
||||
HttpServerTestSupport.random(), TestTlsProvider.class.getClassLoader());
|
||||
}
|
||||
|
||||
private Fixture packagedFixture(Path root, HttpServerTestSupport.PackagedTls tls, int adminPort, int acmePort,
|
||||
int challengePort, KeyPair rootKey) throws Exception {
|
||||
int publicPort, int challengePort, KeyPair rootKey) throws Exception {
|
||||
Path keyring = root.resolve("signing-keyring.zek");
|
||||
try (KeyringPassword password = new KeyringPassword(KEYRING_PASSWORD.clone());
|
||||
KeyringStore store = KeyringStore.create(keyring, password)) {
|
||||
@@ -439,11 +498,16 @@ class AcmeEndToEndTest {
|
||||
URI.create("https://localhost:" + acmePort), 16_384, 1_048_576, lane, lane,
|
||||
Duration.ofMinutes(5), 256, 32, 32, Duration.ofMinutes(1), 32, 32, 16, 32, 2,
|
||||
List.of(http01), List.of());
|
||||
PkiServerConfiguration configuration = new PkiServerConfiguration(4, "packaged-acme", realm,
|
||||
PkiServerConfiguration.PublicListener publicListener = new PkiServerConfiguration.PublicListener(
|
||||
InetAddress.getByName("127.0.0.1"), publicPort, Optional.of(tlsProvider), false,
|
||||
authentication, 16_384, 65_536, lane, Duration.ofSeconds(20), Duration.ofMinutes(5),
|
||||
Duration.ofSeconds(30), true);
|
||||
PkiServerConfiguration configuration = new PkiServerConfiguration(PkiServerConfiguration.CURRENT_VERSION,
|
||||
"packaged-acme", realm,
|
||||
new PkiServerConfiguration.Listener(InetAddress.getByName("127.0.0.1"), adminPort, tlsProvider,
|
||||
true, 16_384, 1_048_576), authentication, lane,
|
||||
new PkiServerConfiguration.RuntimeCapabilities(Optional.of("ZEROECHO_TEST_KEYRING_PASSWORD")),
|
||||
Optional.empty(), Optional.of(acme));
|
||||
Optional.of(publicListener), Optional.of(acme));
|
||||
return new Fixture(configuration, PkiSessionRuntimeDependencies.withKeyringUnlockProvider(
|
||||
() -> new KeyringPassword(KEYRING_PASSWORD.clone())), http01);
|
||||
}
|
||||
@@ -464,7 +528,14 @@ class AcmeEndToEndTest {
|
||||
String workflow = providerJson(signing.workflow());
|
||||
String store = providerJson(realm.pkiSessionConfiguration().store());
|
||||
String audit = providerJson(realm.pkiSessionConfiguration().audit());
|
||||
return "{\"version\":4,\"serverName\":\"packaged-acme\",\"realm\":{"
|
||||
String authenticationJson = "{\"mode\":\"DIRECT_MTLS\",\"directClientMappings\":[{"
|
||||
+ "\"mappingId\":\"packaged-admin\",\"principalId\":\"administrator\","
|
||||
+ "\"certificateSha256\":\""
|
||||
+ configuration.authentication().directClientMappings().get(0).certificateSha256().orElseThrow()
|
||||
+ "\"}]}";
|
||||
PkiServerConfiguration.PublicListener publicListener = configuration.publicListener().orElseThrow();
|
||||
return "{\"version\":" + PkiServerConfiguration.CURRENT_VERSION
|
||||
+ ",\"serverName\":\"packaged-acme\",\"realm\":{"
|
||||
+ "\"realmId\":\"production\",\"displayName\":\"Packaged ACME\","
|
||||
+ "\"authorityExposure\":{\"mode\":\"ALL_REALM_AUTHORITIES\",\"authorityIds\":[],"
|
||||
+ "\"creationPermitted\":true},\"authorizationCommitment\":\"" + realm.authorizationCommitment()
|
||||
@@ -485,11 +556,14 @@ class AcmeEndToEndTest {
|
||||
+ "\"publishers\":[],\"bindingProviders\":[]}},\"listener\":{\"address\":\"127.0.0.1\","
|
||||
+ "\"port\":" + configuration.listener().port() + ",\"tlsProvider\":" + tlsJson
|
||||
+ ",\"clientCertificateRequired\":true,\"maximumHeaderBytes\":16384,"
|
||||
+ "\"maximumBodyBytes\":1048576},\"authentication\":{\"mode\":\"DIRECT_MTLS\","
|
||||
+ "\"directClientMappings\":[{\"mappingId\":\"packaged-admin\","
|
||||
+ "\"principalId\":\"administrator\",\"certificateSha256\":\""
|
||||
+ configuration.authentication().directClientMappings().get(0).certificateSha256().orElseThrow()
|
||||
+ "\"}]},\"execution\":" + executionJson() + ",\"publicListener\":{\"enabled\":false},"
|
||||
+ "\"maximumBodyBytes\":1048576},\"authentication\":" + authenticationJson
|
||||
+ ",\"execution\":" + executionJson() + ",\"publicListener\":{\"enabled\":true,"
|
||||
+ "\"address\":\"127.0.0.1\",\"port\":" + publicListener.port()
|
||||
+ ",\"tlsProvider\":" + tlsJson + ",\"allowPlaintextLoopback\":false,"
|
||||
+ "\"authentication\":" + authenticationJson
|
||||
+ ",\"maximumHeaderBytes\":16384,\"maximumBodyBytes\":65536,\"execution\":" + executionJson()
|
||||
+ ",\"maximumStreamDurationMillis\":20000,\"publicImmutableCacheMillis\":300000,"
|
||||
+ "\"publicAliasCacheMillis\":30000,\"authorityListExposed\":true},"
|
||||
+ "\"acmeListener\":{\"enabled\":true,\"listenerId\":\"packaged-acme\","
|
||||
+ "\"address\":\"127.0.0.1\",\"port\":" + configuration.acmeListener().orElseThrow().port()
|
||||
+ ",\"tlsProvider\":" + tlsJson + ",\"transportMode\":\"DIRECT_TLS\","
|
||||
@@ -576,6 +650,135 @@ class AcmeEndToEndTest {
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private static void assertOcsp(HttpClient client, int publicPort, X509Certificate issuer,
|
||||
X509Certificate certificate, boolean revoked) throws Exception {
|
||||
JcaX509CertificateHolder issuerHolder = new JcaX509CertificateHolder(issuer);
|
||||
org.bouncycastle.operator.DigestCalculator digest = new JcaDigestCalculatorProviderBuilder().build().get(
|
||||
new org.bouncycastle.asn1.x509.AlgorithmIdentifier(
|
||||
org.bouncycastle.asn1.nist.NISTObjectIdentifiers.id_sha256));
|
||||
OCSPReqBuilder builder = new OCSPReqBuilder();
|
||||
builder.addRequest(new CertificateID(digest, issuerHolder, certificate.getSerialNumber()));
|
||||
byte[] request = builder.build().getEncoded();
|
||||
URI endpoint = URI.create("https://localhost:" + publicPort + "/ocsp/root");
|
||||
java.net.http.HttpRequest wireRequest;
|
||||
if (revoked) {
|
||||
String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(request);
|
||||
wireRequest = java.net.http.HttpRequest.newBuilder(URI.create(endpoint + "/" + encoded))
|
||||
.header("Accept", "application/ocsp-response").GET().build();
|
||||
} else {
|
||||
wireRequest = java.net.http.HttpRequest.newBuilder(endpoint)
|
||||
.header("Accept", "application/ocsp-response")
|
||||
.header("Content-Type", "application/ocsp-request")
|
||||
.POST(java.net.http.HttpRequest.BodyPublishers.ofByteArray(request)).build();
|
||||
}
|
||||
java.net.http.HttpResponse<byte[]> response = client.send(wireRequest,
|
||||
java.net.http.HttpResponse.BodyHandlers.ofByteArray());
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals("application/ocsp-response", response.headers().firstValue("Content-Type").orElseThrow());
|
||||
String etag = response.headers().firstValue("ETag").orElseThrow();
|
||||
OCSPResp parsed = new OCSPResp(response.body());
|
||||
assertEquals(0, parsed.getStatus());
|
||||
BasicOCSPResp basic = assertInstanceOf(BasicOCSPResp.class, parsed.getResponseObject());
|
||||
assertEquals(1, basic.getResponses().length);
|
||||
assertTrue(basic.isSignatureValid(new JcaContentVerifierProviderBuilder().build(issuerHolder)));
|
||||
assertTrue(basic.getResponses()[0].getThisUpdate() != null);
|
||||
assertTrue(basic.getResponses()[0].getNextUpdate() != null);
|
||||
if (revoked) {
|
||||
assertInstanceOf(RevokedStatus.class, basic.getResponses()[0].getCertStatus());
|
||||
} else {
|
||||
assertEquals(null, basic.getResponses()[0].getCertStatus());
|
||||
String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(request);
|
||||
java.net.http.HttpResponse<byte[]> conditional = client.send(java.net.http.HttpRequest.newBuilder(
|
||||
URI.create(endpoint + "/" + encoded)).header("Accept", "application/ocsp-response")
|
||||
.header("If-None-Match", etag).GET().build(),
|
||||
java.net.http.HttpResponse.BodyHandlers.ofByteArray());
|
||||
assertEquals(304, conditional.statusCode());
|
||||
assertEquals(0, conditional.body().length);
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertOcspNonce(HttpClient client, int publicPort, X509Certificate issuer,
|
||||
X509Certificate certificate) throws Exception {
|
||||
JcaX509CertificateHolder issuerHolder = new JcaX509CertificateHolder(issuer);
|
||||
org.bouncycastle.operator.DigestCalculator digest = new JcaDigestCalculatorProviderBuilder().build()
|
||||
.get(CertificateID.HASH_SHA1);
|
||||
byte[] nonce = new byte[] { 9, 8, 7, 6, 5, 4, 3, 2 };
|
||||
OCSPReqBuilder builder = new OCSPReqBuilder();
|
||||
builder.addRequest(new CertificateID(digest, issuerHolder, certificate.getSerialNumber()));
|
||||
builder.setRequestExtensions(new Extensions(new Extension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce,
|
||||
false, new DEROctetString(new DEROctetString(nonce).getEncoded()))));
|
||||
java.net.http.HttpResponse<byte[]> response = client.send(java.net.http.HttpRequest.newBuilder(
|
||||
URI.create("https://localhost:" + publicPort + "/ocsp/root"))
|
||||
.header("Accept", "application/ocsp-response")
|
||||
.header("Content-Type", "application/ocsp-request")
|
||||
.POST(java.net.http.HttpRequest.BodyPublishers.ofByteArray(builder.build().getEncoded())).build(),
|
||||
java.net.http.HttpResponse.BodyHandlers.ofByteArray());
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals("no-store", response.headers().firstValue("Cache-Control").orElseThrow());
|
||||
assertTrue(response.headers().firstValue("ETag").isEmpty());
|
||||
BasicOCSPResp basic = assertInstanceOf(BasicOCSPResp.class,
|
||||
new OCSPResp(response.body()).getResponseObject());
|
||||
Extension echoed = basic.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce);
|
||||
assertTrue(echoed != null);
|
||||
assertEquals(java.util.HexFormat.of().formatHex(nonce), java.util.HexFormat.of().formatHex(
|
||||
org.bouncycastle.asn1.ASN1OctetString.getInstance(echoed.getParsedValue()).getOctets()));
|
||||
}
|
||||
|
||||
private static void assertOcspUnknownAndMulti(HttpClient client, int publicPort, X509Certificate issuer,
|
||||
X509Certificate certificate) throws Exception {
|
||||
JcaX509CertificateHolder issuerHolder = new JcaX509CertificateHolder(issuer);
|
||||
org.bouncycastle.operator.DigestCalculator digest = new JcaDigestCalculatorProviderBuilder().build()
|
||||
.get(CertificateID.HASH_SHA1);
|
||||
OCSPReqBuilder builder = new OCSPReqBuilder();
|
||||
builder.addRequest(new CertificateID(digest, issuerHolder, certificate.getSerialNumber()));
|
||||
digest = new JcaDigestCalculatorProviderBuilder().build().get(CertificateID.HASH_SHA1);
|
||||
builder.addRequest(new CertificateID(digest, issuerHolder, certificate.getSerialNumber().add(BigInteger.ONE)));
|
||||
java.net.http.HttpResponse<byte[]> response = client.send(java.net.http.HttpRequest.newBuilder(
|
||||
URI.create("https://localhost:" + publicPort + "/ocsp/root"))
|
||||
.header("Accept", "application/ocsp-response").header("Content-Type", "application/ocsp-request")
|
||||
.POST(java.net.http.HttpRequest.BodyPublishers.ofByteArray(builder.build().getEncoded())).build(),
|
||||
java.net.http.HttpResponse.BodyHandlers.ofByteArray());
|
||||
BasicOCSPResp basic = assertInstanceOf(BasicOCSPResp.class,
|
||||
new OCSPResp(response.body()).getResponseObject());
|
||||
assertEquals(2, basic.getResponses().length);
|
||||
assertEquals(null, basic.getResponses()[0].getCertStatus());
|
||||
assertInstanceOf(org.bouncycastle.cert.ocsp.UnknownStatus.class,
|
||||
basic.getResponses()[1].getCertStatus());
|
||||
}
|
||||
|
||||
private static void assertOcspNoncePolicies(HttpClient client, int publicPort, X509Certificate issuer,
|
||||
X509Certificate certificate) throws Exception {
|
||||
JcaX509CertificateHolder issuerHolder = new JcaX509CertificateHolder(issuer);
|
||||
byte[] nonce = new byte[] { 3, 1, 4, 1, 5, 9, 2, 6 };
|
||||
OCSPReqBuilder nonceBuilder = new OCSPReqBuilder();
|
||||
nonceBuilder.addRequest(new CertificateID(new JcaDigestCalculatorProviderBuilder().build()
|
||||
.get(CertificateID.HASH_SHA1), issuerHolder, certificate.getSerialNumber()));
|
||||
nonceBuilder.setRequestExtensions(new Extensions(new Extension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce,
|
||||
false, new DEROctetString(new DEROctetString(nonce).getEncoded()))));
|
||||
byte[] withNonce = nonceBuilder.build().getEncoded();
|
||||
OCSPReqBuilder plainBuilder = new OCSPReqBuilder();
|
||||
plainBuilder.addRequest(new CertificateID(new JcaDigestCalculatorProviderBuilder().build()
|
||||
.get(CertificateID.HASH_SHA1), issuerHolder, certificate.getSerialNumber()));
|
||||
byte[] withoutNonce = plainBuilder.build().getEncoded();
|
||||
assertEquals(org.bouncycastle.cert.ocsp.OCSPRespBuilder.MALFORMED_REQUEST,
|
||||
postOcsp(client, publicPort, "reject", withNonce).getStatus());
|
||||
assertEquals(org.bouncycastle.cert.ocsp.OCSPRespBuilder.MALFORMED_REQUEST,
|
||||
postOcsp(client, publicPort, "required", withoutNonce).getStatus());
|
||||
assertEquals(org.bouncycastle.cert.ocsp.OCSPRespBuilder.SUCCESSFUL,
|
||||
postOcsp(client, publicPort, "required", withNonce).getStatus());
|
||||
}
|
||||
|
||||
private static OCSPResp postOcsp(HttpClient client, int publicPort, String alias, byte[] request)
|
||||
throws Exception {
|
||||
java.net.http.HttpResponse<byte[]> response = client.send(java.net.http.HttpRequest.newBuilder(
|
||||
URI.create("https://localhost:" + publicPort + "/ocsp/" + alias))
|
||||
.header("Accept", "application/ocsp-response").header("Content-Type", "application/ocsp-request")
|
||||
.POST(java.net.http.HttpRequest.BodyPublishers.ofByteArray(request)).build(),
|
||||
java.net.http.HttpResponse.BodyHandlers.ofByteArray());
|
||||
assertEquals(200, response.statusCode());
|
||||
return new OCSPResp(response.body());
|
||||
}
|
||||
|
||||
private static KeyPair rsa(byte seed) throws Exception {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048, random(seed));
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.server;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.atLeast;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.Set;
|
||||
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.asn1.x509.ExtendedKeyUsage;
|
||||
import org.bouncycastle.asn1.x509.Extension;
|
||||
import org.bouncycastle.asn1.x509.KeyPurposeId;
|
||||
import org.bouncycastle.asn1.x509.KeyUsage;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.cert.X509v3CertificateBuilder;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import zeroecho.pki.api.KeyRef;
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.api.algorithm.X509AlgorithmBindingRegistry;
|
||||
import zeroecho.pki.api.ca.IssuerChainPath;
|
||||
import zeroecho.pki.api.ca.IssuerGeneration;
|
||||
import zeroecho.pki.api.ca.IssuerGenerationState;
|
||||
import zeroecho.pki.application.OcspResponseService;
|
||||
import zeroecho.pki.application.PkiRepository;
|
||||
import zeroecho.pki.application.PkiRepositoryContent;
|
||||
import zeroecho.pki.impl.fs.PosixTransactionalMetadataStore;
|
||||
|
||||
/** Durable responder binding, activation, recovery and dependency-failure coverage. */
|
||||
class OcspResponderServiceTest {
|
||||
@TempDir Path temporaryDirectory;
|
||||
|
||||
@Test
|
||||
void persistsExactIssuerBindingAndFailsOnChangedRegistryCommitment() throws Exception {
|
||||
System.out.println("persistsExactIssuerBindingAndFailsOnChangedRegistryCommitment");
|
||||
PkiId credentialId = new PkiId("credential:ocsp-root");
|
||||
PkiId issuerId = IssuerGeneration.idFor(ServerTestSupport.AUTHORITY, credentialId);
|
||||
KeyRef keyRef = new KeyRef("kref:v1:keyring:ocsp-root");
|
||||
IssuerGeneration issuer = new IssuerGeneration(issuerId, ServerTestSupport.AUTHORITY, credentialId,
|
||||
keyRef, IssuerGenerationState.ACTIVE, ServerTestSupport.DIGEST, ServerTestSupport.DIGEST);
|
||||
IssuerChainPath path = IssuerChainPath.create(ServerTestSupport.AUTHORITY, issuerId,
|
||||
java.util.List.of(credentialId));
|
||||
PkiRepository repository = mock(PkiRepository.class);
|
||||
when(repository.issuer(issuerId)).thenReturn(Optional.of(issuer));
|
||||
when(repository.chainPath(path.pathId())).thenReturn(Optional.of(path));
|
||||
byte[] certificate = certificate();
|
||||
when(repository.openCredential(credentialId)).thenAnswer(ignored -> content(certificate));
|
||||
X509AlgorithmBindingRegistry bindings = mock(X509AlgorithmBindingRegistry.class);
|
||||
when(bindings.commitment()).thenReturn(ServerTestSupport.DIGEST);
|
||||
OcspResponseService signing = mock(OcspResponseService.class);
|
||||
|
||||
Path log;
|
||||
String responderId;
|
||||
try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(temporaryDirectory)) {
|
||||
log = opened.log();
|
||||
OcspResponderService service = new OcspResponderService(ServerTestSupport.REALM, opened.store(),
|
||||
repository, bindings, Optional.of(signing), ServerTestSupport.CLOCK);
|
||||
OcspResponderService.Registration registration = new OcspResponderService.Registration(
|
||||
"ocsp-root", "root", ServerTestSupport.AUTHORITY,
|
||||
issuerId, OcspResponderService.SigningMode.ISSUER_SIGNED, credentialId, keyRef, path.pathId(),
|
||||
"SHA256withRSA", Optional.empty(), ServerTestSupport.DIGEST,
|
||||
OcspResponseService.ResponderId.BY_KEY, Duration.ofMinutes(5),
|
||||
OcspResponderService.NoncePolicy.OPTIONAL_ECHO, 64,
|
||||
Set.of(OcspResponseService.CertIdHash.SHA1, OcspResponseService.CertIdHash.SHA256),
|
||||
16_384, 8, Duration.ofMinutes(1));
|
||||
responderId = service.register(registration).responderId();
|
||||
assertThrows(IllegalStateException.class, () -> service.requireActiveAlias("root"));
|
||||
OcspResponderService.Registration duplicateAlias = new OcspResponderService.Registration(
|
||||
"ocsp-root-duplicate", "root", ServerTestSupport.AUTHORITY, issuerId,
|
||||
OcspResponderService.SigningMode.ISSUER_SIGNED, credentialId, keyRef, path.pathId(),
|
||||
"SHA256withRSA", Optional.empty(), ServerTestSupport.DIGEST,
|
||||
OcspResponseService.ResponderId.BY_KEY, Duration.ofMinutes(5),
|
||||
OcspResponderService.NoncePolicy.REJECT, 64,
|
||||
Set.of(OcspResponseService.CertIdHash.SHA1), 16_384, 8, Duration.ofMinutes(1));
|
||||
assertThrows(IllegalStateException.class, () -> service.register(duplicateAlias));
|
||||
assertEquals(OcspResponderService.State.ACTIVE, service.setActive(responderId, true).state());
|
||||
assertEquals(responderId, service.requireActiveAlias("root").responderId());
|
||||
}
|
||||
|
||||
try (ServerControlStore reopened = new ServerControlStore(PosixTransactionalMetadataStore.open(log,
|
||||
OptionalLong.of(1_048_576)))) {
|
||||
OcspResponderService recovered = new OcspResponderService(ServerTestSupport.REALM, reopened,
|
||||
repository, bindings, Optional.of(signing), ServerTestSupport.CLOCK);
|
||||
recovered.validateAll();
|
||||
assertEquals(responderId, recovered.requireActiveAlias("root").responderId());
|
||||
verify(signing, atLeast(3)).validateSigningBinding(credentialId, keyRef,
|
||||
"SHA256withRSA", Optional.empty());
|
||||
when(bindings.commitment()).thenReturn("f".repeat(64));
|
||||
assertThrows(IllegalStateException.class, recovered::validateAll);
|
||||
}
|
||||
System.out.println("...durable-recovery=true changed-binding-rejected=true");
|
||||
System.out.println("persistsExactIssuerBindingAndFailsOnChangedRegistryCommitment...ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesDelegatedResponderIssuerEkuUsageAndSigningCapability() throws Exception {
|
||||
System.out.println("validatesDelegatedResponderIssuerEkuUsageAndSigningCapability");
|
||||
CertificatePair certificates = delegatedCertificates();
|
||||
PkiId issuerCredential = new PkiId("credential:delegated-issuer");
|
||||
PkiId responderCredential = new PkiId("credential:delegated-responder");
|
||||
PkiId issuerId = IssuerGeneration.idFor(ServerTestSupport.AUTHORITY, issuerCredential);
|
||||
IssuerGeneration issuer = new IssuerGeneration(issuerId, ServerTestSupport.AUTHORITY, issuerCredential,
|
||||
new KeyRef("kref:v1:keyring:delegated-issuer"), IssuerGenerationState.ACTIVE,
|
||||
ServerTestSupport.DIGEST, ServerTestSupport.DIGEST);
|
||||
IssuerChainPath path = IssuerChainPath.create(ServerTestSupport.AUTHORITY, issuerId,
|
||||
java.util.List.of(issuerCredential));
|
||||
PkiRepository repository = mock(PkiRepository.class);
|
||||
when(repository.issuer(issuerId)).thenReturn(Optional.of(issuer));
|
||||
when(repository.chainPath(path.pathId())).thenReturn(Optional.of(path));
|
||||
when(repository.openCredential(issuerCredential)).thenAnswer(ignored -> content(certificates.issuer()));
|
||||
when(repository.openCredential(responderCredential)).thenAnswer(
|
||||
ignored -> content(certificates.responder()));
|
||||
X509AlgorithmBindingRegistry bindings = mock(X509AlgorithmBindingRegistry.class);
|
||||
when(bindings.commitment()).thenReturn(ServerTestSupport.DIGEST);
|
||||
OcspResponseService signing = mock(OcspResponseService.class);
|
||||
KeyRef responderKey = new KeyRef("kref:v1:keyring:delegated-responder");
|
||||
Path delegatedDirectory = temporaryDirectory.resolve("delegated");
|
||||
Files.createDirectory(delegatedDirectory);
|
||||
try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(delegatedDirectory)) {
|
||||
OcspResponderService service = new OcspResponderService(ServerTestSupport.REALM, opened.store(),
|
||||
repository, bindings, Optional.of(signing), ServerTestSupport.CLOCK);
|
||||
OcspResponderService.Registration registration = new OcspResponderService.Registration(
|
||||
"ocsp-delegated", "delegated", ServerTestSupport.AUTHORITY, issuerId,
|
||||
OcspResponderService.SigningMode.DELEGATED_RESPONDER, responderCredential, responderKey,
|
||||
path.pathId(), "SHA256withRSA", Optional.empty(), ServerTestSupport.DIGEST,
|
||||
OcspResponseService.ResponderId.BY_NAME, Duration.ofMinutes(5),
|
||||
OcspResponderService.NoncePolicy.REJECT, 64,
|
||||
Set.of(OcspResponseService.CertIdHash.SHA256), 16_384, 8, Duration.ofMinutes(1));
|
||||
OcspResponderService.Responder registered = service.register(registration);
|
||||
assertEquals(OcspResponderService.SigningMode.DELEGATED_RESPONDER, registered.signingMode());
|
||||
verify(signing).validateSigningBinding(responderCredential, responderKey,
|
||||
"SHA256withRSA", Optional.empty());
|
||||
}
|
||||
System.out.println("...delegated-eku-and-key-usage=true");
|
||||
System.out.println("validatesDelegatedResponderIssuerEkuUsageAndSigningCapability...ok");
|
||||
}
|
||||
|
||||
private static PkiRepositoryContent content(byte[] encoded) throws Exception {
|
||||
PkiRepositoryContent content = mock(PkiRepositoryContent.class);
|
||||
when(content.openStream()).thenAnswer(ignored -> new ByteArrayInputStream(encoded));
|
||||
return content;
|
||||
}
|
||||
|
||||
private static byte[] certificate() throws Exception {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048);
|
||||
KeyPair keys = generator.generateKeyPair();
|
||||
X500Name name = new X500Name("CN=OCSP Responder Root");
|
||||
X509v3CertificateBuilder builder = new X509v3CertificateBuilder(name, BigInteger.ONE,
|
||||
Date.from(Instant.parse("2025-01-01T00:00:00Z")),
|
||||
Date.from(Instant.parse("2030-01-01T00:00:00Z")), name,
|
||||
org.bouncycastle.asn1.x509.SubjectPublicKeyInfo.getInstance(keys.getPublic().getEncoded()));
|
||||
X509CertificateHolder certificate = builder.build(
|
||||
new JcaContentSignerBuilder("SHA256withRSA").build(keys.getPrivate()));
|
||||
return certificate.getEncoded();
|
||||
}
|
||||
|
||||
private static CertificatePair delegatedCertificates() throws Exception {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048);
|
||||
KeyPair issuerKeys = generator.generateKeyPair();
|
||||
KeyPair responderKeys = generator.generateKeyPair();
|
||||
X500Name issuerName = new X500Name("CN=Delegated OCSP Issuer");
|
||||
X509v3CertificateBuilder issuerBuilder = new X509v3CertificateBuilder(issuerName, BigInteger.ONE,
|
||||
Date.from(Instant.parse("2025-01-01T00:00:00Z")),
|
||||
Date.from(Instant.parse("2030-01-01T00:00:00Z")), issuerName,
|
||||
org.bouncycastle.asn1.x509.SubjectPublicKeyInfo.getInstance(issuerKeys.getPublic().getEncoded()));
|
||||
X509CertificateHolder issuer = issuerBuilder.build(
|
||||
new JcaContentSignerBuilder("SHA256withRSA").build(issuerKeys.getPrivate()));
|
||||
X509v3CertificateBuilder responderBuilder = new X509v3CertificateBuilder(issuerName, BigInteger.TWO,
|
||||
Date.from(Instant.parse("2025-01-01T00:00:00Z")),
|
||||
Date.from(Instant.parse("2030-01-01T00:00:00Z")), new X500Name("CN=Delegated OCSP Responder"),
|
||||
org.bouncycastle.asn1.x509.SubjectPublicKeyInfo.getInstance(responderKeys.getPublic().getEncoded()));
|
||||
responderBuilder.addExtension(Extension.extendedKeyUsage, false,
|
||||
new ExtendedKeyUsage(KeyPurposeId.id_kp_OCSPSigning));
|
||||
responderBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature));
|
||||
X509CertificateHolder responder = responderBuilder.build(
|
||||
new JcaContentSignerBuilder("SHA256withRSA").build(issuerKeys.getPrivate()));
|
||||
return new CertificatePair(issuer.getEncoded(), responder.getEncoded());
|
||||
}
|
||||
|
||||
private record CertificatePair(byte[] issuer, byte[] responder) {
|
||||
private CertificatePair { issuer = issuer.clone(); responder = responder.clone(); }
|
||||
@Override public byte[] issuer() { return issuer.clone(); }
|
||||
@Override public byte[] responder() { return responder.clone(); }
|
||||
}
|
||||
}
|
||||
@@ -63,15 +63,15 @@ class ServerControlOperationExecutorTest {
|
||||
void catalogContainsOneExplicitPairOfOperationFamilies() {
|
||||
System.out.println("catalogContainsOneExplicitPairOfOperationFamilies");
|
||||
OperationSecurityDescriptors catalog = new OperationSecurityDescriptors();
|
||||
assertEquals(62, catalog.descriptors().size());
|
||||
assertEquals(67, catalog.descriptors().size());
|
||||
assertEquals(16, catalog.descriptors().values().stream()
|
||||
.filter(value -> value.family() == OperationSecurityDescriptors.Family.PKI_OPERATION).count());
|
||||
assertEquals(46, catalog.descriptors().values().stream()
|
||||
assertEquals(51, catalog.descriptors().values().stream()
|
||||
.filter(value -> value.family() == OperationSecurityDescriptors.Family.SERVER_CONTROL_OPERATION)
|
||||
.count());
|
||||
assertEquals(OperationSecurityDescriptors.Family.SERVER_CONTROL_OPERATION,
|
||||
catalog.require(ServerControlOperation.IssueCapability.NAME).family());
|
||||
System.out.println("...control-operations=46");
|
||||
System.out.println("...control-operations=51");
|
||||
System.out.println("...ok");
|
||||
}
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ class ServerOperationGatewayTest {
|
||||
void descriptorRegistryRejectsUnknownAndDuplicateOperations() {
|
||||
System.out.println("descriptorRegistryRejectsUnknownAndDuplicateOperations");
|
||||
OperationSecurityDescriptors descriptors = new OperationSecurityDescriptors();
|
||||
assertEquals(62, descriptors.descriptors().size());
|
||||
assertEquals(67, descriptors.descriptors().size());
|
||||
assertEquals(16, descriptors.descriptors().values().stream()
|
||||
.filter(value -> value.family() == OperationSecurityDescriptors.Family.PKI_OPERATION).count());
|
||||
assertThrows(SecurityException.class, () -> descriptors.require(new PkiOperation.ValidateConfiguration()));
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.server.http;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.Set;
|
||||
|
||||
import org.bouncycastle.asn1.DEROctetString;
|
||||
import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
|
||||
import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers;
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
|
||||
import org.bouncycastle.asn1.x509.Extension;
|
||||
import org.bouncycastle.asn1.x509.Extensions;
|
||||
import org.bouncycastle.asn1.x509.GeneralName;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.cert.X509v3CertificateBuilder;
|
||||
import org.bouncycastle.cert.ocsp.CertificateID;
|
||||
import org.bouncycastle.cert.ocsp.OCSPReqBuilder;
|
||||
import org.bouncycastle.operator.DigestCalculator;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import zeroecho.pki.application.OcspResponseService;
|
||||
|
||||
/** Strict OCSP DER and GET request framing coverage. */
|
||||
class OcspRequestParserTest {
|
||||
@Test
|
||||
void parsesCanonicalSha1Sha256AndExactNonce() throws Exception {
|
||||
System.out.println("parsesCanonicalSha1Sha256AndExactNonce");
|
||||
X509CertificateHolder issuer = certificate();
|
||||
DigestCalculator sha1 = new JcaDigestCalculatorProviderBuilder().build().get(CertificateID.HASH_SHA1);
|
||||
DigestCalculator sha256 = new JcaDigestCalculatorProviderBuilder().build().get(
|
||||
new AlgorithmIdentifier(NISTObjectIdentifiers.id_sha256));
|
||||
OCSPReqBuilder builder = new OCSPReqBuilder();
|
||||
builder.addRequest(new CertificateID(sha1, issuer, BigInteger.valueOf(17)));
|
||||
builder.addRequest(new CertificateID(sha256, issuer, BigInteger.valueOf(18)));
|
||||
byte[] nonce = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
|
||||
builder.setRequestExtensions(new Extensions(new Extension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce,
|
||||
false, new DEROctetString(new DEROctetString(nonce).getEncoded()))));
|
||||
byte[] encoded = builder.build().getEncoded();
|
||||
|
||||
OcspRequestParser.Parsed parsed = OcspRequestParser.parse(encoded, 2,
|
||||
Set.of(OcspResponseService.CertIdHash.SHA1, OcspResponseService.CertIdHash.SHA256), 32);
|
||||
assertEquals(2, parsed.requests().size());
|
||||
assertEquals(OcspResponseService.CertIdHash.SHA1, parsed.requests().get(0).hash());
|
||||
assertEquals(OcspResponseService.CertIdHash.SHA256, parsed.requests().get(1).hash());
|
||||
assertArrayEquals(nonce, parsed.nonce().orElseThrow());
|
||||
String get = Base64.getUrlEncoder().withoutPadding().encodeToString(encoded);
|
||||
assertArrayEquals(encoded, OcspRequestParser.decodeGet(get, encoded.length));
|
||||
System.out.println("...entries=2 nonce=echoable");
|
||||
System.out.println("parsesCanonicalSha1Sha256AndExactNonce...ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAlternateGetEncodingBoundsAndUnknownCriticalExtension() throws Exception {
|
||||
System.out.println("rejectsAlternateGetEncodingBoundsAndUnknownCriticalExtension");
|
||||
X509CertificateHolder issuer = certificate();
|
||||
DigestCalculator digest = new JcaDigestCalculatorProviderBuilder().build().get(CertificateID.HASH_SHA1);
|
||||
OCSPReqBuilder builder = new OCSPReqBuilder();
|
||||
builder.addRequest(new CertificateID(digest, issuer, BigInteger.ONE));
|
||||
builder.setRequestExtensions(new Extensions(new Extension(
|
||||
new org.bouncycastle.asn1.ASN1ObjectIdentifier("1.3.6.1.4.1.55555.1"), true,
|
||||
new DEROctetString(new byte[] { 5 }))));
|
||||
byte[] critical = builder.build().getEncoded();
|
||||
assertThrows(IllegalArgumentException.class, () -> OcspRequestParser.parse(critical, 1,
|
||||
Set.of(OcspResponseService.CertIdHash.SHA1), 32));
|
||||
assertThrows(IllegalArgumentException.class, () -> OcspRequestParser.parse(critical, 0,
|
||||
Set.of(OcspResponseService.CertIdHash.SHA1), 32));
|
||||
String canonical = Base64.getUrlEncoder().withoutPadding().encodeToString(critical);
|
||||
assertThrows(IllegalArgumentException.class, () -> OcspRequestParser.decodeGet(canonical + "=", 4096));
|
||||
assertThrows(IllegalArgumentException.class, () -> OcspRequestParser.decodeGet(canonical, 1));
|
||||
assertThrows(IllegalArgumentException.class, () -> OcspRequestParser.parse(
|
||||
java.util.Arrays.copyOf(critical, critical.length + 1), 1,
|
||||
Set.of(OcspResponseService.CertIdHash.SHA1), 32));
|
||||
System.out.println("...strict-get-and-der=true");
|
||||
System.out.println("rejectsAlternateGetEncodingBoundsAndUnknownCriticalExtension...ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsSignedRequestsWithoutTreatingSignaturesAsAuthority() throws Exception {
|
||||
System.out.println("rejectsSignedRequestsWithoutTreatingSignaturesAsAuthority");
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048);
|
||||
KeyPair keys = generator.generateKeyPair();
|
||||
X509CertificateHolder issuer = certificate(keys);
|
||||
DigestCalculator digest = new JcaDigestCalculatorProviderBuilder().build().get(CertificateID.HASH_SHA1);
|
||||
OCSPReqBuilder builder = new OCSPReqBuilder();
|
||||
builder.addRequest(new CertificateID(digest, issuer, BigInteger.ONE));
|
||||
builder.setRequestorName(new GeneralName(issuer.getSubject()));
|
||||
byte[] signed = builder.build(new JcaContentSignerBuilder("SHA256withRSA").build(keys.getPrivate()),
|
||||
new X509CertificateHolder[] { issuer }).getEncoded();
|
||||
assertThrows(IllegalArgumentException.class, () -> OcspRequestParser.parse(signed, 1,
|
||||
Set.of(OcspResponseService.CertIdHash.SHA1), 32));
|
||||
System.out.println("...signed-request-authority=false");
|
||||
System.out.println("rejectsSignedRequestsWithoutTreatingSignaturesAsAuthority...ok");
|
||||
}
|
||||
|
||||
private static X509CertificateHolder certificate() throws Exception {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048);
|
||||
return certificate(generator.generateKeyPair());
|
||||
}
|
||||
|
||||
private static X509CertificateHolder certificate(KeyPair keys) throws Exception {
|
||||
X500Name name = new X500Name("CN=OCSP Parser Issuer");
|
||||
X509v3CertificateBuilder builder = new X509v3CertificateBuilder(name, BigInteger.ONE,
|
||||
Date.from(Instant.parse("2025-01-01T00:00:00Z")),
|
||||
Date.from(Instant.parse("2030-01-01T00:00:00Z")), name,
|
||||
org.bouncycastle.asn1.x509.SubjectPublicKeyInfo.getInstance(keys.getPublic().getEncoded()));
|
||||
return builder.build(new JcaContentSignerBuilder("SHA256withRSA").build(keys.getPrivate()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user