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,318 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.application;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.bouncycastle.asn1.DEROctetString;
|
||||
import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers;
|
||||
import org.bouncycastle.asn1.x509.CRLReason;
|
||||
import org.bouncycastle.asn1.x509.Extension;
|
||||
import org.bouncycastle.asn1.x509.Extensions;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.cert.ocsp.BasicOCSPResp;
|
||||
import org.bouncycastle.cert.ocsp.BasicOCSPRespBuilder;
|
||||
import org.bouncycastle.cert.ocsp.CertificateID;
|
||||
import org.bouncycastle.cert.ocsp.CertificateStatus;
|
||||
import org.bouncycastle.cert.ocsp.OCSPResp;
|
||||
import org.bouncycastle.cert.ocsp.OCSPRespBuilder;
|
||||
import org.bouncycastle.cert.ocsp.SingleResp;
|
||||
import org.bouncycastle.cert.ocsp.RevokedStatus;
|
||||
import org.bouncycastle.cert.ocsp.RespID;
|
||||
import org.bouncycastle.cert.ocsp.UnknownStatus;
|
||||
import org.bouncycastle.operator.DigestCalculator;
|
||||
import org.bouncycastle.operator.DigestCalculatorProvider;
|
||||
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;
|
||||
|
||||
import zeroecho.core.spec.AlgorithmIdentity;
|
||||
import zeroecho.pki.api.PkiException;
|
||||
import zeroecho.pki.api.credential.Credential;
|
||||
import zeroecho.pki.api.revocation.RevocationReason;
|
||||
import zeroecho.pki.api.revocation.RevocationState;
|
||||
import zeroecho.pki.impl.core.async.PkiSigningBus;
|
||||
import zeroecho.pki.impl.framework.x509.bc.PkiBusContentSigner;
|
||||
import zeroecho.pki.spi.store.PkiStore;
|
||||
import zeroecho.pki.spi.store.RevocationView;
|
||||
|
||||
/** Store-backed, signing-bus-confined OCSP response application service. */
|
||||
@SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops", "PMD.CyclomaticComplexity",
|
||||
"PMD.PreserveStackTrace", "PMD.ExceptionAsFlowControl", "PMD.SignatureDeclareThrowsException",
|
||||
"PMD.NPathComplexity", "PMD.ControlStatementBraces", "PMD.UseVarargs" })
|
||||
final class DefaultOcspResponseService implements OcspResponseService {
|
||||
private final PkiStore store;
|
||||
private final PkiSigningBus bus;
|
||||
private final Duration signingTtl;
|
||||
private final Runnable requireOpen;
|
||||
|
||||
/* default */ DefaultOcspResponseService(PkiStore store, PkiSigningBus bus, Duration signingTtl,
|
||||
Runnable requireOpen) {
|
||||
this.store = Objects.requireNonNull(store, "store");
|
||||
this.bus = Objects.requireNonNull(bus, "bus");
|
||||
this.signingTtl = Objects.requireNonNull(signingTtl, "signingTtl");
|
||||
this.requireOpen = Objects.requireNonNull(requireOpen, "requireOpen");
|
||||
}
|
||||
|
||||
@Override public Response respond(Command command) {
|
||||
requireOpen.run();
|
||||
Objects.requireNonNull(command, "command");
|
||||
try {
|
||||
X509CertificateHolder issuer = certificate(command.issuerCredentialId());
|
||||
X509CertificateHolder responder = certificate(command.responderCredentialId());
|
||||
validateAuthority(command, issuer, responder);
|
||||
List<Resolved> resolved;
|
||||
long revision;
|
||||
String commitment;
|
||||
try (RevocationView view = store.openRevocationView()) {
|
||||
revision = view.revision();
|
||||
commitment = view.commitment();
|
||||
resolved = resolve(command, issuer, view);
|
||||
}
|
||||
int good = Math.toIntExact(resolved.stream().filter(value -> value.status() == CertificateStatus.GOOD)
|
||||
.count());
|
||||
int revoked = Math.toIntExact(resolved.stream().filter(value -> value.status() instanceof RevokedStatus)
|
||||
.count());
|
||||
int unknown = Math.subtractExact(resolved.size(), Math.addExact(good, revoked));
|
||||
return new Response(sign(command, responder, resolved), revision, commitment, good, revoked, unknown);
|
||||
} catch (Exception failure) {
|
||||
throw new PkiException("OCSP response generation failed: code=OCSP_RESPONSE_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void validateSigningBinding(zeroecho.pki.api.PkiId responderCredentialId,
|
||||
zeroecho.pki.api.KeyRef signingKeyRef, String signatureAlgorithm,
|
||||
Optional<String> signatureBindingId) {
|
||||
requireOpen.run();
|
||||
Objects.requireNonNull(responderCredentialId, "responderCredentialId");
|
||||
Objects.requireNonNull(signingKeyRef, "signingKeyRef");
|
||||
Objects.requireNonNull(signatureBindingId, "signatureBindingId");
|
||||
byte[] challenge = "ZeroEcho OCSP signing binding v1".getBytes(java.nio.charset.StandardCharsets.US_ASCII);
|
||||
try {
|
||||
X509CertificateHolder responder = certificate(responderCredentialId);
|
||||
AlgorithmIdentity identity = bus.authority().resolveIdentity(signatureAlgorithm);
|
||||
PkiBusContentSigner signer = signatureBindingId.isPresent()
|
||||
? new PkiBusContentSigner(bus, signingKeyRef, identity, signatureBindingId.orElseThrow(), signingTtl)
|
||||
: new PkiBusContentSigner(bus, signingKeyRef, identity, signingTtl);
|
||||
try (java.io.OutputStream output = signer.getOutputStream()) { output.write(challenge); }
|
||||
org.bouncycastle.operator.ContentVerifier verifier =
|
||||
new org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder().build(responder)
|
||||
.get(signer.getAlgorithmIdentifier());
|
||||
try (java.io.OutputStream output = verifier.getOutputStream()) { output.write(challenge); }
|
||||
if (!verifier.verify(signer.getSignature())) {
|
||||
throw new IOException("OCSP signing binding differs");
|
||||
}
|
||||
} catch (Exception failure) {
|
||||
throw new PkiException("OCSP signing binding validation failed: code=OCSP_SIGNING_BINDING_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
private List<Resolved> resolve(Command command, X509CertificateHolder issuer, RevocationView view)
|
||||
throws Exception {
|
||||
List<Resolved> result = new ArrayList<>(command.requests().size());
|
||||
DigestCalculatorProvider digests = new JcaDigestCalculatorProviderBuilder().build();
|
||||
for (CertId request : command.requests()) {
|
||||
DigestCalculator digest = digests.get(CertificateID.HASH_SHA1);
|
||||
if (request.hash() == CertIdHash.SHA256) {
|
||||
digest = digests.get(new org.bouncycastle.asn1.x509.AlgorithmIdentifier(
|
||||
org.bouncycastle.asn1.nist.NISTObjectIdentifiers.id_sha256));
|
||||
}
|
||||
CertificateID id = new CertificateID(digest, issuer, request.serial());
|
||||
if (!MessageDigest.isEqual(id.getIssuerNameHash(), request.issuerNameHash())
|
||||
|| !MessageDigest.isEqual(id.getIssuerKeyHash(), request.issuerKeyHash())) {
|
||||
result.add(new Resolved(id, new UnknownStatus()));
|
||||
continue;
|
||||
}
|
||||
Credential credential = store.getCredentialByIssuerAndSerial(command.issuerId(), request.serial())
|
||||
.orElse(null);
|
||||
if (credential == null) {
|
||||
result.add(new Resolved(id, new UnknownStatus()));
|
||||
continue;
|
||||
}
|
||||
CertificateStatus status = view.get(credential.credentialId())
|
||||
.map(record -> status(record.transition().state(), record.transition().time(),
|
||||
record.transition().permanentReason().orElse(RevocationReason.UNSPECIFIED)))
|
||||
.orElse(CertificateStatus.GOOD);
|
||||
result.add(new Resolved(id, status));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private byte[] sign(Command command, X509CertificateHolder responder, List<Resolved> resolved)
|
||||
throws Exception {
|
||||
DigestCalculatorProvider digests = new JcaDigestCalculatorProviderBuilder().build();
|
||||
BasicOCSPRespBuilder builder = command.responderId() == ResponderId.BY_NAME
|
||||
? new BasicOCSPRespBuilder(new RespID(responder.getSubject()))
|
||||
: new BasicOCSPRespBuilder(new RespID(responder.getSubjectPublicKeyInfo(),
|
||||
digests.get(CertificateID.HASH_SHA1)));
|
||||
for (Resolved item : resolved) {
|
||||
builder.addResponse(item.id(), item.status(), Date.from(command.thisUpdate()),
|
||||
Date.from(command.nextUpdate()), null);
|
||||
}
|
||||
if (command.nonce().isPresent()) {
|
||||
builder.setResponseExtensions(new Extensions(new Extension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce,
|
||||
false, new DEROctetString(new DEROctetString(command.nonce().orElseThrow()).getEncoded()))));
|
||||
}
|
||||
AlgorithmIdentity identity = bus.authority().resolveIdentity(command.signatureAlgorithm());
|
||||
PkiBusContentSigner signer = command.signatureBindingId().isPresent()
|
||||
? new PkiBusContentSigner(bus, command.signingKeyRef(), identity,
|
||||
command.signatureBindingId().orElseThrow(), signingTtl)
|
||||
: new PkiBusContentSigner(bus, command.signingKeyRef(), identity, signingTtl);
|
||||
X509CertificateHolder[] chain = new X509CertificateHolder[command.responseChain().size()];
|
||||
for (int index = 0; index < chain.length; index++) chain[index] = certificate(command.responseChain().get(index));
|
||||
BasicOCSPResp basic = builder.build(signer, chain, Date.from(command.producedAt()));
|
||||
OCSPResp outer = new OCSPRespBuilder().build(OCSPRespBuilder.SUCCESSFUL, basic);
|
||||
byte[] encoded = outer.getEncoded();
|
||||
OCSPResp decodedOuter = new OCSPResp(encoded);
|
||||
BasicOCSPResp decoded = (BasicOCSPResp) decodedOuter.getResponseObject();
|
||||
if (!Arrays.equals(encoded, decodedOuter.getEncoded())
|
||||
|| decodedOuter.getStatus() != OCSPRespBuilder.SUCCESSFUL
|
||||
|| decoded == null || !decoded.getProducedAt().equals(Date.from(command.producedAt()))
|
||||
|| !decoded.getResponderId().equals(basic.getResponderId())
|
||||
|| !decoded.getSignatureAlgorithmID().equals(signer.getAlgorithmIdentifier())
|
||||
|| !decoded.isSignatureValid(new org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder()
|
||||
.build(responder))
|
||||
|| !matchesResponses(decoded.getResponses(), resolved, command)
|
||||
|| !matchesCertificates(decoded.getCerts(), chain)
|
||||
|| !matchesNonce(decoded, command.nonce())) {
|
||||
throw new IOException("Generated OCSP response validation failed");
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
private static boolean matchesResponses(SingleResp[] decoded, List<Resolved> expected, Command command) {
|
||||
if (decoded.length != expected.size()) return false;
|
||||
for (int index = 0; index < decoded.length; index++) {
|
||||
SingleResp actual = decoded[index]; Resolved wanted = expected.get(index);
|
||||
if (!actual.getCertID().equals(wanted.id())
|
||||
|| !actual.getThisUpdate().equals(Date.from(command.thisUpdate()))
|
||||
|| !actual.getNextUpdate().equals(Date.from(command.nextUpdate()))
|
||||
|| !sameStatus(actual.getCertStatus(), wanted.status())) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean sameStatus(CertificateStatus actual, CertificateStatus expected) {
|
||||
if (actual == CertificateStatus.GOOD || expected == CertificateStatus.GOOD) {
|
||||
return actual == CertificateStatus.GOOD && expected == CertificateStatus.GOOD;
|
||||
}
|
||||
if (actual instanceof UnknownStatus && expected instanceof UnknownStatus) return true;
|
||||
if (actual instanceof RevokedStatus left && expected instanceof RevokedStatus right) {
|
||||
return left.getRevocationTime().equals(right.getRevocationTime())
|
||||
&& left.hasRevocationReason() == right.hasRevocationReason()
|
||||
&& (!left.hasRevocationReason() || left.getRevocationReason() == right.getRevocationReason());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean matchesCertificates(X509CertificateHolder[] decoded, X509CertificateHolder[] expected)
|
||||
throws IOException {
|
||||
if (decoded.length != expected.length) return false;
|
||||
for (int index = 0; index < decoded.length; index++) {
|
||||
if (!Arrays.equals(decoded[index].getEncoded(), expected[index].getEncoded())) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean matchesNonce(BasicOCSPResp decoded, Optional<byte[]> expected) {
|
||||
Extension extension = decoded.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce);
|
||||
if (expected.isEmpty()) return extension == null;
|
||||
if (extension == null || extension.isCritical()) return false;
|
||||
byte[] actual = org.bouncycastle.asn1.ASN1OctetString.getInstance(extension.getParsedValue()).getOctets();
|
||||
return MessageDigest.isEqual(actual, expected.orElseThrow());
|
||||
}
|
||||
|
||||
private void validateAuthority(Command command, X509CertificateHolder issuer,
|
||||
X509CertificateHolder responder) {
|
||||
zeroecho.pki.api.ca.IssuerGeneration generation = store.getIssuerGeneration(command.issuerId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("OCSP issuer is unavailable"));
|
||||
if (!generation.authorityId().equals(command.authorityId())
|
||||
|| !generation.credentialId().equals(command.issuerCredentialId())
|
||||
|| !command.signingKeyRef().equals(generation.signingKeyRef())
|
||||
&& command.responderCredentialId().equals(command.issuerCredentialId())) {
|
||||
throw new IllegalArgumentException("OCSP responder authority mismatch");
|
||||
}
|
||||
if (issuer.getSerialNumber().signum() <= 0 || responder.getSerialNumber().signum() <= 0) {
|
||||
throw new IllegalArgumentException("OCSP responder certificate is invalid");
|
||||
}
|
||||
if (!responder.isValidOn(Date.from(command.producedAt()))
|
||||
|| command.nextUpdate().isAfter(responder.getNotAfter().toInstant())) {
|
||||
throw new IllegalArgumentException("OCSP response exceeds responder signing validity");
|
||||
}
|
||||
}
|
||||
|
||||
private X509CertificateHolder certificate(zeroecho.pki.api.PkiId credentialId) throws IOException {
|
||||
try (PkiRepositoryContent content = new DefaultPkiRepository(store, requireOpen)
|
||||
.openCredential(credentialId); InputStream input = content.openStream()) {
|
||||
byte[] encoded = input.readNBytes(1_048_577);
|
||||
if (encoded.length > 1_048_576 || input.read() != -1) {
|
||||
throw new IOException("OCSP certificate exceeds its finite bound");
|
||||
}
|
||||
return new X509CertificateHolder(encoded);
|
||||
}
|
||||
}
|
||||
|
||||
private static CertificateStatus status(RevocationState state, java.time.Instant time,
|
||||
RevocationReason reason) {
|
||||
return switch (state) {
|
||||
case CLEAR -> CertificateStatus.GOOD;
|
||||
case HELD -> new RevokedStatus(Date.from(time), CRLReason.certificateHold);
|
||||
case PERMANENTLY_REVOKED -> new RevokedStatus(Date.from(time), reason(reason));
|
||||
};
|
||||
}
|
||||
|
||||
private static int reason(RevocationReason reason) {
|
||||
return switch (reason) {
|
||||
case KEY_COMPROMISE -> CRLReason.keyCompromise;
|
||||
case CA_COMPROMISE -> CRLReason.cACompromise;
|
||||
case AFFILIATION_CHANGED -> CRLReason.affiliationChanged;
|
||||
case SUPERSEDED -> CRLReason.superseded;
|
||||
case CESSATION_OF_OPERATION -> CRLReason.cessationOfOperation;
|
||||
case CERTIFICATE_HOLD -> CRLReason.certificateHold;
|
||||
case REMOVE_FROM_CRL -> CRLReason.removeFromCRL;
|
||||
case PRIVILEGE_WITHDRAWN -> CRLReason.privilegeWithdrawn;
|
||||
case AA_COMPROMISE -> CRLReason.aACompromise;
|
||||
case UNSPECIFIED -> CRLReason.unspecified;
|
||||
};
|
||||
}
|
||||
|
||||
private record Resolved(CertificateID id, CertificateStatus status) { }
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.math.BigInteger;
|
||||
|
||||
import org.bouncycastle.cert.X509CRLHolder;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
@@ -99,6 +100,12 @@ final class DefaultPkiRepository implements PkiRepository {
|
||||
return store.getCredential(Objects.requireNonNull(credentialId, "credentialId"));
|
||||
}
|
||||
|
||||
@Override public Optional<Credential> credential(PkiId issuerId, BigInteger serial) {
|
||||
requireOpen.run();
|
||||
return store.getCredentialByIssuerAndSerial(Objects.requireNonNull(issuerId, "issuerId"),
|
||||
Objects.requireNonNull(serial, "serial"));
|
||||
}
|
||||
|
||||
@Override public Optional<StatusObject> statusObject(PkiId statusObjectId) {
|
||||
requireOpen.run();
|
||||
return store.getStatusObject(Objects.requireNonNull(statusObjectId, "statusObjectId"));
|
||||
|
||||
@@ -110,6 +110,7 @@ final class DefaultPkiSession implements PkiSession {
|
||||
private final Optional<CertificationRequestService> requests;
|
||||
private final Optional<IssuanceService> issuance;
|
||||
private final Optional<StatusObjectService> statusObjects;
|
||||
private final Optional<OcspResponseService> ocsp;
|
||||
private final Optional<PublicationService> publications;
|
||||
private final Optional<PkiSigningBus> signingBus;
|
||||
private final Optional<SignatureWorkflow> signatureWorkflow;
|
||||
@@ -130,6 +131,8 @@ final class DefaultPkiSession implements PkiSession {
|
||||
this.requests = graph.requests();
|
||||
this.issuance = graph.issuance();
|
||||
this.statusObjects = graph.statusObjects();
|
||||
this.ocsp = graph.signingBus().map(bus -> new DefaultOcspResponseService(store, bus,
|
||||
configuration.signing().orElseThrow().signingTtl(), this::requireOpen));
|
||||
this.publications = graph.publications();
|
||||
this.signingBus = graph.signingBus();
|
||||
this.signatureWorkflow = graph.signatureWorkflow();
|
||||
@@ -155,6 +158,11 @@ final class DefaultPkiSession implements PkiSession {
|
||||
return open(configuration, dependencies, Clock.systemUTC(), ProductionBootstrap.INSTANCE);
|
||||
}
|
||||
|
||||
/* default */ static PkiSession open(PkiSessionConfiguration configuration,
|
||||
PkiSessionRuntimeDependencies dependencies, Clock clock) {
|
||||
return open(configuration, dependencies, clock, ProductionBootstrap.INSTANCE);
|
||||
}
|
||||
|
||||
/* default */ static PkiSession open(PkiSessionConfiguration configuration, Clock clock, Bootstrap bootstrap) {
|
||||
return open(configuration, PkiSessionRuntimeDependencies.none(), clock, bootstrap);
|
||||
}
|
||||
@@ -239,6 +247,8 @@ final class DefaultPkiSession implements PkiSession {
|
||||
return statusObjects;
|
||||
}
|
||||
|
||||
@Override public Optional<OcspResponseService> ocsp() { requireOpen(); return ocsp; }
|
||||
|
||||
@Override
|
||||
public Optional<PublicationService> publications() {
|
||||
requireOpen();
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.application;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import zeroecho.pki.api.KeyRef;
|
||||
import zeroecho.pki.api.PkiId;
|
||||
|
||||
/** Transport-neutral strict OCSP response generation through confined signing. */
|
||||
@SuppressWarnings("PMD.ControlStatementBraces")
|
||||
public interface OcspResponseService {
|
||||
/** Supported RFC CertID digest identities. */
|
||||
enum CertIdHash {
|
||||
SHA1(1), SHA256(2);
|
||||
|
||||
private final int code;
|
||||
|
||||
CertIdHash(int code) { this.code = code; }
|
||||
|
||||
/** Stable persistence code. */
|
||||
public int code() { return code; }
|
||||
|
||||
/** Resolves an exact stable persistence code. */
|
||||
public static CertIdHash fromCode(int code) {
|
||||
return switch (code) { case 1 -> SHA1; case 2 -> SHA256;
|
||||
default -> throw new IllegalArgumentException("Unknown OCSP CertID hash code"); };
|
||||
}
|
||||
}
|
||||
/** Exact responder identifier representation. */
|
||||
enum ResponderId {
|
||||
BY_NAME(1), BY_KEY(2);
|
||||
|
||||
private final int code;
|
||||
|
||||
ResponderId(int code) { this.code = code; }
|
||||
|
||||
/** Stable persistence code. */
|
||||
public int code() { return code; }
|
||||
|
||||
/** Resolves an exact stable persistence code. */
|
||||
public static ResponderId fromCode(int code) {
|
||||
return switch (code) { case 1 -> BY_NAME; case 2 -> BY_KEY;
|
||||
default -> throw new IllegalArgumentException("Unknown OCSP responder ID code"); };
|
||||
}
|
||||
}
|
||||
|
||||
/** One strictly parsed CertID. */
|
||||
record CertId(CertIdHash hash, byte[] issuerNameHash, byte[] issuerKeyHash, BigInteger serial) {
|
||||
/** Defensively owns all request values. */
|
||||
public CertId {
|
||||
Objects.requireNonNull(hash, "hash");
|
||||
issuerNameHash = Objects.requireNonNull(issuerNameHash, "issuerNameHash").clone();
|
||||
issuerKeyHash = Objects.requireNonNull(issuerKeyHash, "issuerKeyHash").clone();
|
||||
Objects.requireNonNull(serial, "serial");
|
||||
int length = hash == CertIdHash.SHA1 ? 20 : 32;
|
||||
if (issuerNameHash.length != length || issuerKeyHash.length != length
|
||||
|| serial.signum() <= 0 || serial.bitLength() > 160) {
|
||||
throw new IllegalArgumentException("OCSP CertID is invalid");
|
||||
}
|
||||
}
|
||||
@Override public byte[] issuerNameHash() { return issuerNameHash.clone(); }
|
||||
@Override public byte[] issuerKeyHash() { return issuerKeyHash.clone(); }
|
||||
}
|
||||
|
||||
/** Exact already-authorized responder generation command. */
|
||||
record Command(PkiId authorityId, PkiId issuerId, PkiId issuerCredentialId,
|
||||
PkiId responderCredentialId, KeyRef signingKeyRef, List<PkiId> responseChain,
|
||||
String signatureAlgorithm, Optional<String> signatureBindingId,
|
||||
ResponderId responderId, Instant producedAt, Instant thisUpdate, Instant nextUpdate,
|
||||
Optional<byte[]> nonce, List<CertId> requests) {
|
||||
/** Validates finite exact responder inputs. */
|
||||
public Command {
|
||||
Objects.requireNonNull(authorityId, "authorityId"); Objects.requireNonNull(issuerId, "issuerId");
|
||||
Objects.requireNonNull(issuerCredentialId, "issuerCredentialId");
|
||||
Objects.requireNonNull(responderCredentialId, "responderCredentialId");
|
||||
Objects.requireNonNull(signingKeyRef, "signingKeyRef");
|
||||
responseChain = List.copyOf(Objects.requireNonNull(responseChain, "responseChain"));
|
||||
if (responseChain.isEmpty() || responseChain.size() > 32) throw new IllegalArgumentException("OCSP chain is invalid");
|
||||
if (signatureAlgorithm == null || signatureAlgorithm.isBlank()) throw new IllegalArgumentException("OCSP algorithm is invalid");
|
||||
signatureBindingId = Objects.requireNonNull(signatureBindingId, "signatureBindingId");
|
||||
Objects.requireNonNull(responderId, "responderId"); Objects.requireNonNull(producedAt, "producedAt");
|
||||
Objects.requireNonNull(thisUpdate, "thisUpdate"); Objects.requireNonNull(nextUpdate, "nextUpdate");
|
||||
if (thisUpdate.isAfter(producedAt) || !nextUpdate.isAfter(thisUpdate)) throw new IllegalArgumentException("OCSP times are invalid");
|
||||
nonce = Objects.requireNonNull(nonce, "nonce").map(byte[]::clone);
|
||||
requests = List.copyOf(Objects.requireNonNull(requests, "requests"));
|
||||
if (requests.isEmpty()) throw new IllegalArgumentException("OCSP request is empty");
|
||||
}
|
||||
@Override public Optional<byte[]> nonce() { return nonce.map(byte[]::clone); }
|
||||
}
|
||||
|
||||
/** Signed canonical response and stable revocation provenance. */
|
||||
record Response(byte[] der, long revocationRevision, String revocationCommitment,
|
||||
int goodCount, int revokedCount, int unknownCount) {
|
||||
/** Defensively owns response DER. */
|
||||
public Response {
|
||||
der = Objects.requireNonNull(der, "der").clone();
|
||||
if (revocationRevision < 0 || revocationCommitment == null
|
||||
|| !revocationCommitment.matches("[0-9a-f]{64}") || goodCount < 0 || revokedCount < 0
|
||||
|| unknownCount < 0 || Math.addExact(Math.addExact(goodCount, revokedCount), unknownCount) == 0) {
|
||||
throw new IllegalArgumentException("OCSP provenance is invalid");
|
||||
}
|
||||
}
|
||||
@Override public byte[] der() { return der.clone(); }
|
||||
}
|
||||
|
||||
/** Resolves one stable view and signs exactly one response. */
|
||||
Response respond(Command command);
|
||||
|
||||
/**
|
||||
* Proves that one configured confined key capability signs as the exact
|
||||
* responder certificate before a durable responder is activated.
|
||||
*/
|
||||
void validateSigningBinding(PkiId responderCredentialId, KeyRef signingKeyRef,
|
||||
String signatureAlgorithm, Optional<String> signatureBindingId);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ package zeroecho.pki.application;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.math.BigInteger;
|
||||
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.api.ca.CaRecord;
|
||||
@@ -55,6 +56,12 @@ public interface PkiRepository {
|
||||
Optional<IssuerChainPath> chainPath(PkiId pathId);
|
||||
/** Returns an exact credential metadata record. */
|
||||
Optional<Credential> credential(PkiId credentialId);
|
||||
/** Returns an exact credential through the issuer-generation/serial index. */
|
||||
default Optional<Credential> credential(PkiId issuerId, BigInteger serial) {
|
||||
java.util.Objects.requireNonNull(issuerId, "issuerId");
|
||||
java.util.Objects.requireNonNull(serial, "serial");
|
||||
return Optional.empty();
|
||||
}
|
||||
/** Returns an exact status-object metadata record. */
|
||||
Optional<StatusObject> statusObject(PkiId statusObjectId);
|
||||
/** Opens validated immutable certificate content. */
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.application;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.util.Optional;
|
||||
|
||||
import zeroecho.pki.api.CaService;
|
||||
@@ -77,6 +78,20 @@ public interface PkiSession extends AutoCloseable {
|
||||
return DefaultPkiSession.open(configuration, dependencies);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a production session with explicit process-local capabilities and a
|
||||
* lifecycle clock shared by every time-dependent backend service.
|
||||
*
|
||||
* @param configuration validated immutable provider configuration
|
||||
* @param dependencies process-local key-access capabilities
|
||||
* @param clock authoritative session clock
|
||||
* @return opened lifecycle-owned session
|
||||
*/
|
||||
static PkiSession open(PkiSessionConfiguration configuration, PkiSessionRuntimeDependencies dependencies,
|
||||
Clock clock) {
|
||||
return DefaultPkiSession.open(configuration, dependencies, clock);
|
||||
}
|
||||
|
||||
/** @return profile lifecycle service owned by this session */
|
||||
ProfileService profiles();
|
||||
|
||||
@@ -103,6 +118,9 @@ public interface PkiSession extends AutoCloseable {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/** @return confined OCSP responder service, or empty when signing is unavailable */
|
||||
default Optional<OcspResponseService> ocsp() { return Optional.empty(); }
|
||||
|
||||
/** @return configured publication service, or empty when no destination is enabled */
|
||||
default Optional<PublicationService> publications() {
|
||||
return Optional.empty();
|
||||
|
||||
@@ -35,8 +35,12 @@ package zeroecho.pki.impl.fs;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.FileLock;
|
||||
@@ -108,6 +112,7 @@ import zeroecho.pki.api.content.DurableContentReference;
|
||||
import zeroecho.pki.api.content.DurableContentOwner;
|
||||
import zeroecho.pki.impl.ProfileLifecycleFailure;
|
||||
import zeroecho.pki.impl.ProfileLifecycleFailure.Code;
|
||||
import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialFramework;
|
||||
import zeroecho.pki.impl.core.async.PkiSigningBus;
|
||||
import zeroecho.pki.spi.store.PkiStore;
|
||||
import zeroecho.core.io.CancellationSignal;
|
||||
@@ -122,6 +127,7 @@ import zeroecho.pki.spi.store.StagedContentStore;
|
||||
import zeroecho.pki.spi.store.SignWorkflowStore;
|
||||
import zeroecho.pki.spi.store.TemporaryUniqueIndex;
|
||||
import zeroecho.pki.spi.store.RevocationSnapshot;
|
||||
import zeroecho.pki.spi.store.RevocationView;
|
||||
import zeroecho.pki.spi.store.RevocationHistory;
|
||||
|
||||
/**
|
||||
@@ -175,7 +181,9 @@ import zeroecho.pki.spi.store.RevocationHistory;
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity", "PMD.TooManyMethods",
|
||||
"PMD.ExcessivePublicCount", "PMD.UseTryWithResources", "PMD.ExceptionAsFlowControl",
|
||||
"PMD.PreserveStackTrace", "PMD.NcssCount", "PMD.AvoidDuplicateLiterals" })
|
||||
"PMD.PreserveStackTrace", "PMD.NcssCount", "PMD.AvoidDuplicateLiterals",
|
||||
"PMD.ControlStatementBraces", "PMD.CollapsibleIfStatements", "PMD.AvoidDeeplyNestedIfStmts",
|
||||
"PMD.AvoidLiteralsInIfCondition" })
|
||||
public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(FilesystemPkiStore.class.getName());
|
||||
@@ -186,6 +194,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
private static final String STATUS_RECORD_NAMESPACE = "io.zeroecho.pki.status-object-record";
|
||||
private static final String STATUS_OWNER_NAMESPACE = "io.zeroecho.pki.status-object-owner";
|
||||
private static final String PUBLICATION_RECORD_NAMESPACE = "io.zeroecho.pki.publication-record";
|
||||
private static final String CREDENTIAL_SERIAL_INDEX_NAMESPACE = "io.zeroecho.pki.credential-serial-index";
|
||||
private static final int CURRENT_SIGN_RECORD_VERSION = 2;
|
||||
private static final int SIGN_OWNER_VALUE_VERSION = 1;
|
||||
private static final int STATUS_OWNER_VALUE_VERSION = 1;
|
||||
@@ -294,6 +303,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
this.signingTimeWatermark = new AtomicLong(loadSigningTimeWatermark());
|
||||
this.historySeq = new AtomicLong(0L);
|
||||
recoverStagedContent();
|
||||
rebuildCredentialSerialIndex();
|
||||
recoverPublicationRecords();
|
||||
boolean snapshotRestore = requireSnapshotBoundary();
|
||||
openedRevocations = FilesystemRevocationAuthority.open(
|
||||
@@ -637,6 +647,10 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
writeOnce(paths.issuerGenerationPath(generation.issuerId()),
|
||||
FsCodec.encode(FsCodec.ISSUER_GENERATION, generation), "ISSUER_GENERATION",
|
||||
FsUtil.safeId(generation.issuerId()));
|
||||
x509Serial(credential).ifPresent(serial -> {
|
||||
requireSerialAvailable(generation.issuerId(), serial, generation.credentialId());
|
||||
ensureSerialIndex(generation.issuerId(), serial, generation.credentialId());
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -687,7 +701,11 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
public void putCredential(final Credential credential) {
|
||||
requireStoreUsable();
|
||||
Objects.requireNonNull(credential, "credential");
|
||||
Optional<BigInteger> serial = indexableSerial(credential);
|
||||
serial.ifPresent(value -> requireSerialAvailable(credential.issuerRef().issuerId(), value, credential));
|
||||
credentialContentTransactions.put(credential);
|
||||
serial.ifPresent(value -> ensureSerialIndex(credential.issuerRef().issuerId(), value,
|
||||
credential.credentialId()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -706,6 +724,184 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Credential> getCredentialByIssuerAndSerial(PkiId issuerId, BigInteger serial) {
|
||||
requireStoreUsable();
|
||||
Objects.requireNonNull(issuerId, "issuerId");
|
||||
requirePositiveSerial(serial);
|
||||
MetadataKey key = serialIndexKey(issuerId, serial);
|
||||
try (MetadataSnapshot snapshot = metadataStore.snapshot()) {
|
||||
Optional<MetadataSnapshot.Record> stored = snapshot.get(key);
|
||||
if (stored.isEmpty()) return Optional.empty();
|
||||
PkiId credentialId;
|
||||
try (MetadataSnapshot.Record record = stored.orElseThrow()) {
|
||||
credentialId = decodeSerialIndex(record, issuerId, serial);
|
||||
}
|
||||
Credential credential = getCredential(credentialId)
|
||||
.orElseThrow(() -> new IllegalStateException("Credential serial index target is missing"));
|
||||
if (!credential.issuerRef().issuerId().equals(issuerId)
|
||||
|| !x509Serial(credential).filter(serial::equals).isPresent()) {
|
||||
throw new IllegalStateException("Credential serial index authority mismatch");
|
||||
}
|
||||
return Optional.of(credential);
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Credential serial index is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private void rebuildCredentialSerialIndex() throws IOException {
|
||||
Path root = paths.root().resolve("credentials").resolve("by-id");
|
||||
if (!Files.isDirectory(root)) return;
|
||||
try (Stream<Path> records = Files.list(root)) {
|
||||
java.util.Iterator<Path> iterator = records
|
||||
.filter(path -> path.getFileName().toString().endsWith(".bin"))
|
||||
.sorted(Comparator.comparing(path -> path.getFileName().toString())).iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Credential credential = FsCodec.decode(FsCodec.CREDENTIAL,
|
||||
FsOperations.readAll(iterator.next()), stagedContent);
|
||||
Optional<BigInteger> serial = indexableSerial(credential);
|
||||
if (serial.isPresent()) {
|
||||
requireSerialAvailable(credential.issuerRef().issuerId(), serial.orElseThrow(), credential);
|
||||
ensureSerialIndex(credential.issuerRef().issuerId(), serial.orElseThrow(),
|
||||
credential.credentialId());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void requireSerialAvailable(PkiId issuerId, BigInteger serial, PkiId credentialId) {
|
||||
Credential candidate = getCredential(credentialId).orElse(null);
|
||||
requireSerialAvailable(issuerId, serial, candidate);
|
||||
}
|
||||
|
||||
private void requireSerialAvailable(PkiId issuerId, BigInteger serial, Credential candidate) {
|
||||
Optional<Credential> existing = getCredentialByIssuerAndSerial(issuerId, serial);
|
||||
if (existing.isPresent() && (candidate == null
|
||||
|| !existing.orElseThrow().credentialId().equals(candidate.credentialId()))) {
|
||||
if (candidate == null || !sameImmutableContent(existing.orElseThrow(), candidate)) {
|
||||
throw new IllegalStateException("Duplicate issuer-generation certificate serial");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureSerialIndex(PkiId issuerId, BigInteger serial, PkiId credentialId) {
|
||||
MetadataKey key = serialIndexKey(issuerId, serial);
|
||||
try (MetadataSnapshot snapshot = metadataStore.snapshot()) {
|
||||
Optional<MetadataSnapshot.Record> stored = snapshot.get(key);
|
||||
if (stored.isPresent()) {
|
||||
try (MetadataSnapshot.Record record = stored.orElseThrow()) {
|
||||
PkiId indexedId = decodeSerialIndex(record, issuerId, serial);
|
||||
if (!indexedId.equals(credentialId)) {
|
||||
Credential indexed = getCredential(indexedId).orElseThrow(
|
||||
() -> new IllegalStateException("Credential serial index target is missing"));
|
||||
Credential candidate = getCredential(credentialId).orElseThrow(
|
||||
() -> new IllegalStateException("Credential serial candidate is missing"));
|
||||
if (!sameImmutableContent(indexed, candidate)) {
|
||||
throw new IllegalStateException("Duplicate issuer-generation certificate serial");
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Credential serial index read failed");
|
||||
}
|
||||
try (MetadataTransaction transaction = metadataStore.beginTransaction()) {
|
||||
transaction.create(key, byteContent(encodeSerialIndex(issuerId, serial, credentialId)),
|
||||
CancellationSignal.NONE);
|
||||
MetadataCommitResult result = transaction.commit();
|
||||
if (result.outcome() == MetadataCommitResult.Outcome.COMMITTED) return;
|
||||
if (result.outcome() == MetadataCommitResult.Outcome.UNKNOWN) {
|
||||
durabilityUncertain.set(true);
|
||||
throw new PkiException("Store durability unconfirmed: code=STORE_DURABILITY_UNCONFIRMED");
|
||||
}
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Credential serial index persistence failed");
|
||||
}
|
||||
requireSerialAvailable(issuerId, serial, credentialId);
|
||||
}
|
||||
|
||||
private static boolean sameImmutableContent(Credential left, Credential right) {
|
||||
return left.content().storeId().equals(right.content().storeId())
|
||||
&& left.content().contentId().equals(right.content().contentId())
|
||||
&& left.content().sha256().equals(right.content().sha256())
|
||||
&& left.content().length() == right.content().length();
|
||||
}
|
||||
|
||||
private static Optional<BigInteger> x509Serial(Credential credential) {
|
||||
if (!BcX509CredentialFramework.FORMAT_ID.equals(credential.formatId())) return Optional.empty();
|
||||
try {
|
||||
BigInteger serial = new BigInteger(credential.serialOrUniqueId());
|
||||
if (serial.signum() <= 0 || serial.bitLength() > 160
|
||||
|| !serial.toString().equals(credential.serialOrUniqueId())) return Optional.empty();
|
||||
return Optional.of(serial);
|
||||
} catch (NumberFormatException failure) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<BigInteger> indexableSerial(Credential credential) {
|
||||
if (credential.issuerRef().issuerId().value().startsWith("issuer-unresolved:")
|
||||
|| getIssuerGeneration(credential.issuerRef().issuerId()).isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return x509Serial(credential);
|
||||
}
|
||||
|
||||
private static void requirePositiveSerial(BigInteger serial) {
|
||||
Objects.requireNonNull(serial, "serial");
|
||||
if (serial.signum() <= 0 || serial.bitLength() > 160) {
|
||||
throw new IllegalArgumentException("X.509 serial must be a positive value of at most 20 octets");
|
||||
}
|
||||
}
|
||||
|
||||
private static MetadataKey serialIndexKey(PkiId issuerId, BigInteger serial) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] issuer = issuerId.value().getBytes(StandardCharsets.UTF_8);
|
||||
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(issuer.length).array());
|
||||
digest.update(issuer);
|
||||
digest.update(serial.toByteArray());
|
||||
return new MetadataKey(CREDENTIAL_SERIAL_INDEX_NAMESPACE,
|
||||
HexFormat.of().formatHex(digest.digest()));
|
||||
} catch (java.security.NoSuchAlgorithmException impossible) {
|
||||
throw new IllegalStateException("SHA-256 unavailable", impossible);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] encodeSerialIndex(PkiId issuerId, BigInteger serial, PkiId credentialId) {
|
||||
try (ByteArrayOutputStream bytes = new ByteArrayOutputStream(256);
|
||||
DataOutputStream output = new DataOutputStream(bytes)) {
|
||||
output.writeInt(1);
|
||||
output.writeUTF(issuerId.value());
|
||||
output.writeUTF(serial.toString());
|
||||
output.writeUTF(credentialId.value());
|
||||
output.flush();
|
||||
return bytes.toByteArray();
|
||||
} catch (IOException impossible) {
|
||||
throw new IllegalStateException("Credential serial index encoding failed", impossible);
|
||||
}
|
||||
}
|
||||
|
||||
private static PkiId decodeSerialIndex(MetadataSnapshot.Record record, PkiId issuerId,
|
||||
BigInteger serial) throws IOException {
|
||||
byte[] encoded = readMetadataValue(record);
|
||||
if (encoded.length > 16_384) throw new IOException("Credential serial index exceeds its bound");
|
||||
try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded))) {
|
||||
if (input.readInt() != 1) throw new IOException("Credential serial index version is obsolete");
|
||||
PkiId storedIssuer = new PkiId(input.readUTF());
|
||||
String storedSerial = input.readUTF();
|
||||
PkiId credentialId = new PkiId(input.readUTF());
|
||||
if (input.read() != -1 || !storedIssuer.equals(issuerId)
|
||||
|| !serial.toString().equals(storedSerial)) {
|
||||
throw new IOException("Credential serial index authority mismatch");
|
||||
}
|
||||
return credentialId;
|
||||
} catch (IllegalArgumentException failure) {
|
||||
throw new IOException("Credential serial index is malformed", failure);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Credential> getCredentialByIssuanceIntent(IssuanceIntent intent) {
|
||||
requireStoreUsable();
|
||||
@@ -941,6 +1137,16 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public RevocationView openRevocationView() {
|
||||
requireStoreUsable();
|
||||
try {
|
||||
return revocations.view();
|
||||
} catch (IOException failure) {
|
||||
throw corruptRevocationState();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putStatusObject(final StatusObject object) {
|
||||
requireStoreUsable();
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.impl.fs;
|
||||
|
||||
@@ -24,10 +53,11 @@ import zeroecho.pki.api.revocation.RevocationTransition;
|
||||
import zeroecho.pki.spi.store.MetadataStoreId;
|
||||
import zeroecho.pki.spi.store.RevocationHistory;
|
||||
import zeroecho.pki.spi.store.RevocationSnapshot;
|
||||
import zeroecho.pki.spi.store.RevocationView;
|
||||
|
||||
/** Store-owned coordination of the authoritative log and its derived state. */
|
||||
@SuppressWarnings({ "PMD.CloseResource", "PMD.UseTryWithResources", "PMD.AvoidSynchronizedAtMethodLevel",
|
||||
"PMD.UnusedAssignment" })
|
||||
"PMD.UnusedAssignment", "PMD.ControlStatementBraces" })
|
||||
final class FilesystemRevocationAuthority implements AutoCloseable {
|
||||
|
||||
private static final FilesystemRevocationCurrentIndex.Configuration INDEX_CONFIGURATION =
|
||||
@@ -183,6 +213,21 @@ final class FilesystemRevocationAuthority implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
/* default */ RevocationView view() throws IOException {
|
||||
lifecycle.writeLock().lock();
|
||||
try {
|
||||
requireOperational();
|
||||
ensureIndex();
|
||||
FilesystemRevocationLog.RecoveryTarget head = log.recoveryTarget();
|
||||
if (index.coveredGlobalRevision() != head.globalRevision()) recoverIndex(head);
|
||||
DirectView view = new DirectView(this, head);
|
||||
view.acquire();
|
||||
return view;
|
||||
} finally {
|
||||
lifecycle.writeLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/* default */ FilesystemRevocationLog.RecoveryTarget head() throws IOException {
|
||||
lifecycle.readLock().lock();
|
||||
try {
|
||||
@@ -389,6 +434,36 @@ final class FilesystemRevocationAuthority implements AutoCloseable {
|
||||
void closeFromOwner() throws IOException;
|
||||
}
|
||||
|
||||
/** Read-lock-backed direct view; callers close it before external signing. */
|
||||
private static final class DirectView implements RevocationView {
|
||||
private final FilesystemRevocationAuthority owner;
|
||||
private final FilesystemRevocationLog.RecoveryTarget head;
|
||||
private boolean closed;
|
||||
|
||||
private DirectView(FilesystemRevocationAuthority owner,
|
||||
FilesystemRevocationLog.RecoveryTarget head) {
|
||||
this.owner = owner;
|
||||
this.head = head;
|
||||
}
|
||||
|
||||
private void acquire() { owner.lifecycle.readLock().lock(); }
|
||||
@Override public long revision() { requireOpen(); return head.globalRevision(); }
|
||||
@Override public String commitment() { requireOpen(); return head.globalCommitment().value(); }
|
||||
@Override public Optional<RevocationRecord> get(PkiId credentialId) throws IOException {
|
||||
requireOpen();
|
||||
return owner.index.lookup(Objects.requireNonNull(credentialId, "credentialId"))
|
||||
.map(FilesystemRevocationAuthority::record);
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
owner.lifecycle.readLock().unlock();
|
||||
}
|
||||
private void requireOpen() {
|
||||
if (closed) throw new IllegalStateException("Revocation view is closed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Immutable checkpoint-backed current-state view. */
|
||||
private static final class StableView implements RevocationSnapshot, OwnedResource {
|
||||
private final FilesystemRevocationAuthority owner;
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
package zeroecho.pki.spi.store;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -194,6 +195,18 @@ public interface PkiStore extends SignWorkflowStore, AutoCloseable {
|
||||
*/
|
||||
Optional<Credential> getCredential(PkiId credentialId);
|
||||
|
||||
/**
|
||||
* Resolves one exact X.509 credential through the derived issuer-generation
|
||||
* and canonical positive serial index. Implementations must validate the
|
||||
* returned authoritative credential and must not scan the credential
|
||||
* population for a lookup.
|
||||
*
|
||||
* @param issuerId exact issuer-generation identity
|
||||
* @param serial canonical positive X.509 serial
|
||||
* @return matching authoritative credential, when issued
|
||||
*/
|
||||
Optional<Credential> getCredentialByIssuerAndSerial(PkiId issuerId, BigInteger serial);
|
||||
|
||||
/**
|
||||
* Resolves the exact credential atomically persisted with one issuance intent.
|
||||
* Implementations must reject duplicate matches and mismatched command
|
||||
@@ -270,6 +283,9 @@ public interface PkiStore extends SignWorkflowStore, AutoCloseable {
|
||||
*/
|
||||
RevocationSnapshot openRevocationSnapshot();
|
||||
|
||||
/** Opens a stable direct-lookup current-state view for finite protocol work. */
|
||||
RevocationView openRevocationView();
|
||||
|
||||
/**
|
||||
* Persists a status object.
|
||||
*
|
||||
|
||||
52
pki/src/main/java/zeroecho/pki/spi/store/RevocationView.java
Normal file
52
pki/src/main/java/zeroecho/pki/spi/store/RevocationView.java
Normal file
@@ -0,0 +1,52 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.spi.store;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.api.revocation.RevocationRecord;
|
||||
|
||||
/** Stable direct-lookup view of one authoritative revocation log revision. */
|
||||
public interface RevocationView extends AutoCloseable {
|
||||
/** @return captured global revision */
|
||||
long revision();
|
||||
/** @return captured global commitment */
|
||||
String commitment();
|
||||
/** Returns one current state without scanning the revocation population. */
|
||||
Optional<RevocationRecord> get(PkiId credentialId) throws IOException;
|
||||
/** Releases the stable view. */
|
||||
@Override void close() throws IOException;
|
||||
}
|
||||
@@ -128,6 +128,7 @@ import zeroecho.pki.api.publication.PublicationTargetType;
|
||||
import zeroecho.pki.api.request.ParsedCertificationRequest;
|
||||
import zeroecho.pki.api.revocation.RevocationCommand;
|
||||
import zeroecho.pki.api.revocation.RevocationRecord;
|
||||
import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialFramework;
|
||||
import zeroecho.pki.api.revocation.RevocationReason;
|
||||
import zeroecho.pki.api.status.StatusObject;
|
||||
import zeroecho.pki.api.status.StatusObjectType;
|
||||
@@ -624,6 +625,39 @@ public final class FilesystemPkiStoreTest {
|
||||
System.out.println("caReferencesRequireValidStandaloneCredentialsAndPreserveOrder...ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void issuerSerialIndexSeparatesEqualSerialsAndRecoversOnRestart() throws Exception {
|
||||
System.out.println("issuerSerialIndexSeparatesEqualSerialsAndRecoversOnRestart");
|
||||
Path root = tmp.resolve("store-issuer-serial-index");
|
||||
PkiId firstIssuer;
|
||||
PkiId secondIssuer;
|
||||
PkiId firstCredential;
|
||||
PkiId secondCredential;
|
||||
try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) {
|
||||
CaRecord first = TestObjects.minimalCaRecord(store, "ca-index-one", CaState.ACTIVE);
|
||||
CaRecord second = TestObjects.minimalCaRecord(store, "ca-index-two", CaState.ACTIVE);
|
||||
store.putCa(first);
|
||||
store.putCa(second);
|
||||
firstIssuer = first.currentIssuanceIssuerId();
|
||||
secondIssuer = second.currentIssuanceIssuerId();
|
||||
firstCredential = store.getIssuerGeneration(firstIssuer).orElseThrow().credentialId();
|
||||
secondCredential = store.getIssuerGeneration(secondIssuer).orElseThrow().credentialId();
|
||||
assertEquals(firstCredential, store.getCredentialByIssuerAndSerial(firstIssuer, BigInteger.ONE)
|
||||
.orElseThrow().credentialId());
|
||||
assertEquals(secondCredential, store.getCredentialByIssuerAndSerial(secondIssuer, BigInteger.ONE)
|
||||
.orElseThrow().credentialId());
|
||||
assertTrue(store.getCredentialByIssuerAndSerial(firstIssuer, BigInteger.TWO).isEmpty());
|
||||
}
|
||||
try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) {
|
||||
assertEquals(firstCredential, reopened.getCredentialByIssuerAndSerial(firstIssuer, BigInteger.ONE)
|
||||
.orElseThrow().credentialId());
|
||||
assertEquals(secondCredential, reopened.getCredentialByIssuerAndSerial(secondIssuer, BigInteger.ONE)
|
||||
.orElseThrow().credentialId());
|
||||
}
|
||||
System.out.println("...same-serial-separated=true");
|
||||
System.out.println("issuerSerialIndexSeparatesEqualSerialsAndRecoversOnRestart...ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void oldStoreVersionIsRejected() throws Exception {
|
||||
System.out.println("oldStoreVersionIsRejected");
|
||||
@@ -1336,9 +1370,9 @@ public final class FilesystemPkiStoreTest {
|
||||
.build(keyPair.getPrivate())).getEncoded();
|
||||
zeroecho.pki.api.content.DurableContentReference content =
|
||||
zeroecho.pki.testkit.PkiTestRuntime.fixtureReference(store.stagedContent(), Encoding.DER, der);
|
||||
return new Credential(credentialId, new FormatId("fmt-x509"),
|
||||
return new Credential(credentialId, BcX509CredentialFramework.FORMAT_ID,
|
||||
new IssuerRef(authorityId, issuerId, pathId), subject,
|
||||
new Validity(notBefore, notAfter), "CA-" + authorityId.value(),
|
||||
new Validity(notBefore, notAfter), "1",
|
||||
new PkiId("pk-" + authorityId.value()),
|
||||
new CaProfileBinding(new CertificateProfileRef("profile-ca", 1, new byte[32])),
|
||||
CredentialStatus.ISSUED, content, emptyAttributes());
|
||||
|
||||
Reference in New Issue
Block a user