security(pki): enforce authoritative revocation state

This commit is contained in:
2026-07-30 10:11:26 +02:00
parent 8b2f3df41f
commit f06b25fa39
37 changed files with 3563 additions and 699 deletions

View File

@@ -33,7 +33,6 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.api; package zeroecho.pki.api;
import zeroecho.pki.api.revocation.RevokedRecord;
import zeroecho.pki.api.transfer.ExportArtifact; import zeroecho.pki.api.transfer.ExportArtifact;
import zeroecho.pki.api.transfer.ExportFormat; import zeroecho.pki.api.transfer.ExportFormat;
import zeroecho.pki.api.transfer.ExportQuery; import zeroecho.pki.api.transfer.ExportQuery;
@@ -73,17 +72,6 @@ public interface ImportExportService {
*/ */
PkiId importCaCertificate(PkiId caId, EncodedObject caCertificate, ImportPolicy policy); PkiId importCaCertificate(PkiId caId, EncodedObject caCertificate, ImportPolicy policy);
/**
* Imports a revocation record.
*
* @param record revocation record
* @param policy import policy
* @return imported revocation record id (implementation-defined)
* @throws IllegalArgumentException if inputs are invalid
* @throws PkiException if import fails
*/
PkiId importRevocation(RevokedRecord record, ImportPolicy policy);
/** /**
* Exports credentials matching the query constraints in the requested export * Exports credentials matching the query constraints in the requested export
* format. * format.

View File

@@ -1,99 +1,58 @@
/******************************************************************************* /*******************************************************************************
* Copyright (C) 2026, Leo Galambos * Copyright (C) 2026, Leo Galambos
* All rights reserved. * 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.api; package zeroecho.pki.api;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import zeroecho.pki.api.revocation.HoldCommand; import zeroecho.pki.api.revocation.RevocationCommand;
import zeroecho.pki.api.revocation.RevocationJournal;
import zeroecho.pki.api.revocation.RevocationQuery; import zeroecho.pki.api.revocation.RevocationQuery;
import zeroecho.pki.api.revocation.RevokeCommand;
import zeroecho.pki.api.revocation.RevokedRecord;
import zeroecho.pki.api.revocation.UnholdCommand;
/** /**
* Revocation operations and revocation record management. * Authoritative revocation transition and journal administration.
*/ */
public interface RevocationService { public interface RevocationService {
/**
* Revokes a credential.
*
* @param command revoke command
* @return revocation record
* @throws IllegalArgumentException if {@code command} is invalid
* @throws PkiException if revocation fails
*/
RevokedRecord revoke(RevokeCommand command);
/** /**
* Places a credential on hold. * Places a credential on hold.
* *
* @param command hold command * @param command hold command
* @return revocation record * @return committed journal
* @throws IllegalArgumentException if {@code command} is invalid
* @throws PkiException if hold fails
*/ */
RevokedRecord hold(HoldCommand command); RevocationJournal hold(RevocationCommand.Hold command);
/** /**
* Removes a hold from a credential. * Removes an existing hold.
* *
* @param command unhold command * @param command unhold command
* @return revocation record * @return committed journal
* @throws IllegalArgumentException if {@code command} is invalid
* @throws PkiException if unhold fails
*/ */
RevokedRecord unhold(UnholdCommand command); RevocationJournal unhold(RevocationCommand.Unhold command);
/** /**
* Retrieves revocation record for a credential. * Permanently revokes a credential.
* *
* @param credentialId credential id * @param command permanent revocation command
* @return record if present * @return committed journal
* @throws IllegalArgumentException if {@code credentialId} is null
* @throws PkiException if retrieval fails
*/ */
Optional<RevokedRecord> get(PkiId credentialId); RevocationJournal revokePermanently(RevocationCommand.RevokePermanently command);
/** /**
* Searches revocation records. * Retrieves the authoritative journal for one credential.
* *
* @param query query constraints * @param credentialId credential identifier
* @return matching records * @return journal when present
* @throws IllegalArgumentException if {@code query} is null
* @throws PkiException if search fails
*/ */
List<RevokedRecord> search(RevocationQuery query); Optional<RevocationJournal> get(PkiId credentialId);
/**
* Searches authoritative journals by their latest transition.
*
* @param query administrative query
* @return matching journals
*/
List<RevocationJournal> search(RevocationQuery query);
} }

View File

@@ -65,7 +65,11 @@ import zeroecho.pki.api.attr.AttributeSet;
* X.509) * X.509)
* @param publicKeyId stable identifier derived from the subject public key * @param publicKeyId stable identifier derived from the subject public key
* @param profileId profile governing issuance * @param profileId profile governing issuance
* @param status inventory status * @param status persisted issuance and inventory metadata; this value
* is not sufficient for a trust decision because current
* revocation state and evaluation time are external
* runtime inputs. Security-sensitive callers must use
* {@link EffectiveCredentialStatusResolver}.
* @param encoded encoded credential bytes * @param encoded encoded credential bytes
* @param attributes universal attribute set * @param attributes universal attribute set
*/ */

View File

@@ -37,8 +37,9 @@ package zeroecho.pki.api.credential;
* Status of a credential as tracked by PKI inventory. * Status of a credential as tracked by PKI inventory.
* *
* <p> * <p>
* Status may be computed from validity and revocation state or stored directly * This value is persisted issuance and inventory metadata. It is not an
* depending on implementation. * authoritative trust decision: current revocation state and evaluation time
* must be resolved through {@link EffectiveCredentialStatusResolver}.
* </p> * </p>
*/ */
public enum CredentialStatus { public enum CredentialStatus {

View File

@@ -31,36 +31,30 @@
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.api.revocation; package zeroecho.pki.api.credential;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.attr.AttributeSet;
/** /**
* Command to remove a hold from a credential. * Closed categories of currently reachable credential trust decisions.
*
* <p>
* Frameworks may map this to X.509 {@code removeFromCRL} or equivalent
* semantics.
* </p>
*
* @param credentialId credential identifier
* @param attributes optional additional attributes (may be empty but not
* null)
*/ */
public record UnholdCommand(PkiId credentialId, AttributeSet attributes) { public enum CredentialUse {
/** /**
* Creates an unhold command. * Issuer credential used for end-entity issuance.
*
* @throws IllegalArgumentException if inputs are null
*/ */
public UnholdCommand { END_ENTITY_ISSUER,
if (credentialId == null) {
throw new IllegalArgumentException("credentialId must not be null"); /**
} * Issuer credential used for intermediate CA issuance.
if (attributes == null) { */
throw new IllegalArgumentException("attributes must not be null"); INTERMEDIATE_ISSUER,
}
} /**
* Issuer credential used to sign a status object.
*/
STATUS_OBJECT_ISSUER,
/**
* Leaf credential delivered in a trusted credential bundle.
*/
BUNDLE_DELIVERY
} }

View File

@@ -31,36 +31,41 @@
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.api.revocation; package zeroecho.pki.api.credential;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.attr.AttributeSet;
/** /**
* Command to place a credential on hold. * Runtime status used when deciding whether a credential may participate in a
* security-sensitive operation.
* *
* <p> * <p>
* Frameworks may map this to X.509 {@code certificateHold} or equivalent * This status is derived from persisted inventory metadata, current revocation
* semantics. * state, and one authoritative evaluation time. It is not persisted.
* </p> * </p>
*
* @param credentialId credential identifier
* @param attributes optional additional attributes (may be empty but not
* null)
*/ */
public record HoldCommand(PkiId credentialId, AttributeSet attributes) { public enum EffectiveCredentialStatus {
/** /**
* Creates a hold command. * The credential is issued, unrevoked, and within its validity interval.
*
* @throws IllegalArgumentException if inputs are null
*/ */
public HoldCommand { USABLE,
if (credentialId == null) {
throw new IllegalArgumentException("credentialId must not be null"); /**
} * The credential validity interval has not started.
if (attributes == null) { */
throw new IllegalArgumentException("attributes must not be null"); NOT_YET_VALID,
}
} /**
* The credential is expired by metadata or evaluation time.
*/
EXPIRED,
/**
* The credential is temporarily held.
*/
HELD,
/**
* The credential is permanently revoked.
*/
PERMANENTLY_REVOKED
} }

View File

@@ -31,40 +31,64 @@
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.api.revocation; package zeroecho.pki.api.credential;
import zeroecho.pki.api.PkiId; import java.time.Instant;
import zeroecho.pki.api.attr.AttributeSet;
import zeroecho.pki.api.PkiException;
/** /**
* Command to revoke a credential. * Resolves authoritative runtime credential status for trust decisions.
* *
* <p> * <p>
* Additional revocation metadata (e.g., invalidity date) may be conveyed via * One {@link Evaluation} captures one authoritative instant and must be reused
* {@code attributes} using universal attribute definitions. * for every candidate considered by the same operation.
* </p> * </p>
*
* @param credentialId credential identifier to revoke
* @param reason revocation reason
* @param attributes additional revocation attributes (may be empty but not
* null)
*/ */
public record RevokeCommand(PkiId credentialId, RevocationReason reason, AttributeSet attributes) { @FunctionalInterface
public interface EffectiveCredentialStatusResolver {
/** /**
* Creates a revoke command. * Starts one status evaluation using one captured authoritative time.
* *
* @throws IllegalArgumentException if inputs are null * @return evaluation session
* @throws PkiException if the authoritative time cannot be obtained safely
*/ */
public RevokeCommand { Evaluation beginEvaluation();
if (credentialId == null) {
throw new IllegalArgumentException("credentialId must not be null"); /**
} * One immutable-time effective-status evaluation.
if (reason == null) { */
throw new IllegalArgumentException("reason must not be null"); interface Evaluation {
}
if (attributes == null) { /**
throw new IllegalArgumentException("attributes must not be null"); * Returns the authoritative instant captured for this evaluation.
} *
* @return evaluation instant
*/
Instant evaluationTime();
/**
* Resolves one credential using current revocation state and the captured
* evaluation time.
*
* @param credential credential to resolve
* @return effective runtime status
* @throws NullPointerException if {@code credential} is {@code null}
* @throws PkiException if current status cannot be resolved safely
*/
EffectiveCredentialStatus resolve(Credential credential);
/**
* Requires a credential to be usable for the supplied operation category.
*
* @param credential credential to check
* @param use trust-decision category
* @return the supplied credential when usable
* @throws NullPointerException if an argument is {@code null}
* @throws PkiException if status resolution fails or the credential is not
* usable
*/
Credential requireUsable(Credential credential, CredentialUse use);
} }
} }

View File

@@ -43,8 +43,8 @@
* <h2>Notes</h2> * <h2>Notes</h2>
* <ul> * <ul>
* <li>Credentials are treated as immutable artifacts once issued.</li> * <li>Credentials are treated as immutable artifacts once issued.</li>
* <li>Status values capture the operational lifecycle (e.g., issued, expired, * <li>Persisted inventory status does not replace runtime effective-status
* revoked, on hold).</li> * resolution for security-sensitive credential use.</li>
* </ul> * </ul>
* *
* @since 1.0 * @since 1.0

View File

@@ -0,0 +1,79 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.pki.api.revocation;
import java.util.Objects;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.attr.AttributeSet;
/**
* Closed, immutable commands accepted by the revocation state machine.
*/
public sealed interface RevocationCommand
permits RevocationCommand.Hold, RevocationCommand.Unhold, RevocationCommand.RevokePermanently {
/**
* Returns the target credential.
*
* @return credential identifier
*/
PkiId credentialId();
/**
* Returns an immutable snapshot of safe administrative metadata.
*
* @return administrative attributes
*/
AttributeSet attributes();
/**
* Places a credential on hold.
*
* @param credentialId credential identifier
* @param attributes safe administrative metadata
*/
record Hold(PkiId credentialId, AttributeSet attributes) implements RevocationCommand {
/** Validates and snapshots the command. */
public Hold {
Objects.requireNonNull(credentialId, "credentialId");
attributes = RevocationJournal.snapshot(attributes);
}
}
/**
* Removes an existing hold.
*
* @param credentialId credential identifier
* @param attributes safe administrative metadata
*/
record Unhold(PkiId credentialId, AttributeSet attributes) implements RevocationCommand {
/** Validates and snapshots the command. */
public Unhold {
Objects.requireNonNull(credentialId, "credentialId");
attributes = RevocationJournal.snapshot(attributes);
}
}
/**
* Permanently revokes a credential.
*
* @param credentialId credential identifier
* @param reason permanent revocation reason
* @param attributes safe administrative metadata
*/
record RevokePermanently(PkiId credentialId, RevocationReason reason, AttributeSet attributes)
implements RevocationCommand {
/** Validates and snapshots the command. */
public RevokePermanently {
Objects.requireNonNull(credentialId, "credentialId");
Objects.requireNonNull(reason, "reason");
if (reason == RevocationReason.CERTIFICATE_HOLD || reason == RevocationReason.REMOVE_FROM_CRL) {
throw new IllegalArgumentException("reason must be permanent");
}
attributes = RevocationJournal.snapshot(attributes);
}
}
}

View File

@@ -0,0 +1,170 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.pki.api.revocation;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.time.Instant;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.attr.AttributeId;
import zeroecho.pki.api.attr.AttributeSet;
import zeroecho.pki.api.attr.AttributeValue;
/**
* Immutable single-file authority for one credential's revocation lifecycle.
*
* @param credentialId credential namespace identity
* @param transitions ordered committed transitions
*/
public record RevocationJournal(PkiId credentialId, List<RevocationTransition> transitions) {
/** Current journal format version. */
public static final int CURRENT_VERSION = 1;
/**
* Creates an immutable journal snapshot.
*/
public RevocationJournal {
Objects.requireNonNull(credentialId, "credentialId");
Objects.requireNonNull(transitions, "transitions");
transitions = List.copyOf(transitions);
validate(transitions);
}
private static void validate(List<RevocationTransition> transitions) {
if (transitions.isEmpty()) {
throw new IllegalArgumentException("transitions must not be empty");
}
RevocationState previous = null;
Instant previousTime = null;
long expectedRevision = 1L;
for (RevocationTransition transition : transitions) {
if (transition.revision() != expectedRevision
|| previousTime != null && transition.time().isBefore(previousTime)
|| !validTransition(previous, transition)) {
throw new IllegalArgumentException("invalid revocation journal");
}
expectedRevision++;
previous = transition.state();
previousTime = transition.time();
}
}
private static boolean validTransition(RevocationState previous, RevocationTransition transition) {
boolean reasonValid = transition.state() == RevocationState.PERMANENTLY_REVOKED
? transition.permanentReason().filter(RevocationJournal::isPermanent).isPresent()
: transition.permanentReason().isEmpty();
if (!reasonValid || previous == RevocationState.PERMANENTLY_REVOKED) {
return false;
}
if (previous == null) {
return transition.state() == RevocationState.HELD
|| transition.state() == RevocationState.PERMANENTLY_REVOKED;
}
return switch (previous) {
case CLEAR -> transition.state() == RevocationState.HELD
|| transition.state() == RevocationState.PERMANENTLY_REVOKED;
case HELD -> transition.state() == RevocationState.CLEAR
|| transition.state() == RevocationState.PERMANENTLY_REVOKED;
case PERMANENTLY_REVOKED -> false;
};
}
private static boolean isPermanent(RevocationReason reason) {
return reason != RevocationReason.CERTIFICATE_HOLD && reason != RevocationReason.REMOVE_FROM_CRL;
}
/**
* Returns the last committed transition.
*
* @return last transition
* @throws IllegalStateException if the journal is empty
*/
public RevocationTransition latest() {
if (transitions.isEmpty()) {
throw new IllegalStateException("Revocation journal is empty");
}
return transitions.get(transitions.size() - 1);
}
// Defensive snapshotting necessarily allocates immutable entries while walking
// the caller-owned attribute collection.
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
/* default */ static AttributeSet snapshot(AttributeSet source) {
Objects.requireNonNull(source, "source");
List<SnapshotEntry> entries = new ArrayList<>();
for (AttributeId id : source.ids()) {
List<AttributeValue> values = new ArrayList<>();
for (AttributeValue value : source.getAll(id)) {
values.add(snapshotValue(value));
}
entries.add(new SnapshotEntry(id, List.copyOf(values)));
}
return new SnapshotAttributeSet(List.copyOf(entries));
}
private static AttributeValue snapshotValue(AttributeValue value) {
Objects.requireNonNull(value, "value");
if (value instanceof AttributeValue.BytesValue bytesValue) {
return new AttributeValue.BytesValue(bytesValue.value().clone());
}
return value;
}
/**
* One immutable attribute entry owned by the journal.
*/
private record SnapshotEntry(AttributeId id, List<AttributeValue> values) {
}
/**
* Immutable defensive implementation used for journal metadata ownership.
*/
private static final class SnapshotAttributeSet implements AttributeSet {
private final List<SnapshotEntry> entries;
private SnapshotAttributeSet(List<SnapshotEntry> entries) {
this.entries = entries;
}
@Override
public Set<AttributeId> ids() {
Set<AttributeId> ids = new LinkedHashSet<>();
for (SnapshotEntry entry : entries) {
ids.add(entry.id());
}
return Set.copyOf(ids);
}
@Override
public Optional<AttributeValue> get(AttributeId id) {
List<AttributeValue> values = getAll(id);
return values.isEmpty() ? Optional.empty() : Optional.of(values.get(0));
}
// Byte values are copied per accessor invocation so callers cannot mutate
// journal-owned storage.
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
@Override
public List<AttributeValue> getAll(AttributeId id) {
Objects.requireNonNull(id, "id");
for (SnapshotEntry entry : entries) {
if (entry.id().equals(id)) {
List<AttributeValue> copies = new ArrayList<>(entry.values().size());
for (AttributeValue value : entry.values()) {
copies.add(snapshotValue(value));
}
return List.copyOf(copies);
}
}
return List.of();
}
}
}

View File

@@ -0,0 +1,17 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.pki.api.revocation;
/**
* Effective state recorded by an authoritative revocation journal.
*/
public enum RevocationState {
/** Credential has no effective revocation restriction. */
CLEAR,
/** Credential is temporarily held. */
HELD,
/** Credential is permanently revoked. */
PERMANENTLY_REVOKED
}

View File

@@ -0,0 +1,33 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.pki.api.revocation;
import java.time.Instant;
import java.util.Optional;
import zeroecho.pki.api.attr.AttributeSet;
/**
* One committed transition in an authoritative revocation journal.
*
* @param revision positive contiguous revision
* @param state resulting state
* @param time authoritative transition time
* @param permanentReason permanent reason only for permanent state
* @param attributes safe administrative metadata
*/
public record RevocationTransition(long revision, RevocationState state, Instant time,
Optional<RevocationReason> permanentReason, AttributeSet attributes) {
/**
* Validates structural transition fields.
*/
public RevocationTransition {
if (revision <= 0L || state == null || time == null || permanentReason == null || attributes == null) {
throw new IllegalArgumentException("Invalid revocation transition");
}
attributes = RevocationJournal.snapshot(attributes);
}
}

View File

@@ -31,47 +31,44 @@
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.api.revocation; package zeroecho.pki.impl.core;
import java.time.Instant; import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.audit.AuditEvent;
import zeroecho.pki.api.attr.AttributeSet; import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.audit.Purpose;
import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialUse;
import zeroecho.pki.api.credential.EffectiveCredentialStatus;
import zeroecho.pki.spi.audit.AuditSink;
/** /**
* Persisted revocation record. * Emits one best-effort, redacted caller-level credential trust rejection.
*
* <p>
* This record is the authoritative input for generating status objects (CRLs,
* OCSP responses, or framework-specific revocation lists).
* </p>
*
* @param credentialId revoked credential id
* @param revocationTime server time when revocation was recorded
* @param reason revocation reason
* @param attributes additional revocation attributes (e.g., invalidity
* date), must not contain secrets
*/ */
public record RevokedRecord(PkiId credentialId, Instant revocationTime, RevocationReason reason, final class CredentialTrustAudit {
AttributeSet attributes) { private static final Principal SYSTEM_PKI = new Principal("SYSTEM", "pki");
private static final Purpose TRUST_PURPOSE = new Purpose("CREDENTIAL_TRUST");
/** private CredentialTrustAudit() {
* Creates a revocation record. // Utility class.
*
* @throws IllegalArgumentException if inputs are null
*/
public RevokedRecord {
if (credentialId == null) {
throw new IllegalArgumentException("credentialId must not be null");
} }
if (revocationTime == null) {
throw new IllegalArgumentException("revocationTime must not be null"); // Best-effort audit callbacks are untrusted; broad catch and empty handling preserve
} // the authoritative trust rejection without exposing listener diagnostics.
if (reason == null) { @SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.EmptyCatchBlock" })
throw new IllegalArgumentException("reason must not be null"); /* default */ static void rejected(AuditSink sink, Instant time, Credential credential, CredentialUse use,
} String code, EffectiveCredentialStatus status) {
if (attributes == null) { Map<String, String> details = status == null
throw new IllegalArgumentException("attributes must not be null"); ? Map.of("code", code, "operation", use.name())
: Map.of("code", code, "operation", use.name(), "effectiveStatus", status.name());
try {
sink.record(new AuditEvent(time, "CREDENTIAL_TRUST", "CREDENTIAL_REJECTED", SYSTEM_PKI, TRUST_PURPOSE,
Optional.of(credential.credentialId()), Optional.empty(), details));
} catch (RuntimeException auditFailure) {
// Best-effort auditing must not change the trust decision or expose sink data.
} }
} }
} }

View File

@@ -81,6 +81,9 @@ import zeroecho.pki.api.ca.IntermediateCertIssueCommand;
import zeroecho.pki.api.ca.IntermediateCreateCommand; import zeroecho.pki.api.ca.IntermediateCreateCommand;
import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialStatus; import zeroecho.pki.api.credential.CredentialStatus;
import zeroecho.pki.api.credential.CredentialUse;
import zeroecho.pki.api.credential.EffectiveCredentialStatus;
import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver;
import zeroecho.pki.impl.core.async.PkiSigningBus; import zeroecho.pki.impl.core.async.PkiSigningBus;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes; import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes;
@@ -152,6 +155,8 @@ public final class DefaultCaService implements CaService {
private final CredentialFramework framework; private final CredentialFramework framework;
private final CredentialIssuerBackend issuerBackend; private final CredentialIssuerBackend issuerBackend;
private final CaProofGate proofGate; private final CaProofGate proofGate;
private final AuditSink auditSink;
private final EffectiveCredentialStatusResolver statusResolver;
/** /**
* Creates a CA service bound to a specific store, credential framework, and * Creates a CA service bound to a specific store, credential framework, and
@@ -189,6 +194,8 @@ public final class DefaultCaService implements CaService {
* {@code null} * {@code null}
* @param auditSink required sink for safe CA proof rejection events; * @param auditSink required sink for safe CA proof rejection events;
* must not be {@code null} * must not be {@code null}
* @param statusResolver authoritative runtime credential-status resolver;
* must not be {@code null}
* @param signatureAlgorithmId non-blank JCA signature algorithm identifier used * @param signatureAlgorithmId non-blank JCA signature algorithm identifier used
* for certificate signing requests initiated by * for certificate signing requests initiated by
* this service * this service
@@ -203,14 +210,15 @@ public final class DefaultCaService implements CaService {
*/ */
public DefaultCaService(PkiStore store, CredentialFramework framework, CredentialIssuerBackend issuerBackend, public DefaultCaService(PkiStore store, CredentialFramework framework, CredentialIssuerBackend issuerBackend,
PublicKeyInfoResolver publicKeyResolver, PkiSigningBus signingBus, AuditSink auditSink, PublicKeyInfoResolver publicKeyResolver, PkiSigningBus signingBus, AuditSink auditSink,
String signatureAlgorithmId, Duration signingTtl) { EffectiveCredentialStatusResolver statusResolver, String signatureAlgorithmId, Duration signingTtl) {
this.store = Objects.requireNonNull(store, "store"); this.store = Objects.requireNonNull(store, "store");
this.framework = Objects.requireNonNull(framework, "framework"); this.framework = Objects.requireNonNull(framework, "framework");
this.issuerBackend = Objects.requireNonNull(issuerBackend, "issuerBackend"); this.issuerBackend = Objects.requireNonNull(issuerBackend, "issuerBackend");
Objects.requireNonNull(publicKeyResolver, "publicKeyResolver"); Objects.requireNonNull(publicKeyResolver, "publicKeyResolver");
Objects.requireNonNull(signingBus, "signingBus"); Objects.requireNonNull(signingBus, "signingBus");
Objects.requireNonNull(auditSink, "auditSink"); this.auditSink = Objects.requireNonNull(auditSink, "auditSink");
this.statusResolver = Objects.requireNonNull(statusResolver, "statusResolver");
if (signatureAlgorithmId == null || signatureAlgorithmId.isBlank()) { if (signatureAlgorithmId == null || signatureAlgorithmId.isBlank()) {
throw new IllegalArgumentException("signatureAlgorithmId must not be null/blank"); throw new IllegalArgumentException("signatureAlgorithmId must not be null/blank");
} }
@@ -406,6 +414,13 @@ public final class DefaultCaService implements CaService {
* generation is performed by this service. * generation is performed by this service.
* </p> * </p>
* *
* <p>
* Issuer credentials are evaluated against current revocation state and one
* authoritative evaluation instant before managed-key proof or signing work.
* Non-usable candidates are skipped; status-resolution failure aborts the
* operation.
* </p>
*
* @param command intermediate CA creation command; must not be {@code null} * @param command intermediate CA creation command; must not be {@code null}
* @return identifier of the newly persisted intermediate CA * @return identifier of the newly persisted intermediate CA
* @throws NullPointerException if {@code command} is {@code null} * @throws NullPointerException if {@code command} is {@code null}
@@ -430,6 +445,9 @@ public final class DefaultCaService implements CaService {
throw proofGate.rejection("CREATE_INTERMEDIATE_REJECTED", command.formatId(), Optional.empty(), throw proofGate.rejection("CREATE_INTERMEDIATE_REJECTED", command.formatId(), Optional.empty(),
"FORMAT_UNSUPPORTED"); "FORMAT_UNSUPPORTED");
} }
EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation();
Credential issuerCredential = CredentialSnapshots.copy(selectIssuerCredential(issuer, command.formatId(),
CredentialUse.INTERMEDIATE_ISSUER, statusEvaluation));
PkiId caId = new PkiId("ca:" + sha256Hex((issuer.caId().value() + "\n" + command.subjectRef().value()) PkiId caId = new PkiId("ca:" + sha256Hex((issuer.caId().value() + "\n" + command.subjectRef().value())
.getBytes(java.nio.charset.StandardCharsets.UTF_8)).substring(0, 16)); .getBytes(java.nio.charset.StandardCharsets.UTF_8)).substring(0, 16));
@@ -437,7 +455,6 @@ public final class DefaultCaService implements CaService {
CaProofGate.ManagedKeyProof subjectProof = proofGate.proveManagedKey(command.keyRef().get(), CaProofGate.ManagedKeyProof subjectProof = proofGate.proveManagedKey(command.keyRef().get(),
command.formatId(), "CREATE_INTERMEDIATE_REJECTED", Optional.of(caId)); command.formatId(), "CREATE_INTERMEDIATE_REJECTED", Optional.of(caId));
EncodedObject subjectSpki = subjectProof.exactPublicKey(); EncodedObject subjectSpki = subjectProof.exactPublicKey();
Credential issuerCredential = CredentialSnapshots.copy(selectIssuerCredential(issuer, command.formatId()));
requireIssuerKeyBinding(issuer, issuerCredential, command.formatId(), "CREATE_INTERMEDIATE_REJECTED", requireIssuerKeyBinding(issuer, issuerCredential, command.formatId(), "CREATE_INTERMEDIATE_REJECTED",
Optional.of(caId)); Optional.of(caId));
AttributeSet authoritative = authoritativeIntermediateAttributes(command.attributes(), issuer, AttributeSet authoritative = authoritativeIntermediateAttributes(command.attributes(), issuer,
@@ -473,6 +490,13 @@ public final class DefaultCaService implements CaService {
* stored and appended to the subject CA credential history. * stored and appended to the subject CA credential history.
* </p> * </p>
* *
* <p>
* Issuer credentials are evaluated against current revocation state and one
* authoritative evaluation instant before managed-key proof or signing work.
* Non-usable candidates are skipped; status-resolution failure aborts the
* operation.
* </p>
*
* @param command intermediate certificate issuance command; must not be * @param command intermediate certificate issuance command; must not be
* {@code null} * {@code null}
* @return issued credential persisted for the subject CA * @return issued credential persisted for the subject CA
@@ -491,11 +515,13 @@ public final class DefaultCaService implements CaService {
throw proofGate.rejection("ISSUE_INTERMEDIATE_REJECTED", command.formatId(), Optional.of(subject.caId()), throw proofGate.rejection("ISSUE_INTERMEDIATE_REJECTED", command.formatId(), Optional.of(subject.caId()),
"FORMAT_UNSUPPORTED"); "FORMAT_UNSUPPORTED");
} }
EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation();
Credential issuerCredential = CredentialSnapshots.copy(selectIssuerCredential(issuer, command.formatId(),
CredentialUse.INTERMEDIATE_ISSUER, statusEvaluation));
CaProofGate.ManagedKeyProof subjectProof = proofGate.proveManagedKey(subject.issuerKeyRef(), CaProofGate.ManagedKeyProof subjectProof = proofGate.proveManagedKey(subject.issuerKeyRef(),
command.formatId(), "ISSUE_INTERMEDIATE_REJECTED", Optional.of(subject.caId())); command.formatId(), "ISSUE_INTERMEDIATE_REJECTED", Optional.of(subject.caId()));
EncodedObject subjectSpki = subjectProof.exactPublicKey(); EncodedObject subjectSpki = subjectProof.exactPublicKey();
Credential issuerCredential = CredentialSnapshots.copy(selectIssuerCredential(issuer, command.formatId()));
requireIssuerKeyBinding(issuer, issuerCredential, command.formatId(), "ISSUE_INTERMEDIATE_REJECTED", requireIssuerKeyBinding(issuer, issuerCredential, command.formatId(), "ISSUE_INTERMEDIATE_REJECTED",
Optional.of(subject.caId())); Optional.of(subject.caId()));
AttributeSet authoritative = authoritativeIntermediateAttributes(command.attributes(), issuer, AttributeSet authoritative = authoritativeIntermediateAttributes(command.attributes(), issuer,
@@ -669,17 +695,33 @@ public final class DefaultCaService implements CaService {
} }
} }
private static Credential selectIssuerCredential(CaRecord issuer, FormatId formatId) { private Credential selectIssuerCredential(CaRecord issuer, FormatId formatId, CredentialUse use,
Instant now = Instant.now(); EffectiveCredentialStatusResolver.Evaluation evaluation) {
Credential lastRejected = null;
EffectiveCredentialStatus lastStatus = null;
for (Credential credential : issuer.caCredentials()) { for (Credential credential : issuer.caCredentials()) {
if (credential != null && formatId.equals(credential.formatId()) if (credential == null || !formatId.equals(credential.formatId())) {
&& credential.status() == CredentialStatus.ISSUED continue;
&& !now.isBefore(credential.validity().notBefore()) }
&& !now.isAfter(credential.validity().notAfter())) { EffectiveCredentialStatus status;
try {
status = evaluation.resolve(credential);
} catch (PkiException exception) {
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), credential, use,
StoreBackedEffectiveCredentialStatusResolver.RESOLUTION_FAILED_CODE, null);
throw exception;
}
if (status == EffectiveCredentialStatus.USABLE) {
return credential; return credential;
} }
lastRejected = credential;
lastStatus = status;
} }
throw new PkiException("Issuer CA has no current issued credential for formatId " + formatId.value()); if (lastRejected != null) {
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), lastRejected, use,
"ISSUER_CREDENTIAL_UNAVAILABLE", lastStatus);
}
throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE");
} }
private void requireValidImportedRoot(CaImportCommand command, X509CertificateHolder holder) { private void requireValidImportedRoot(CaImportCommand command, X509CertificateHolder holder) {

View File

@@ -62,6 +62,9 @@ import zeroecho.pki.api.ca.CaState;
import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialBundle; import zeroecho.pki.api.credential.CredentialBundle;
import zeroecho.pki.api.credential.CredentialStatus; import zeroecho.pki.api.credential.CredentialStatus;
import zeroecho.pki.api.credential.CredentialUse;
import zeroecho.pki.api.credential.EffectiveCredentialStatus;
import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver;
import zeroecho.pki.api.issuance.BundleCommand; import zeroecho.pki.api.issuance.BundleCommand;
import zeroecho.pki.api.issuance.IssueEndEntityCommand; import zeroecho.pki.api.issuance.IssueEndEntityCommand;
import zeroecho.pki.api.issuance.ReissueCommand; import zeroecho.pki.api.issuance.ReissueCommand;
@@ -98,9 +101,8 @@ import zeroecho.pki.spi.store.PkiStore;
* <ul> * <ul>
* <li>the issuer CA must exist,</li> * <li>the issuer CA must exist,</li>
* <li>the issuer CA must be in {@link CaState#ACTIVE} state,</li> * <li>the issuer CA must be in {@link CaState#ACTIVE} state,</li>
* <li>the issuer CA must expose a currently valid * <li>the issuer CA must expose an effectively usable credential for the active
* {@link CredentialStatus#ISSUED} credential for the active framework * framework {@link FormatId},</li>
* {@link FormatId},</li>
* <li>issuer material required by the current X.509 runtime wiring must be * <li>issuer material required by the current X.509 runtime wiring must be
* present in issuance overrides before the backend is invoked,</li> * present in issuance overrides before the backend is invoked,</li>
* <li>the backend result is defensively snapshotted and its X.509 subject key, * <li>the backend result is defensively snapshotted and its X.509 subject key,
@@ -150,6 +152,7 @@ public final class DefaultIssuanceService implements IssuanceService {
private final CredentialFramework framework; private final CredentialFramework framework;
private final CredentialIssuerBackend issuerBackend; private final CredentialIssuerBackend issuerBackend;
private final AuditSink auditSink; private final AuditSink auditSink;
private final EffectiveCredentialStatusResolver statusResolver;
/** /**
* Creates the issuance service bound to the supplied persistence and framework * Creates the issuance service bound to the supplied persistence and framework
@@ -163,14 +166,18 @@ public final class DefaultIssuanceService implements IssuanceService {
* gate-produced candidates; must not be {@code null} * gate-produced candidates; must not be {@code null}
* @param auditSink required sink for safe rejection audit events; must not be * @param auditSink required sink for safe rejection audit events; must not be
* {@code null} * {@code null}
* @param statusResolver authoritative runtime credential-status resolver; must
* not be {@code null}
* @throws NullPointerException if an argument is {@code null} * @throws NullPointerException if an argument is {@code null}
*/ */
public DefaultIssuanceService(PkiStore store, CredentialFramework framework, public DefaultIssuanceService(PkiStore store, CredentialFramework framework,
CredentialIssuerBackend issuerBackend, AuditSink auditSink) { CredentialIssuerBackend issuerBackend, AuditSink auditSink,
EffectiveCredentialStatusResolver statusResolver) {
this.store = Objects.requireNonNull(store, "store"); this.store = Objects.requireNonNull(store, "store");
this.framework = Objects.requireNonNull(framework, "framework"); this.framework = Objects.requireNonNull(framework, "framework");
this.issuerBackend = Objects.requireNonNull(issuerBackend, "issuerBackend"); this.issuerBackend = Objects.requireNonNull(issuerBackend, "issuerBackend");
this.auditSink = Objects.requireNonNull(auditSink, "auditSink"); this.auditSink = Objects.requireNonNull(auditSink, "auditSink");
this.statusResolver = Objects.requireNonNull(statusResolver, "statusResolver");
} }
/** /**
@@ -195,8 +202,9 @@ public final class DefaultIssuanceService implements IssuanceService {
* @return defensive snapshot of the validated credential bundle * @return defensive snapshot of the validated credential bundle
* @throws NullPointerException if {@code command} is {@code null} * @throws NullPointerException if {@code command} is {@code null}
* @throws PkiException if the issuer CA does not exist, is not active, * @throws PkiException if the issuer CA does not exist, is not active,
* has no credentials, no compatible issuer * has no credentials, no compatible effectively
* current issued credential can be selected, * usable issuer credential can be selected, or
* current revocation state cannot be resolved,
* issuer material enrichment fails, backend * issuer material enrichment fails, backend
* issuance or result validation fails, or * issuance or result validation fails, or
* persistence of the validated leaf fails * persistence of the validated leaf fails
@@ -204,7 +212,6 @@ public final class DefaultIssuanceService implements IssuanceService {
@Override @Override
public CredentialBundle issueEndEntity(IssueEndEntityCommand command) { public CredentialBundle issueEndEntity(IssueEndEntityCommand command) {
Objects.requireNonNull(command, "command"); Objects.requireNonNull(command, "command");
VerifiedIssuanceCandidate candidate = verifyIssuanceCandidate(command);
CaRecord issuer = store.getCa(command.issuerCaId()).orElseThrow(() -> new PkiException("Issuer CA not found")); CaRecord issuer = store.getCa(command.issuerCaId()).orElseThrow(() -> new PkiException("Issuer CA not found"));
if (issuer.state() != CaState.ACTIVE) { if (issuer.state() != CaState.ACTIVE) {
@@ -214,7 +221,10 @@ public final class DefaultIssuanceService implements IssuanceService {
throw new PkiException("Issuer CA has no credentials"); throw new PkiException("Issuer CA has no credentials");
} }
Credential issuerCred = CredentialSnapshots.copy(selectIssuerCredential(issuer, framework.formatId())); EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation();
Credential issuerCred = CredentialSnapshots.copy(selectIssuerCredential(issuer, framework.formatId(),
CredentialUse.END_ENTITY_ISSUER, statusEvaluation));
VerifiedIssuanceCandidate candidate = verifyIssuanceCandidate(command);
AttributeSet enrichedOverrides = enrichOverrides(command.overrides(), issuerCred.encoded(), AttributeSet enrichedOverrides = enrichOverrides(command.overrides(), issuerCred.encoded(),
issuer.issuerKeyRef()); issuer.issuerKeyRef());
@@ -243,15 +253,10 @@ public final class DefaultIssuanceService implements IssuanceService {
* format. * format.
* *
* <p> * <p>
* The selected credential must match the requested {@link FormatId}, have * The selected credential must match the requested {@link FormatId} and
* {@link CredentialStatus#ISSUED} status, and contain the current instant * resolve as effectively usable against current revocation state and the
* within its inclusive validity interval. No status or validity fallback is * operation's captured evaluation instant. Resolution failures abort
* permitted. * selection.
* </p>
*
* <p>
* This method does not evaluate profile suitability or revocation information
* external to {@link CredentialStatus}.
* </p> * </p>
* *
* @param issuer issuer CA record containing candidate credentials; must not * @param issuer issuer CA record containing candidate credentials; must not
@@ -261,24 +266,39 @@ public final class DefaultIssuanceService implements IssuanceService {
* @return selected issuer credential * @return selected issuer credential
* @throws NullPointerException if {@code issuer} or {@code formatId} is * @throws NullPointerException if {@code issuer} or {@code formatId} is
* {@code null} * {@code null}
* @throws PkiException if the issuer CA has no current issued * @throws PkiException if the issuer CA has no compatible effectively
* credential compatible with the requested format * usable credential or revocation resolution fails
*/ */
private static Credential selectIssuerCredential(CaRecord issuer, FormatId formatId) { private Credential selectIssuerCredential(CaRecord issuer, FormatId formatId, CredentialUse use,
EffectiveCredentialStatusResolver.Evaluation evaluation) {
Objects.requireNonNull(issuer, "issuer"); Objects.requireNonNull(issuer, "issuer");
Objects.requireNonNull(formatId, "formatId"); Objects.requireNonNull(formatId, "formatId");
Instant now = Instant.now(); Credential lastRejected = null;
EffectiveCredentialStatus lastStatus = null;
for (Credential c : issuer.caCredentials()) { for (Credential c : issuer.caCredentials()) {
if (c == null) { if (c == null || !formatId.equals(c.formatId())) {
continue; continue;
} }
if (formatId.equals(c.formatId()) && c.status() == CredentialStatus.ISSUED EffectiveCredentialStatus status;
&& !now.isBefore(c.validity().notBefore()) && !now.isAfter(c.validity().notAfter())) { try {
status = evaluation.resolve(c);
} catch (PkiException exception) {
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), c, use,
StoreBackedEffectiveCredentialStatusResolver.RESOLUTION_FAILED_CODE, null);
throw exception;
}
if (status == EffectiveCredentialStatus.USABLE) {
return c; return c;
} }
lastRejected = c;
lastStatus = status;
} }
throw new PkiException("Issuer CA has no current issued credential for formatId " + formatId.value()); if (lastRejected != null) {
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), lastRejected, use,
"ISSUER_CREDENTIAL_UNAVAILABLE", lastStatus);
}
throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE");
} }
/** /**
@@ -561,13 +581,30 @@ public final class DefaultIssuanceService implements IssuanceService {
* no chain elements * no chain elements
* @throws NullPointerException if {@code command} is {@code null} * @throws NullPointerException if {@code command} is {@code null}
* @throws PkiException if the requested credential does not exist in * @throws PkiException if the requested credential does not exist in
* the store * the store or is not currently usable
*/ */
@Override @Override
public CredentialBundle buildBundle(BundleCommand command) { public CredentialBundle buildBundle(BundleCommand command) {
Objects.requireNonNull(command, "command"); Objects.requireNonNull(command, "command");
PkiId credId = command.credentialId(); PkiId credId = command.credentialId();
Credential leaf = store.getCredential(credId).orElseThrow(() -> new PkiException("Credential not found")); Credential leaf = store.getCredential(credId).orElseThrow(() -> new PkiException("Credential not found"));
EffectiveCredentialStatusResolver.Evaluation evaluation = statusResolver.beginEvaluation();
EffectiveCredentialStatus status;
try {
status = evaluation.resolve(leaf);
} catch (PkiException exception) {
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), leaf,
CredentialUse.BUNDLE_DELIVERY,
StoreBackedEffectiveCredentialStatusResolver.RESOLUTION_FAILED_CODE, null);
throw exception;
}
if (status != EffectiveCredentialStatus.USABLE) {
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), leaf,
CredentialUse.BUNDLE_DELIVERY,
StoreBackedEffectiveCredentialStatusResolver.NOT_USABLE_CODE, status);
throw new PkiException("Credential trust rejected: code="
+ StoreBackedEffectiveCredentialStatusResolver.NOT_USABLE_CODE);
}
// Minimal bundle: leaf only. Chain selection and publication are higher-layer // Minimal bundle: leaf only. Chain selection and publication are higher-layer
// concerns. // concerns.
return new CredentialBundle(leaf, List.of()); return new CredentialBundle(leaf, List.of());

View File

@@ -1,288 +1,181 @@
/******************************************************************************* /*******************************************************************************
* Copyright (C) 2026, Leo Galambos * Copyright (C) 2026, Leo Galambos
* All rights reserved. * 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.core; package zeroecho.pki.impl.core;
import java.time.Clock;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.RevocationService; import zeroecho.pki.api.RevocationService;
import zeroecho.pki.api.revocation.HoldCommand; import zeroecho.pki.api.audit.AuditEvent;
import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.audit.Purpose;
import zeroecho.pki.api.revocation.RevocationCommand;
import zeroecho.pki.api.revocation.RevocationJournal;
import zeroecho.pki.api.revocation.RevocationQuery; import zeroecho.pki.api.revocation.RevocationQuery;
import zeroecho.pki.api.revocation.RevocationReason; import zeroecho.pki.api.revocation.RevocationTransition;
import zeroecho.pki.api.revocation.RevokeCommand; import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.api.revocation.RevokedRecord;
import zeroecho.pki.api.revocation.UnholdCommand;
import zeroecho.pki.spi.store.PkiStore; import zeroecho.pki.spi.store.PkiStore;
/** /**
* Default implementation of {@link RevocationService}. * Store-backed authoritative revocation transition service.
*
* <p>
* This service provides the store-backed runtime path for credential revocation
* state recording and lookup. It validates that the referenced credential
* exists, creates a corresponding {@link RevokedRecord}, and persists that
* record into the configured {@link PkiStore}.
* </p>
*
* <p>
* The implementation is intentionally minimal and does not attempt to enforce a
* complex revocation state machine. In particular, it does not prevent repeated
* writes for the same credential, does not reconcile prior revocation records,
* and does not validate higher-level business transitions such as whether a
* hold can be removed only after a prior hold. Such governance is expected to
* be handled by higher layers or by stricter store policies.
* </p>
*
* <h2>Persistence model</h2>
* <ul>
* <li>Each successful operation creates a new {@link RevokedRecord} timestamped
* with the current service time.</li>
* <li>The resulting record is written to the configured {@link PkiStore}
* through {@link PkiStore#putRevocation(RevokedRecord)}.</li>
* <li>Lookup and search operations read from the store and do not maintain an
* internal cache.</li>
* </ul>
*
* <h2>Security considerations</h2>
* <ul>
* <li>This service does not modify the credential object itself; it records
* revocation state separately.</li>
* <li>The trustworthiness of revocation status therefore depends on consumers
* consulting the revocation store or derivative status objects such as CRLs or
* OCSP-equivalent artifacts.</li>
* <li>Attributes carried in revocation commands are passed through to the
* stored record unchanged and should therefore be governed upstream to avoid
* leaking sensitive material.</li>
* </ul>
*
* <h2>Thread-safety</h2>
* <p>
* This class is stateless apart from its immutable store dependency. It is safe
* for concurrent use provided that the configured {@link PkiStore} is safe for
* the intended concurrency model.
* </p>
*/ */
public final class DefaultRevocationService implements RevocationService { public final class DefaultRevocationService implements RevocationService {
private static final Instant UNKNOWN_AUDIT_TIME = Instant.EPOCH;
private static final Principal SYSTEM_PKI = new Principal("SYSTEM", "pki");
private static final Purpose REVOCATION_PURPOSE = new Purpose("REVOCATION");
private static final List<String> SAFE_STORE_CODES = List.of(
"REVOCATION_CREDENTIAL_NOT_FOUND", "REVOCATION_TRANSITION_ILLEGAL", "REVOCATION_TERMINAL",
"REVOCATION_TRANSITION_CONFLICT", "REVOCATION_STATE_CORRUPT", "REVOCATION_PERSIST_FAILED",
"REVOCATION_DURABILITY_UNCONFIRMED", "STORE_DURABILITY_UNCONFIRMED");
private final PkiStore store; private final PkiStore store;
private final Clock clock;
private final AuditSink auditSink;
/** /**
* Creates the revocation service backed by the supplied PKI store. * Creates the service with one authoritative clock.
* *
* @param store PKI store used for credential existence checks, revocation * @param store authoritative store
* persistence, and revocation lookup; must not be {@code null} * @param clock authoritative clock
* @throws NullPointerException if {@code store} is {@code null} * @param auditSink best-effort audit sink
*/ */
public DefaultRevocationService(PkiStore store) { public DefaultRevocationService(PkiStore store, Clock clock, AuditSink auditSink) {
this.store = Objects.requireNonNull(store, "store"); this.store = Objects.requireNonNull(store, "store");
this.clock = Objects.requireNonNull(clock, "clock");
this.auditSink = Objects.requireNonNull(auditSink, "auditSink");
} }
/**
* Records a revocation entry for an existing credential using the caller-
* supplied revocation reason.
*
* <p>
* The referenced credential must already exist in the store. On success, this
* method creates a new {@link RevokedRecord} timestamped with
* {@link Instant#now()}, persists it, and returns the stored record.
* </p>
*
* <p>
* This method does not check whether the credential has already been revoked or
* whether the requested reason conflicts with a previously persisted
* revocation-state history.
* </p>
*
* @param command revocation request describing the target credential, reason,
* and auxiliary attributes; must not be {@code null}
* @return newly created revocation record
* @throws NullPointerException if {@code command} is {@code null}
* @throws PkiException if the referenced credential does not exist or
* if the revocation record cannot be persisted
*/
@Override @Override
public RevokedRecord revoke(RevokeCommand command) { public RevocationJournal hold(RevocationCommand.Hold command) {
Objects.requireNonNull(command, "command"); return transition(Objects.requireNonNull(command, "command"), "HOLD");
store.getCredential(command.credentialId()).orElseThrow(() -> new PkiException("Credential not found"));
RevokedRecord rr = new RevokedRecord(command.credentialId(), Instant.now(), command.reason(),
command.attributes());
store.putRevocation(rr);
return rr;
} }
/**
* Records a certificate-hold entry for an existing credential.
*
* <p>
* This method is a specialization of revocation recording that always uses
* {@link RevocationReason#CERTIFICATE_HOLD} as the persisted reason.
* </p>
*
* <p>
* The method does not verify whether the credential is already on hold or
* whether a prior revocation history would make the hold semantically invalid.
* </p>
*
* @param command hold request describing the target credential and auxiliary
* attributes; must not be {@code null}
* @return newly created hold record
* @throws NullPointerException if {@code command} is {@code null}
* @throws PkiException if the referenced credential does not exist or
* if the hold record cannot be persisted
*/
@Override @Override
public RevokedRecord hold(HoldCommand command) { public RevocationJournal unhold(RevocationCommand.Unhold command) {
Objects.requireNonNull(command, "command"); return transition(Objects.requireNonNull(command, "command"), "UNHOLD");
store.getCredential(command.credentialId()).orElseThrow(() -> new PkiException("Credential not found"));
RevokedRecord rr = new RevokedRecord(command.credentialId(), Instant.now(), RevocationReason.CERTIFICATE_HOLD,
command.attributes());
store.putRevocation(rr);
return rr;
} }
/**
* Records a removal-from-CRL entry for an existing credential.
*
* <p>
* This method is a specialization of revocation recording that always uses
* {@link RevocationReason#REMOVE_FROM_CRL} as the persisted reason.
* </p>
*
* <p>
* The method does not verify whether the credential was previously placed on
* hold or whether the resulting transition is semantically valid according to a
* stricter revocation-state model.
* </p>
*
* @param command unhold request describing the target credential and auxiliary
* attributes; must not be {@code null}
* @return newly created unhold record
* @throws NullPointerException if {@code command} is {@code null}
* @throws PkiException if the referenced credential does not exist or
* if the unhold record cannot be persisted
*/
@Override @Override
public RevokedRecord unhold(UnholdCommand command) { public RevocationJournal revokePermanently(RevocationCommand.RevokePermanently command) {
Objects.requireNonNull(command, "command"); return transition(Objects.requireNonNull(command, "command"), "REVOKE_PERMANENTLY");
store.getCredential(command.credentialId()).orElseThrow(() -> new PkiException("Credential not found"));
RevokedRecord rr = new RevokedRecord(command.credentialId(), Instant.now(), RevocationReason.REMOVE_FROM_CRL,
command.attributes());
store.putRevocation(rr);
return rr;
} }
/**
* Resolves the current revocation record associated with a credential
* identifier.
*
* <p>
* The exact meaning of the returned record depends on the configured
* {@link PkiStore} implementation. For stores that keep only one effective
* revocation entry per credential, this method returns that effective record.
* For stores with historical tracking, the SPI contract determines which record
* is exposed as the direct lookup result.
* </p>
*
* @param credentialId identifier of the credential whose revocation state is to
* be resolved; must not be {@code null}
* @return optional containing a revocation record when one is available for the
* credential, or an empty optional otherwise
* @throws NullPointerException if {@code credentialId} is {@code null}
*/
@Override @Override
public Optional<RevokedRecord> get(PkiId credentialId) { @SuppressWarnings("PMD.AvoidCatchingGenericException")
public Optional<RevocationJournal> get(PkiId credentialId) {
Objects.requireNonNull(credentialId, "credentialId"); Objects.requireNonNull(credentialId, "credentialId");
return store.getRevocation(credentialId); try {
return store.getRevocationJournal(credentialId);
} catch (RuntimeException failure) {
throw sanitized(failure);
}
} }
/**
* Searches revocation records matching the supplied query constraints.
*
* <p>
* Filtering is performed in memory over the revocation records returned by the
* configured {@link PkiStore}. Only constraints present in the
* {@link RevocationQuery} are applied.
* </p>
*
* <h4>Time filtering semantics</h4>
* <ul>
* <li>{@code revokedAfter}: records strictly earlier than the supplied instant
* are excluded; records exactly at the supplied instant are retained.</li>
* <li>{@code revokedBefore}: records at or after the supplied instant are
* excluded; only records strictly before the supplied instant are
* retained.</li>
* </ul>
*
* <p>
* When {@code issuerCaId} is present, the service resolves each revocation's
* credential from the store and compares its issuer CA identifier. Revocation
* records whose referenced credential can no longer be resolved are excluded
* from the search result for that branch of filtering.
* </p>
*
* @param query revocation search criteria; must not be {@code null}
* @return immutable list of revocation records matching the supplied criteria;
* never {@code null}
* @throws NullPointerException if {@code query} is {@code null}
*/
@Override @Override
public List<RevokedRecord> search(RevocationQuery query) { @SuppressWarnings("PMD.AvoidCatchingGenericException")
public List<RevocationJournal> search(RevocationQuery query) {
Objects.requireNonNull(query, "query"); Objects.requireNonNull(query, "query");
List<RevokedRecord> all = store.listRevocations(); try {
return all.stream().filter(r -> { return store.listRevocationJournals().stream().filter(journal -> matches(journal, query)).toList();
if (query.reason().isPresent() && query.reason().get() != r.reason()) { } catch (RuntimeException failure) {
throw sanitized(failure);
}
}
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
private RevocationJournal transition(RevocationCommand command, String operation) {
Instant time = null;
try {
time = Objects.requireNonNull(clock.instant(), "clock instant");
RevocationJournal journal = store.transitionRevocation(command, time);
auditSuccess(time, journal, operation);
return journal;
} catch (RuntimeException failure) {
PkiException sanitized = time == null
? new PkiException("Revocation operation failed: code=REVOCATION_TIME_UNAVAILABLE")
: sanitized(failure);
auditRejected(time == null ? UNKNOWN_AUDIT_TIME : time, command.credentialId(), operation,
codeOf(sanitized), time == null ? Optional.empty() : currentState(command));
throw sanitized;
}
}
private boolean matches(RevocationJournal journal, RevocationQuery query) {
RevocationTransition latest = journal.latest();
if (query.reason().isPresent() && !latest.permanentReason().filter(query.reason().get()::equals).isPresent()) {
return false; return false;
} }
if (query.revokedAfter().isPresent() && r.revocationTime().isBefore(query.revokedAfter().get())) { if (query.revokedAfter().isPresent() && latest.time().isBefore(query.revokedAfter().get())) {
return false; return false;
} }
if (query.revokedBefore().isPresent() && !r.revocationTime().isBefore(query.revokedBefore().get())) { if (query.revokedBefore().isPresent() && !latest.time().isBefore(query.revokedBefore().get())) {
return false; return false;
} }
if (query.issuerCaId().isPresent()) { return query.issuerCaId().isEmpty() || store.getCredential(journal.credentialId())
Optional<zeroecho.pki.api.credential.Credential> c = store.getCredential(r.credentialId()); .map(credential -> query.issuerCaId().get().equals(credential.issuerRef().caId())).orElse(false);
if (c.isEmpty()) {
return false;
} }
PkiId issuerId = c.get().issuerRef().caId();
return query.issuerCaId().get().equals(issuerId); @SuppressWarnings("PMD.AvoidCatchingGenericException")
private Optional<String> currentState(RevocationCommand command) {
try {
return store.getRevocationJournal(command.credentialId()).map(RevocationJournal::latest)
.map(transition -> transition.state().name());
} catch (RuntimeException ignored) {
return Optional.empty();
} }
return true; }
}).toList();
private void auditSuccess(Instant time, RevocationJournal journal, String operation) {
RevocationTransition latest = journal.latest();
audit(time, journal.credentialId(), operation, Map.of("result", "COMMITTED", "state", latest.state().name(),
"revision", Long.toString(latest.revision())));
}
private void auditRejected(Instant time, PkiId credentialId, String operation, String code,
Optional<String> currentState) {
Map<String, String> details = currentState
.map(state -> Map.of("result", "REJECTED", "code", code, "state", state))
.orElseGet(() -> Map.of("result", "REJECTED", "code", code));
audit(time, credentialId, operation, details);
}
// Audit listeners are external and best-effort; their failures cannot alter
// authoritative transition results.
@SuppressWarnings("PMD.AvoidCatchingGenericException")
private void audit(Instant time, PkiId credentialId, String operation, Map<String, String> details) {
try {
auditSink.record(new AuditEvent(time, "REVOCATION", operation, SYSTEM_PKI, REVOCATION_PURPOSE,
Optional.of(credentialId), Optional.empty(), details));
} catch (RuntimeException ignored) {
// Best-effort audit must not change revocation state.
}
}
// Store failures are hostile boundaries. Preserve only enumerated stable codes,
// never raw messages or causes.
private static PkiException sanitized(RuntimeException failure) {
String message = failure.getMessage();
if (message != null) {
for (String code : SAFE_STORE_CODES) {
if (message.contains("code=" + code)) {
return new PkiException("Revocation operation failed: code=" + code);
}
}
}
return new PkiException("Revocation operation failed: code=REVOCATION_STATE_UPDATE_FAILED");
}
private static String codeOf(PkiException failure) {
String message = failure.getMessage();
int marker = message.indexOf("code=");
return marker < 0 ? "REVOCATION_STATE_UPDATE_FAILED" : message.substring(marker + 5);
} }
} }

View File

@@ -34,29 +34,40 @@
package zeroecho.pki.impl.core; package zeroecho.pki.impl.core;
import java.math.BigInteger; import java.math.BigInteger;
import java.time.Instant;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.logging.Level; import java.util.Set;
import java.util.logging.Logger;
import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.cert.X509CertificateHolder;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.StatusObjectService; import zeroecho.pki.api.StatusObjectService;
import zeroecho.pki.api.attr.AttributeId;
import zeroecho.pki.api.attr.AttributeValue; import zeroecho.pki.api.attr.AttributeValue;
import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.ca.CaState; import zeroecho.pki.api.ca.CaState;
import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.revocation.RevokedRecord; import zeroecho.pki.api.credential.CredentialUse;
import zeroecho.pki.api.credential.EffectiveCredentialStatus;
import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver;
import zeroecho.pki.api.revocation.RevocationJournal;
import zeroecho.pki.api.revocation.RevocationReason;
import zeroecho.pki.api.revocation.RevocationState;
import zeroecho.pki.api.revocation.RevocationTransition;
import zeroecho.pki.api.status.StatusObject; import zeroecho.pki.api.status.StatusObject;
import zeroecho.pki.api.status.StatusObjectGenerateCommand; import zeroecho.pki.api.status.StatusObjectGenerateCommand;
import zeroecho.pki.api.status.StatusObjectQuery; import zeroecho.pki.api.status.StatusObjectQuery;
import zeroecho.pki.api.status.StatusObjectType; import zeroecho.pki.api.status.StatusObjectType;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes; import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes;
import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialFramework;
import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.spi.framework.CrlEntry;
import zeroecho.pki.spi.framework.CredentialFramework; import zeroecho.pki.spi.framework.CredentialFramework;
import zeroecho.pki.spi.store.PkiStore; import zeroecho.pki.spi.store.PkiStore;
@@ -68,15 +79,15 @@ import zeroecho.pki.spi.store.PkiStore;
* the configured {@link PkiStore} and delegates format-specific object creation * the configured {@link PkiStore} and delegates format-specific object creation
* to the active {@link CredentialFramework}. The store acts as the * to the active {@link CredentialFramework}. The store acts as the
* authoritative source of issuer CA state, issuer credentials, revocation * authoritative source of issuer CA state, issuer credentials, revocation
* records, and previously generated status objects. * journals, and previously generated status objects.
* </p> * </p>
* *
* <p> * <p>
* The current runtime implementation primarily supports generation workflows * The current runtime implementation primarily supports generation workflows
* that require issuer certificate material and issuer signing key indirection * that require issuer certificate material and issuer signing key indirection
* to be provided through status-object attributes. For X.509 CRL generation, * to be provided through status-object attributes. For X.509 CRL generation,
* this class additionally derives revoked certificate serial numbers from the * this class derives structured CRL entries from authoritative journals and the
* revocation and credential records currently available in the store. * referenced X.509 credentials.
* </p> * </p>
* *
* <h2>Persistence model</h2> * <h2>Persistence model</h2>
@@ -93,9 +104,9 @@ import zeroecho.pki.spi.store.PkiStore;
* <li>This service does not access private key material directly.</li> * <li>This service does not access private key material directly.</li>
* <li>Issuer signing capability is conveyed only through * <li>Issuer signing capability is conveyed only through
* {@link BcX509Attributes#ISSUER_KEYREF}.</li> * {@link BcX509Attributes#ISSUER_KEYREF}.</li>
* <li>For CRL generation, malformed credentials and unresolved revocation * <li>For CRL generation, every active journal and referenced target is
* targets are silently ignored by the current implementation rather than * validated before signing. Any unresolved or malformed state aborts generation
* failing the entire generation request.</li> * with a stable redacted failure.</li>
* <li>The correctness of the generated status object depends on the configured * <li>The correctness of the generated status object depends on the configured
* {@link CredentialFramework} matching the requested runtime format and * {@link CredentialFramework} matching the requested runtime format and
* honoring the supplied attributes.</li> * honoring the supplied attributes.</li>
@@ -109,23 +120,12 @@ import zeroecho.pki.spi.store.PkiStore;
* </p> * </p>
*/ */
public final class DefaultStatusObjectService implements StatusObjectService { public final class DefaultStatusObjectService implements StatusObjectService {
private static final Logger LOG = Logger.getLogger(DefaultStatusObjectService.class.getName()); private static final String CRL_GENERATION_FAILED = "CRL_GENERATION_FAILED";
/**
* Attribute identifier used to pass one revoked certificate serial number to
* the current X.509 CRL backend wiring.
*
* <p>
* The current implementation adds this attribute repeatedly during CRL input
* assembly. The effective handling of multiple values is therefore determined
* by the semantics of {@link SimpleAttributeSet.Builder} and by the downstream
* status object generator contract.
* </p>
*/
private static final AttributeId CRL_REVOKED_SERIAL = new AttributeId("urn:zeroecho:pki:x509:crl:revoked");
private final PkiStore store; private final PkiStore store;
private final CredentialFramework framework; private final CredentialFramework framework;
private final AuditSink auditSink;
private final EffectiveCredentialStatusResolver statusResolver;
/** /**
* Creates a status object service bound to the supplied persistence and * Creates a status object service bound to the supplied persistence and
@@ -136,12 +136,16 @@ public final class DefaultStatusObjectService implements StatusObjectService {
* not be {@code null} * not be {@code null}
* @param framework credential framework providing the format-specific status * @param framework credential framework providing the format-specific status
* object generator; must not be {@code null} * object generator; must not be {@code null}
* @throws NullPointerException if {@code store} or {@code framework} is * @param auditSink required sink for safe trust-rejection audit events
* {@code null} * @param statusResolver authoritative runtime credential-status resolver
* @throws NullPointerException if an argument is {@code null}
*/ */
public DefaultStatusObjectService(PkiStore store, CredentialFramework framework) { public DefaultStatusObjectService(PkiStore store, CredentialFramework framework, AuditSink auditSink,
EffectiveCredentialStatusResolver statusResolver) {
this.store = Objects.requireNonNull(store, "store"); this.store = Objects.requireNonNull(store, "store");
this.framework = Objects.requireNonNull(framework, "framework"); this.framework = Objects.requireNonNull(framework, "framework");
this.auditSink = Objects.requireNonNull(auditSink, "auditSink");
this.statusResolver = Objects.requireNonNull(statusResolver, "statusResolver");
} }
/** /**
@@ -150,9 +154,9 @@ public final class DefaultStatusObjectService implements StatusObjectService {
* *
* <p> * <p>
* The issuer CA must already exist, must be in {@link CaState#ACTIVE} state, * The issuer CA must already exist, must be in {@link CaState#ACTIVE} state,
* and must expose at least one CA credential. The current implementation uses * and must expose at least one usable credential matching the requested
* the last credential in {@link CaRecord#caCredentials()} as the effective * format. Candidates are evaluated newest-first against current revocation
* issuer credential for status-object generation. * state and one authoritative evaluation time.
* </p> * </p>
* *
* <p> * <p>
@@ -167,17 +171,17 @@ public final class DefaultStatusObjectService implements StatusObjectService {
* </ul> * </ul>
* *
* <p> * <p>
* When {@link StatusObjectType#CRL} is requested, the service additionally * When {@link StatusObjectType#CRL} is requested, the service validates every
* scans all revocation records in the store, excludes entries with * authoritative active journal before signing. Current hold and permanent
* {@code REMOVE_FROM_CRL}, resolves the referenced credentials, filters them to * states are transported with the exact positive X.509 serial, authoritative
* the requested issuer CA, extracts X.509 serial numbers, and forwards serials * transition time, and explicit reason. Current {@code CLEAR} states are
* that fit into a signed 64-bit integer as repeated {@link #CRL_REVOKED_SERIAL} * omitted.
* attributes.
* </p> * </p>
* *
* <p> * <p>
* Malformed credential payloads encountered during CRL input derivation are * Missing targets, malformed credentials, duplicate serials, future
* ignored by the current implementation and do not abort generation. * transitions, corrupt journals, and store failures abort the complete CRL
* before generator invocation or persistence with a stable redacted error.
* </p> * </p>
* *
* @param command status object generation request; must not be {@code null} * @param command status object generation request; must not be {@code null}
@@ -200,44 +204,144 @@ public final class DefaultStatusObjectService implements StatusObjectService {
if (ca.caCredentials().isEmpty()) { if (ca.caCredentials().isEmpty()) {
throw new PkiException("Issuer CA has no credentials"); throw new PkiException("Issuer CA has no credentials");
} }
Credential issuerCred = ca.caCredentials().get(ca.caCredentials().size() - 1); EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation();
Credential issuerCred = selectIssuerCredential(ca, command, statusEvaluation);
List<CrlEntry> crlEntries = command.type() == StatusObjectType.CRL
? collectCrlEntries(command.issuerCaId(), statusEvaluation.evaluationTime())
: List.of();
SimpleAttributeSet.Builder b = SimpleAttributeSet.builder(); SimpleAttributeSet.Builder b = SimpleAttributeSet.builder();
b.putAll(command.attributes()); b.putAll(command.attributes());
b.put(BcX509Attributes.ISSUER_CERT_DER, new AttributeValue.BytesValue(issuerCred.encoded().bytes())); b.put(BcX509Attributes.ISSUER_CERT_DER, new AttributeValue.BytesValue(issuerCred.encoded().bytes()));
b.put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue(ca.issuerKeyRef().value())); b.put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue(ca.issuerKeyRef().value()));
StatusObjectGenerateCommand wired = new StatusObjectGenerateCommand(command.issuerCaId(), command.type(),
command.formatId(), b.build());
if (command.type() == StatusObjectType.CRL) { if (command.type() == StatusObjectType.CRL) {
List<RevokedRecord> revs = store.listRevocations(); return generateAndPersistCrl(wired, crlEntries);
for (RevokedRecord rr : revs) {
if (rr.reason() == zeroecho.pki.api.revocation.RevocationReason.REMOVE_FROM_CRL) {
continue;
} }
Optional<Credential> credOpt = store.getCredential(rr.credentialId()); StatusObject obj = framework.statusObjectGenerator().generate(wired, crlEntries);
if (credOpt.isEmpty()) { store.putStatusObject(obj);
continue; return obj;
}
Credential cred = credOpt.get();
if (!command.issuerCaId().equals(cred.issuerRef().caId())) {
continue;
} }
// Framework, signing, and store failures may carry provider or persisted
// material. CRL generation deliberately replaces the complete boundary with
// one fresh cause-free and suppressed-free exception.
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
private StatusObject generateAndPersistCrl(StatusObjectGenerateCommand command, List<CrlEntry> entries) {
try { try {
X509CertificateHolder h = new X509CertificateHolder(cred.encoded().bytes()); // NOPMD StatusObject generated = framework.statusObjectGenerator().generate(command, entries);
BigInteger serial = h.getSerialNumber(); store.putStatusObject(generated);
if (serial.bitLength() <= 63) { // NOPMD return generated;
b.put(CRL_REVOKED_SERIAL, new AttributeValue.IntegerValue(serial.longValue())); // NOPMD } catch (RuntimeException exception) {
} throw crlGenerationFailure();
} catch (Exception ex) {
LOG.log(Level.FINE, "malformed credential ignored", ex);
}
} }
} }
StatusObjectGenerateCommand wired = new StatusObjectGenerateCommand(command.issuerCaId(), command.type(), // Store and parser failures may contain persisted material; the complete
command.formatId(), b.build()); // collection boundary deliberately replaces every cause with one stable code.
StatusObject obj = framework.statusObjectGenerator().generate(wired); @SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
store.putStatusObject(obj); private List<CrlEntry> collectCrlEntries(PkiId issuerCaId, Instant evaluationTime) {
return obj; try {
List<RevocationJournal> journals =
Objects.requireNonNull(store.listRevocationJournals(), "revocation journals");
List<CrlEntry> entries = new java.util.ArrayList<>();
Set<BigInteger> serials = new HashSet<>();
for (RevocationJournal journal : journals) {
collectCrlEntry(issuerCaId, evaluationTime, journal, serials).ifPresent(entries::add);
}
return List.copyOf(entries);
} catch (RuntimeException exception) {
throw crlGenerationFailure();
}
}
private Optional<CrlEntry> collectCrlEntry(PkiId issuerCaId, Instant evaluationTime,
RevocationJournal journal, Set<BigInteger> serials) {
Objects.requireNonNull(journal, "journal");
RevocationTransition latest = Objects.requireNonNull(journal.latest(), "latest transition");
if (latest.time().isAfter(evaluationTime)) {
throw crlGenerationFailure();
}
if (latest.state() == RevocationState.CLEAR) {
return Optional.empty();
}
Credential credential = store.getCredential(journal.credentialId()).orElseThrow(
DefaultStatusObjectService::crlGenerationFailure);
if (!issuerCaId.equals(credential.issuerRef().caId())) {
return Optional.empty();
}
if (!BcX509CredentialFramework.FORMAT_ID.equals(credential.formatId())
|| credential.encoded().encoding() != Encoding.DER) {
throw crlGenerationFailure();
}
BigInteger serial = certificateSerial(credential);
if (!serials.add(serial)) {
throw crlGenerationFailure();
}
RevocationReason reason = switch (latest.state()) {
case HELD -> RevocationReason.CERTIFICATE_HOLD;
case PERMANENTLY_REVOKED -> latest.permanentReason().orElseThrow(
DefaultStatusObjectService::crlGenerationFailure);
case CLEAR -> throw crlGenerationFailure();
};
return Optional.of(new CrlEntry(serial, latest.time(), reason));
}
// Parser failures can contain persisted certificate details; the original
// cause is intentionally removed at this public service boundary.
@SuppressWarnings("PMD.PreserveStackTrace")
private static BigInteger certificateSerial(Credential credential) {
byte[] der = credential.encoded().bytes();
BigInteger serial;
try {
serial = new X509CertificateHolder(der).getSerialNumber();
} catch (Exception exception) {
throw crlGenerationFailure();
} finally {
Arrays.fill(der, (byte) 0);
}
if (serial == null || serial.signum() <= 0) {
throw crlGenerationFailure();
}
return serial;
}
private static PkiException crlGenerationFailure() {
return new PkiException("CRL generation failed: code=" + CRL_GENERATION_FAILED);
}
private Credential selectIssuerCredential(CaRecord ca, StatusObjectGenerateCommand command,
EffectiveCredentialStatusResolver.Evaluation evaluation) {
Credential lastRejected = null;
EffectiveCredentialStatus lastStatus = null;
List<Credential> credentials = ca.caCredentials();
for (int index = credentials.size() - 1; index >= 0; index--) {
Credential credential = credentials.get(index);
if (credential == null || !command.formatId().equals(credential.formatId())) {
continue;
}
EffectiveCredentialStatus status;
try {
status = evaluation.resolve(credential);
} catch (PkiException exception) {
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), credential,
CredentialUse.STATUS_OBJECT_ISSUER,
StoreBackedEffectiveCredentialStatusResolver.RESOLUTION_FAILED_CODE, null);
throw exception;
}
if (status == EffectiveCredentialStatus.USABLE) {
return credential;
}
lastRejected = credential;
lastStatus = status;
}
if (lastRejected != null) {
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), lastRejected,
CredentialUse.STATUS_OBJECT_ISSUER, "ISSUER_CREDENTIAL_UNAVAILABLE", lastStatus);
}
throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE");
} }
/** /**

View File

@@ -0,0 +1,200 @@
/*******************************************************************************
* 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.core;
import java.time.Clock;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialStatus;
import zeroecho.pki.api.credential.CredentialUse;
import zeroecho.pki.api.credential.EffectiveCredentialStatus;
import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver;
import zeroecho.pki.api.revocation.RevocationJournal;
import zeroecho.pki.api.revocation.RevocationState;
import zeroecho.pki.api.revocation.RevocationTransition;
import zeroecho.pki.spi.store.PkiStore;
/**
* Store-backed authoritative resolver for runtime credential status.
*
* <p>
* Revocation lookup errors and inconsistent current records fail closed with a
* stable redacted error. The resolver never falls back to persisted inventory
* status when revocation state cannot be read safely.
* </p>
*/
public final class StoreBackedEffectiveCredentialStatusResolver implements EffectiveCredentialStatusResolver {
/**
* Stable status-resolution failure code.
*/
public static final String RESOLUTION_FAILED_CODE = "CREDENTIAL_STATUS_RESOLUTION_FAILED";
/**
* Stable unusable-credential failure code.
*/
public static final String NOT_USABLE_CODE = "CREDENTIAL_NOT_USABLE";
private final PkiStore store;
private final Clock clock;
/**
* Creates a resolver using the supplied authoritative store and clock.
*
* @param store current PKI store
* @param clock authoritative status-evaluation clock
* @throws NullPointerException if an argument is {@code null}
*/
public StoreBackedEffectiveCredentialStatusResolver(PkiStore store, Clock clock) {
this.store = Objects.requireNonNull(store, "store");
this.clock = Objects.requireNonNull(clock, "clock");
}
// Clock implementations are external boundaries; their causes are deliberately
// removed from the stable redacted status-resolution exception.
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
@Override
public Evaluation beginEvaluation() {
Instant evaluationTime;
try {
evaluationTime = Objects.requireNonNull(clock.instant(), "clock instant");
} catch (RuntimeException exception) {
throw resolutionFailure();
}
return new StoreEvaluation(evaluationTime);
}
/**
* Evaluation session bound to one captured authoritative instant.
*/
private final class StoreEvaluation implements Evaluation {
private final Instant evaluationTime;
private StoreEvaluation(Instant evaluationTime) {
this.evaluationTime = evaluationTime;
}
@Override
public Instant evaluationTime() {
return evaluationTime;
}
@Override
public EffectiveCredentialStatus resolve(Credential credential) {
Objects.requireNonNull(credential, "credential");
RevocationTransition revocation = currentRevocation(credential);
return resolveStatus(credential, revocation);
}
// Store implementations are external boundaries; their causes are deliberately
// removed from the stable redacted status-resolution exception.
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
private RevocationTransition currentRevocation(Credential credential) {
Optional<RevocationJournal> current;
try {
current = store.getRevocationJournal(credential.credentialId());
} catch (RuntimeException exception) {
throw resolutionFailure();
}
if (current == null) {
throw resolutionFailure();
}
if (current.isEmpty()) {
return null;
}
RevocationJournal journal = current.get();
if (!credential.credentialId().equals(journal.credentialId())) {
throw resolutionFailure();
}
RevocationTransition latest;
try {
latest = journal.latest();
} catch (RuntimeException exception) {
throw resolutionFailure();
}
if (latest.time().isAfter(evaluationTime)) {
throw resolutionFailure();
}
return latest;
}
private EffectiveCredentialStatus resolveStatus(Credential credential, RevocationTransition revocation) {
if (credential.status() == CredentialStatus.REVOKED) {
return EffectiveCredentialStatus.PERMANENTLY_REVOKED;
}
if (revocation != null) {
if (revocation.state() == RevocationState.PERMANENTLY_REVOKED) {
return EffectiveCredentialStatus.PERMANENTLY_REVOKED;
}
if (revocation.state() == RevocationState.HELD) {
return EffectiveCredentialStatus.HELD;
}
}
return resolveValidity(credential, revocation);
}
private EffectiveCredentialStatus resolveValidity(Credential credential, RevocationTransition revocation) {
if (credential.status() == CredentialStatus.EXPIRED
|| evaluationTime.isAfter(credential.validity().notAfter())) {
return EffectiveCredentialStatus.EXPIRED;
}
if (evaluationTime.isBefore(credential.validity().notBefore())) {
return EffectiveCredentialStatus.NOT_YET_VALID;
}
if (credential.status() == CredentialStatus.ISSUED
&& (revocation == null || revocation.state() == RevocationState.CLEAR)) {
return EffectiveCredentialStatus.USABLE;
}
throw resolutionFailure();
}
@Override
public Credential requireUsable(Credential credential, CredentialUse use) {
Objects.requireNonNull(use, "use");
if (resolve(credential) != EffectiveCredentialStatus.USABLE) {
throw new PkiException("Credential trust rejected: code=" + NOT_USABLE_CODE);
}
return credential;
}
}
private static PkiException resolutionFailure() {
return new PkiException("Credential status resolution failed: code=" + RESOLUTION_FAILED_CODE);
}
}

View File

@@ -278,12 +278,14 @@ public final class BcX509CredentialFramework implements CredentialFramework {
* status-object generator has been wired. * status-object generator has been wired.
* *
* @param command ignored command parameter * @param command ignored command parameter
* @param crlEntries ignored structured CRL entries
* @return never returns normally * @return never returns normally
* @throws UnsupportedOperationException always * @throws UnsupportedOperationException always
*/ */
@Override @Override
public zeroecho.pki.api.status.StatusObject generate( public zeroecho.pki.api.status.StatusObject generate(
zeroecho.pki.api.status.StatusObjectGenerateCommand command) { zeroecho.pki.api.status.StatusObjectGenerateCommand command,
java.util.List<zeroecho.pki.spi.framework.CrlEntry> crlEntries) {
throw new UnsupportedOperationException("X.509 status object generator not wired"); throw new UnsupportedOperationException("X.509 status object generator not wired");
} }
} }

View File

@@ -33,17 +33,19 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.impl.framework.x509.bc; package zeroecho.pki.impl.framework.x509.bc;
import java.math.BigInteger;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date; import java.util.Date;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.AuthorityKeyIdentifier; import org.bouncycastle.asn1.x509.AuthorityKeyIdentifier;
import org.bouncycastle.asn1.x509.CRLReason;
import org.bouncycastle.asn1.x509.Extension; import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.cert.X509CRLHolder; import org.bouncycastle.cert.X509CRLHolder;
import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.cert.X509CertificateHolder;
@@ -57,14 +59,14 @@ import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.attr.AttributeId;
import zeroecho.pki.api.attr.AttributeSet; import zeroecho.pki.api.attr.AttributeSet;
import zeroecho.pki.api.attr.AttributeValue; import zeroecho.pki.api.attr.AttributeValue;
import zeroecho.pki.api.attr.AttributeValue.IntegerValue; import zeroecho.pki.api.revocation.RevocationReason;
import zeroecho.pki.api.status.StatusObject; import zeroecho.pki.api.status.StatusObject;
import zeroecho.pki.api.status.StatusObjectGenerateCommand; import zeroecho.pki.api.status.StatusObjectGenerateCommand;
import zeroecho.pki.api.status.StatusObjectType; import zeroecho.pki.api.status.StatusObjectType;
import zeroecho.pki.impl.core.async.PkiSigningBus; import zeroecho.pki.impl.core.async.PkiSigningBus;
import zeroecho.pki.spi.framework.CrlEntry;
import zeroecho.pki.spi.framework.StatusObjectGenerator; import zeroecho.pki.spi.framework.StatusObjectGenerator;
/** /**
@@ -73,8 +75,8 @@ import zeroecho.pki.spi.framework.StatusObjectGenerator;
* <p> * <p>
* The current implementation supports only generation of X.509 certificate * The current implementation supports only generation of X.509 certificate
* revocation lists (CRLs). It assembles a version 2 CRL from issuer-side wiring * revocation lists (CRLs). It assembles a version 2 CRL from issuer-side wiring
* attributes, optional revoked-certificate serial numbers supplied through the * attributes, structured {@link CrlEntry} values, and delegated signing
* attribute set, and delegated signing performed via {@link PkiSigningBus}. * performed via {@link PkiSigningBus}.
* </p> * </p>
* *
* <p> * <p>
@@ -86,10 +88,7 @@ import zeroecho.pki.spi.framework.StatusObjectGenerator;
* {@link AttributeValue.BytesValue} containing the issuer certificate in DER * {@link AttributeValue.BytesValue} containing the issuer certificate in DER
* form,</li> * form,</li>
* <li>{@link BcX509Attributes#ISSUER_KEYREF} as an * <li>{@link BcX509Attributes#ISSUER_KEYREF} as an
* {@link AttributeValue.StringValue} identifying the issuer signing key,</li> * {@link AttributeValue.StringValue} identifying the issuer signing key.</li>
* <li>optionally one or more {@link #CRL_REVOKED_SERIAL} values as
* {@link AttributeValue.IntegerValue} instances representing serial numbers of
* revoked certificates.</li>
* </ul> * </ul>
* *
* <p> * <p>
@@ -105,10 +104,11 @@ import zeroecho.pki.spi.framework.StatusObjectGenerator;
* *
* <h2>Revoked-entry handling</h2> * <h2>Revoked-entry handling</h2>
* <p> * <p>
* Revoked certificate serial numbers are read from all values associated with * Structured entries preserve the full positive {@code BigInteger} serial,
* {@link #CRL_REVOKED_SERIAL}. Only {@link IntegerValue} entries with a * authoritative transition instant, and exact supported revocation reason.
* positive numeric value are included as CRL entries. Non-positive serials and * X.509 {@code Date} carries millisecond precision; this generator deliberately
* non-integer attribute values are ignored. * truncates each transition instant to whole seconds only at the DER CRL-entry
* boundary.
* </p> * </p>
* *
* <h2>Update semantics</h2> * <h2>Update semantics</h2>
@@ -143,18 +143,6 @@ import zeroecho.pki.spi.framework.StatusObjectGenerator;
*/ */
public final class BcX509StatusObjectGenerator implements StatusObjectGenerator { public final class BcX509StatusObjectGenerator implements StatusObjectGenerator {
/**
* Attribute identifier used to carry one revoked certificate serial number for
* CRL generation.
*
* <p>
* Multiple values associated with this identifier may be supplied in the input
* {@link AttributeSet}. Each positive {@link IntegerValue} contributes one CRL
* entry.
* </p>
*/
private static final AttributeId CRL_REVOKED_SERIAL = new AttributeId("urn:zeroecho:pki:x509:crl:revoked");
private final PkiSigningBus signingBus; private final PkiSigningBus signingBus;
private final String signatureAlgorithmId; private final String signatureAlgorithmId;
private final Duration signingTtl; private final Duration signingTtl;
@@ -212,6 +200,7 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
* </ul> * </ul>
* *
* @param command status object generation command; must not be {@code null} * @param command status object generation command; must not be {@code null}
* @param crlEntries structured current CRL entries; must not be {@code null}
* @return generated CRL status object * @return generated CRL status object
* @throws IllegalArgumentException if {@code command} is {@code null}, if the * @throws IllegalArgumentException if {@code command} is {@code null}, if the
* requested status object type is unsupported, * requested status object type is unsupported,
@@ -223,8 +212,9 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
* if signing fails, or if CRL encoding fails * if signing fails, or if CRL encoding fails
*/ */
@Override @Override
public StatusObject generate(StatusObjectGenerateCommand command) { public StatusObject generate(StatusObjectGenerateCommand command, List<CrlEntry> crlEntries) {
validateCommandOrThrow(command); validateCommandOrThrow(command);
List<CrlEntry> validatedEntries = validateEntries(crlEntries);
IssuerMaterial issuerMaterial = extractIssuerMaterialOrThrow(command.attributes()); IssuerMaterial issuerMaterial = extractIssuerMaterialOrThrow(command.attributes());
Instant thisUpdate = Instant.now(); Instant thisUpdate = Instant.now();
@@ -232,7 +222,7 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
Date thisUpdateDate = Date.from(thisUpdate); Date thisUpdateDate = Date.from(thisUpdate);
X509v2CRLBuilder builder = newCrlBuilder(issuerMaterial.issuerHolder(), thisUpdateDate, nextUpdate); X509v2CRLBuilder builder = newCrlBuilder(issuerMaterial.issuerHolder(), thisUpdateDate, nextUpdate);
addRevokedEntries(builder, command.attributes(), thisUpdateDate); addRevokedEntries(builder, validatedEntries);
addAuthorityKeyIdentifierOrThrow(builder, issuerMaterial.issuerHolder()); addAuthorityKeyIdentifierOrThrow(builder, issuerMaterial.issuerHolder());
X509CRLHolder crl = buildSignedCrlOrThrow(builder, issuerMaterial.keyRef()); X509CRLHolder crl = buildSignedCrlOrThrow(builder, issuerMaterial.keyRef());
@@ -241,6 +231,11 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
return toStatusObject(command, crlDer, thisUpdate, nextUpdate); return toStatusObject(command, crlDer, thisUpdate, nextUpdate);
} }
private static List<CrlEntry> validateEntries(List<CrlEntry> crlEntries) {
Objects.requireNonNull(crlEntries, "crlEntries");
return List.copyOf(crlEntries);
}
/** /**
* Validates the high-level command contract for CRL generation. * Validates the high-level command contract for CRL generation.
* *
@@ -318,34 +313,32 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
} }
/** /**
* Adds revoked-certificate entries to the CRL builder from framework * Adds validated structured revoked-certificate entries to the CRL builder.
* attributes.
*
* <p>
* Only positive {@link IntegerValue} attribute values associated with
* {@link #CRL_REVOKED_SERIAL} are added. Non-integer and non-positive values
* are ignored.
* </p>
* *
* @param builder target CRL builder; must not be {@code null} * @param builder target CRL builder; must not be {@code null}
* @param attributes source attributes; must not be {@code null} * @param entries validated structured entries
* @param revocationDate date to use for all generated CRL entries; must not be
* {@code null}
*/ */
private static void addRevokedEntries(X509v2CRLBuilder builder, AttributeSet attributes, Date revocationDate) { private static void addRevokedEntries(X509v2CRLBuilder builder, List<CrlEntry> entries) {
List<AttributeValue> revoked = attributes.getAll(CRL_REVOKED_SERIAL); for (CrlEntry entry : entries) {
for (AttributeValue value : revoked) { Instant encodedTime = entry.transitionTime().truncatedTo(ChronoUnit.SECONDS);
if (!(value instanceof IntegerValue)) { builder.addCRLEntry(entry.serialNumber(), Date.from(encodedTime), reasonCode(entry.reason()));
continue; }
} }
long serial = ((IntegerValue) value).value(); private static int reasonCode(RevocationReason reason) {
if (serial <= 0) { return switch (reason) {
continue; case UNSPECIFIED -> CRLReason.unspecified;
} case KEY_COMPROMISE -> CRLReason.keyCompromise;
case CA_COMPROMISE -> CRLReason.cACompromise;
builder.addCRLEntry(BigInteger.valueOf(serial), revocationDate, 0); case AFFILIATION_CHANGED -> CRLReason.affiliationChanged;
} case SUPERSEDED -> CRLReason.superseded;
case CESSATION_OF_OPERATION -> CRLReason.cessationOfOperation;
case CERTIFICATE_HOLD -> CRLReason.certificateHold;
case REMOVE_FROM_CRL -> throw new IllegalArgumentException(
"REMOVE_FROM_CRL is not an active CRL entry");
case PRIVILEGE_WITHDRAWN -> CRLReason.privilegeWithdrawn;
case AA_COMPROMISE -> CRLReason.aACompromise;
};
} }
/** /**

View File

@@ -62,11 +62,14 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantLock;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
import java.util.stream.Stream;
import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.Credential;
@@ -76,7 +79,11 @@ import zeroecho.pki.api.policy.PolicyTrace;
import zeroecho.pki.api.profile.CertificateProfile; import zeroecho.pki.api.profile.CertificateProfile;
import zeroecho.pki.api.publication.PublicationRecord; import zeroecho.pki.api.publication.PublicationRecord;
import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.api.revocation.RevokedRecord; import zeroecho.pki.api.revocation.RevocationCommand;
import zeroecho.pki.api.revocation.RevocationJournal;
import zeroecho.pki.api.revocation.RevocationReason;
import zeroecho.pki.api.revocation.RevocationState;
import zeroecho.pki.api.revocation.RevocationTransition;
import zeroecho.pki.api.status.StatusObject; import zeroecho.pki.api.status.StatusObject;
import zeroecho.pki.impl.core.async.PkiSigningBus; import zeroecho.pki.impl.core.async.PkiSigningBus;
import zeroecho.pki.spi.store.PkiStore; import zeroecho.pki.spi.store.PkiStore;
@@ -102,11 +109,10 @@ import zeroecho.pki.spi.store.SignWorkflowStore;
* fails with {@link IllegalStateException}. This is intentional to surface * fails with {@link IllegalStateException}. This is intentional to surface
* anomalous behavior for audit and incident analysis. * anomalous behavior for audit and incident analysis.
* *
* <li><strong>Audit history for mutable entities:</strong> CA records, * <li><strong>Audit history for mutable entities:</strong> CA records and
* profiles, and revocations are treated as "mutable but auditable": each update * profiles append history before updating {@code current.bin}. Revocation state
* appends an immutable history entry and then updates {@code current.bin} * is instead one atomically replaced, internally ordered journal per
* atomically. This supports forensic reconstruction, and snapshot export ("time * credential.</li>
* travel") without mutating the store.</li>
* *
* <li><strong>Deterministic behavior:</strong> filenames, ordering, and cleanup * <li><strong>Deterministic behavior:</strong> filenames, ordering, and cleanup
* semantics are deterministic. Cleanup occurs only during writes * semantics are deterministic. Cleanup occurs only during writes
@@ -155,6 +161,8 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
private final AtomicLong signingTimeWatermark; private final AtomicLong signingTimeWatermark;
private final ReentrantLock signingTimeLock; private final ReentrantLock signingTimeLock;
private final ConcurrentMap<PkiId, SignLockEntry> signLocks; private final ConcurrentMap<PkiId, SignLockEntry> signLocks;
private final ConcurrentMap<PkiId, RevocationLockEntry> revocationLocks;
private final AtomicBoolean durabilityUncertain;
private final StoreOwnership ownership; private final StoreOwnership ownership;
@@ -188,6 +196,8 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
Objects.requireNonNull(root, "root"); Objects.requireNonNull(root, "root");
this.clock = Objects.requireNonNull(clock, "clock"); this.clock = Objects.requireNonNull(clock, "clock");
this.signLocks = new ConcurrentHashMap<>(); this.signLocks = new ConcurrentHashMap<>();
this.revocationLocks = new ConcurrentHashMap<>();
this.durabilityUncertain = new AtomicBoolean();
this.signingTimeLock = new ReentrantLock(); this.signingTimeLock = new ReentrantLock();
this.paths = new FsPaths(root); this.paths = new FsPaths(root);
@@ -239,6 +249,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
* @throws IllegalStateException if export fails * @throws IllegalStateException if export fails
*/ */
public void exportSnapshot(final Path targetRoot, final Instant at) { public void exportSnapshot(final Path targetRoot, final Instant at) {
requireStoreUsable();
Objects.requireNonNull(targetRoot, "targetRoot"); Objects.requireNonNull(targetRoot, "targetRoot");
Objects.requireNonNull(at, "at"); Objects.requireNonNull(at, "at");
new FsSnapshotExporter(this.options).exportSnapshot(this.paths.root(), targetRoot, at); new FsSnapshotExporter(this.options).exportSnapshot(this.paths.root(), targetRoot, at);
@@ -246,6 +257,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override @Override
public void putCa(final CaRecord record) { public void putCa(final CaRecord record) {
requireStoreUsable();
Objects.requireNonNull(record, "record"); Objects.requireNonNull(record, "record");
PkiId caId = record.caId(); PkiId caId = record.caId();
Path current = this.paths.caCurrent(caId); Path current = this.paths.caCurrent(caId);
@@ -256,6 +268,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override @Override
public Optional<CaRecord> getCa(final PkiId caId) { public Optional<CaRecord> getCa(final PkiId caId) {
requireStoreUsable();
Objects.requireNonNull(caId, "caId"); Objects.requireNonNull(caId, "caId");
Path p = this.paths.caCurrent(caId); Path p = this.paths.caCurrent(caId);
return readOptional(p, FsCodec.CA_RECORD); return readOptional(p, FsCodec.CA_RECORD);
@@ -263,12 +276,14 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override @Override
public List<CaRecord> listCas() { public List<CaRecord> listCas() {
requireStoreUsable();
Path casRoot = this.paths.root().resolve("cas").resolve("by-id"); Path casRoot = this.paths.root().resolve("cas").resolve("by-id");
return listCurrentRecords(casRoot, FsCodec.CA_RECORD); return listCurrentRecords(casRoot, FsCodec.CA_RECORD);
} }
@Override @Override
public void putCredential(final Credential credential) { public void putCredential(final Credential credential) {
requireStoreUsable();
Objects.requireNonNull(credential, "credential"); Objects.requireNonNull(credential, "credential");
PkiId id = credential.credentialId(); PkiId id = credential.credentialId();
Path p = this.paths.credentialPath(id); Path p = this.paths.credentialPath(id);
@@ -277,12 +292,14 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override @Override
public Optional<Credential> getCredential(final PkiId credentialId) { public Optional<Credential> getCredential(final PkiId credentialId) {
requireStoreUsable();
Objects.requireNonNull(credentialId, "credentialId"); Objects.requireNonNull(credentialId, "credentialId");
return readOptional(this.paths.credentialPath(credentialId), FsCodec.CREDENTIAL); return readOptional(this.paths.credentialPath(credentialId), FsCodec.CREDENTIAL);
} }
@Override @Override
public void putRequest(final ParsedCertificationRequest request) { public void putRequest(final ParsedCertificationRequest request) {
requireStoreUsable();
Objects.requireNonNull(request, "request"); Objects.requireNonNull(request, "request");
PkiId id = request.requestId(); PkiId id = request.requestId();
writeOnce(this.paths.requestPath(id), FsCodec.encode(FsCodec.PARSED_REQUEST, request), "REQUEST", writeOnce(this.paths.requestPath(id), FsCodec.encode(FsCodec.PARSED_REQUEST, request), "REQUEST",
@@ -291,34 +308,91 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override @Override
public Optional<ParsedCertificationRequest> getRequest(final PkiId requestId) { public Optional<ParsedCertificationRequest> getRequest(final PkiId requestId) {
requireStoreUsable();
Objects.requireNonNull(requestId, "requestId"); Objects.requireNonNull(requestId, "requestId");
return readOptional(this.paths.requestPath(requestId), FsCodec.PARSED_REQUEST); return readOptional(this.paths.requestPath(requestId), FsCodec.PARSED_REQUEST);
} }
@Override @Override
public void putRevocation(final RevokedRecord record) { // Store failures are intentionally replaced by one stable redacted boundary.
Objects.requireNonNull(record, "record"); @SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
PkiId credId = record.credentialId(); public RevocationJournal transitionRevocation(final RevocationCommand command, final Instant transitionTime) {
Path current = this.paths.revocationCurrent(credId); requireStoreUsable();
Objects.requireNonNull(command, "command");
writeWithHistory(this.paths.revocationHistoryDir(credId), current, FsCodec.encode(FsCodec.REVOCATION, record), Objects.requireNonNull(transitionTime, "transitionTime");
this.options.revocationHistoryPolicy(), "REVOCATION", FsUtil.safeId(credId)); PkiId credentialId = command.credentialId();
RevocationLockEntry lock = acquireRevocationLock(credentialId);
try {
Optional<Credential> credential;
try {
credential = getCredential(credentialId);
} catch (RuntimeException failure) {
throw corruptRevocationState();
}
if (credential.isEmpty()) {
throw new PkiException("Revocation target unavailable: code=REVOCATION_CREDENTIAL_NOT_FOUND");
}
Optional<RevocationJournal> current;
try {
current = readRevocationJournal(credentialId);
} catch (RuntimeException failure) {
throw corruptRevocationState();
}
RevocationJournal updated = appendRevocation(current, credentialId, command, transitionTime);
try {
FsOperations.writeAtomicStrict(paths.revocationJournal(credentialId),
FsCodec.encode(FsCodec.REVOCATION_JOURNAL, updated));
} catch (FsOperations.DurabilityUncertainException failure) {
durabilityUncertain.set(true);
throw new PkiException(
"Revocation durability unconfirmed: code=REVOCATION_DURABILITY_UNCONFIRMED");
} catch (IOException ex) {
throw new PkiException("Revocation persistence failed: code=REVOCATION_PERSIST_FAILED");
}
return updated;
} finally {
releaseRevocationLock(credentialId, lock);
}
} }
@Override @Override
public Optional<RevokedRecord> getRevocation(final PkiId credentialId) { public Optional<RevocationJournal> getRevocationJournal(final PkiId credentialId) {
requireStoreUsable();
Objects.requireNonNull(credentialId, "credentialId"); Objects.requireNonNull(credentialId, "credentialId");
return readOptional(this.paths.revocationCurrent(credentialId), FsCodec.REVOCATION); return readRevocationJournal(credentialId);
} }
@Override @Override
public List<RevokedRecord> listRevocations() { // Listing failures are intentionally replaced by one stable redacted boundary.
@SuppressWarnings("PMD.PreserveStackTrace")
public List<RevocationJournal> listRevocationJournals() {
requireStoreUsable();
Path root = this.paths.root().resolve("revocations").resolve("by-credential"); Path root = this.paths.root().resolve("revocations").resolve("by-credential");
return listCurrentRecords(root, FsCodec.REVOCATION); if (!Files.isDirectory(root)) {
return List.of();
}
try (Stream<Path> directories = Files.list(root)) {
List<RevocationJournal> journals = new ArrayList<>();
for (Path entityDir : directories.filter(Files::isDirectory)
.sorted(Comparator.comparing(path -> path.getFileName().toString())).toList()) {
Path journalPath = entityDir.resolve("journal.bin");
if (Files.exists(journalPath)) {
RevocationJournal journal = decodeRevocationJournal(journalPath);
if (!entityDir.getFileName().toString().equals(FsUtil.safeId(journal.credentialId()))) {
throw corruptRevocationState();
}
journals.add(journal);
}
}
return List.copyOf(journals);
} catch (IOException ex) {
throw corruptRevocationState();
}
} }
@Override @Override
public void putStatusObject(final StatusObject object) { public void putStatusObject(final StatusObject object) {
requireStoreUsable();
Objects.requireNonNull(object, "object"); Objects.requireNonNull(object, "object");
PkiId id = object.statusObjectId(); PkiId id = object.statusObjectId();
writeOnce(this.paths.statusObjectPath(id), FsCodec.encode(FsCodec.STATUS_OBJECT, object), "STATUS_OBJECT", writeOnce(this.paths.statusObjectPath(id), FsCodec.encode(FsCodec.STATUS_OBJECT, object), "STATUS_OBJECT",
@@ -327,12 +401,14 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override @Override
public Optional<StatusObject> getStatusObject(final PkiId statusObjectId) { public Optional<StatusObject> getStatusObject(final PkiId statusObjectId) {
requireStoreUsable();
Objects.requireNonNull(statusObjectId, "statusObjectId"); Objects.requireNonNull(statusObjectId, "statusObjectId");
return readOptional(this.paths.statusObjectPath(statusObjectId), FsCodec.STATUS_OBJECT); return readOptional(this.paths.statusObjectPath(statusObjectId), FsCodec.STATUS_OBJECT);
} }
@Override @Override
public List<StatusObject> listStatusObjects(final PkiId issuerCaId) { public List<StatusObject> listStatusObjects(final PkiId issuerCaId) {
requireStoreUsable();
Objects.requireNonNull(issuerCaId, "issuerCaId"); Objects.requireNonNull(issuerCaId, "issuerCaId");
// Deterministic but coarse: scan all and filter by issuer id. // Deterministic but coarse: scan all and filter by issuer id.
@@ -351,6 +427,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override @Override
public void putPublicationRecord(final PublicationRecord record) { public void putPublicationRecord(final PublicationRecord record) {
requireStoreUsable();
Objects.requireNonNull(record, "record"); Objects.requireNonNull(record, "record");
PkiId id = record.publicationId(); PkiId id = record.publicationId();
writeOnce(this.paths.publicationPath(id), FsCodec.encode(FsCodec.PUBLICATION, record), "PUBLICATION", writeOnce(this.paths.publicationPath(id), FsCodec.encode(FsCodec.PUBLICATION, record), "PUBLICATION",
@@ -359,12 +436,14 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override @Override
public List<PublicationRecord> listPublicationRecords() { public List<PublicationRecord> listPublicationRecords() {
requireStoreUsable();
Path byId = this.paths.root().resolve("publications").resolve("by-id"); Path byId = this.paths.root().resolve("publications").resolve("by-id");
return listBinaryFiles(byId, FsCodec.PUBLICATION); return listBinaryFiles(byId, FsCodec.PUBLICATION);
} }
@Override @Override
public void putProfile(final CertificateProfile profile) { public void putProfile(final CertificateProfile profile) {
requireStoreUsable();
Objects.requireNonNull(profile, "profile"); Objects.requireNonNull(profile, "profile");
String profileId = profile.profileId(); String profileId = profile.profileId();
Path current = this.paths.profileCurrent(profileId); Path current = this.paths.profileCurrent(profileId);
@@ -376,6 +455,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override @Override
public Optional<CertificateProfile> getProfile(final String profileId) { public Optional<CertificateProfile> getProfile(final String profileId) {
requireStoreUsable();
if (profileId == null || profileId.isBlank()) { if (profileId == null || profileId.isBlank()) {
throw new IllegalArgumentException("profileId must not be null/blank"); throw new IllegalArgumentException("profileId must not be null/blank");
} }
@@ -384,12 +464,14 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override @Override
public List<CertificateProfile> listProfiles() { public List<CertificateProfile> listProfiles() {
requireStoreUsable();
Path root = this.paths.root().resolve("profiles").resolve("by-id"); Path root = this.paths.root().resolve("profiles").resolve("by-id");
return listCurrentRecords(root, FsCodec.CERTIFICATE_PROFILE); return listCurrentRecords(root, FsCodec.CERTIFICATE_PROFILE);
} }
@Override @Override
public void putPolicyTrace(final PolicyTrace trace) { public void putPolicyTrace(final PolicyTrace trace) {
requireStoreUsable();
Objects.requireNonNull(trace, "trace"); Objects.requireNonNull(trace, "trace");
PkiId id = trace.decisionId(); PkiId id = trace.decisionId();
writeOnce(this.paths.policyTracePath(id), FsCodec.encode(FsCodec.POLICY_TRACE, trace), "POLICY_TRACE", writeOnce(this.paths.policyTracePath(id), FsCodec.encode(FsCodec.POLICY_TRACE, trace), "POLICY_TRACE",
@@ -398,6 +480,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override @Override
public Optional<PolicyTrace> getPolicyTrace(final PkiId decisionId) { public Optional<PolicyTrace> getPolicyTrace(final PkiId decisionId) {
requireStoreUsable();
Objects.requireNonNull(decisionId, "decisionId"); Objects.requireNonNull(decisionId, "decisionId");
return readOptional(this.paths.policyTracePath(decisionId), FsCodec.POLICY_TRACE); return readOptional(this.paths.policyTracePath(decisionId), FsCodec.POLICY_TRACE);
} }
@@ -408,6 +491,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override @Override
public void putWorkflowState(final WorkflowStateRecord record) { public void putWorkflowState(final WorkflowStateRecord record) {
requireStoreUsable();
Objects.requireNonNull(record, "record"); Objects.requireNonNull(record, "record");
PkiId opId = record.opId(); PkiId opId = record.opId();
@@ -420,12 +504,14 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override @Override
public Optional<WorkflowStateRecord> getWorkflowState(final PkiId opId) { public Optional<WorkflowStateRecord> getWorkflowState(final PkiId opId) {
requireStoreUsable();
Objects.requireNonNull(opId, "opId"); Objects.requireNonNull(opId, "opId");
return readOptional(this.paths.workflowCurrent(opId), FsCodec.WORKFLOW_STATE); return readOptional(this.paths.workflowCurrent(opId), FsCodec.WORKFLOW_STATE);
} }
@Override @Override
public void deleteWorkflowState(final PkiId opId) { public void deleteWorkflowState(final PkiId opId) {
requireStoreUsable();
Objects.requireNonNull(opId, "opId"); Objects.requireNonNull(opId, "opId");
Path current = this.paths.workflowCurrent(opId); Path current = this.paths.workflowCurrent(opId);
@@ -438,12 +524,14 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override @Override
public List<WorkflowStateRecord> listWorkflowStates() { public List<WorkflowStateRecord> listWorkflowStates() {
requireStoreUsable();
// List by operation directory (workflows/by-op/<opId>/current.bin) // List by operation directory (workflows/by-op/<opId>/current.bin)
return listCurrentRecords(this.paths.workflowRoot(), FsCodec.WORKFLOW_STATE); return listCurrentRecords(this.paths.workflowRoot(), FsCodec.WORKFLOW_STATE);
} }
@Override @Override
public Instant signingNow() { public Instant signingNow() {
requireStoreUsable();
signingTimeLock.lock(); signingTimeLock.lock();
try { try {
long monotonic = Math.max(signingTimeWatermark.get(), clock.instant().toEpochMilli()); long monotonic = Math.max(signingTimeWatermark.get(), clock.instant().toEpochMilli());
@@ -457,21 +545,25 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override @Override
public String signingNamespace() { public String signingNamespace() {
requireStoreUsable();
return signingNamespace; return signingNamespace;
} }
@Override @Override
public Duration signingHorizon() { public Duration signingHorizon() {
requireStoreUsable();
return options.signingOperationHorizon(); return options.signingOperationHorizon();
} }
@Override @Override
public Duration signingPermittedSkew() { public Duration signingPermittedSkew() {
requireStoreUsable();
return options.signingIdPermittedSkew(); return options.signingIdPermittedSkew();
} }
@Override @Override
public SignWorkflowStore.CreateResult createSignIntent(SignWorkflowStore.Record intent) { public SignWorkflowStore.CreateResult createSignIntent(SignWorkflowStore.Record intent) {
requireStoreUsable();
Objects.requireNonNull(intent, "intent"); Objects.requireNonNull(intent, "intent");
SignLockEntry lock = acquireSignLock(intent.submissionId()); SignLockEntry lock = acquireSignLock(intent.submissionId());
try { try {
@@ -504,6 +596,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override @Override
public Optional<SignWorkflowStore.Record> getSignRecord(PkiId submissionId) { public Optional<SignWorkflowStore.Record> getSignRecord(PkiId submissionId) {
requireStoreUsable();
Objects.requireNonNull(submissionId, "submissionId"); Objects.requireNonNull(submissionId, "submissionId");
SignLockEntry lock = acquireSignLock(submissionId); SignLockEntry lock = acquireSignLock(submissionId);
try { try {
@@ -515,11 +608,12 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override @Override
public List<SignWorkflowStore.Record> listSignRecords() { public List<SignWorkflowStore.Record> listSignRecords() {
requireStoreUsable();
Path root = paths.signWorkflowRoot(); Path root = paths.signWorkflowRoot();
if (!Files.isDirectory(root)) { if (!Files.isDirectory(root)) {
return List.of(); return List.of();
} }
try (java.util.stream.Stream<Path> directories = Files.list(root)) { try (Stream<Path> directories = Files.list(root)) {
return directories.filter(Files::isDirectory) return directories.filter(Files::isDirectory)
.map(directory -> directory.resolve(FsPaths.CURRENT_FILE)) .map(directory -> directory.resolve(FsPaths.CURRENT_FILE))
.filter(Files::isRegularFile) .filter(Files::isRegularFile)
@@ -535,6 +629,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override @Override
public Optional<SignWorkflowStore.Record> tryClaimSign(PkiId submissionId, long expectedRevision, public Optional<SignWorkflowStore.Record> tryClaimSign(PkiId submissionId, long expectedRevision,
Duration lease) { Duration lease) {
requireStoreUsable();
requirePositive(lease, "lease"); requirePositive(lease, "lease");
SignLockEntry lock = acquireSignLock(submissionId); SignLockEntry lock = acquireSignLock(submissionId);
try { try {
@@ -562,6 +657,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override @Override
public Optional<SignWorkflowStore.Record> renewSignClaim(PkiId submissionId, long expectedRevision, long fence, public Optional<SignWorkflowStore.Record> renewSignClaim(PkiId submissionId, long expectedRevision, long fence,
Duration lease) { Duration lease) {
requireStoreUsable();
requirePositive(lease, "lease"); requirePositive(lease, "lease");
SignLockEntry lock = acquireSignLock(submissionId); SignLockEntry lock = acquireSignLock(submissionId);
try { try {
@@ -587,6 +683,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
public Optional<SignWorkflowStore.Record> transitionSign(PkiId submissionId, long expectedRevision, long fence, public Optional<SignWorkflowStore.Record> transitionSign(PkiId submissionId, long expectedRevision, long fence,
SignWorkflowStore.State target, Optional<String> detailCode, Optional<EncodedObject> result, SignWorkflowStore.State target, Optional<String> detailCode, Optional<EncodedObject> result,
Optional<Instant> providerUpdatedAt) { Optional<Instant> providerUpdatedAt) {
requireStoreUsable();
Objects.requireNonNull(target, "target"); Objects.requireNonNull(target, "target");
Objects.requireNonNull(detailCode, "detailCode"); Objects.requireNonNull(detailCode, "detailCode");
Objects.requireNonNull(result, "result"); Objects.requireNonNull(result, "result");
@@ -622,6 +719,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override @Override
public Optional<SignWorkflowStore.Record> retireSign(PkiId submissionId, long expectedRevision, long fence) { public Optional<SignWorkflowStore.Record> retireSign(PkiId submissionId, long expectedRevision, long fence) {
requireStoreUsable();
SignLockEntry lock = acquireSignLock(submissionId); SignLockEntry lock = acquireSignLock(submissionId);
try { try {
Optional<SignWorkflowStore.Record> optional = readSignRecord(submissionId); Optional<SignWorkflowStore.Record> optional = readSignRecord(submissionId);
@@ -645,6 +743,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override @Override
public int purgeExpiredSignRecords() { public int purgeExpiredSignRecords() {
requireStoreUsable();
Path root = paths.signWorkflowRoot(); Path root = paths.signWorkflowRoot();
if (!Files.isDirectory(root)) { if (!Files.isDirectory(root)) {
return 0; return 0;
@@ -677,6 +776,12 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
this.ownership.close(); this.ownership.close();
} }
private void requireStoreUsable() {
if (durabilityUncertain.get()) {
throw new PkiException("Store durability unconfirmed: code=STORE_DURABILITY_UNCONFIRMED");
}
}
private SignLockEntry acquireSignLock(PkiId submissionId) { private SignLockEntry acquireSignLock(PkiId submissionId) {
SignLockEntry entry = signLocks.compute(submissionId, (ignored, current) -> { SignLockEntry entry = signLocks.compute(submissionId, (ignored, current) -> {
SignLockEntry selected = current == null ? new SignLockEntry() : current; SignLockEntry selected = current == null ? new SignLockEntry() : current;
@@ -697,6 +802,115 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
}); });
} }
private RevocationLockEntry acquireRevocationLock(PkiId credentialId) {
RevocationLockEntry entry = revocationLocks.compute(credentialId, (ignored, current) -> {
RevocationLockEntry selected = current == null ? new RevocationLockEntry() : current;
selected.references.incrementAndGet();
return selected;
});
entry.lock.lock();
return entry;
}
private void releaseRevocationLock(PkiId credentialId, RevocationLockEntry entry) {
entry.lock.unlock();
revocationLocks.computeIfPresent(credentialId, (ignored, current) -> {
if (current != entry) { // NOPMD - identity protects a replacement lock entry
return current;
}
return current.references.decrementAndGet() == 0 ? null : current;
});
}
private Optional<RevocationJournal> readRevocationJournal(PkiId credentialId) {
Path path = paths.revocationJournal(credentialId);
if (!Files.exists(path)) {
return Optional.empty();
}
RevocationJournal journal = decodeRevocationJournal(path);
validateRevocationJournal(credentialId, journal);
return Optional.of(journal);
}
// Codec and filesystem failures are external persisted-state boundaries; raw
// causes are deliberately removed from the stable corruption exception.
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
private static RevocationJournal decodeRevocationJournal(Path path) {
try {
return FsCodec.decode(FsCodec.REVOCATION_JOURNAL, FsOperations.readAll(path));
} catch (IOException | RuntimeException ex) {
throw corruptRevocationState();
}
}
// Revision overflow is persisted-state corruption and is mapped to the stable,
// redacted state-corruption boundary.
@SuppressWarnings("PMD.PreserveStackTrace")
private static RevocationJournal appendRevocation(Optional<RevocationJournal> current, PkiId credentialId,
RevocationCommand command, Instant transitionTime) {
if (current.isPresent() && transitionTime.isBefore(current.get().latest().time())) {
throw new PkiException("Revocation transition conflict: code=REVOCATION_TRANSITION_CONFLICT");
}
RevocationState previous = current.map(RevocationJournal::latest).map(RevocationTransition::state)
.orElse(null);
RevocationState next = nextRevocationState(previous, command);
long revision = current.map(RevocationJournal::latest).map(RevocationTransition::revision)
.map(value -> {
try {
return Math.addExact(value, 1L);
} catch (ArithmeticException ex) {
throw corruptRevocationState();
}
}).orElse(1L);
List<RevocationTransition> transitions = new ArrayList<>(
current.map(RevocationJournal::transitions).orElseGet(List::of));
Optional<RevocationReason> permanentReason = command instanceof RevocationCommand.RevokePermanently revoke
? Optional.of(revoke.reason()) : Optional.empty();
transitions.add(new RevocationTransition(revision, next, transitionTime,
permanentReason, command.attributes()));
RevocationJournal journal = new RevocationJournal(credentialId, transitions);
validateRevocationJournal(credentialId, journal);
return journal;
}
private static RevocationState nextRevocationState(RevocationState previous, RevocationCommand command) {
if (previous == RevocationState.PERMANENTLY_REVOKED) {
throw new PkiException("Revocation is terminal: code=REVOCATION_TERMINAL");
}
boolean hold = command instanceof RevocationCommand.Hold;
boolean unhold = command instanceof RevocationCommand.Unhold;
if (previous == null) {
return unhold ? throwIllegalTransition()
: hold ? RevocationState.HELD : RevocationState.PERMANENTLY_REVOKED;
}
return switch (previous) {
case CLEAR -> unhold ? throwIllegalTransition()
: hold ? RevocationState.HELD : RevocationState.PERMANENTLY_REVOKED;
case HELD -> hold ? throwIllegalTransition()
: unhold ? RevocationState.CLEAR : RevocationState.PERMANENTLY_REVOKED;
case PERMANENTLY_REVOKED -> throw new PkiException(
"Revocation is terminal: code=REVOCATION_TERMINAL");
};
}
private static void validateRevocationJournal(PkiId expectedId, RevocationJournal journal) {
if (!expectedId.equals(journal.credentialId())) {
throw corruptRevocationState();
}
}
private static RevocationState throwIllegalTransition() {
throw illegalRevocationTransition();
}
private static PkiException illegalRevocationTransition() {
return new PkiException("Revocation transition rejected: code=REVOCATION_TRANSITION_ILLEGAL");
}
private static PkiException corruptRevocationState() {
return new PkiException("Revocation state invalid: code=REVOCATION_STATE_CORRUPT");
}
private Optional<SignWorkflowStore.Record> readSignRecord(PkiId submissionId) { private Optional<SignWorkflowStore.Record> readSignRecord(PkiId submissionId) {
Path path = paths.signWorkflowPath(submissionId); Path path = paths.signWorkflowPath(submissionId);
if (!Files.exists(path)) { if (!Files.exists(path)) {
@@ -995,6 +1209,14 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
private final AtomicInteger references = new AtomicInteger(); private final AtomicInteger references = new AtomicInteger();
} }
/**
* Reference-counted per-credential revocation transition lock.
*/
private static final class RevocationLockEntry {
private final ReentrantLock lock = new ReentrantLock();
private final AtomicInteger references = new AtomicInteger();
}
/** /**
* Owns the operating-system resources that exclude a second store process. * Owns the operating-system resources that exclude a second store process.
* *

View File

@@ -77,8 +77,10 @@ import zeroecho.pki.api.publication.PublicationStatus;
import zeroecho.pki.api.publication.PublicationTarget; import zeroecho.pki.api.publication.PublicationTarget;
import zeroecho.pki.api.publication.PublicationTargetType; import zeroecho.pki.api.publication.PublicationTargetType;
import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.api.revocation.RevocationJournal;
import zeroecho.pki.api.revocation.RevocationReason; import zeroecho.pki.api.revocation.RevocationReason;
import zeroecho.pki.api.revocation.RevokedRecord; import zeroecho.pki.api.revocation.RevocationState;
import zeroecho.pki.api.revocation.RevocationTransition;
import zeroecho.pki.api.status.StatusObject; import zeroecho.pki.api.status.StatusObject;
import zeroecho.pki.api.status.StatusObjectType; import zeroecho.pki.api.status.StatusObjectType;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
@@ -154,6 +156,8 @@ final class FsCodec {
private static final int TYPE_PUBLICATION_STATUS_ENUM = 57; private static final int TYPE_PUBLICATION_STATUS_ENUM = 57;
private static final int TYPE_DURABILITY_POLICY_ENUM = 58; private static final int TYPE_DURABILITY_POLICY_ENUM = 58;
private static final int TYPE_SIGN_STATE_ENUM = 59; private static final int TYPE_SIGN_STATE_ENUM = 59;
private static final int TYPE_REVOCATION_STATE_ENUM = 60;
private static final int TYPE_REVOCATION_TRANSITION = 61;
private static final int ATTRIBUTE_STRING = 1; private static final int ATTRIBUTE_STRING = 1;
private static final int ATTRIBUTE_BOOLEAN = 2; private static final int ATTRIBUTE_BOOLEAN = 2;
@@ -248,6 +252,18 @@ final class FsCodec {
case 10 -> RevocationReason.AA_COMPROMISE; case 10 -> RevocationReason.AA_COMPROMISE;
default -> throw unknownEnum("RevocationReason", code); default -> throw unknownEnum("RevocationReason", code);
}); });
private static final ValueSchema<RevocationState> REVOCATION_STATE = enumSchema(TYPE_REVOCATION_STATE_ENUM,
value -> switch (value) {
case CLEAR -> 1;
case HELD -> 2;
case PERMANENTLY_REVOKED -> 3;
},
code -> switch (code) {
case 1 -> RevocationState.CLEAR;
case 2 -> RevocationState.HELD;
case 3 -> RevocationState.PERMANENTLY_REVOKED;
default -> throw unknownEnum("RevocationState", code);
});
private static final ValueSchema<StatusObjectType> STATUS_OBJECT_TYPE = enumSchema(TYPE_STATUS_OBJECT_TYPE_ENUM, private static final ValueSchema<StatusObjectType> STATUS_OBJECT_TYPE = enumSchema(TYPE_STATUS_OBJECT_TYPE_ENUM,
value -> switch (value) { value -> switch (value) {
case CRL -> 1; case CRL -> 1;
@@ -379,6 +395,13 @@ final class FsCodec {
private static final ValueSchema<Optional<Instant>> OPTIONAL_INSTANT = optionalOf(INSTANT); private static final ValueSchema<Optional<Instant>> OPTIONAL_INSTANT = optionalOf(INSTANT);
private static final ValueSchema<Optional<Duration>> OPTIONAL_DURATION = optionalOf(DURATION); private static final ValueSchema<Optional<Duration>> OPTIONAL_DURATION = optionalOf(DURATION);
private static final ValueSchema<Optional<EncodedObject>> OPTIONAL_ENCODED_OBJECT = optionalOf(ENCODED_OBJECT); private static final ValueSchema<Optional<EncodedObject>> OPTIONAL_ENCODED_OBJECT = optionalOf(ENCODED_OBJECT);
private static final ValueSchema<Optional<RevocationReason>> OPTIONAL_REVOCATION_REASON =
optionalOf(REVOCATION_REASON);
private static final ValueSchema<RevocationTransition> REVOCATION_TRANSITION =
valueSchema(TYPE_REVOCATION_TRANSITION, FsCodec::writeRevocationTransition,
FsCodec::readRevocationTransition);
private static final ValueSchema<List<RevocationTransition>> REVOCATION_TRANSITIONS =
listOf(REVOCATION_TRANSITION);
private static final ValueSchema<Credential> CREDENTIAL_VALUE = valueSchema(TYPE_CREDENTIAL_RECORD, private static final ValueSchema<Credential> CREDENTIAL_VALUE = valueSchema(TYPE_CREDENTIAL_RECORD,
FsCodec::writeCredential, FsCodec::readCredential); FsCodec::writeCredential, FsCodec::readCredential);
@@ -390,8 +413,9 @@ final class FsCodec {
CREDENTIAL_VALUE); CREDENTIAL_VALUE);
/* package */ static final Schema<ParsedCertificationRequest> PARSED_REQUEST = topLevel(TOP_PARSED_REQUEST, /* package */ static final Schema<ParsedCertificationRequest> PARSED_REQUEST = topLevel(TOP_PARSED_REQUEST,
"PARSED_REQUEST", valueSchema(101, FsCodec::writeParsedRequest, FsCodec::readParsedRequest)); "PARSED_REQUEST", valueSchema(101, FsCodec::writeParsedRequest, FsCodec::readParsedRequest));
/* package */ static final Schema<RevokedRecord> REVOCATION = topLevel(TOP_REVOCATION, "REVOCATION", /* package */ static final Schema<RevocationJournal> REVOCATION_JOURNAL =
valueSchema(102, FsCodec::writeRevocation, FsCodec::readRevocation)); topLevel(TOP_REVOCATION, "REVOCATION_JOURNAL",
valueSchema(102, FsCodec::writeRevocationJournal, FsCodec::readRevocationJournal));
/* package */ static final Schema<StatusObject> STATUS_OBJECT = topLevel(TOP_STATUS_OBJECT, "STATUS_OBJECT", /* package */ static final Schema<StatusObject> STATUS_OBJECT = topLevel(TOP_STATUS_OBJECT, "STATUS_OBJECT",
valueSchema(103, FsCodec::writeStatusObject, FsCodec::readStatusObject)); valueSchema(103, FsCodec::writeStatusObject, FsCodec::readStatusObject));
/* package */ static final Schema<PublicationRecord> PUBLICATION = topLevel(TOP_PUBLICATION, "PUBLICATION", /* package */ static final Schema<PublicationRecord> PUBLICATION = topLevel(TOP_PUBLICATION, "PUBLICATION",
@@ -411,7 +435,7 @@ final class FsCodec {
Map.entry(TOP_CA_RECORD, CA_RECORD), Map.entry(TOP_CA_RECORD, CA_RECORD),
Map.entry(TOP_CREDENTIAL, CREDENTIAL), Map.entry(TOP_CREDENTIAL, CREDENTIAL),
Map.entry(TOP_PARSED_REQUEST, PARSED_REQUEST), Map.entry(TOP_PARSED_REQUEST, PARSED_REQUEST),
Map.entry(TOP_REVOCATION, REVOCATION), Map.entry(TOP_REVOCATION, REVOCATION_JOURNAL),
Map.entry(TOP_STATUS_OBJECT, STATUS_OBJECT), Map.entry(TOP_STATUS_OBJECT, STATUS_OBJECT),
Map.entry(TOP_PUBLICATION, PUBLICATION), Map.entry(TOP_PUBLICATION, PUBLICATION),
Map.entry(TOP_CERTIFICATE_PROFILE, CERTIFICATE_PROFILE), Map.entry(TOP_CERTIFICATE_PROFILE, CERTIFICATE_PROFILE),
@@ -615,16 +639,33 @@ final class FsCodec {
reader.readValue(OPTIONAL_STRING), reader.readValue(ATTRIBUTE_SET)); reader.readValue(OPTIONAL_STRING), reader.readValue(ATTRIBUTE_SET));
} }
private static void writeRevocation(Writer writer, RevokedRecord value) throws IOException { private static void writeRevocationJournal(Writer writer, RevocationJournal value) throws IOException {
writer.writeValue(PKI_ID, value.credentialId()); writer.writeValue(PKI_ID, value.credentialId());
writer.writeValue(INSTANT, value.revocationTime()); writer.writeValue(LONG, (long) RevocationJournal.CURRENT_VERSION);
writer.writeValue(REVOCATION_REASON, value.reason()); writer.writeValue(REVOCATION_TRANSITIONS, value.transitions());
}
private static RevocationJournal readRevocationJournal(Reader reader) throws IOException {
PkiId credentialId = reader.readValue(PKI_ID);
long version = reader.readValue(LONG);
if (version != RevocationJournal.CURRENT_VERSION) {
throw new IOException("Unsupported revocation journal version");
}
return new RevocationJournal(credentialId, reader.readValue(REVOCATION_TRANSITIONS));
}
private static void writeRevocationTransition(Writer writer, RevocationTransition value) throws IOException {
writer.writeValue(LONG, value.revision());
writer.writeValue(REVOCATION_STATE, value.state());
writer.writeValue(INSTANT, value.time());
writer.writeValue(OPTIONAL_REVOCATION_REASON, value.permanentReason());
writer.writeValue(ATTRIBUTE_SET, value.attributes()); writer.writeValue(ATTRIBUTE_SET, value.attributes());
} }
private static RevokedRecord readRevocation(Reader reader) throws IOException { private static RevocationTransition readRevocationTransition(Reader reader) throws IOException {
return new RevokedRecord(reader.readValue(PKI_ID), reader.readValue(INSTANT), return new RevocationTransition(reader.readValue(LONG), reader.readValue(REVOCATION_STATE),
reader.readValue(REVOCATION_REASON), reader.readValue(ATTRIBUTE_SET)); reader.readValue(INSTANT), reader.readValue(OPTIONAL_REVOCATION_REASON),
reader.readValue(ATTRIBUTE_SET));
} }
private static void writeStatusObject(Writer writer, StatusObject value) throws IOException { private static void writeStatusObject(Writer writer, StatusObject value) throws IOException {

View File

@@ -36,6 +36,7 @@ package zeroecho.pki.impl.fs;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel; import java.nio.channels.FileChannel;
import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.FileAlreadyExistsException; import java.nio.file.FileAlreadyExistsException;
@@ -157,6 +158,66 @@ final class FsOperations {
forceDirectoryBestEffort(parent); forceDirectoryBestEffort(parent);
} }
/**
* Strictly persists one authoritative revocation journal image.
*
* <p>The namespace commit point is an {@link StandardCopyOption#ATOMIC_MOVE}
* in the target directory. No non-atomic fallback is permitted. A directory
* force failure after that move is reported distinctly because the durable
* authoritative image is then uncertain.</p>
*
* @param target journal target
* @param data complete encoded journal
* @throws IOException on a pre-commit persistence failure
* @throws DurabilityUncertainException after a committed move whose directory
* force failed
*/
// The original directory-force cause is replaced by a marker that cannot expose
// a filesystem path or operating-system diagnostic.
@SuppressWarnings("PMD.PreserveStackTrace")
/* default */ static void writeAtomicStrict(final Path target, final byte[] data) throws IOException {
Objects.requireNonNull(target, "target");
Objects.requireNonNull(data, "data");
Path parent = requireParent(target);
ensureDir(parent);
Path temporary = tempSibling(target);
boolean moved = false;
try {
createOrTruncateTemp(temporary);
try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE,
StandardOpenOption.TRUNCATE_EXISTING)) {
ByteBuffer buffer = ByteBuffer.wrap(data);
while (buffer.hasRemaining()) {
channel.write(buffer);
}
channel.force(true);
}
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
moved = true;
try (FileChannel directory = FileChannel.open(parent, StandardOpenOption.READ)) {
directory.force(true);
} catch (IOException failure) {
throw new DurabilityUncertainException();
}
} finally {
if (!moved) {
Files.deleteIfExists(temporary);
}
}
}
/**
* Signals a post-commit directory durability failure without exposing an
* operating-system cause.
*/
/* default */ static final class DurabilityUncertainException extends IOException {
private static final long serialVersionUID = -2422560154076956224L;
private DurabilityUncertainException() {
super("revocation journal durability unconfirmed");
}
}
/** /**
* Writes bytes to {@code target} as a write-once operation. * Writes bytes to {@code target} as a write-once operation.
* *

View File

@@ -154,7 +154,7 @@ final class FsPaths {
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Revocations (mutable with history) // Revocations (single authoritative journal)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/* default */ Path revocationDir(final PkiId credentialId) { /* default */ Path revocationDir(final PkiId credentialId) {
@@ -162,12 +162,8 @@ final class FsPaths {
return this.root.resolve("revocations").resolve("by-credential").resolve(FsUtil.safeId(credentialId)); return this.root.resolve("revocations").resolve("by-credential").resolve(FsUtil.safeId(credentialId));
} }
/* default */ Path revocationCurrent(final PkiId credentialId) { /* default */ Path revocationJournal(final PkiId credentialId) {
return revocationDir(credentialId).resolve(CURRENT_FILE); return revocationDir(credentialId).resolve("journal.bin");
}
/* default */ Path revocationHistoryDir(final PkiId credentialId) {
return revocationDir(credentialId).resolve(HISTORY_DIR);
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

@@ -97,14 +97,13 @@ final class FsSnapshotExporter {
copyTreeIfExists(sourceRoot.resolve("policy"), targetRoot.resolve("policy")); copyTreeIfExists(sourceRoot.resolve("policy"), targetRoot.resolve("policy"));
copyTreeIfExists(sourceRoot.resolve("publications"), targetRoot.resolve("publications")); copyTreeIfExists(sourceRoot.resolve("publications"), targetRoot.resolve("publications"));
copyTreeIfExists(sourceRoot.resolve("sign-workflows"), targetRoot.resolve("sign-workflows")); copyTreeIfExists(sourceRoot.resolve("sign-workflows"), targetRoot.resolve("sign-workflows"));
copyTreeIfExists(sourceRoot.resolve("revocations"), targetRoot.resolve("revocations"));
// reconstruct mutable entities from history (CAS, PROFILES, REVOCATIONS) // reconstruct mutable entities from history (CAS and profiles)
reconstructMutableTree(sourceRoot.resolve("cas"), targetRoot.resolve("cas"), at, reconstructMutableTree(sourceRoot.resolve("cas"), targetRoot.resolve("cas"), at,
this.options.caHistoryPolicy(), this.options.strictSnapshotExport()); this.options.caHistoryPolicy(), this.options.strictSnapshotExport());
reconstructMutableTree(sourceRoot.resolve("profiles"), targetRoot.resolve("profiles"), at, reconstructMutableTree(sourceRoot.resolve("profiles"), targetRoot.resolve("profiles"), at,
this.options.profileHistoryPolicy(), this.options.strictSnapshotExport()); this.options.profileHistoryPolicy(), this.options.strictSnapshotExport());
reconstructMutableTree(sourceRoot.resolve("revocations"), targetRoot.resolve("revocations"), at,
this.options.revocationHistoryPolicy(), this.options.strictSnapshotExport());
// reconstruct workflow continuation state from history // reconstruct workflow continuation state from history
reconstructMutableTree(sourceRoot.resolve("workflows"), targetRoot.resolve("workflows"), at, reconstructMutableTree(sourceRoot.resolve("workflows"), targetRoot.resolve("workflows"), at,
this.options.workflowHistoryPolicy(), this.options.strictSnapshotExport()); this.options.workflowHistoryPolicy(), this.options.strictSnapshotExport());

View File

@@ -0,0 +1,45 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.pki.spi.framework;
import java.math.BigInteger;
import java.time.Instant;
import java.util.Objects;
import zeroecho.pki.api.revocation.RevocationReason;
/**
* Immutable structured input for one certificate-revocation-list entry.
*
* <p>The serial number retains the full positive X.509 integer domain. The
* transition time is authoritative runtime state and is not replaced by CRL
* generation time. {@link RevocationReason#REMOVE_FROM_CRL} is excluded because
* removal is represented by omission from the current CRL.</p>
*
* @param serialNumber positive certificate serial number
* @param transitionTime authoritative revocation or hold transition time
* @param reason explicit CRL reason
*/
public record CrlEntry(BigInteger serialNumber, Instant transitionTime, RevocationReason reason) {
/**
* Validates one structured CRL entry.
*
* @throws IllegalArgumentException if the serial is non-positive, a value is
* missing, or the reason is
* {@code REMOVE_FROM_CRL}
*/
public CrlEntry {
Objects.requireNonNull(serialNumber, "serialNumber");
Objects.requireNonNull(transitionTime, "transitionTime");
Objects.requireNonNull(reason, "reason");
if (serialNumber.signum() <= 0) {
throw new IllegalArgumentException("serialNumber must be positive");
}
if (reason == RevocationReason.REMOVE_FROM_CRL) {
throw new IllegalArgumentException("REMOVE_FROM_CRL is not an active CRL entry");
}
}
}

View File

@@ -33,6 +33,8 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.spi.framework; package zeroecho.pki.spi.framework;
import java.util.List;
import zeroecho.pki.api.status.StatusObject; import zeroecho.pki.api.status.StatusObject;
import zeroecho.pki.api.status.StatusObjectGenerateCommand; import zeroecho.pki.api.status.StatusObjectGenerateCommand;
@@ -47,9 +49,11 @@ public interface StatusObjectGenerator {
* Generates a status object. * Generates a status object.
* *
* @param command generation command * @param command generation command
* @param crlEntries structured CRL entries; empty for non-CRL objects
* @return generated status object * @return generated status object
* @throws IllegalArgumentException if {@code command} is invalid * @throws IllegalArgumentException if {@code command} or {@code crlEntries}
* is invalid
* @throws RuntimeException if generation fails * @throws RuntimeException if generation fails
*/ */
StatusObject generate(StatusObjectGenerateCommand command); StatusObject generate(StatusObjectGenerateCommand command, List<CrlEntry> crlEntries);
} }

View File

@@ -33,6 +33,7 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.spi.store; package zeroecho.pki.spi.store;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -44,7 +45,8 @@ import zeroecho.pki.api.policy.PolicyTrace;
import zeroecho.pki.api.profile.CertificateProfile; import zeroecho.pki.api.profile.CertificateProfile;
import zeroecho.pki.api.publication.PublicationRecord; import zeroecho.pki.api.publication.PublicationRecord;
import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.api.revocation.RevokedRecord; import zeroecho.pki.api.revocation.RevocationCommand;
import zeroecho.pki.api.revocation.RevocationJournal;
import zeroecho.pki.api.status.StatusObject; import zeroecho.pki.api.status.StatusObject;
/** /**
@@ -154,31 +156,28 @@ public interface PkiStore extends SignWorkflowStore {
Optional<ParsedCertificationRequest> getRequest(PkiId requestId); Optional<ParsedCertificationRequest> getRequest(PkiId requestId);
/** /**
* Persists or updates a revocation record. * Atomically validates and appends one legal revocation transition.
* *
* @param record revocation record (never {@code null}) * @param command trusted transition command
* @throws NullPointerException if {@code record} is {@code null} * @param transitionTime authoritative transition time
* @throws IllegalStateException if persistence fails * @return committed journal
*/ */
void putRevocation(RevokedRecord record); RevocationJournal transitionRevocation(RevocationCommand command, Instant transitionTime);
/** /**
* Retrieves a revocation record for a given credential. * Retrieves the authoritative revocation journal for a credential.
* *
* @param credentialId credential identifier (never {@code null}) * @param credentialId credential identifier
* @return revocation record if present * @return journal when present
* @throws NullPointerException if {@code credentialId} is {@code null}
* @throws IllegalStateException if retrieval fails
*/ */
Optional<RevokedRecord> getRevocation(PkiId credentialId); Optional<RevocationJournal> getRevocationJournal(PkiId credentialId);
/** /**
* Lists all revocation records. * Lists authoritative revocation journals.
* *
* @return list of revocation records (never {@code null}) * @return immutable journal list
* @throws IllegalStateException if listing fails
*/ */
List<RevokedRecord> listRevocations(); List<RevocationJournal> listRevocationJournals();
/** /**
* Persists a status object. * Persists a status object.

View File

@@ -35,6 +35,9 @@ package zeroecho.pki.e2e;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path; import java.nio.file.Path;
@@ -42,9 +45,13 @@ import java.security.KeyPair;
import java.security.KeyPairGenerator; import java.security.KeyPairGenerator;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.cert.X509CRLHolder; import org.bouncycastle.cert.X509CRLHolder;
@@ -63,6 +70,7 @@ import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding; import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.IssuanceService; import zeroecho.pki.api.IssuanceService;
import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.RevocationService; import zeroecho.pki.api.RevocationService;
import zeroecho.pki.api.StatusObjectService; import zeroecho.pki.api.StatusObjectService;
@@ -70,7 +78,15 @@ import zeroecho.pki.api.SubjectRef;
import zeroecho.pki.api.Validity; import zeroecho.pki.api.Validity;
import zeroecho.pki.api.attr.AttributeSet; import zeroecho.pki.api.attr.AttributeSet;
import zeroecho.pki.api.ca.CaCreateCommand; import zeroecho.pki.api.ca.CaCreateCommand;
import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.ca.IntermediateCertIssueCommand;
import zeroecho.pki.api.ca.IntermediateCreateCommand;
import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialBundle; import zeroecho.pki.api.credential.CredentialBundle;
import zeroecho.pki.api.credential.CredentialUse;
import zeroecho.pki.api.credential.EffectiveCredentialStatus;
import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver;
import zeroecho.pki.api.issuance.BundleCommand;
import zeroecho.pki.api.issuance.IssueEndEntityCommand; import zeroecho.pki.api.issuance.IssueEndEntityCommand;
import zeroecho.pki.api.issuance.VerificationPolicy; import zeroecho.pki.api.issuance.VerificationPolicy;
import zeroecho.pki.api.request.CertificationRequest; import zeroecho.pki.api.request.CertificationRequest;
@@ -79,11 +95,14 @@ import zeroecho.pki.api.request.ProofOfPossessionResult;
import zeroecho.pki.api.request.ProofOfPossessionStatus; import zeroecho.pki.api.request.ProofOfPossessionStatus;
import zeroecho.pki.api.request.RequestStorePolicy; import zeroecho.pki.api.request.RequestStorePolicy;
import zeroecho.pki.api.revocation.RevocationReason; import zeroecho.pki.api.revocation.RevocationReason;
import zeroecho.pki.api.revocation.RevokeCommand; import zeroecho.pki.api.revocation.RevocationCommand;
import zeroecho.pki.api.status.StatusObject; import zeroecho.pki.api.status.StatusObject;
import zeroecho.pki.api.status.StatusObjectGenerateCommand; import zeroecho.pki.api.status.StatusObjectGenerateCommand;
import zeroecho.pki.api.status.StatusObjectType; import zeroecho.pki.api.status.StatusObjectType;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
import zeroecho.pki.impl.core.ManagedCaIssuance;
import zeroecho.pki.impl.core.VerifiedIssuanceCandidate;
import zeroecho.pki.spi.framework.CredentialIssuerBackend;
import zeroecho.pki.testkit.PkiTestRuntime; import zeroecho.pki.testkit.PkiTestRuntime;
/** /**
@@ -91,6 +110,136 @@ import zeroecho.pki.testkit.PkiTestRuntime;
*/ */
public final class PkiCoreE2eTest { public final class PkiCoreE2eTest {
@Test
void nonUsableAndIndeterminateCredentialsFailClosedAcrossReachableTrustPaths(@TempDir Path tempDir)
throws Exception {
assertAll(
() -> assertRejectedAcrossTrustPaths(tempDir.resolve("held"), EffectiveCredentialStatus.HELD, false),
() -> assertRejectedAcrossTrustPaths(tempDir.resolve("expired"), EffectiveCredentialStatus.EXPIRED,
false),
() -> assertRejectedAcrossTrustPaths(tempDir.resolve("not-yet-valid"),
EffectiveCredentialStatus.NOT_YET_VALID, false),
() -> assertRejectedAcrossTrustPaths(tempDir.resolve("permanently-revoked"),
EffectiveCredentialStatus.PERMANENTLY_REVOKED, false),
() -> assertRejectedAcrossTrustPaths(tempDir.resolve("resolution-failure"), null, true));
}
@Test
void everyIssuerPathSkipsEarlierUnusableCredentialAndSelectsLaterUsable(@TempDir Path tempDir)
throws Exception {
KeyPair rootKey = genRsa();
KeyPair intermediateKey = genRsa();
KeyPair nextIntermediateKey = genRsa();
KeyPair leafKey = genRsa();
KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:matrix-root");
KeyRef intermediateKeyRef = new KeyRef("kref:v1:keyring:test:matrix-intermediate");
KeyRef nextIntermediateKeyRef = new KeyRef("kref:v1:keyring:test:matrix-next");
Map<KeyRef, KeyPair> keys = Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey,
nextIntermediateKeyRef, nextIntermediateKey);
try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), keys)) {
PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(),
new SubjectRef("CN=Matrix Root"), "default", Optional.of(rootKeyRef), emptyAttributes()));
Credential usable = runtime.caService().getCa(rootCaId).caCredentials().get(0);
Credential unusable = copyWithId(usable, new PkiId("credential:matrix-unusable"));
CaRecord root = runtime.caService().getCa(rootCaId);
runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(),
root.subjectRef(), List.of(unusable, usable)));
List<PkiId> resolved = new ArrayList<>();
EffectiveCredentialStatusResolver resolver = scriptedResolver(credential -> {
resolved.add(credential.credentialId());
return credential.credentialId().equals(unusable.credentialId())
? EffectiveCredentialStatus.HELD : EffectiveCredentialStatus.USABLE;
}, false);
CountingIssuerBackend backend = new CountingIssuerBackend(runtime.issuerBackend());
IssuanceService issuance = runtime.issuanceService(backend, resolver);
CaService caService = runtime.caService(backend, resolver);
StatusObjectService statusService = runtime.statusObjectService(resolver);
ParsedCertificationRequest leafRequest = runtime.certificationRequestService().parse(
new CertificationRequest(runtime.framework().formatId(),
new EncodedObject(Encoding.DER, makeCsr(leafKey, "CN=Matrix Leaf").getEncoded())));
issuance.issueEndEntity(new IssueEndEntityCommand(rootCaId, leafRequest, "default", Optional.empty(),
emptyAttributes()));
assertEquals(List.of(unusable.credentialId(), usable.credentialId()), List.copyOf(resolved));
resolved.clear();
PkiId intermediateCaId = caService.createIntermediate(new IntermediateCreateCommand(
runtime.framework().formatId(), rootCaId, new SubjectRef("CN=Matrix Intermediate"), "default",
Optional.of(intermediateKeyRef), emptyAttributes()));
assertEquals(List.of(unusable.credentialId(), usable.credentialId()), List.copyOf(resolved));
resolved.clear();
caService.issueIntermediateCertificate(new IntermediateCertIssueCommand(runtime.framework().formatId(),
rootCaId, intermediateCaId, "default", Optional.empty(), emptyAttributes()));
assertEquals(List.of(unusable.credentialId(), usable.credentialId()), List.copyOf(resolved));
resolved.clear();
runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(),
root.subjectRef(), List.of(usable, unusable)));
statusService.generate(new StatusObjectGenerateCommand(rootCaId, StatusObjectType.CRL,
runtime.framework().formatId(), emptyAttributes()));
assertEquals(List.of(unusable.credentialId(), usable.credentialId()), List.copyOf(resolved));
assertEquals(1, backend.endEntityCalls.get());
assertEquals(2, backend.intermediateCalls.get());
}
}
@Test
void permanentlyRevokedIssuerIsRejectedByEveryReachableTrustPath(@TempDir Path tempDir) throws Exception {
KeyPair rootKey = genRsa();
KeyPair intermediateKey = genRsa();
KeyPair nextIntermediateKey = genRsa();
KeyPair leafKey = genRsa();
KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:h6-root");
KeyRef intermediateKeyRef = new KeyRef("kref:v1:keyring:test:h6-intermediate");
KeyRef nextIntermediateKeyRef = new KeyRef("kref:v1:keyring:test:h6-next-intermediate");
Map<KeyRef, KeyPair> keys = Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey,
nextIntermediateKeyRef, nextIntermediateKey);
try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), keys)) {
PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(),
new SubjectRef("CN=H6 Root"), "default", Optional.of(rootKeyRef), emptyAttributes()));
PkiId intermediateCaId = runtime.caService()
.createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId,
new SubjectRef("CN=H6 Intermediate"), "default", Optional.of(intermediateKeyRef),
emptyAttributes()));
PkiId rootCredentialId = runtime.caService().getCa(rootCaId).caCredentials().get(0).credentialId();
runtime.revocationService().revokePermanently(new RevocationCommand.RevokePermanently(rootCredentialId,
RevocationReason.KEY_COMPROMISE, emptyAttributes()));
int submissionsBeforeRejections = runtime.submittedSignCount();
CertificationRequest request = new CertificationRequest(runtime.framework().formatId(),
new EncodedObject(Encoding.DER, makeCsr(leafKey, "CN=H6 Leaf").getEncoded()));
ParsedCertificationRequest parsed = runtime.certificationRequestService().parse(request);
PkiException endEntityFailure = assertThrows(PkiException.class,
() -> runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(rootCaId, parsed,
"default", Optional.empty(), emptyAttributes())));
assertTrue(endEntityFailure.getMessage().contains("ISSUER_CREDENTIAL_UNAVAILABLE"));
assertThrows(PkiException.class,
() -> runtime.caService()
.createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId,
new SubjectRef("CN=H6 Rejected"), "default",
Optional.of(nextIntermediateKeyRef), emptyAttributes())));
assertThrows(PkiException.class,
() -> runtime.caService().issueIntermediateCertificate(
new IntermediateCertIssueCommand(runtime.framework().formatId(), rootCaId,
intermediateCaId, "default", Optional.empty(), emptyAttributes())));
assertThrows(PkiException.class,
() -> runtime.statusObjectService().generate(new StatusObjectGenerateCommand(rootCaId,
StatusObjectType.CRL, runtime.framework().formatId(), emptyAttributes())));
assertThrows(PkiException.class,
() -> runtime.issuanceService().buildBundle(
new BundleCommand(rootCredentialId, Optional.empty(), Optional.empty())));
assertEquals(submissionsBeforeRejections, runtime.submittedSignCount());
assertTrue(runtime.store().getCredential(rootCredentialId).isPresent());
assertTrue(runtime.caService().getCa(intermediateCaId).caCredentials().size() == 1);
}
}
@Test @Test
void e2eRootIssueRevokeCrl(@TempDir Path tempDir) throws Exception { void e2eRootIssueRevokeCrl(@TempDir Path tempDir) throws Exception {
System.out.println("e2eRootIssueRevokeCrl"); System.out.println("e2eRootIssueRevokeCrl");
@@ -144,8 +293,8 @@ public final class PkiCoreE2eTest {
assertEquals("CN=Root", eeCert.getIssuer().toString()); assertEquals("CN=Root", eeCert.getIssuer().toString());
assertEquals("CN=Alice", eeCert.getSubject().toString()); assertEquals("CN=Alice", eeCert.getSubject().toString());
revSvc.revoke(new RevokeCommand(bundle.credential().credentialId(), RevocationReason.KEY_COMPROMISE, revSvc.revokePermanently(new RevocationCommand.RevokePermanently(bundle.credential().credentialId(),
emptyAttributes())); RevocationReason.KEY_COMPROMISE, emptyAttributes()));
StatusObject crl = stSvc.generate(new StatusObjectGenerateCommand(rootCaId, StatusObjectType.CRL, StatusObject crl = stSvc.generate(new StatusObjectGenerateCommand(rootCaId, StatusObjectType.CRL,
runtime.framework().formatId(), emptyAttributes())); runtime.framework().formatId(), emptyAttributes()));
@@ -159,6 +308,125 @@ public final class PkiCoreE2eTest {
System.out.println("e2eRootIssueRevokeCrl...ok"); System.out.println("e2eRootIssueRevokeCrl...ok");
} }
private static void assertRejectedAcrossTrustPaths(Path tempDir, EffectiveCredentialStatus status,
boolean resolutionFailure) throws Exception {
KeyPair rootKey = genRsa();
KeyPair intermediateKey = genRsa();
KeyPair nextIntermediateKey = genRsa();
KeyPair leafKey = genRsa();
KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:reject-root");
KeyRef intermediateKeyRef = new KeyRef("kref:v1:keyring:test:reject-intermediate");
KeyRef nextIntermediateKeyRef = new KeyRef("kref:v1:keyring:test:reject-next");
Map<KeyRef, KeyPair> keys = Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey,
nextIntermediateKeyRef, nextIntermediateKey);
try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), keys)) {
PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(),
new SubjectRef("CN=Rejected Root"), "default", Optional.of(rootKeyRef), emptyAttributes()));
PkiId intermediateCaId = runtime.caService().createIntermediate(new IntermediateCreateCommand(
runtime.framework().formatId(), rootCaId, new SubjectRef("CN=Rejected Intermediate"), "default",
Optional.of(intermediateKeyRef), emptyAttributes()));
Credential rootCredential = runtime.caService().getCa(rootCaId).caCredentials().get(0);
EffectiveCredentialStatusResolver resolver = scriptedResolver(credential -> status, resolutionFailure);
CountingIssuerBackend backend = new CountingIssuerBackend(runtime.issuerBackend());
IssuanceService issuance = runtime.issuanceService(backend, resolver);
CaService caService = runtime.caService(backend, resolver);
StatusObjectService statusService = runtime.statusObjectService(resolver);
ParsedCertificationRequest leafRequest = runtime.certificationRequestService().parse(
new CertificationRequest(runtime.framework().formatId(),
new EncodedObject(Encoding.DER, makeCsr(leafKey, "CN=Rejected Leaf").getEncoded())));
int signCount = runtime.submittedSignCount();
int caCount = runtime.store().listCas().size();
int statusCount = runtime.store().listStatusObjects(rootCaId).size();
int intermediateCredentialCount = runtime.caService().getCa(intermediateCaId).caCredentials().size();
assertThrows(PkiException.class,
() -> issuance.issueEndEntity(new IssueEndEntityCommand(rootCaId, leafRequest, "default",
Optional.empty(), emptyAttributes())));
assertThrows(PkiException.class,
() -> caService.createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(),
rootCaId, new SubjectRef("CN=Rejected Next"), "default",
Optional.of(nextIntermediateKeyRef), emptyAttributes())));
assertThrows(PkiException.class,
() -> caService.issueIntermediateCertificate(new IntermediateCertIssueCommand(
runtime.framework().formatId(), rootCaId, intermediateCaId, "default", Optional.empty(),
emptyAttributes())));
assertThrows(PkiException.class,
() -> statusService.generate(new StatusObjectGenerateCommand(rootCaId, StatusObjectType.CRL,
runtime.framework().formatId(), emptyAttributes())));
assertThrows(PkiException.class,
() -> issuance.buildBundle(new BundleCommand(rootCredential.credentialId(), Optional.empty(),
Optional.empty())));
assertEquals(0, backend.endEntityCalls.get());
assertEquals(0, backend.intermediateCalls.get());
assertEquals(signCount, runtime.submittedSignCount());
assertEquals(caCount, runtime.store().listCas().size());
assertEquals(statusCount, runtime.store().listStatusObjects(rootCaId).size());
assertEquals(intermediateCredentialCount,
runtime.caService().getCa(intermediateCaId).caCredentials().size());
assertTrue(runtime.store().getCredential(rootCredential.credentialId()).isPresent());
assertFalse(runtime.auditSink().snapshot().toString().contains("DO_NOT_EXPOSE_REVOCATION_SENTINEL"));
}
}
private static EffectiveCredentialStatusResolver scriptedResolver(
Function<Credential, EffectiveCredentialStatus> status, boolean fail) {
return () -> new EffectiveCredentialStatusResolver.Evaluation() {
@Override
public Instant evaluationTime() {
return Instant.parse("2026-06-01T12:00:00Z");
}
@Override
public EffectiveCredentialStatus resolve(Credential credential) {
if (fail) {
throw new PkiException(
"Credential status resolution failed: code=CREDENTIAL_STATUS_RESOLUTION_FAILED");
}
return status.apply(credential);
}
@Override
public Credential requireUsable(Credential credential, CredentialUse use) {
if (resolve(credential) != EffectiveCredentialStatus.USABLE) {
throw new PkiException("Credential trust rejected: code=CREDENTIAL_NOT_USABLE");
}
return credential;
}
};
}
private static Credential copyWithId(Credential source, PkiId id) {
return new Credential(id, source.formatId(), source.issuerRef(), source.subjectRef(), source.validity(),
source.serialOrUniqueId(), source.publicKeyId(), source.profileId(), source.status(), source.encoded(),
source.attributes());
}
private static final class CountingIssuerBackend implements CredentialIssuerBackend {
private final CredentialIssuerBackend delegate;
private final AtomicInteger endEntityCalls;
private final AtomicInteger intermediateCalls;
private CountingIssuerBackend(CredentialIssuerBackend delegate) {
this.delegate = delegate;
this.endEntityCalls = new AtomicInteger();
this.intermediateCalls = new AtomicInteger();
}
@Override
public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) {
endEntityCalls.incrementAndGet();
return delegate.issueEndEntity(candidate);
}
@Override
public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) {
intermediateCalls.incrementAndGet();
return delegate.issueIntermediateCertificate(issuance);
}
}
private static KeyPair genRsa() throws Exception { private static KeyPair genRsa() throws Exception {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048); kpg.initialize(2048);

View File

@@ -172,7 +172,7 @@ final class PkiProofGateE2eTest {
try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), keys)) { try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), keys)) {
CountingIssuerBackend counting = new CountingIssuerBackend(runtime.issuerBackend()); CountingIssuerBackend counting = new CountingIssuerBackend(runtime.issuerBackend());
DefaultIssuanceService issuance = new DefaultIssuanceService(runtime.store(), runtime.framework(), DefaultIssuanceService issuance = new DefaultIssuanceService(runtime.store(), runtime.framework(),
counting, runtime.auditSink()); counting, runtime.auditSink(), runtime.statusResolver());
ParsedCertificationRequest valid = parse(runtime, makeCsr(subjectKey, subjectKey, "CN=Leaf")); ParsedCertificationRequest valid = parse(runtime, makeCsr(subjectKey, subjectKey, "CN=Leaf"));
assertThrows(PkiException.class, assertThrows(PkiException.class,
@@ -217,7 +217,7 @@ final class PkiProofGateE2eTest {
try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), Map.of())) { try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), Map.of())) {
CountingIssuerBackend counting = new CountingIssuerBackend(runtime.issuerBackend()); CountingIssuerBackend counting = new CountingIssuerBackend(runtime.issuerBackend());
DefaultIssuanceService issuance = new DefaultIssuanceService(runtime.store(), runtime.framework(), DefaultIssuanceService issuance = new DefaultIssuanceService(runtime.store(), runtime.framework(),
counting, runtime.auditSink()); counting, runtime.auditSink(), runtime.statusResolver());
CaService caService = runtime.caService(counting); CaService caService = runtime.caService(counting);
ParsedCertificationRequest request = new ParsedCertificationRequest(new PkiId("csr:unsupported"), ParsedCertificationRequest request = new ParsedCertificationRequest(new PkiId("csr:unsupported"),
runtime.framework().formatId(), new SubjectRef("CN=Unsupported"), runtime.framework().formatId(), new SubjectRef("CN=Unsupported"),
@@ -265,11 +265,15 @@ final class PkiProofGateE2eTest {
void endEntityGateRejectsMalformedTamperedAndSubstitutedRequests(@TempDir Path tempDir) throws Exception { void endEntityGateRejectsMalformedTamperedAndSubstitutedRequests(@TempDir Path tempDir) throws Exception {
System.out.println("endEntityGateRejectsMalformedTamperedAndSubstitutedRequests"); System.out.println("endEntityGateRejectsMalformedTamperedAndSubstitutedRequests");
KeyPair rootKey = generateRsa();
KeyPair subjectKey = generateRsa(); KeyPair subjectKey = generateRsa();
KeyPair otherKey = generateRsa(); KeyPair otherKey = generateRsa();
KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:root");
KeyRef subjectKeyRef = new KeyRef("kref:v1:keyring:test:subject"); KeyRef subjectKeyRef = new KeyRef("kref:v1:keyring:test:subject");
try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"),
Map.of(subjectKeyRef, subjectKey))) { Map.of(rootKeyRef, rootKey, subjectKeyRef, subjectKey))) {
PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(),
new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet()));
PKCS10CertificationRequest validCsr = makeCsr(subjectKey, subjectKey, "CN=Subject"); PKCS10CertificationRequest validCsr = makeCsr(subjectKey, subjectKey, "CN=Subject");
ParsedCertificationRequest valid = parse(runtime, validCsr); ParsedCertificationRequest valid = parse(runtime, validCsr);
ParsedCertificationRequest pss = parse(runtime, ParsedCertificationRequest pss = parse(runtime,
@@ -289,44 +293,44 @@ final class PkiProofGateE2eTest {
new zeroecho.pki.api.issuance.VerificationPolicy(true, Optional.empty())); new zeroecho.pki.api.issuance.VerificationPolicy(true, Optional.empty()));
assertEquals(ProofOfPossessionStatus.FAILED, unsupportedProof.status()); assertEquals(ProofOfPossessionStatus.FAILED, unsupportedProof.status());
assertRejected(runtime, withAttributes(valid, new SimpleAttributeSet()), "CSR_MISSING"); assertRejected(runtime, rootCaId, withAttributes(valid, new SimpleAttributeSet()), "CSR_MISSING");
assertRejected(runtime, withCsr(valid, new byte[] { 0x01 }), "CSR_MALFORMED"); assertRejected(runtime, rootCaId, withCsr(valid, new byte[] { 0x01 }), "CSR_MALFORMED");
byte[] tampered = csrDer(valid).clone(); byte[] tampered = csrDer(valid).clone();
tampered[tampered.length - 1] ^= 0x01; tampered[tampered.length - 1] ^= 0x01;
ParsedCertificationRequest tamperedParsed = parse(runtime, new PKCS10CertificationRequest(tampered)); ParsedCertificationRequest tamperedParsed = parse(runtime, new PKCS10CertificationRequest(tampered));
assertRejected(runtime, tamperedParsed, "PROOF_FAILED"); assertRejected(runtime, rootCaId, tamperedParsed, "PROOF_FAILED");
ParsedCertificationRequest wrongSigner = parse(runtime, ParsedCertificationRequest wrongSigner = parse(runtime,
makeCsr(subjectKey, otherKey, "CN=Subject")); makeCsr(subjectKey, otherKey, "CN=Subject"));
assertRejected(runtime, wrongSigner, "PROOF_FAILED"); assertRejected(runtime, rootCaId, wrongSigner, "PROOF_FAILED");
assertRejected(runtime, assertRejected(runtime, rootCaId,
new ParsedCertificationRequest(new PkiId("csr:substituted"), valid.formatId(), valid.subjectRef(), new ParsedCertificationRequest(new PkiId("csr:substituted"), valid.formatId(), valid.subjectRef(),
valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(), valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(),
valid.attributes()), valid.attributes()),
"REQUEST_ID_MISMATCH"); "REQUEST_ID_MISMATCH");
assertRejected(runtime, assertRejected(runtime, rootCaId,
new ParsedCertificationRequest(valid.requestId(), valid.formatId(), new SubjectRef("CN=Other"), new ParsedCertificationRequest(valid.requestId(), valid.formatId(), new SubjectRef("CN=Other"),
valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(), valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(),
valid.attributes()), valid.attributes()),
"SUBJECT_MISMATCH"); "SUBJECT_MISMATCH");
assertRejected(runtime, assertRejected(runtime, rootCaId,
new ParsedCertificationRequest(valid.requestId(), valid.formatId(), valid.subjectRef(), new ParsedCertificationRequest(valid.requestId(), valid.formatId(), valid.subjectRef(),
new EncodedObject(Encoding.DER, otherKey.getPublic().getEncoded()), new EncodedObject(Encoding.DER, otherKey.getPublic().getEncoded()),
valid.requestedValidity(), valid.requestedProfileId(), valid.attributes()), valid.requestedValidity(), valid.requestedProfileId(), valid.attributes()),
"SPKI_MISMATCH"); "SPKI_MISMATCH");
assertRejected(runtime, assertRejected(runtime, rootCaId,
new ParsedCertificationRequest(valid.requestId(), new FormatId("unsupported"), valid.subjectRef(), new ParsedCertificationRequest(valid.requestId(), new FormatId("unsupported"), valid.subjectRef(),
valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(), valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(),
valid.attributes()), valid.attributes()),
"FORMAT_UNSUPPORTED"); "FORMAT_UNSUPPORTED");
byte[] maximum = new byte[1024 * 1024]; byte[] maximum = new byte[1024 * 1024];
System.arraycopy(csrDer(valid), 0, maximum, 0, csrDer(valid).length); System.arraycopy(csrDer(valid), 0, maximum, 0, csrDer(valid).length);
assertRejected(runtime, withCsr(valid, maximum), "CSR_MALFORMED"); assertRejected(runtime, rootCaId, withCsr(valid, maximum), "CSR_MALFORMED");
assertRejected(runtime, withCsr(valid, new byte[1024 * 1024 + 1]), "CSR_TOO_LARGE"); assertRejected(runtime, rootCaId, withCsr(valid, new byte[1024 * 1024 + 1]), "CSR_TOO_LARGE");
assertTrue(runtime.store().listCas().isEmpty()); assertEquals(1, runtime.store().listCas().size());
AuditEvent last = runtime.auditSink().snapshot().get(runtime.auditSink().snapshot().size() - 1); AuditEvent last = runtime.auditSink().snapshot().get(runtime.auditSink().snapshot().size() - 1);
assertEquals("ISSUE_END_ENTITY_REJECTED", last.action()); assertEquals("ISSUE_END_ENTITY_REJECTED", last.action());
assertEquals("SYSTEM", last.principal().type()); assertEquals("SYSTEM", last.principal().type());
@@ -341,7 +345,9 @@ final class PkiProofGateE2eTest {
void endEntityGateForcesRequiredPolicyAndRejectsEveryNonVerifiedStatus(@TempDir Path tempDir) throws Exception { void endEntityGateForcesRequiredPolicyAndRejectsEveryNonVerifiedStatus(@TempDir Path tempDir) throws Exception {
System.out.println("endEntityGateForcesRequiredPolicyAndRejectsEveryNonVerifiedStatus"); System.out.println("endEntityGateForcesRequiredPolicyAndRejectsEveryNonVerifiedStatus");
KeyPair rootKey = generateRsa();
KeyPair subjectKey = generateRsa(); KeyPair subjectKey = generateRsa();
KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:root");
KeyRef subjectKeyRef = new KeyRef("kref:v1:keyring:test:subject"); KeyRef subjectKeyRef = new KeyRef("kref:v1:keyring:test:subject");
for (ProofOfPossessionStatus status : new ProofOfPossessionStatus[] { for (ProofOfPossessionStatus status : new ProofOfPossessionStatus[] {
ProofOfPossessionStatus.NOT_PRESENT, ProofOfPossessionStatus.NOT_PRESENT,
@@ -350,17 +356,20 @@ final class PkiProofGateE2eTest {
AtomicBoolean required = new AtomicBoolean(); AtomicBoolean required = new AtomicBoolean();
Path caseDir = tempDir.resolve(status.name()); Path caseDir = tempDir.resolve(status.name());
try (PkiTestRuntime runtime = PkiTestRuntime.create(caseDir, caseDir.resolve("bus.log"), try (PkiTestRuntime runtime = PkiTestRuntime.create(caseDir, caseDir.resolve("bus.log"),
Map.of(subjectKeyRef, subjectKey), Map.of(subjectKeyRef, subjectKey.getPublic()), Map.of(rootKeyRef, rootKey, subjectKeyRef, subjectKey),
Map.of(rootKeyRef, rootKey.getPublic(), subjectKeyRef, subjectKey.getPublic()),
(request, policy) -> { (request, policy) -> {
required.set(policy.requireProofOfPossession()); required.set(policy.requireProofOfPossession());
return new ProofOfPossessionResult(status, Optional.empty()); return new ProofOfPossessionResult(status, Optional.empty());
})) { })) {
PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(),
new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet()));
ParsedCertificationRequest parsed = parse(runtime, makeCsr(subjectKey, subjectKey, "CN=Subject")); ParsedCertificationRequest parsed = parse(runtime, makeCsr(subjectKey, subjectKey, "CN=Subject"));
assertThrows(PkiException.class, () -> issue(runtime, parsed)); assertThrows(PkiException.class, () -> issue(runtime, rootCaId, parsed));
assertTrue(required.get()); assertTrue(required.get());
assertEquals("PROOF_" + status.name(), assertEquals("PROOF_" + status.name(),
runtime.auditSink().snapshot().get(0).details().get("code")); runtime.auditSink().snapshot().get(0).details().get("code"));
assertTrue(runtime.store().listCas().isEmpty()); assertEquals(1, runtime.store().listCas().size());
} }
} }
@@ -653,7 +662,7 @@ final class PkiProofGateE2eTest {
} }
}; };
DefaultIssuanceService throwingBackendService = new DefaultIssuanceService(runtime.store(), DefaultIssuanceService throwingBackendService = new DefaultIssuanceService(runtime.store(),
runtime.framework(), throwingBackend, runtime.auditSink()); runtime.framework(), throwingBackend, runtime.auditSink(), runtime.statusResolver());
PkiException backendRejection = assertThrows(PkiException.class, PkiException backendRejection = assertThrows(PkiException.class,
() -> throwingBackendService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, () -> throwingBackendService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject,
"default", Optional.empty(), new SimpleAttributeSet()))); "default", Optional.empty(), new SimpleAttributeSet())));
@@ -678,7 +687,7 @@ final class PkiProofGateE2eTest {
} }
}; };
DefaultIssuanceService service = new DefaultIssuanceService(runtime.store(), runtime.framework(), DefaultIssuanceService service = new DefaultIssuanceService(runtime.store(), runtime.framework(),
maliciousBackend, runtime.auditSink()); maliciousBackend, runtime.auditSink(), runtime.statusResolver());
assertThrows(PkiException.class, assertThrows(PkiException.class,
() -> service.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, "default", () -> service.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, "default",
Optional.empty(), new SimpleAttributeSet()))); Optional.empty(), new SimpleAttributeSet())));
@@ -707,7 +716,7 @@ final class PkiProofGateE2eTest {
} }
}; };
DefaultIssuanceService invalidSignatureService = new DefaultIssuanceService(runtime.store(), DefaultIssuanceService invalidSignatureService = new DefaultIssuanceService(runtime.store(),
runtime.framework(), invalidSignatureBackend, runtime.auditSink()); runtime.framework(), invalidSignatureBackend, runtime.auditSink(), runtime.statusResolver());
assertThrows(PkiException.class, assertThrows(PkiException.class,
() -> invalidSignatureService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, () -> invalidSignatureService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject,
"default", Optional.empty(), new SimpleAttributeSet()))); "default", Optional.empty(), new SimpleAttributeSet())));
@@ -727,7 +736,7 @@ final class PkiProofGateE2eTest {
} }
}; };
DefaultIssuanceService snapshotService = new DefaultIssuanceService(runtime.store(), DefaultIssuanceService snapshotService = new DefaultIssuanceService(runtime.store(),
runtime.framework(), mutableBackend, runtime.auditSink()); runtime.framework(), mutableBackend, runtime.auditSink(), runtime.statusResolver());
CredentialBundle returned = snapshotService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, CredentialBundle returned = snapshotService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject,
"default", Optional.empty(), new SimpleAttributeSet())); "default", Optional.empty(), new SimpleAttributeSet()));
byte[] expectedLeaf = returned.credential().encoded().bytes().clone(); byte[] expectedLeaf = returned.credential().encoded().bytes().clone();
@@ -763,6 +772,8 @@ final class PkiProofGateE2eTest {
"default", Optional.empty(), new SimpleAttributeSet()))); "default", Optional.empty(), new SimpleAttributeSet())));
assertEquals(before, runtime.submittedSignCount()); assertEquals(before, runtime.submittedSignCount());
runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(),
root.subjectRef(), List.of(original)));
ParsedCertificationRequest missing = withAttributes(subject, new SimpleAttributeSet()); ParsedCertificationRequest missing = withAttributes(subject, new SimpleAttributeSet());
AttributeSet hostileAttributes = new AttributeSet() { AttributeSet hostileAttributes = new AttributeSet() {
@Override @Override
@@ -789,7 +800,7 @@ final class PkiProofGateE2eTest {
DefaultIssuanceService failingAudit = new DefaultIssuanceService(runtime.store(), runtime.framework(), DefaultIssuanceService failingAudit = new DefaultIssuanceService(runtime.store(), runtime.framework(),
runtime.issuerBackend(), event -> { runtime.issuerBackend(), event -> {
throw new IllegalStateException("DO_NOT_LOG_PAYLOAD_SENTINEL"); throw new IllegalStateException("DO_NOT_LOG_PAYLOAD_SENTINEL");
}); }, runtime.statusResolver());
PkiException rejection = assertThrows(PkiException.class, PkiException rejection = assertThrows(PkiException.class,
() -> failingAudit.issueEndEntity(new IssueEndEntityCommand(rootCaId, missing, "default", () -> failingAudit.issueEndEntity(new IssueEndEntityCommand(rootCaId, missing, "default",
Optional.empty(), new SimpleAttributeSet()))); Optional.empty(), new SimpleAttributeSet())));
@@ -1080,15 +1091,16 @@ final class PkiProofGateE2eTest {
source.publicKeyInfo(), source.requestedValidity(), source.requestedProfileId(), attributes); source.publicKeyInfo(), source.requestedValidity(), source.requestedProfileId(), attributes);
} }
private static void assertRejected(PkiTestRuntime runtime, ParsedCertificationRequest request, String code) { private static void assertRejected(PkiTestRuntime runtime, PkiId issuerCaId,
ParsedCertificationRequest request, String code) {
int before = runtime.auditSink().snapshot().size(); int before = runtime.auditSink().snapshot().size();
assertThrows(PkiException.class, () -> issue(runtime, request)); assertThrows(PkiException.class, () -> issue(runtime, issuerCaId, request));
assertEquals(before + 1, runtime.auditSink().snapshot().size()); assertEquals(before + 1, runtime.auditSink().snapshot().size());
assertEquals(code, runtime.auditSink().snapshot().get(before).details().get("code")); assertEquals(code, runtime.auditSink().snapshot().get(before).details().get("code"));
} }
private static void issue(PkiTestRuntime runtime, ParsedCertificationRequest request) { private static void issue(PkiTestRuntime runtime, PkiId issuerCaId, ParsedCertificationRequest request) {
runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(new PkiId("ca:absent"), request, "default", runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(issuerCaId, request, "default",
Optional.empty(), new SimpleAttributeSet())); Optional.empty(), new SimpleAttributeSet()));
} }

View File

@@ -0,0 +1,135 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.pki.impl.core;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.lang.reflect.Proxy;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.audit.AuditEvent;
import zeroecho.pki.api.revocation.RevocationCommand;
import zeroecho.pki.api.revocation.RevocationJournal;
import zeroecho.pki.api.revocation.RevocationState;
import zeroecho.pki.api.revocation.RevocationTransition;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
import zeroecho.pki.spi.store.PkiStore;
final class DefaultRevocationServiceTest {
private static final Instant NOW = Instant.parse("2026-07-01T12:00:00Z");
private static final PkiId CREDENTIAL_ID = new PkiId("credential:audit");
@Test
void successAuditsCommittedStateAndRevisionOnce() {
RevocationJournal committed = new RevocationJournal(CREDENTIAL_ID,
List.of(new RevocationTransition(1L, RevocationState.HELD, NOW, Optional.empty(),
new SimpleAttributeSet())));
List<AuditEvent> events = new ArrayList<>();
DefaultRevocationService service = new DefaultRevocationService(store((method, arguments) -> {
if ("transitionRevocation".equals(method)) {
return committed;
}
throw new UnsupportedOperationException(method);
}), Clock.fixed(NOW, ZoneOffset.UTC), events::add);
assertEquals(committed,
service.hold(new RevocationCommand.Hold(CREDENTIAL_ID, new SimpleAttributeSet())));
assertEquals(1, events.size());
assertEquals("COMMITTED", events.get(0).details().get("result"));
assertEquals("HELD", events.get(0).details().get("state"));
assertEquals("1", events.get(0).details().get("revision"));
}
@Test
void hostileStoreFailureIsRedactedAndProducesOnlyOneRejectionAudit() {
String sentinel = "DO_NOT_EXPOSE_REVOCATION_STORE_SENTINEL";
List<AuditEvent> events = new ArrayList<>();
AtomicBoolean lookupAttempted = new AtomicBoolean();
DefaultRevocationService service = new DefaultRevocationService(store((method, arguments) -> {
if ("transitionRevocation".equals(method)) {
throw new IllegalStateException(sentinel);
}
if ("getRevocationJournal".equals(method)) {
lookupAttempted.set(true);
throw new IllegalStateException(sentinel);
}
throw new UnsupportedOperationException(method);
}), Clock.fixed(NOW, ZoneOffset.UTC), events::add);
PkiException failure = assertThrows(PkiException.class,
() -> service.hold(new RevocationCommand.Hold(CREDENTIAL_ID, new SimpleAttributeSet())));
assertEquals("Revocation operation failed: code=REVOCATION_STATE_UPDATE_FAILED", failure.getMessage());
assertNull(failure.getCause());
assertFalse(failure.toString().contains(sentinel));
assertEquals(1, events.size());
assertEquals("REJECTED", events.get(0).details().get("result"));
assertEquals("REVOCATION_STATE_UPDATE_FAILED", events.get(0).details().get("code"));
assertFalse(events.toString().contains(sentinel));
assertTrue(lookupAttempted.get());
}
@Test
void hostileClockFailureIsRedactedAuditedOnceAndNeverReachesStoreTransition() {
String sentinel = "DO_NOT_EXPOSE_REVOCATION_CLOCK_SENTINEL";
List<AuditEvent> events = new ArrayList<>();
AtomicBoolean storeInvoked = new AtomicBoolean();
Clock failingClock = new Clock() {
@Override
public ZoneId getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(ZoneId zone) {
return this;
}
@Override
public Instant instant() {
throw new IllegalStateException(sentinel);
}
};
DefaultRevocationService service = new DefaultRevocationService(store((method, arguments) -> {
storeInvoked.set(true);
throw new AssertionError("Store must not be invoked after clock failure");
}), failingClock, events::add);
PkiException failure = assertThrows(PkiException.class,
() -> service.hold(new RevocationCommand.Hold(CREDENTIAL_ID, new SimpleAttributeSet())));
assertEquals("Revocation operation failed: code=REVOCATION_TIME_UNAVAILABLE", failure.getMessage());
assertNull(failure.getCause());
assertFalse(failure.toString().contains(sentinel));
assertFalse(storeInvoked.get());
assertEquals(1, events.size());
assertEquals("REJECTED", events.get(0).details().get("result"));
assertEquals("REVOCATION_TIME_UNAVAILABLE", events.get(0).details().get("code"));
assertFalse(events.toString().contains(sentinel));
}
private static PkiStore store(Invocation invocation) {
return (PkiStore) Proxy.newProxyInstance(PkiStore.class.getClassLoader(), new Class<?>[] { PkiStore.class },
(proxy, method, arguments) -> invocation.invoke(method.getName(), arguments));
}
@FunctionalInterface
private interface Invocation {
Object invoke(String method, Object[] arguments);
}
}

View File

@@ -0,0 +1,488 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.pki.impl.core;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Proxy;
import java.math.BigInteger;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.CRLReason;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.cert.X509CRLEntryHolder;
import org.bouncycastle.cert.X509CRLHolder;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.pkcs.PKCS10CertificationRequest;
import org.bouncycastle.pkcs.PKCS10CertificationRequestBuilder;
import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.IssuanceService;
import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.SubjectRef;
import zeroecho.pki.api.Validity;
import zeroecho.pki.api.attr.AttributeSet;
import zeroecho.pki.api.attr.AttributeValue;
import zeroecho.pki.api.ca.CaCreateCommand;
import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialStatus;
import zeroecho.pki.api.credential.CredentialUse;
import zeroecho.pki.api.credential.EffectiveCredentialStatus;
import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver;
import zeroecho.pki.api.issuance.IssueEndEntityCommand;
import zeroecho.pki.api.issuance.VerificationPolicy;
import zeroecho.pki.api.request.CertificationRequest;
import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.api.revocation.RevocationCommand;
import zeroecho.pki.api.revocation.RevocationJournal;
import zeroecho.pki.api.revocation.RevocationReason;
import zeroecho.pki.api.revocation.RevocationState;
import zeroecho.pki.api.revocation.RevocationTransition;
import zeroecho.pki.api.status.StatusObject;
import zeroecho.pki.api.status.StatusObjectGenerateCommand;
import zeroecho.pki.api.status.StatusObjectType;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes;
import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialFramework;
import zeroecho.pki.spi.framework.CrlEntry;
import zeroecho.pki.spi.framework.CredentialFramework;
import zeroecho.pki.spi.framework.StatusObjectGenerator;
import zeroecho.pki.spi.store.PkiStore;
import zeroecho.pki.testkit.PkiTestRuntime;
final class DefaultStatusObjectServiceCrlTest {
private static final Instant EVALUATION_TIME = Instant.parse("2026-07-30T12:00:00Z");
private static final String SENTINEL = "DO_NOT_EXPOSE_CRL_SENTINEL";
@Test
void bouncyCastleGeneratorPreservesFullSerialReasonAndTransitionTime(@TempDir Path root) throws Exception {
KeyPair rootKey = generateRsa();
KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:crl-generator");
try (PkiTestRuntime runtime = PkiTestRuntime.create(root, root.resolve("bus.log"),
Map.of(rootKeyRef, rootKey))) {
PkiId caId = createRoot(runtime, rootKeyRef, "CRL Generator Root");
Credential issuer = runtime.caService().getCa(caId).caCredentials().get(0);
StatusObjectGenerateCommand command = crlCommand(caId, issuer, rootKeyRef);
List<RevocationReason> reasons = activeReasons();
List<BigInteger> serials = serials(reasons.size());
List<CrlEntry> entries = new ArrayList<>();
for (int index = 0; index < reasons.size(); index++) {
entries.add(new CrlEntry(serials.get(index),
EVALUATION_TIME.minusSeconds(index + 1L).plusNanos(987_654_321L),
reasons.get(index)));
}
StatusObject status = runtime.framework().statusObjectGenerator().generate(command, entries);
X509CRLHolder crl = new X509CRLHolder(status.encoded().bytes());
for (CrlEntry entry : entries) {
X509CRLEntryHolder encoded = crl.getRevokedCertificate(entry.serialNumber());
assertTrue(encoded != null, () -> "missing serial " + entry.serialNumber().bitLength());
assertEquals(Date.from(entry.transitionTime().truncatedTo(ChronoUnit.SECONDS)),
encoded.getRevocationDate());
assertEquals(reasonCode(entry.reason()), encodedReason(encoded));
}
}
}
@Test
void serviceProjectsLatestHeldAndPermanentEntriesAndOmitsClear(@TempDir Path root) throws Exception {
KeyPair rootKey = generateRsa();
KeyPair heldKey = generateRsa();
KeyPair permanentKey = generateRsa();
KeyPair clearKey = generateRsa();
KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:crl-service");
try (PkiTestRuntime runtime = PkiTestRuntime.create(root, root.resolve("bus.log"),
Map.of(rootKeyRef, rootKey))) {
PkiId caId = createRoot(runtime, rootKeyRef, "CRL Service Root");
Credential held = issue(runtime, caId, heldKey, "Held");
Credential permanent = issue(runtime, caId, permanentKey, "Permanent");
Credential clear = issue(runtime, caId, clearKey, "Clear");
RevocationJournal heldJournal = runtime.revocationService().hold(
new RevocationCommand.Hold(held.credentialId(), emptyAttributes()));
RevocationJournal permanentJournal = runtime.revocationService().revokePermanently(
new RevocationCommand.RevokePermanently(permanent.credentialId(),
RevocationReason.AA_COMPROMISE, emptyAttributes()));
runtime.revocationService().hold(
new RevocationCommand.Hold(clear.credentialId(), emptyAttributes()));
runtime.revocationService().unhold(
new RevocationCommand.Unhold(clear.credentialId(), emptyAttributes()));
StatusObject status = runtime.statusObjectService().generate(new StatusObjectGenerateCommand(
caId, StatusObjectType.CRL, runtime.framework().formatId(), emptyAttributes()));
X509CRLHolder crl = new X509CRLHolder(status.encoded().bytes());
X509CertificateHolderView heldCertificate = certificate(held);
X509CertificateHolderView permanentCertificate = certificate(permanent);
X509CertificateHolderView clearCertificate = certificate(clear);
assertEntry(crl, heldCertificate.serial(), heldJournal.latest().time(),
RevocationReason.CERTIFICATE_HOLD);
assertEntry(crl, permanentCertificate.serial(), permanentJournal.latest().time(),
RevocationReason.AA_COMPROMISE);
assertNull(crl.getRevokedCertificate(clearCertificate.serial()));
}
}
@Test
void malformedAuthoritativeInputsAbortBeforeSigningAndPersistence(@TempDir Path root) throws Exception {
KeyPair rootKey = generateRsa();
KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:crl-fail-closed");
try (PkiTestRuntime runtime = PkiTestRuntime.create(root, root.resolve("bus.log"),
Map.of(rootKeyRef, rootKey))) {
PkiId caId = createRoot(runtime, rootKeyRef, "CRL Failure Root");
Credential template = runtime.caService().getCa(caId).caCredentials().get(0);
StatusObjectGenerateCommand command = new StatusObjectGenerateCommand(caId, StatusObjectType.CRL,
runtime.framework().formatId(), emptyAttributes());
assertCrlFailure(runtime, command,
List.of(journal(new PkiId("credential:missing"), RevocationState.HELD,
EVALUATION_TIME.minusSeconds(1), Optional.empty())),
Map.of(), false);
Credential wrongFormat = copy(template, "wrong-format", new FormatId("not-x509"),
template.encoded());
assertCrlFailure(runtime, command,
List.of(journal(wrongFormat.credentialId(), RevocationState.HELD,
EVALUATION_TIME.minusSeconds(1), Optional.empty())),
Map.of(wrongFormat.credentialId(), wrongFormat), false);
Credential wrongEncoding = copy(template, "wrong-encoding",
BcX509CredentialFramework.FORMAT_ID,
new EncodedObject(Encoding.PEM, template.encoded().bytes()));
assertCrlFailure(runtime, command,
List.of(journal(wrongEncoding.credentialId(), RevocationState.HELD,
EVALUATION_TIME.minusSeconds(1), Optional.empty())),
Map.of(wrongEncoding.credentialId(), wrongEncoding), false);
Credential malformed = copy(template, "malformed", BcX509CredentialFramework.FORMAT_ID,
new EncodedObject(Encoding.DER, new byte[] { 1, 2, 3 }));
assertCrlFailure(runtime, command,
List.of(journal(malformed.credentialId(), RevocationState.HELD,
EVALUATION_TIME.minusSeconds(1), Optional.empty())),
Map.of(malformed.credentialId(), malformed), false);
Credential duplicateOne = copy(template, "duplicate-one", BcX509CredentialFramework.FORMAT_ID,
template.encoded());
Credential duplicateTwo = copy(template, "duplicate-two", BcX509CredentialFramework.FORMAT_ID,
template.encoded());
assertCrlFailure(runtime, command, List.of(
journal(duplicateOne.credentialId(), RevocationState.HELD,
EVALUATION_TIME.minusSeconds(1), Optional.empty()),
journal(duplicateTwo.credentialId(), RevocationState.PERMANENTLY_REVOKED,
EVALUATION_TIME.minusSeconds(1), Optional.of(RevocationReason.KEY_COMPROMISE))),
Map.of(duplicateOne.credentialId(), duplicateOne,
duplicateTwo.credentialId(), duplicateTwo), false);
Credential future = copy(template, "future", BcX509CredentialFramework.FORMAT_ID,
template.encoded());
assertCrlFailure(runtime, command,
List.of(journal(future.credentialId(), RevocationState.HELD,
EVALUATION_TIME.plusSeconds(1), Optional.empty())),
Map.of(future.credentialId(), future), false);
assertCrlFailure(runtime, command, List.of(), Map.of(), true);
}
}
@Test
void generatorAndPersistenceFailuresExposeNoPartialCrlOrHostileDiagnostics(@TempDir Path root)
throws Exception {
KeyPair rootKey = generateRsa();
KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:crl-boundary");
try (PkiTestRuntime runtime = PkiTestRuntime.create(root, root.resolve("bus.log"),
Map.of(rootKeyRef, rootKey))) {
PkiId caId = createRoot(runtime, rootKeyRef, "CRL Boundary Root");
StatusObjectGenerateCommand command = new StatusObjectGenerateCommand(caId, StatusObjectType.CRL,
runtime.framework().formatId(), emptyAttributes());
int signCount = runtime.submittedSignCount();
int statusCount = runtime.store().listStatusObjects(caId).size();
AtomicInteger generatorCalls = new AtomicInteger();
StatusObjectGenerator hostileGenerator = (ignoredCommand, ignoredEntries) -> {
generatorCalls.incrementAndGet();
throw new IllegalStateException(SENTINEL);
};
DefaultStatusObjectService generatorFailureService = new DefaultStatusObjectService(
runtime.store(), frameworkView(runtime.framework(), hostileGenerator),
runtime.auditSink(), usableResolver());
AtomicReference<StatusObject> generatorResult = new AtomicReference<>();
PkiException generatorFailure = assertThrows(PkiException.class,
() -> generatorResult.set(generatorFailureService.generate(command)));
assertBoundaryFailure(generatorFailure);
assertNull(generatorResult.get());
assertEquals(1, generatorCalls.get());
assertEquals(statusCount, runtime.store().listStatusObjects(caId).size());
assertEquals(signCount, runtime.submittedSignCount());
AtomicInteger persistenceCalls = new AtomicInteger();
StatusObject generated = new StatusObject(new PkiId("status:controlled-crl"),
command.formatId(), caId, StatusObjectType.CRL, EVALUATION_TIME,
Optional.empty(), new EncodedObject(Encoding.DER, new byte[] { 1 }),
emptyAttributes());
StatusObjectGenerator controlledGenerator = (ignoredCommand, ignoredEntries) -> generated;
DefaultStatusObjectService persistenceFailureService = new DefaultStatusObjectService(
failingPersistenceStore(runtime.store(), persistenceCalls),
frameworkView(runtime.framework(), controlledGenerator),
runtime.auditSink(), usableResolver());
AtomicReference<StatusObject> persistenceResult = new AtomicReference<>();
PkiException persistenceFailure = assertThrows(PkiException.class,
() -> persistenceResult.set(persistenceFailureService.generate(command)));
assertBoundaryFailure(persistenceFailure);
assertNull(persistenceResult.get());
assertEquals(1, persistenceCalls.get());
assertEquals(statusCount, runtime.store().listStatusObjects(caId).size());
assertEquals(signCount, runtime.submittedSignCount());
}
}
private static void assertCrlFailure(PkiTestRuntime runtime, StatusObjectGenerateCommand command,
List<RevocationJournal> journals, Map<PkiId, Credential> credentials, boolean failListing) {
int signCount = runtime.submittedSignCount();
int statusCount = runtime.store().listStatusObjects(command.issuerCaId()).size();
PkiStore view = storeView(runtime.store(), journals, credentials, failListing);
DefaultStatusObjectService service = new DefaultStatusObjectService(view, runtime.framework(),
runtime.auditSink(), usableResolver());
PkiException failure = assertThrows(PkiException.class, () -> service.generate(command));
assertTrue(failure.getMessage().contains("code=CRL_GENERATION_FAILED"));
assertFalse(failure.getMessage().contains(SENTINEL));
assertNull(failure.getCause());
assertEquals(signCount, runtime.submittedSignCount());
assertEquals(statusCount, runtime.store().listStatusObjects(command.issuerCaId()).size());
}
private static PkiStore storeView(PkiStore delegate, List<RevocationJournal> journals,
Map<PkiId, Credential> credentials, boolean failListing) {
return (PkiStore) Proxy.newProxyInstance(PkiStore.class.getClassLoader(), new Class<?>[] { PkiStore.class },
(proxy, method, arguments) -> {
if (method.getName().equals("listRevocationJournals")) {
if (failListing) {
throw new IllegalStateException(SENTINEL);
}
return journals;
}
if (method.getName().equals("getCredential")) {
PkiId id = (PkiId) arguments[0];
if (credentials.containsKey(id)) {
return Optional.of(credentials.get(id));
}
}
try {
return method.invoke(delegate, arguments);
} catch (InvocationTargetException exception) {
throw exception.getCause();
}
});
}
private static PkiStore failingPersistenceStore(PkiStore delegate, AtomicInteger persistenceCalls) {
return (PkiStore) Proxy.newProxyInstance(PkiStore.class.getClassLoader(), new Class<?>[] { PkiStore.class },
(proxy, method, arguments) -> {
if (method.getName().equals("putStatusObject")) {
persistenceCalls.incrementAndGet();
throw new IllegalStateException(SENTINEL);
}
try {
return method.invoke(delegate, arguments);
} catch (InvocationTargetException exception) {
throw exception.getCause();
}
});
}
private static CredentialFramework frameworkView(CredentialFramework delegate,
StatusObjectGenerator generator) {
return (CredentialFramework) Proxy.newProxyInstance(CredentialFramework.class.getClassLoader(),
new Class<?>[] { CredentialFramework.class }, (proxy, method, arguments) -> {
if (method.getName().equals("statusObjectGenerator")) {
return generator;
}
try {
return method.invoke(delegate, arguments);
} catch (InvocationTargetException exception) {
throw exception.getCause();
}
});
}
private static void assertBoundaryFailure(PkiException failure) {
assertTrue(failure.getMessage().contains("code=CRL_GENERATION_FAILED"));
assertFalse(failure.getMessage().contains(SENTINEL));
assertNull(failure.getCause());
assertEquals(0, failure.getSuppressed().length);
}
private static EffectiveCredentialStatusResolver usableResolver() {
return () -> new EffectiveCredentialStatusResolver.Evaluation() {
@Override
public Instant evaluationTime() {
return EVALUATION_TIME;
}
@Override
public EffectiveCredentialStatus resolve(Credential credential) {
return EffectiveCredentialStatus.USABLE;
}
@Override
public Credential requireUsable(Credential credential, CredentialUse use) {
return credential;
}
};
}
private static RevocationJournal journal(PkiId credentialId, RevocationState state, Instant time,
Optional<RevocationReason> reason) {
return new RevocationJournal(credentialId, List.of(new RevocationTransition(
1L, state, time, reason, emptyAttributes())));
}
private static Credential copy(Credential template, String suffix, FormatId formatId, EncodedObject encoded) {
return new Credential(new PkiId("credential:" + suffix), formatId, template.issuerRef(),
template.subjectRef(), template.validity(), template.serialOrUniqueId(),
template.publicKeyId(), template.profileId(), CredentialStatus.ISSUED, encoded,
template.attributes());
}
private static StatusObjectGenerateCommand crlCommand(PkiId caId, Credential issuer, KeyRef keyRef) {
AttributeSet attributes = SimpleAttributeSet.builder()
.put(BcX509Attributes.ISSUER_CERT_DER,
new AttributeValue.BytesValue(issuer.encoded().bytes()))
.put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue(keyRef.value()))
.build();
return new StatusObjectGenerateCommand(caId, StatusObjectType.CRL,
BcX509CredentialFramework.FORMAT_ID, attributes);
}
private static Credential issue(PkiTestRuntime runtime, PkiId caId, KeyPair subjectKey, String commonName)
throws Exception {
PKCS10CertificationRequest request = certificationRequest(subjectKey, commonName);
ParsedCertificationRequest parsed = runtime.certificationRequestService().parse(
new CertificationRequest(runtime.framework().formatId(),
new EncodedObject(Encoding.DER, request.getEncoded())));
runtime.certificationRequestService().verifyProofOfPossession(parsed,
new VerificationPolicy(true, Optional.empty()));
IssuanceService issuance = runtime.issuanceService();
return issuance.issueEndEntity(new IssueEndEntityCommand(caId, parsed, "default",
Optional.of(new Validity(EVALUATION_TIME.minus(Duration.ofDays(1)),
EVALUATION_TIME.plus(Duration.ofDays(365)))),
emptyAttributes())).credential();
}
private static PKCS10CertificationRequest certificationRequest(KeyPair pair, String commonName)
throws Exception {
X500Name subject = new X500Name("CN=" + commonName);
PKCS10CertificationRequestBuilder builder =
new JcaPKCS10CertificationRequestBuilder(subject, pair.getPublic());
ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(pair.getPrivate());
return builder.build(signer);
}
private static PkiId createRoot(PkiTestRuntime runtime, KeyRef keyRef, String commonName) {
return runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(),
new SubjectRef("CN=" + commonName), "default", Optional.of(keyRef), emptyAttributes()));
}
private static KeyPair generateRsa() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
return generator.generateKeyPair();
}
private static List<RevocationReason> activeReasons() {
return Arrays.stream(RevocationReason.values())
.filter(reason -> reason != RevocationReason.REMOVE_FROM_CRL)
.toList();
}
private static List<BigInteger> serials(int size) {
List<BigInteger> values = new ArrayList<>(List.of(
BigInteger.ONE,
BigInteger.valueOf(Long.MAX_VALUE),
BigInteger.valueOf(Long.MAX_VALUE).add(BigInteger.ONE),
BigInteger.ONE.shiftLeft(159).subtract(BigInteger.ONE)));
for (long value = 2L; values.size() < size; value++) {
values.add(BigInteger.valueOf(value));
}
return List.copyOf(values);
}
private static int encodedReason(X509CRLEntryHolder entry) {
org.bouncycastle.asn1.x509.Extension extension = entry.getExtension(Extension.reasonCode);
if (extension == null) {
return CRLReason.unspecified;
}
return CRLReason.getInstance(extension.getParsedValue()).getValue().intValueExact();
}
private static int reasonCode(RevocationReason reason) {
return switch (reason) {
case UNSPECIFIED -> CRLReason.unspecified;
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 -> throw new IllegalArgumentException("inactive reason");
case PRIVILEGE_WITHDRAWN -> CRLReason.privilegeWithdrawn;
case AA_COMPROMISE -> CRLReason.aACompromise;
};
}
private static void assertEntry(X509CRLHolder crl, BigInteger serial, Instant time,
RevocationReason reason) {
X509CRLEntryHolder entry = crl.getRevokedCertificate(serial);
assertTrue(entry != null);
assertEquals(Date.from(time.truncatedTo(ChronoUnit.SECONDS)), entry.getRevocationDate());
assertEquals(reasonCode(reason), encodedReason(entry));
}
private static X509CertificateHolderView certificate(Credential credential) throws Exception {
byte[] encoded = credential.encoded().bytes();
try {
return new X509CertificateHolderView(
new org.bouncycastle.cert.X509CertificateHolder(encoded).getSerialNumber());
} finally {
Arrays.fill(encoded, (byte) 0);
}
}
private static SimpleAttributeSet emptyAttributes() {
return new SimpleAttributeSet();
}
private record X509CertificateHolderView(BigInteger serial) {
}
}

View File

@@ -0,0 +1,284 @@
/*******************************************************************************
* 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.core;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.lang.reflect.Proxy;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import org.junit.jupiter.api.Test;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.IssuerRef;
import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.SubjectRef;
import zeroecho.pki.api.Validity;
import zeroecho.pki.api.audit.AuditEvent;
import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialStatus;
import zeroecho.pki.api.credential.CredentialUse;
import zeroecho.pki.api.credential.EffectiveCredentialStatus;
import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver;
import zeroecho.pki.api.revocation.RevocationJournal;
import zeroecho.pki.api.revocation.RevocationReason;
import zeroecho.pki.api.revocation.RevocationState;
import zeroecho.pki.api.revocation.RevocationTransition;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
import zeroecho.pki.spi.store.PkiStore;
final class StoreBackedEffectiveCredentialStatusResolverTest {
private static final Instant NOW = Instant.parse("2026-06-01T12:00:00Z");
@Test
void resolvesInventoryValidityAndCurrentRevocationPrecedence() {
Credential usable = credential("usable", CredentialStatus.ISSUED, NOW.minusSeconds(60), NOW.plusSeconds(60));
assertStatus(usable, Optional.empty(), EffectiveCredentialStatus.USABLE);
Credential future = credential("future", CredentialStatus.ISSUED, NOW.plusSeconds(1), NOW.plusSeconds(60));
assertStatus(future, Optional.empty(), EffectiveCredentialStatus.NOT_YET_VALID);
Credential exactNotAfter = credential("boundary", CredentialStatus.ISSUED, NOW.minusSeconds(60), NOW);
assertStatus(exactNotAfter, Optional.empty(), EffectiveCredentialStatus.USABLE);
Credential expiredByTime = credential("expired-time", CredentialStatus.ISSUED, NOW.minusSeconds(60),
NOW.minusNanos(1));
assertStatus(expiredByTime, Optional.empty(), EffectiveCredentialStatus.EXPIRED);
Credential expiredMetadata = credential("expired-metadata", CredentialStatus.EXPIRED, NOW.minusSeconds(60),
NOW.plusSeconds(60));
assertStatus(expiredMetadata, Optional.empty(), EffectiveCredentialStatus.EXPIRED);
Credential held = credential("held", CredentialStatus.ISSUED, NOW.minusSeconds(60), NOW.plusSeconds(60));
assertStatus(held, revocation(held, RevocationReason.CERTIFICATE_HOLD, NOW),
EffectiveCredentialStatus.HELD);
Credential revoked = credential("revoked", CredentialStatus.ISSUED, NOW.minusSeconds(60),
NOW.plusSeconds(60));
assertStatus(revoked, revocation(revoked, RevocationReason.KEY_COMPROMISE, NOW),
EffectiveCredentialStatus.PERMANENTLY_REVOKED);
}
@Test
void embeddedRevokedIsPermanentFloorAcrossHoldAndUnholdRecords() {
Credential revoked = credential("floor", CredentialStatus.REVOKED, NOW.minusSeconds(60),
NOW.plusSeconds(60));
assertStatus(revoked, Optional.empty(), EffectiveCredentialStatus.PERMANENTLY_REVOKED);
assertStatus(revoked, revocation(revoked, RevocationReason.CERTIFICATE_HOLD, NOW),
EffectiveCredentialStatus.PERMANENTLY_REVOKED);
assertStatus(revoked, revocation(revoked, RevocationReason.REMOVE_FROM_CRL, NOW),
EffectiveCredentialStatus.PERMANENTLY_REVOKED);
}
@Test
void futureAndMismatchedJournalsFailClosed() {
Credential credential = credential("invalid", CredentialStatus.ISSUED, NOW.minusSeconds(60),
NOW.plusSeconds(60));
assertResolutionFailure(credential,
revocation(credential, RevocationReason.KEY_COMPROMISE, NOW.plusNanos(1)));
RevocationJournal mismatch = new RevocationJournal(new PkiId("credential:other"),
List.of(new RevocationTransition(1L, RevocationState.PERMANENTLY_REVOKED, NOW,
Optional.of(RevocationReason.KEY_COMPROMISE), new SimpleAttributeSet())));
assertResolutionFailure(credential, Optional.of(mismatch));
}
@Test
void lookupFailureIsRedactedAndRequireUsableUsesStableCode() {
Credential credential = credential("failure", CredentialStatus.ISSUED, NOW.minusSeconds(60),
NOW.plusSeconds(60));
PkiStore failing = store(id -> {
throw new IllegalStateException("DO_NOT_EXPOSE_REVOCATION_SENTINEL");
}, new AtomicInteger());
StoreBackedEffectiveCredentialStatusResolver resolver =
new StoreBackedEffectiveCredentialStatusResolver(failing, Clock.fixed(NOW, ZoneOffset.UTC));
PkiException failure = assertThrows(PkiException.class,
() -> resolver.beginEvaluation().resolve(credential));
assertEquals("Credential status resolution failed: code=CREDENTIAL_STATUS_RESOLUTION_FAILED",
failure.getMessage());
assertFalse(messages(failure).contains("DO_NOT_EXPOSE_REVOCATION_SENTINEL"));
Credential held = credential("not-usable", CredentialStatus.ISSUED, NOW.minusSeconds(60),
NOW.plusSeconds(60));
EffectiveCredentialStatusResolver.Evaluation evaluation = resolver(
revocation(held, RevocationReason.CERTIFICATE_HOLD, NOW)).beginEvaluation();
PkiException rejected = assertThrows(PkiException.class,
() -> evaluation.requireUsable(held, CredentialUse.BUNDLE_DELIVERY));
assertEquals("Credential trust rejected: code=CREDENTIAL_NOT_USABLE", rejected.getMessage());
}
@Test
void oneEvaluationCapturesClockOnceAndLooksUpEachCredentialOnce() {
CountingClock clock = new CountingClock(NOW);
AtomicInteger lookups = new AtomicInteger();
StoreBackedEffectiveCredentialStatusResolver resolver =
new StoreBackedEffectiveCredentialStatusResolver(store(id -> Optional.empty(), lookups), clock);
EffectiveCredentialStatusResolver.Evaluation evaluation = resolver.beginEvaluation();
Credential first = credential("first", CredentialStatus.ISSUED, NOW.minusSeconds(60), NOW.plusSeconds(60));
Credential second = credential("second", CredentialStatus.ISSUED, NOW.minusSeconds(60), NOW.plusSeconds(60));
assertEquals(EffectiveCredentialStatus.USABLE, evaluation.resolve(first));
assertSame(second, evaluation.requireUsable(second, CredentialUse.END_ENTITY_ISSUER));
assertEquals(NOW, evaluation.evaluationTime());
assertEquals(1, clock.reads());
assertEquals(2, lookups.get());
}
@Test
void trustAuditOmitsFormatAndSensitiveMetadata() {
String sentinel = "DO_NOT_AUDIT_FORMAT_SENTINEL";
Credential credential = new Credential(new PkiId("credential:audit"), new FormatId(sentinel),
new IssuerRef(new PkiId("ca:issuer")), new SubjectRef("CN=Audit"),
new Validity(NOW.minusSeconds(60), NOW.plusSeconds(60)), "audit", new PkiId("key:audit"),
"default", CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, new byte[] { 1 }),
new SimpleAttributeSet());
AtomicReference<AuditEvent> recorded = new AtomicReference<>();
CredentialTrustAudit.rejected(recorded::set, NOW, credential, CredentialUse.END_ENTITY_ISSUER,
"ISSUER_CREDENTIAL_UNAVAILABLE", EffectiveCredentialStatus.HELD);
AuditEvent event = recorded.get();
assertEquals(Optional.empty(), event.formatId());
assertFalse(event.toString().contains(sentinel));
assertFalse(event.details().toString().contains(sentinel));
}
private static void assertStatus(Credential credential, Optional<RevocationJournal> revocation,
EffectiveCredentialStatus expected) {
assertEquals(expected, resolver(revocation).beginEvaluation().resolve(credential));
}
private static void assertResolutionFailure(Credential credential, Optional<RevocationJournal> revocation) {
PkiException failure = assertThrows(PkiException.class,
() -> resolver(revocation).beginEvaluation().resolve(credential));
assertEquals("Credential status resolution failed: code=CREDENTIAL_STATUS_RESOLUTION_FAILED",
failure.getMessage());
}
private static StoreBackedEffectiveCredentialStatusResolver resolver(Optional<RevocationJournal> revocation) {
return new StoreBackedEffectiveCredentialStatusResolver(store(id -> revocation, new AtomicInteger()),
Clock.fixed(NOW, ZoneOffset.UTC));
}
private static PkiStore store(Function<PkiId, Optional<RevocationJournal>> lookup, AtomicInteger calls) {
return (PkiStore) Proxy.newProxyInstance(PkiStore.class.getClassLoader(), new Class<?>[] { PkiStore.class },
(proxy, method, arguments) -> {
if ("getRevocationJournal".equals(method.getName())) {
calls.incrementAndGet();
return lookup.apply((PkiId) arguments[0]);
}
throw new UnsupportedOperationException(method.getName());
});
}
private static Optional<RevocationJournal> revocation(Credential credential, RevocationReason reason,
Instant time) {
if (reason == RevocationReason.REMOVE_FROM_CRL) {
return Optional.of(new RevocationJournal(credential.credentialId(), List.of(
new RevocationTransition(1L, RevocationState.HELD, time.minusNanos(1), Optional.empty(),
new SimpleAttributeSet()),
new RevocationTransition(2L, RevocationState.CLEAR, time, Optional.empty(),
new SimpleAttributeSet()))));
}
RevocationState state = reason == RevocationReason.CERTIFICATE_HOLD
? RevocationState.HELD : RevocationState.PERMANENTLY_REVOKED;
Optional<RevocationReason> permanentReason = state == RevocationState.PERMANENTLY_REVOKED
? Optional.of(reason) : Optional.empty();
return Optional.of(new RevocationJournal(credential.credentialId(),
List.of(new RevocationTransition(1L, state, time, permanentReason, new SimpleAttributeSet()))));
}
private static Credential credential(String suffix, CredentialStatus status, Instant notBefore, Instant notAfter) {
return new Credential(new PkiId("credential:" + suffix), new FormatId("x509"),
new IssuerRef(new PkiId("ca:issuer")), new SubjectRef("CN=" + suffix),
new Validity(notBefore, notAfter), suffix, new PkiId("key:" + suffix), "default", status,
new EncodedObject(Encoding.DER, new byte[] { 1, 2, 3 }), new SimpleAttributeSet());
}
private static String messages(Throwable throwable) {
StringBuilder messages = new StringBuilder();
Throwable current = throwable;
while (current != null) {
if (current.getMessage() != null) {
messages.append(current.getMessage());
}
current = current.getCause();
}
return messages.toString();
}
private static final class CountingClock extends Clock {
private final Instant instant;
private final AtomicInteger reads;
private CountingClock(Instant instant) {
this.instant = instant;
this.reads = new AtomicInteger();
}
@Override
public ZoneId getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(ZoneId zone) {
return this;
}
@Override
public Instant instant() {
reads.incrementAndGet();
return instant;
}
private int reads() {
return reads.get();
}
}
}

View File

@@ -78,8 +78,9 @@ import zeroecho.pki.api.publication.PublicationStatus;
import zeroecho.pki.api.publication.PublicationTarget; import zeroecho.pki.api.publication.PublicationTarget;
import zeroecho.pki.api.publication.PublicationTargetType; import zeroecho.pki.api.publication.PublicationTargetType;
import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.api.revocation.RevocationCommand;
import zeroecho.pki.api.revocation.RevocationJournal;
import zeroecho.pki.api.revocation.RevocationReason; import zeroecho.pki.api.revocation.RevocationReason;
import zeroecho.pki.api.revocation.RevokedRecord;
import zeroecho.pki.api.status.StatusObject; import zeroecho.pki.api.status.StatusObject;
import zeroecho.pki.api.status.StatusObjectType; import zeroecho.pki.api.status.StatusObjectType;
import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy; import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy;
@@ -120,8 +121,6 @@ public final class FilesystemPkiStoreTest {
new FormatId("fmt-x509"), new SubjectRef("CN=request-all"), new FormatId("fmt-x509"), new SubjectRef("CN=request-all"),
new EncodedObject(Encoding.DER, new byte[] { 4, 5, 6 }), Optional.empty(), new EncodedObject(Encoding.DER, new byte[] { 4, 5, 6 }), Optional.empty(),
Optional.of("profile-all"), attributes); Optional.of("profile-all"), attributes);
RevokedRecord revocation = TestObjects.minimalRevocation(credential.credentialId().value(), now,
RevocationReason.KEY_COMPROMISE);
StatusObject status = new StatusObject(new PkiId("status-all"), new FormatId("fmt-x509"), ca.caId(), StatusObject status = new StatusObject(new PkiId("status-all"), new FormatId("fmt-x509"), ca.caId(),
StatusObjectType.CRL, now, Optional.of(now.plusSeconds(60L)), StatusObjectType.CRL, now, Optional.of(now.plusSeconds(60L)),
new EncodedObject(Encoding.DER, new byte[] { 7, 8 }), attributes); new EncodedObject(Encoding.DER, new byte[] { 7, 8 }), attributes);
@@ -140,7 +139,8 @@ public final class FilesystemPkiStoreTest {
store.putCa(ca); store.putCa(ca);
store.putCredential(credential); store.putCredential(credential);
store.putRequest(request); store.putRequest(request);
store.putRevocation(revocation); RevocationJournal revocation = store.transitionRevocation(new RevocationCommand.RevokePermanently(
credential.credentialId(), RevocationReason.KEY_COMPROMISE, attributes), now);
store.putStatusObject(status); store.putStatusObject(status);
store.putPublicationRecord(publication); store.putPublicationRecord(publication);
store.putProfile(profile); store.putProfile(profile);
@@ -152,7 +152,7 @@ public final class FilesystemPkiStoreTest {
store.getCredential(credential.credentialId()).orElseThrow().credentialId()); store.getCredential(credential.credentialId()).orElseThrow().credentialId());
assertEquals(request.requestId(), store.getRequest(request.requestId()).orElseThrow().requestId()); assertEquals(request.requestId(), store.getRequest(request.requestId()).orElseThrow().requestId());
assertEquals(revocation.credentialId(), assertEquals(revocation.credentialId(),
store.getRevocation(revocation.credentialId()).orElseThrow().credentialId()); store.getRevocationJournal(revocation.credentialId()).orElseThrow().credentialId());
assertEquals(status.statusObjectId(), assertEquals(status.statusObjectId(),
store.getStatusObject(status.statusObjectId()).orElseThrow().statusObjectId()); store.getStatusObject(status.statusObjectId()).orElseThrow().statusObjectId());
assertEquals(publication.publicationId(), store.listPublicationRecords().get(0).publicationId()); assertEquals(publication.publicationId(), store.listPublicationRecords().get(0).publicationId());
@@ -213,30 +213,29 @@ public final class FilesystemPkiStoreTest {
} }
@Test @Test
void revocationHistorySupportsOverwriteWithTrail() throws Exception { void revocationJournalPersistsLegalTransitions() throws Exception {
System.out.println("revocationHistorySupportsOverwriteWithTrail"); System.out.println("revocationJournalPersistsLegalTransitions");
Path root = tmp.resolve("store-revocation-history"); Path root = tmp.resolve("store-revocation-history");
FsPkiStoreOptions options = FsPkiStoreOptions.defaults(); FsPkiStoreOptions options = FsPkiStoreOptions.defaults();
try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) {
RevokedRecord r1 = TestObjects.minimalRevocation("cred-rev-1", Instant.EPOCH.plusSeconds(10L), Credential credential = TestObjects.minimalCredential("SERIAL-REV", "profile-rev");
RevocationReason.KEY_COMPROMISE); store.putCredential(credential);
store.putRevocation(r1); store.transitionRevocation(new RevocationCommand.Hold(credential.credentialId(),
TestObjects.emptyAttributes()), Instant.EPOCH.plusSeconds(10L));
store.transitionRevocation(new RevocationCommand.Unhold(credential.credentialId(),
TestObjects.emptyAttributes()), Instant.EPOCH.plusSeconds(11L));
RevokedRecord r2 = new RevokedRecord(r1.credentialId(), r1.revocationTime().plusSeconds(1L), r1.reason(), Optional<RevocationJournal> loaded = store.getRevocationJournal(credential.credentialId());
r1.attributes());
store.putRevocation(r2);
Optional<RevokedRecord> loaded = store.getRevocation(r1.credentialId());
assertTrue(loaded.isPresent()); assertTrue(loaded.isPresent());
assertEquals(r2.revocationTime(), loaded.get().revocationTime()); assertEquals(2L, loaded.get().latest().revision());
} }
System.out.println("...store tree:"); System.out.println("...store tree:");
dumpTree(root); dumpTree(root);
System.out.println("revocationHistorySupportsOverwriteWithTrail...ok"); System.out.println("revocationJournalPersistsLegalTransitions...ok");
} }
@Test @Test
@@ -563,12 +562,6 @@ public final class FilesystemPkiStoreTest {
profileId, status, encoded, attrs); profileId, status, encoded, attrs);
} }
static RevokedRecord minimalRevocation(String credentialId, Instant when, RevocationReason reason) {
PkiId id = new PkiId(credentialId);
AttributeSet attrs = emptyAttributes();
return new RevokedRecord(id, when, reason, attrs);
}
static AttributeSet emptyAttributes() { static AttributeSet emptyAttributes() {
return new TestAttributeSet(List.of()); return new TestAttributeSet(List.of());
} }

View File

@@ -0,0 +1,688 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.pki.impl.fs;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestFactory;
import org.junit.jupiter.api.io.TempDir;
import zeroecho.core.io.Util;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.IssuerRef;
import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.SubjectRef;
import zeroecho.pki.api.Validity;
import zeroecho.pki.api.attr.AttributeId;
import zeroecho.pki.api.attr.AttributeSet;
import zeroecho.pki.api.attr.AttributeValue;
import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialStatus;
import zeroecho.pki.api.credential.EffectiveCredentialStatus;
import zeroecho.pki.api.revocation.RevocationCommand;
import zeroecho.pki.api.revocation.RevocationJournal;
import zeroecho.pki.api.revocation.RevocationReason;
import zeroecho.pki.api.revocation.RevocationState;
import zeroecho.pki.api.revocation.RevocationTransition;
import zeroecho.pki.impl.core.StoreBackedEffectiveCredentialStatusResolver;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
final class FilesystemRevocationJournalTest {
private static final int CODEC_MAGIC = 0x5A454346;
private static final int CODEC_VERSION = 2;
private static final int TOP_REVOCATION = 4;
private static final int TYPE_STRING = 1;
private static final int TYPE_LONG = 3;
private static final int TYPE_INSTANT = 5;
private static final int TYPE_LIST = 7;
private static final int TYPE_OPTIONAL = 8;
private static final int TYPE_PKI_ID = 20;
private static final int TYPE_ATTRIBUTE_SET = 31;
private static final int TYPE_REVOCATION_REASON = 54;
private static final int TYPE_REVOCATION_STATE = 60;
private static final int TYPE_REVOCATION_TRANSITION = 61;
private static final Instant TIME = Instant.parse("2026-07-01T12:00:00Z");
private static final AttributeId NOTE = new AttributeId("test.note");
@TempDir
private Path temporaryDirectory;
@Test
void legalTransitionsAreContiguousDurableAndPermanentIsTerminal() throws Exception {
Path root = temporaryDirectory.resolve("legal");
Credential credential = credential("legal");
try (FilesystemPkiStore store = store(root)) {
store.putCredential(credential);
assertTransition(store, hold(credential), 1L, RevocationState.HELD);
assertTransition(store, unhold(credential), 2L, RevocationState.CLEAR);
assertTransition(store, hold(credential), 3L, RevocationState.HELD);
assertTransition(store, revoke(credential, RevocationReason.KEY_COMPROMISE), 4L,
RevocationState.PERMANENTLY_REVOKED);
assertCode("REVOCATION_TERMINAL", () -> transition(store, unhold(credential)));
assertCode("REVOCATION_TERMINAL",
() -> transition(store, revoke(credential, RevocationReason.CA_COMPROMISE)));
}
try (FilesystemPkiStore reopened = store(root)) {
RevocationJournal journal = reopened.getRevocationJournal(credential.credentialId()).orElseThrow();
assertEquals(4, journal.transitions().size());
assertEquals(RevocationState.PERMANENTLY_REVOKED, journal.latest().state());
assertEquals(RevocationReason.KEY_COMPROMISE, journal.latest().permanentReason().orElseThrow());
}
}
@Test
void everyLegalPermanentTransitionIsAccepted() throws Exception {
try (FilesystemPkiStore store = store(temporaryDirectory.resolve("legal-permanent"))) {
Credential direct = credential("permanent-from-none");
Credential fromClear = credential("permanent-from-clear");
Credential fromHeld = credential("permanent-from-held");
store.putCredential(direct);
store.putCredential(fromClear);
store.putCredential(fromHeld);
RevocationJournal directJournal = transition(store,
revoke(direct, RevocationReason.KEY_COMPROMISE));
assertEquals(List.of(RevocationState.PERMANENTLY_REVOKED),
states(directJournal));
assertEquals(1L, directJournal.latest().revision());
transition(store, hold(fromClear));
transition(store, unhold(fromClear));
RevocationJournal clearJournal = transition(store,
revoke(fromClear, RevocationReason.CA_COMPROMISE));
assertEquals(List.of(RevocationState.HELD, RevocationState.CLEAR,
RevocationState.PERMANENTLY_REVOKED), states(clearJournal));
assertEquals(3L, clearJournal.latest().revision());
transition(store, hold(fromHeld));
RevocationJournal heldJournal = transition(store,
revoke(fromHeld, RevocationReason.SUPERSEDED));
assertEquals(List.of(RevocationState.HELD, RevocationState.PERMANENTLY_REVOKED),
states(heldJournal));
assertEquals(2L, heldJournal.latest().revision());
}
}
@Test
void illegalAndInvalidCommandsFailBeforeJournalMutation() throws Exception {
try (FilesystemPkiStore store = store(temporaryDirectory.resolve("illegal"))) {
Credential unknown = credential("unknown");
assertCode("REVOCATION_CREDENTIAL_NOT_FOUND",
() -> transition(store, hold(unknown)));
Credential credential = credential("known");
store.putCredential(credential);
assertCode("REVOCATION_TRANSITION_ILLEGAL",
() -> transition(store, unhold(credential)));
assertThrows(IllegalArgumentException.class,
() -> revoke(credential, RevocationReason.CERTIFICATE_HOLD));
assertTrue(store.getRevocationJournal(credential.credentialId()).isEmpty());
transition(store, hold(credential));
assertCode("REVOCATION_TRANSITION_ILLEGAL",
() -> transition(store, hold(credential)));
assertEquals(1, store.getRevocationJournal(credential.credentialId()).orElseThrow().transitions().size());
transition(store, unhold(credential));
Path journalPath = new FsPaths(temporaryDirectory.resolve("illegal"))
.revocationJournal(credential.credentialId());
byte[] clearBytes = FsOperations.readAll(journalPath);
assertCode("REVOCATION_TRANSITION_ILLEGAL",
() -> transition(store, unhold(credential)));
assertArrayEquals(clearBytes, FsOperations.readAll(journalPath));
transition(store, revoke(credential, RevocationReason.KEY_COMPROMISE));
byte[] permanentBytes = FsOperations.readAll(journalPath);
assertCode("REVOCATION_TERMINAL", () -> transition(store, hold(credential)));
assertCode("REVOCATION_TERMINAL",
() -> transition(store, revoke(credential, RevocationReason.KEY_COMPROMISE)));
assertCode("REVOCATION_TERMINAL",
() -> transition(store, revoke(credential, RevocationReason.CA_COMPROMISE)));
assertArrayEquals(permanentBytes, FsOperations.readAll(journalPath));
Credential invalidRemove = credential("invalid-remove");
store.putCredential(invalidRemove);
assertThrows(IllegalArgumentException.class,
() -> revoke(invalidRemove, RevocationReason.REMOVE_FROM_CRL));
assertTrue(store.getRevocationJournal(invalidRemove.credentialId()).isEmpty());
}
}
@Test
void sameCredentialConcurrentHoldHasExactlyOneCommittedWinner() throws Exception {
try (FilesystemPkiStore store = store(temporaryDirectory.resolve("concurrent"))) {
Credential credential = credential("concurrent");
store.putCredential(credential);
CyclicBarrier barrier = new CyclicBarrier(2);
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
CompletableFuture<Boolean> first = attemptHold(store, credential, barrier, executor);
CompletableFuture<Boolean> second = attemptHold(store, credential, barrier, executor);
assertEquals(1L, List.of(first.join(), second.join()).stream().filter(Boolean::booleanValue).count());
} finally {
executor.shutdownNow();
}
RevocationJournal journal = store.getRevocationJournal(credential.credentialId()).orElseThrow();
assertEquals(1, journal.transitions().size());
assertEquals(1L, journal.latest().revision());
}
}
@Test
void conflictingTransitionsSerializeWithoutLostUpdates() throws Exception {
try (FilesystemPkiStore store = store(temporaryDirectory.resolve("concurrent-conflicts"))) {
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
Credential fromNone = credential("hold-vs-permanent");
store.putCredential(fromNone);
runConcurrent(store, hold(fromNone),
revoke(fromNone, RevocationReason.KEY_COMPROMISE), executor);
RevocationJournal fromNoneJournal =
store.getRevocationJournal(fromNone.credentialId()).orElseThrow();
assertEquals(RevocationState.PERMANENTLY_REVOKED, fromNoneJournal.latest().state());
assertTrue(fromNoneJournal.transitions().size() == 1
|| fromNoneJournal.transitions().size() == 2);
Credential fromHeld = credential("unhold-vs-permanent");
store.putCredential(fromHeld);
transition(store, hold(fromHeld));
runConcurrent(store, unhold(fromHeld),
revoke(fromHeld, RevocationReason.CA_COMPROMISE), executor);
RevocationJournal fromHeldJournal =
store.getRevocationJournal(fromHeld.credentialId()).orElseThrow();
assertEquals(RevocationState.PERMANENTLY_REVOKED, fromHeldJournal.latest().state());
assertTrue(fromHeldJournal.transitions().size() == 2
|| fromHeldJournal.transitions().size() == 3);
Credential reasons = credential("competing-permanent-reasons");
store.putCredential(reasons);
List<Boolean> results = runConcurrent(store,
revoke(reasons, RevocationReason.KEY_COMPROMISE),
revoke(reasons, RevocationReason.CA_COMPROMISE), executor);
assertEquals(1L, results.stream().filter(Boolean::booleanValue).count());
RevocationJournal reasonsJournal =
store.getRevocationJournal(reasons.credentialId()).orElseThrow();
assertEquals(1, reasonsJournal.transitions().size());
assertTrue(reasonsJournal.latest().permanentReason()
.filter(reason -> reason == RevocationReason.KEY_COMPROMISE
|| reason == RevocationReason.CA_COMPROMISE)
.isPresent());
} finally {
executor.shutdownNow();
}
}
}
@Test
void unrelatedCredentialTransitionProgressesWhileAnotherCredentialLockIsHeld() throws Exception {
try (FilesystemPkiStore store = store(temporaryDirectory.resolve("independent-locks"))) {
Credential blocked = credential("blocked");
Credential independent = credential("independent");
store.putCredential(blocked);
store.putCredential(independent);
Method acquire = FilesystemPkiStore.class.getDeclaredMethod("acquireRevocationLock", PkiId.class);
Method release = Arrays.stream(FilesystemPkiStore.class.getDeclaredMethods())
.filter(method -> method.getName().equals("releaseRevocationLock"))
.findFirst().orElseThrow();
acquire.setAccessible(true);
release.setAccessible(true);
Object heldLock = acquire.invoke(store, blocked.credentialId());
ExecutorService executor = Executors.newFixedThreadPool(2);
CountDownLatch blockedStarted = new CountDownLatch(1);
try {
CompletableFuture<RevocationJournal> blockedTransition = CompletableFuture.supplyAsync(() -> {
blockedStarted.countDown();
return transition(store, hold(blocked));
}, executor);
assertTrue(blockedStarted.await(5, TimeUnit.SECONDS));
CompletableFuture<RevocationJournal> independentTransition = CompletableFuture.supplyAsync(
() -> transition(store, hold(independent)), executor);
assertEquals(RevocationState.HELD,
independentTransition.get(5, TimeUnit.SECONDS).latest().state());
assertFalse(blockedTransition.isDone());
release.invoke(store, blocked.credentialId(), heldLock);
heldLock = null;
assertEquals(RevocationState.HELD,
blockedTransition.get(5, TimeUnit.SECONDS).latest().state());
} finally {
if (heldLock != null) {
release.invoke(store, blocked.credentialId(), heldLock);
}
executor.shutdownNow();
}
}
}
@Test
void copiedNamespaceJournalAndRegressingTransitionTimeFailClosed() throws Exception {
Path root = temporaryDirectory.resolve("corrupt");
Credential credential = credential("corrupt");
try (FilesystemPkiStore store = store(root)) {
store.putCredential(credential);
transition(store, hold(credential));
assertCode("REVOCATION_TRANSITION_CONFLICT",
() -> store.transitionRevocation(unhold(credential), TIME.minusSeconds(1)));
RevocationJournal invalid = new RevocationJournal(new PkiId("credential:copied"),
List.of(new RevocationTransition(1L, RevocationState.HELD, TIME, Optional.empty(),
new SimpleAttributeSet())));
FsOperations.writeAtomic(new FsPaths(root).revocationJournal(credential.credentialId()),
FsCodec.encode(FsCodec.REVOCATION_JOURNAL, invalid));
assertCode("REVOCATION_STATE_CORRUPT", () -> store.getRevocationJournal(credential.credentialId()));
}
}
@Test
void transitionMetadataDefensivelySnapshotsByteValues() {
byte[] source = { 1, 2, 3 };
AttributeSet attributes = SimpleAttributeSet.builder()
.put(NOTE, new AttributeValue.BytesValue(source)).build();
RevocationTransition transition = new RevocationTransition(1L, RevocationState.HELD, TIME,
Optional.empty(), attributes);
source[0] = 9;
byte[] first = ((AttributeValue.BytesValue) transition.attributes().get(NOTE).orElseThrow()).value();
assertArrayEquals(new byte[] { 1, 2, 3 }, first);
first[1] = 9;
byte[] second = ((AttributeValue.BytesValue) transition.attributes().get(NOTE).orElseThrow()).value();
assertArrayEquals(new byte[] { 1, 2, 3 }, second);
}
@Test
void journalConstructorRejectsEveryHostileSequenceShape() {
PkiId id = new PkiId("credential:hostile");
SimpleAttributeSet attributes = new SimpleAttributeSet();
assertThrows(IllegalArgumentException.class,
() -> new RevocationJournal(id, List.of(new RevocationTransition(1L, RevocationState.CLEAR, TIME,
Optional.empty(), attributes))));
assertThrows(IllegalArgumentException.class,
() -> new RevocationJournal(id, List.of(new RevocationTransition(2L, RevocationState.HELD, TIME,
Optional.empty(), attributes))));
assertThrows(IllegalArgumentException.class, () -> new RevocationJournal(id, List.of(
new RevocationTransition(1L, RevocationState.HELD, TIME, Optional.empty(), attributes),
new RevocationTransition(2L, RevocationState.CLEAR, TIME.minusNanos(1), Optional.empty(),
attributes))));
assertThrows(IllegalArgumentException.class,
() -> new RevocationJournal(id, List.of(new RevocationTransition(1L,
RevocationState.PERMANENTLY_REVOKED, TIME,
Optional.of(RevocationReason.CERTIFICATE_HOLD), attributes))));
assertThrows(IllegalArgumentException.class, () -> new RevocationJournal(id, List.of(
new RevocationTransition(1L, RevocationState.PERMANENTLY_REVOKED, TIME,
Optional.of(RevocationReason.KEY_COMPROMISE), attributes),
new RevocationTransition(2L, RevocationState.HELD, TIME, Optional.empty(), attributes))));
}
@TestFactory
Stream<DynamicTest> strictCodecCorruptionFailsReadAndTransitionWithoutOverwrite() {
return Arrays.stream(CorruptionCase.values()).map(corruption -> DynamicTest.dynamicTest(
corruption.description, () -> assertPersistedCorruptionRejected(corruption)));
}
@Test
void obsoleteCurrentAndHistoryLayoutCannotInfluenceTrustedJournalOrResolver() throws Exception {
Path root = temporaryDirectory.resolve("obsolete-layout");
Credential credential = credential("obsolete-layout");
try (FilesystemPkiStore store = store(root)) {
store.putCredential(credential);
}
Path legacyDirectory = new FsPaths(root).revocationDir(credential.credentialId());
FsOperations.ensureDir(legacyDirectory.resolve("history"));
FsOperations.writeAtomic(legacyDirectory.resolve("current.bin"),
oldRevokedRecordPayload(credential.credentialId()));
FsOperations.writeAtomic(legacyDirectory.resolve("history").resolve("legacy.bin"),
oldRevokedRecordPayload(credential.credentialId()));
try (FilesystemPkiStore reopened = store(root)) {
assertTrue(reopened.getRevocationJournal(credential.credentialId()).isEmpty());
StoreBackedEffectiveCredentialStatusResolver resolver =
new StoreBackedEffectiveCredentialStatusResolver(reopened, Clock.fixed(TIME,
java.time.ZoneOffset.UTC));
assertEquals(EffectiveCredentialStatus.USABLE,
resolver.beginEvaluation().resolve(
reopened.getCredential(credential.credentialId()).orElseThrow()));
transition(reopened, hold(credential));
assertEquals(RevocationState.HELD,
reopened.getRevocationJournal(credential.credentialId()).orElseThrow().latest().state());
}
}
@Test
void durabilityUncertaintyMakesEveryStoreOperationFailClosed() throws Exception {
try (FilesystemPkiStore store = store(temporaryDirectory.resolve("uncertain"))) {
Credential credential = credential("uncertain");
store.putCredential(credential);
Field field = FilesystemPkiStore.class.getDeclaredField("durabilityUncertain");
field.setAccessible(true);
((AtomicBoolean) field.get(store)).set(true);
assertCode("STORE_DURABILITY_UNCONFIRMED",
() -> store.getCredential(credential.credentialId()));
assertCode("STORE_DURABILITY_UNCONFIRMED", store::listCas);
assertCode("STORE_DURABILITY_UNCONFIRMED", store::signingNow);
}
}
private static CompletableFuture<Boolean> attemptHold(FilesystemPkiStore store, Credential credential,
CyclicBarrier barrier, ExecutorService executor) {
return CompletableFuture.supplyAsync(() -> {
try {
barrier.await();
transition(store, hold(credential));
return true;
} catch (PkiException expected) {
assertTrue(expected.getMessage().contains("REVOCATION_TRANSITION_ILLEGAL"));
return false;
} catch (Exception unexpected) {
throw new IllegalStateException(unexpected);
}
}, executor);
}
private static List<Boolean> runConcurrent(FilesystemPkiStore store, RevocationCommand firstCommand,
RevocationCommand secondCommand, ExecutorService executor) {
CyclicBarrier barrier = new CyclicBarrier(2);
CompletableFuture<Boolean> first = attemptTransition(store, firstCommand, barrier, executor);
CompletableFuture<Boolean> second = attemptTransition(store, secondCommand, barrier, executor);
return List.of(first.join(), second.join());
}
private static CompletableFuture<Boolean> attemptTransition(FilesystemPkiStore store,
RevocationCommand command, CyclicBarrier barrier, ExecutorService executor) {
return CompletableFuture.supplyAsync(() -> {
try {
barrier.await();
transition(store, command);
return true;
} catch (PkiException expected) {
assertTrue(expected.getMessage().contains("code=REVOCATION_TERMINAL")
|| expected.getMessage().contains("code=REVOCATION_TRANSITION_ILLEGAL"));
return false;
} catch (Exception unexpected) {
throw new IllegalStateException(unexpected);
}
}, executor);
}
private void assertPersistedCorruptionRejected(CorruptionCase corruption) throws Exception {
String suffix = "corrupt-" + corruption.name().toLowerCase(java.util.Locale.ROOT);
Path root = temporaryDirectory.resolve(suffix);
Credential credential = credential(suffix);
Path journalPath;
byte[] corrupt;
try (FilesystemPkiStore store = store(root)) {
store.putCredential(credential);
journalPath = new FsPaths(root).revocationJournal(credential.credentialId());
corrupt = corruption.bytes(credential.credentialId());
FsOperations.writeAtomic(journalPath, corrupt);
}
try (FilesystemPkiStore reopened = store(root)) {
byte[] before = FsOperations.readAll(journalPath);
assertCode("REVOCATION_STATE_CORRUPT",
() -> reopened.getRevocationJournal(credential.credentialId()));
assertCode("REVOCATION_STATE_CORRUPT",
() -> transition(reopened, hold(credential)));
assertArrayEquals(before, FsOperations.readAll(journalPath));
}
}
private static List<RevocationState> states(RevocationJournal journal) {
return journal.transitions().stream().map(RevocationTransition::state).toList();
}
private static void assertTransition(FilesystemPkiStore store, RevocationCommand command, long revision,
RevocationState state) {
RevocationJournal journal = transition(store, command);
assertEquals(revision, journal.latest().revision());
assertEquals(state, journal.latest().state());
}
private static RevocationJournal transition(FilesystemPkiStore store, RevocationCommand command) {
return store.transitionRevocation(command, TIME);
}
private static RevocationCommand.Hold hold(Credential credential) {
return new RevocationCommand.Hold(credential.credentialId(), new SimpleAttributeSet());
}
private static RevocationCommand.Unhold unhold(Credential credential) {
return new RevocationCommand.Unhold(credential.credentialId(), new SimpleAttributeSet());
}
private static RevocationCommand.RevokePermanently revoke(Credential credential, RevocationReason reason) {
return new RevocationCommand.RevokePermanently(credential.credentialId(), reason,
new SimpleAttributeSet());
}
private static void assertCode(String code, Runnable operation) {
PkiException failure = assertThrows(PkiException.class, operation::run);
assertTrue(failure.getMessage().contains("code=" + code));
}
private static FilesystemPkiStore store(Path root) {
return new FilesystemPkiStore(root, FsPkiStoreOptions.defaults());
}
private static Credential credential(String suffix) {
return new Credential(new PkiId("credential:" + suffix), new FormatId("x509"),
new IssuerRef(new PkiId("ca:issuer")), new SubjectRef("CN=" + suffix),
new Validity(TIME.minusSeconds(60), TIME.plusSeconds(60)), suffix,
new PkiId("key:" + suffix), "default", CredentialStatus.ISSUED,
new EncodedObject(Encoding.DER, new byte[] { 1, 2, 3 }), new SimpleAttributeSet());
}
private static byte[] journalPayload(PkiId credentialId, long journalVersion, RawTransition... transitions)
throws IOException {
ByteArrayOutputStream output = envelope();
writePkiId(output, credentialId);
writeTypedLong(output, journalVersion);
output.write(TYPE_LIST);
output.write(TYPE_REVOCATION_TRANSITION);
Util.writePack7I(output, transitions.length);
for (RawTransition transition : transitions) {
output.write(TYPE_REVOCATION_TRANSITION);
writeTypedLong(output, transition.revision);
output.write(TYPE_REVOCATION_STATE);
output.write(stateCode(transition.state));
output.write(TYPE_INSTANT);
Util.writeLong(output, transition.time.getEpochSecond());
Util.writePack7I(output, transition.time.getNano());
output.write(TYPE_OPTIONAL);
output.write(TYPE_REVOCATION_REASON);
if (transition.reason.isPresent()) {
output.write(1);
output.write(TYPE_REVOCATION_REASON);
output.write(reasonCode(transition.reason.orElseThrow()));
} else {
output.write(0);
}
output.write(TYPE_ATTRIBUTE_SET);
Util.writePack7I(output, 0);
}
return output.toByteArray();
}
private static byte[] oldRevokedRecordPayload(PkiId credentialId) throws IOException {
ByteArrayOutputStream output = envelope();
writePkiId(output, credentialId);
output.write(TYPE_INSTANT);
Util.writeLong(output, TIME.getEpochSecond());
Util.writePack7I(output, TIME.getNano());
output.write(TYPE_REVOCATION_REASON);
output.write(reasonCode(RevocationReason.KEY_COMPROMISE));
output.write(TYPE_ATTRIBUTE_SET);
Util.writePack7I(output, 0);
return output.toByteArray();
}
private static ByteArrayOutputStream envelope() {
ByteArrayOutputStream output = new ByteArrayOutputStream();
output.write(CODEC_MAGIC >>> 24);
output.write(CODEC_MAGIC >>> 16);
output.write(CODEC_MAGIC >>> 8);
output.write(CODEC_MAGIC);
output.write(CODEC_VERSION);
output.write(TOP_REVOCATION);
return output;
}
private static void writePkiId(ByteArrayOutputStream output, PkiId id) throws IOException {
output.write(TYPE_PKI_ID);
output.write(TYPE_STRING);
Util.writeUTF8(output, id.value());
}
private static void writeTypedLong(ByteArrayOutputStream output, long value) throws IOException {
output.write(TYPE_LONG);
Util.writeLong(output, value);
}
private static int stateCode(RevocationState state) {
return switch (state) {
case CLEAR -> 1;
case HELD -> 2;
case PERMANENTLY_REVOKED -> 3;
};
}
private static int reasonCode(RevocationReason reason) {
return switch (reason) {
case UNSPECIFIED -> 1;
case KEY_COMPROMISE -> 2;
case CA_COMPROMISE -> 3;
case AFFILIATION_CHANGED -> 4;
case SUPERSEDED -> 5;
case CESSATION_OF_OPERATION -> 6;
case CERTIFICATE_HOLD -> 7;
case REMOVE_FROM_CRL -> 8;
case PRIVILEGE_WITHDRAWN -> 9;
case AA_COMPROMISE -> 10;
};
}
private record RawTransition(long revision, RevocationState state, Instant time,
Optional<RevocationReason> reason) {
}
private enum CorruptionCase {
ZERO_TRANSITIONS("zero transitions"),
ZERO_REVISION("zero transition revision"),
FIRST_REVISION_NOT_ONE("first revision is not one"),
REVISION_GAP("revision gap"),
DUPLICATE_REVISION("duplicate revision"),
BACKWARD_TIME("backward transition time"),
CLEAR_FIRST("CLEAR as first state"),
CLEAR_NOT_AFTER_HELD("CLEAR not immediately after HELD"),
REPEATED_HELD("repeated HELD"),
PERMANENT_REASON_MISSING("permanent revocation without reason"),
PERMANENT_REASON_CERTIFICATE_HOLD("permanent revocation with hold reason"),
PERMANENT_REASON_REMOVE_FROM_CRL("permanent revocation with remove-from-CRL reason"),
HELD_WITH_REASON("HELD with permanent reason"),
CLEAR_WITH_REASON("CLEAR with permanent reason"),
TRANSITION_AFTER_PERMANENT("transition after permanent revocation"),
MISMATCHED_CREDENTIAL_ID("mismatched credential namespace"),
UNSUPPORTED_JOURNAL_VERSION("unsupported embedded journal version"),
OLD_REVOKED_RECORD_PAYLOAD("old RevokedRecord payload"),
TRUNCATED_PAYLOAD("truncated current journal"),
TRAILING_PAYLOAD("trailing journal data");
private final String description;
CorruptionCase(String description) {
this.description = description;
}
private byte[] bytes(PkiId credentialId) throws IOException {
RawTransition held = raw(1L, RevocationState.HELD, TIME);
return switch (this) {
case ZERO_TRANSITIONS -> journalPayload(credentialId, 1L);
case ZERO_REVISION -> journalPayload(credentialId, 1L,
raw(0L, RevocationState.HELD, TIME));
case FIRST_REVISION_NOT_ONE -> journalPayload(credentialId, 1L,
raw(2L, RevocationState.HELD, TIME));
case REVISION_GAP -> journalPayload(credentialId, 1L, held,
raw(3L, RevocationState.CLEAR, TIME.plusSeconds(1)));
case DUPLICATE_REVISION -> journalPayload(credentialId, 1L, held,
raw(1L, RevocationState.CLEAR, TIME.plusSeconds(1)));
case BACKWARD_TIME -> journalPayload(credentialId, 1L, held,
raw(2L, RevocationState.CLEAR, TIME.minusSeconds(1)));
case CLEAR_FIRST -> journalPayload(credentialId, 1L,
raw(1L, RevocationState.CLEAR, TIME));
case CLEAR_NOT_AFTER_HELD -> journalPayload(credentialId, 1L, held,
raw(2L, RevocationState.CLEAR, TIME.plusSeconds(1)),
raw(3L, RevocationState.CLEAR, TIME.plusSeconds(2)));
case REPEATED_HELD -> journalPayload(credentialId, 1L, held,
raw(2L, RevocationState.HELD, TIME.plusSeconds(1)));
case PERMANENT_REASON_MISSING -> journalPayload(credentialId, 1L,
raw(1L, RevocationState.PERMANENTLY_REVOKED, TIME));
case PERMANENT_REASON_CERTIFICATE_HOLD -> journalPayload(credentialId, 1L,
raw(1L, RevocationState.PERMANENTLY_REVOKED, TIME,
RevocationReason.CERTIFICATE_HOLD));
case PERMANENT_REASON_REMOVE_FROM_CRL -> journalPayload(credentialId, 1L,
raw(1L, RevocationState.PERMANENTLY_REVOKED, TIME,
RevocationReason.REMOVE_FROM_CRL));
case HELD_WITH_REASON -> journalPayload(credentialId, 1L,
raw(1L, RevocationState.HELD, TIME, RevocationReason.KEY_COMPROMISE));
case CLEAR_WITH_REASON -> journalPayload(credentialId, 1L, held,
raw(2L, RevocationState.CLEAR, TIME.plusSeconds(1),
RevocationReason.KEY_COMPROMISE));
case TRANSITION_AFTER_PERMANENT -> journalPayload(credentialId, 1L,
raw(1L, RevocationState.PERMANENTLY_REVOKED, TIME,
RevocationReason.KEY_COMPROMISE),
raw(2L, RevocationState.HELD, TIME.plusSeconds(1)));
case MISMATCHED_CREDENTIAL_ID -> journalPayload(
new PkiId("credential:other-namespace"), 1L, held);
case UNSUPPORTED_JOURNAL_VERSION -> journalPayload(credentialId, 2L, held);
case OLD_REVOKED_RECORD_PAYLOAD -> oldRevokedRecordPayload(credentialId);
case TRUNCATED_PAYLOAD -> {
byte[] valid = journalPayload(credentialId, 1L, held);
yield Arrays.copyOf(valid, valid.length - 1);
}
case TRAILING_PAYLOAD -> {
byte[] valid = journalPayload(credentialId, 1L, held);
yield Arrays.copyOf(valid, valid.length + 1);
}
};
}
private static RawTransition raw(long revision, RevocationState state, Instant time) {
return new RawTransition(revision, state, time, Optional.empty());
}
private static RawTransition raw(long revision, RevocationState state, Instant time,
RevocationReason reason) {
return new RawTransition(revision, state, time, Optional.of(reason));
}
}
}

View File

@@ -0,0 +1,59 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.pki.spi.framework;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.math.BigInteger;
import java.time.Instant;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
import zeroecho.pki.api.revocation.RevocationReason;
final class CrlEntryTest {
private static final Instant TIME = Instant.parse("2026-07-30T12:00:00.987654321Z");
@Test
void acceptsEveryExplicitActiveReasonAndFullPositiveSerialDomain() {
BigInteger largestFeasibleX509Serial = BigInteger.ONE.shiftLeft(159).subtract(BigInteger.ONE);
BigInteger[] serials = {
BigInteger.ONE,
BigInteger.valueOf(Long.MAX_VALUE),
BigInteger.valueOf(Long.MAX_VALUE).add(BigInteger.ONE),
largestFeasibleX509Serial
};
for (RevocationReason reason : activeReasons()) {
for (BigInteger serial : serials) {
CrlEntry entry = new CrlEntry(serial, TIME, reason);
assertEquals(serial, entry.serialNumber());
assertEquals(TIME, entry.transitionTime());
assertEquals(reason, entry.reason());
}
}
}
@Test
void rejectsInvalidSerialTimeAndReason() {
assertThrows(IllegalArgumentException.class,
() -> new CrlEntry(BigInteger.ZERO, TIME, RevocationReason.UNSPECIFIED));
assertThrows(IllegalArgumentException.class,
() -> new CrlEntry(BigInteger.valueOf(-1L), TIME, RevocationReason.UNSPECIFIED));
assertThrows(NullPointerException.class,
() -> new CrlEntry(BigInteger.ONE, null, RevocationReason.UNSPECIFIED));
assertThrows(NullPointerException.class,
() -> new CrlEntry(BigInteger.ONE, TIME, null));
assertThrows(IllegalArgumentException.class,
() -> new CrlEntry(BigInteger.ONE, TIME, RevocationReason.REMOVE_FROM_CRL));
}
private static RevocationReason[] activeReasons() {
return Arrays.stream(RevocationReason.values())
.filter(reason -> reason != RevocationReason.REMOVE_FROM_CRL)
.toArray(RevocationReason[]::new);
}
}

View File

@@ -37,6 +37,7 @@ import java.io.IOException;
import java.nio.file.Path; import java.nio.file.Path;
import java.security.KeyPair; import java.security.KeyPair;
import java.security.PublicKey; import java.security.PublicKey;
import java.time.Clock;
import java.time.Duration; import java.time.Duration;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@@ -51,11 +52,13 @@ import zeroecho.pki.api.IssuanceService;
import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.RevocationService; import zeroecho.pki.api.RevocationService;
import zeroecho.pki.api.StatusObjectService; import zeroecho.pki.api.StatusObjectService;
import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver;
import zeroecho.pki.impl.core.DefaultCaService; import zeroecho.pki.impl.core.DefaultCaService;
import zeroecho.pki.impl.core.DefaultCertificationRequestService; import zeroecho.pki.impl.core.DefaultCertificationRequestService;
import zeroecho.pki.impl.core.DefaultIssuanceService; import zeroecho.pki.impl.core.DefaultIssuanceService;
import zeroecho.pki.impl.core.DefaultRevocationService; import zeroecho.pki.impl.core.DefaultRevocationService;
import zeroecho.pki.impl.core.DefaultStatusObjectService; import zeroecho.pki.impl.core.DefaultStatusObjectService;
import zeroecho.pki.impl.core.StoreBackedEffectiveCredentialStatusResolver;
import zeroecho.pki.impl.core.async.PkiSigningBus; import zeroecho.pki.impl.core.async.PkiSigningBus;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
import zeroecho.pki.impl.audit.InMemoryAuditSink; import zeroecho.pki.impl.audit.InMemoryAuditSink;
@@ -88,6 +91,7 @@ public final class PkiTestRuntime implements AutoCloseable {
private final CredentialFramework framework; private final CredentialFramework framework;
private final CredentialIssuerBackend issuerBackend; private final CredentialIssuerBackend issuerBackend;
private final EffectiveCredentialStatusResolver statusResolver;
private final CaService caService; private final CaService caService;
private final CertificationRequestService certificationRequestService; private final CertificationRequestService certificationRequestService;
@@ -107,17 +111,19 @@ public final class PkiTestRuntime implements AutoCloseable {
this.auditSink = new InMemoryAuditSink(); this.auditSink = new InMemoryAuditSink();
this.framework = framework; this.framework = framework;
this.issuerBackend = issuerBackend; this.issuerBackend = issuerBackend;
Clock clock = Clock.systemUTC();
this.statusResolver = new StoreBackedEffectiveCredentialStatusResolver(store, clock);
this.publicKeysByKeyRef = publicKeysByKeyRef; this.publicKeysByKeyRef = publicKeysByKeyRef;
this.publicKeyResolveHook = () -> { this.publicKeyResolveHook = () -> {
}; };
this.certificationRequestService = new DefaultCertificationRequestService(store, framework); this.certificationRequestService = new DefaultCertificationRequestService(store, framework);
this.issuanceService = new DefaultIssuanceService(store, framework, issuerBackend, auditSink); this.issuanceService = new DefaultIssuanceService(store, framework, issuerBackend, auditSink, statusResolver);
this.revocationService = new DefaultRevocationService(store); this.revocationService = new DefaultRevocationService(store, clock, auditSink);
this.statusObjectService = new DefaultStatusObjectService(store, framework); this.statusObjectService = new DefaultStatusObjectService(store, framework, auditSink, statusResolver);
this.caService = new DefaultCaService(store, framework, issuerBackend, this::resolvePublicKeyInfo, signingBus, this.caService = new DefaultCaService(store, framework, issuerBackend, this::resolvePublicKeyInfo, signingBus,
auditSink, "SHA256withRSA", signingTtl); auditSink, statusResolver, "SHA256withRSA", signingTtl);
} }
/** /**
@@ -276,19 +282,30 @@ public final class PkiTestRuntime implements AutoCloseable {
return issuerBackend; return issuerBackend;
} }
public EffectiveCredentialStatusResolver statusResolver() {
return statusResolver;
}
public CaService caService() { public CaService caService() {
return caService; return caService;
} }
public CaService caService(CredentialFramework credentialFramework) { public CaService caService(CredentialFramework credentialFramework) {
return new DefaultCaService(store, Objects.requireNonNull(credentialFramework, "credentialFramework"), return new DefaultCaService(store, Objects.requireNonNull(credentialFramework, "credentialFramework"),
issuerBackend, this::resolvePublicKeyInfo, signingBus, auditSink, "SHA256withRSA", issuerBackend, this::resolvePublicKeyInfo, signingBus, auditSink, statusResolver, "SHA256withRSA",
Duration.ofSeconds(2)); Duration.ofSeconds(2));
} }
public CaService caService(CredentialIssuerBackend backend) { public CaService caService(CredentialIssuerBackend backend) {
return new DefaultCaService(store, framework, Objects.requireNonNull(backend, "backend"), return new DefaultCaService(store, framework, Objects.requireNonNull(backend, "backend"),
this::resolvePublicKeyInfo, signingBus, auditSink, "SHA256withRSA", Duration.ofSeconds(2)); this::resolvePublicKeyInfo, signingBus, auditSink, statusResolver, "SHA256withRSA",
Duration.ofSeconds(2));
}
public CaService caService(CredentialIssuerBackend backend, EffectiveCredentialStatusResolver resolver) {
return new DefaultCaService(store, framework, Objects.requireNonNull(backend, "backend"),
this::resolvePublicKeyInfo, signingBus, auditSink, Objects.requireNonNull(resolver, "resolver"),
"SHA256withRSA", Duration.ofSeconds(2));
} }
public CertificationRequestService certificationRequestService() { public CertificationRequestService certificationRequestService() {
@@ -299,6 +316,12 @@ public final class PkiTestRuntime implements AutoCloseable {
return issuanceService; return issuanceService;
} }
public IssuanceService issuanceService(CredentialIssuerBackend backend,
EffectiveCredentialStatusResolver resolver) {
return new DefaultIssuanceService(store, framework, Objects.requireNonNull(backend, "backend"), auditSink,
Objects.requireNonNull(resolver, "resolver"));
}
public RevocationService revocationService() { public RevocationService revocationService() {
return revocationService; return revocationService;
} }
@@ -307,6 +330,11 @@ public final class PkiTestRuntime implements AutoCloseable {
return statusObjectService; return statusObjectService;
} }
public StatusObjectService statusObjectService(EffectiveCredentialStatusResolver resolver) {
return new DefaultStatusObjectService(store, framework, auditSink, Objects.requireNonNull(resolver,
"resolver"));
}
/** /**
* Returns a new empty attribute set suitable for test commands. * Returns a new empty attribute set suitable for test commands.
* *