diff --git a/pki/src/main/java/zeroecho/pki/api/ImportExportService.java b/pki/src/main/java/zeroecho/pki/api/ImportExportService.java
index 4baacaf..b1fa4ed 100644
--- a/pki/src/main/java/zeroecho/pki/api/ImportExportService.java
+++ b/pki/src/main/java/zeroecho/pki/api/ImportExportService.java
@@ -33,7 +33,6 @@
******************************************************************************/
package zeroecho.pki.api;
-import zeroecho.pki.api.revocation.RevokedRecord;
import zeroecho.pki.api.transfer.ExportArtifact;
import zeroecho.pki.api.transfer.ExportFormat;
import zeroecho.pki.api.transfer.ExportQuery;
@@ -73,17 +72,6 @@ public interface ImportExportService {
*/
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
* format.
diff --git a/pki/src/main/java/zeroecho/pki/api/RevocationService.java b/pki/src/main/java/zeroecho/pki/api/RevocationService.java
index aa71da2..a7027f1 100644
--- a/pki/src/main/java/zeroecho/pki/api/RevocationService.java
+++ b/pki/src/main/java/zeroecho/pki/api/RevocationService.java
@@ -1,99 +1,58 @@
/*******************************************************************************
* 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.api;
import java.util.List;
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.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 {
- /**
- * 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.
*
* @param command hold command
- * @return revocation record
- * @throws IllegalArgumentException if {@code command} is invalid
- * @throws PkiException if hold fails
+ * @return committed journal
*/
- RevokedRecord hold(HoldCommand command);
+ RevocationJournal hold(RevocationCommand.Hold command);
/**
- * Removes a hold from a credential.
+ * Removes an existing hold.
*
* @param command unhold command
- * @return revocation record
- * @throws IllegalArgumentException if {@code command} is invalid
- * @throws PkiException if unhold fails
+ * @return committed journal
*/
- RevokedRecord unhold(UnholdCommand command);
+ RevocationJournal unhold(RevocationCommand.Unhold command);
/**
- * Retrieves revocation record for a credential.
+ * Permanently revokes a credential.
*
- * @param credentialId credential id
- * @return record if present
- * @throws IllegalArgumentException if {@code credentialId} is null
- * @throws PkiException if retrieval fails
+ * @param command permanent revocation command
+ * @return committed journal
*/
- Optional get(PkiId credentialId);
+ RevocationJournal revokePermanently(RevocationCommand.RevokePermanently command);
/**
- * Searches revocation records.
+ * Retrieves the authoritative journal for one credential.
*
- * @param query query constraints
- * @return matching records
- * @throws IllegalArgumentException if {@code query} is null
- * @throws PkiException if search fails
+ * @param credentialId credential identifier
+ * @return journal when present
*/
- List search(RevocationQuery query);
+ Optional get(PkiId credentialId);
+
+ /**
+ * Searches authoritative journals by their latest transition.
+ *
+ * @param query administrative query
+ * @return matching journals
+ */
+ List search(RevocationQuery query);
}
diff --git a/pki/src/main/java/zeroecho/pki/api/credential/Credential.java b/pki/src/main/java/zeroecho/pki/api/credential/Credential.java
index 13ec26f..ab48830 100644
--- a/pki/src/main/java/zeroecho/pki/api/credential/Credential.java
+++ b/pki/src/main/java/zeroecho/pki/api/credential/Credential.java
@@ -65,7 +65,11 @@ import zeroecho.pki.api.attr.AttributeSet;
* X.509)
* @param publicKeyId stable identifier derived from the subject public key
* @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 attributes universal attribute set
*/
diff --git a/pki/src/main/java/zeroecho/pki/api/credential/CredentialStatus.java b/pki/src/main/java/zeroecho/pki/api/credential/CredentialStatus.java
index 22ab7ec..f0d1d38 100644
--- a/pki/src/main/java/zeroecho/pki/api/credential/CredentialStatus.java
+++ b/pki/src/main/java/zeroecho/pki/api/credential/CredentialStatus.java
@@ -37,8 +37,9 @@ package zeroecho.pki.api.credential;
* Status of a credential as tracked by PKI inventory.
*
*
- * Status may be computed from validity and revocation state or stored directly
- * depending on implementation.
+ * This value is persisted issuance and inventory metadata. It is not an
+ * authoritative trust decision: current revocation state and evaluation time
+ * must be resolved through {@link EffectiveCredentialStatusResolver}.
*
*/
public enum CredentialStatus {
diff --git a/pki/src/main/java/zeroecho/pki/api/revocation/UnholdCommand.java b/pki/src/main/java/zeroecho/pki/api/credential/CredentialUse.java
similarity index 69%
rename from pki/src/main/java/zeroecho/pki/api/revocation/UnholdCommand.java
rename to pki/src/main/java/zeroecho/pki/api/credential/CredentialUse.java
index 6c6ec28..340d6ba 100644
--- a/pki/src/main/java/zeroecho/pki/api/revocation/UnholdCommand.java
+++ b/pki/src/main/java/zeroecho/pki/api/credential/CredentialUse.java
@@ -31,36 +31,30 @@
* (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.revocation;
-
-import zeroecho.pki.api.PkiId;
-import zeroecho.pki.api.attr.AttributeSet;
+package zeroecho.pki.api.credential;
/**
- * Command to remove a hold from a credential.
- *
- *
- * Frameworks may map this to X.509 {@code removeFromCRL} or equivalent
- * semantics.
- *
- *
- * @param credentialId credential identifier
- * @param attributes optional additional attributes (may be empty but not
- * null)
+ * Closed categories of currently reachable credential trust decisions.
*/
-public record UnholdCommand(PkiId credentialId, AttributeSet attributes) {
+public enum CredentialUse {
/**
- * Creates an unhold command.
- *
- * @throws IllegalArgumentException if inputs are null
+ * Issuer credential used for end-entity issuance.
*/
- public UnholdCommand {
- if (credentialId == null) {
- throw new IllegalArgumentException("credentialId must not be null");
- }
- if (attributes == null) {
- throw new IllegalArgumentException("attributes must not be null");
- }
- }
+ END_ENTITY_ISSUER,
+
+ /**
+ * Issuer credential used for intermediate CA issuance.
+ */
+ INTERMEDIATE_ISSUER,
+
+ /**
+ * Issuer credential used to sign a status object.
+ */
+ STATUS_OBJECT_ISSUER,
+
+ /**
+ * Leaf credential delivered in a trusted credential bundle.
+ */
+ BUNDLE_DELIVERY
}
diff --git a/pki/src/main/java/zeroecho/pki/api/revocation/HoldCommand.java b/pki/src/main/java/zeroecho/pki/api/credential/EffectiveCredentialStatus.java
similarity index 70%
rename from pki/src/main/java/zeroecho/pki/api/revocation/HoldCommand.java
rename to pki/src/main/java/zeroecho/pki/api/credential/EffectiveCredentialStatus.java
index 5cf00fc..6f02820 100644
--- a/pki/src/main/java/zeroecho/pki/api/revocation/HoldCommand.java
+++ b/pki/src/main/java/zeroecho/pki/api/credential/EffectiveCredentialStatus.java
@@ -31,36 +31,41 @@
* (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.revocation;
-
-import zeroecho.pki.api.PkiId;
-import zeroecho.pki.api.attr.AttributeSet;
+package zeroecho.pki.api.credential;
/**
- * Command to place a credential on hold.
+ * Runtime status used when deciding whether a credential may participate in a
+ * security-sensitive operation.
*
*
- * Frameworks may map this to X.509 {@code certificateHold} or equivalent
- * semantics.
+ * This status is derived from persisted inventory metadata, current revocation
+ * state, and one authoritative evaluation time. It is not persisted.
*
- *
- * @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.
- *
- * @throws IllegalArgumentException if inputs are null
+ * The credential is issued, unrevoked, and within its validity interval.
*/
- public HoldCommand {
- if (credentialId == null) {
- throw new IllegalArgumentException("credentialId must not be null");
- }
- if (attributes == null) {
- throw new IllegalArgumentException("attributes must not be null");
- }
- }
+ USABLE,
+
+ /**
+ * The credential validity interval has not started.
+ */
+ 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
}
diff --git a/pki/src/main/java/zeroecho/pki/api/revocation/RevokeCommand.java b/pki/src/main/java/zeroecho/pki/api/credential/EffectiveCredentialStatusResolver.java
similarity index 51%
rename from pki/src/main/java/zeroecho/pki/api/revocation/RevokeCommand.java
rename to pki/src/main/java/zeroecho/pki/api/credential/EffectiveCredentialStatusResolver.java
index 92c9bec..be5593c 100644
--- a/pki/src/main/java/zeroecho/pki/api/revocation/RevokeCommand.java
+++ b/pki/src/main/java/zeroecho/pki/api/credential/EffectiveCredentialStatusResolver.java
@@ -31,40 +31,64 @@
* (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.revocation;
+package zeroecho.pki.api.credential;
-import zeroecho.pki.api.PkiId;
-import zeroecho.pki.api.attr.AttributeSet;
+import java.time.Instant;
+
+import zeroecho.pki.api.PkiException;
/**
- * Command to revoke a credential.
+ * Resolves authoritative runtime credential status for trust decisions.
*
*
- * Additional revocation metadata (e.g., invalidity date) may be conveyed via
- * {@code attributes} using universal attribute definitions.
+ * One {@link Evaluation} captures one authoritative instant and must be reused
+ * for every candidate considered by the same operation.
*
- *
- * @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 {
- if (credentialId == null) {
- throw new IllegalArgumentException("credentialId must not be null");
- }
- if (reason == null) {
- throw new IllegalArgumentException("reason must not be null");
- }
- if (attributes == null) {
- throw new IllegalArgumentException("attributes must not be null");
- }
+ Evaluation beginEvaluation();
+
+ /**
+ * One immutable-time effective-status evaluation.
+ */
+ interface Evaluation {
+
+ /**
+ * 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);
}
}
diff --git a/pki/src/main/java/zeroecho/pki/api/credential/package-info.java b/pki/src/main/java/zeroecho/pki/api/credential/package-info.java
index 0333aa8..b9efdef 100644
--- a/pki/src/main/java/zeroecho/pki/api/credential/package-info.java
+++ b/pki/src/main/java/zeroecho/pki/api/credential/package-info.java
@@ -43,8 +43,8 @@
* Notes
*
* - Credentials are treated as immutable artifacts once issued.
- * - Status values capture the operational lifecycle (e.g., issued, expired,
- * revoked, on hold).
+ * - Persisted inventory status does not replace runtime effective-status
+ * resolution for security-sensitive credential use.
*
*
* @since 1.0
diff --git a/pki/src/main/java/zeroecho/pki/api/revocation/RevocationCommand.java b/pki/src/main/java/zeroecho/pki/api/revocation/RevocationCommand.java
new file mode 100644
index 0000000..f063e5d
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/api/revocation/RevocationCommand.java
@@ -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);
+ }
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/api/revocation/RevocationJournal.java b/pki/src/main/java/zeroecho/pki/api/revocation/RevocationJournal.java
new file mode 100644
index 0000000..f157644
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/api/revocation/RevocationJournal.java
@@ -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 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 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 entries = new ArrayList<>();
+ for (AttributeId id : source.ids()) {
+ List 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 values) {
+ }
+
+ /**
+ * Immutable defensive implementation used for journal metadata ownership.
+ */
+ private static final class SnapshotAttributeSet implements AttributeSet {
+ private final List entries;
+
+ private SnapshotAttributeSet(List entries) {
+ this.entries = entries;
+ }
+
+ @Override
+ public Set ids() {
+ Set ids = new LinkedHashSet<>();
+ for (SnapshotEntry entry : entries) {
+ ids.add(entry.id());
+ }
+ return Set.copyOf(ids);
+ }
+
+ @Override
+ public Optional get(AttributeId id) {
+ List 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 getAll(AttributeId id) {
+ Objects.requireNonNull(id, "id");
+ for (SnapshotEntry entry : entries) {
+ if (entry.id().equals(id)) {
+ List copies = new ArrayList<>(entry.values().size());
+ for (AttributeValue value : entry.values()) {
+ copies.add(snapshotValue(value));
+ }
+ return List.copyOf(copies);
+ }
+ }
+ return List.of();
+ }
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/api/revocation/RevocationState.java b/pki/src/main/java/zeroecho/pki/api/revocation/RevocationState.java
new file mode 100644
index 0000000..20a11d7
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/api/revocation/RevocationState.java
@@ -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
+}
diff --git a/pki/src/main/java/zeroecho/pki/api/revocation/RevocationTransition.java b/pki/src/main/java/zeroecho/pki/api/revocation/RevocationTransition.java
new file mode 100644
index 0000000..e9fcbab
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/api/revocation/RevocationTransition.java
@@ -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 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);
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/api/revocation/RevokedRecord.java b/pki/src/main/java/zeroecho/pki/impl/core/CredentialTrustAudit.java
similarity index 53%
rename from pki/src/main/java/zeroecho/pki/api/revocation/RevokedRecord.java
rename to pki/src/main/java/zeroecho/pki/impl/core/CredentialTrustAudit.java
index a5b1c1c..aed9ae6 100644
--- a/pki/src/main/java/zeroecho/pki/api/revocation/RevokedRecord.java
+++ b/pki/src/main/java/zeroecho/pki/impl/core/CredentialTrustAudit.java
@@ -31,47 +31,44 @@
* (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.revocation;
+package zeroecho.pki.impl.core;
import java.time.Instant;
+import java.util.Map;
+import java.util.Optional;
-import zeroecho.pki.api.PkiId;
-import zeroecho.pki.api.attr.AttributeSet;
+import zeroecho.pki.api.audit.AuditEvent;
+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.
- *
- *
- * This record is the authoritative input for generating status objects (CRLs,
- * OCSP responses, or framework-specific revocation lists).
- *
- *
- * @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
+ * Emits one best-effort, redacted caller-level credential trust rejection.
*/
-public record RevokedRecord(PkiId credentialId, Instant revocationTime, RevocationReason reason,
- AttributeSet attributes) {
+final class CredentialTrustAudit {
+ private static final Principal SYSTEM_PKI = new Principal("SYSTEM", "pki");
+ private static final Purpose TRUST_PURPOSE = new Purpose("CREDENTIAL_TRUST");
- /**
- * Creates a revocation record.
- *
- * @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");
- }
- if (reason == null) {
- throw new IllegalArgumentException("reason must not be null");
- }
- if (attributes == null) {
- throw new IllegalArgumentException("attributes must not be null");
+ private CredentialTrustAudit() {
+ // Utility class.
+ }
+
+ // Best-effort audit callbacks are untrusted; broad catch and empty handling preserve
+ // the authoritative trust rejection without exposing listener diagnostics.
+ @SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.EmptyCatchBlock" })
+ /* default */ static void rejected(AuditSink sink, Instant time, Credential credential, CredentialUse use,
+ String code, EffectiveCredentialStatus status) {
+ Map details = status == 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.
}
}
}
diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java
index 099d9b1..00759bb 100644
--- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java
+++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java
@@ -81,6 +81,9 @@ import zeroecho.pki.api.ca.IntermediateCertIssueCommand;
import zeroecho.pki.api.ca.IntermediateCreateCommand;
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.impl.core.async.PkiSigningBus;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes;
@@ -152,6 +155,8 @@ public final class DefaultCaService implements CaService {
private final CredentialFramework framework;
private final CredentialIssuerBackend issuerBackend;
private final CaProofGate proofGate;
+ private final AuditSink auditSink;
+ private final EffectiveCredentialStatusResolver statusResolver;
/**
* Creates a CA service bound to a specific store, credential framework, and
@@ -189,6 +194,8 @@ public final class DefaultCaService implements CaService {
* {@code null}
* @param auditSink required sink for safe CA proof rejection events;
* 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
* for certificate signing requests initiated by
* this service
@@ -203,14 +210,15 @@ public final class DefaultCaService implements CaService {
*/
public DefaultCaService(PkiStore store, CredentialFramework framework, CredentialIssuerBackend issuerBackend,
PublicKeyInfoResolver publicKeyResolver, PkiSigningBus signingBus, AuditSink auditSink,
- String signatureAlgorithmId, Duration signingTtl) {
+ EffectiveCredentialStatusResolver statusResolver, String signatureAlgorithmId, Duration signingTtl) {
this.store = Objects.requireNonNull(store, "store");
this.framework = Objects.requireNonNull(framework, "framework");
this.issuerBackend = Objects.requireNonNull(issuerBackend, "issuerBackend");
Objects.requireNonNull(publicKeyResolver, "publicKeyResolver");
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()) {
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.
*
*
+ *
+ * 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.
+ *
+ *
* @param command intermediate CA creation command; must not be {@code null}
* @return identifier of the newly persisted intermediate CA
* @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(),
"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())
.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(),
command.formatId(), "CREATE_INTERMEDIATE_REJECTED", Optional.of(caId));
EncodedObject subjectSpki = subjectProof.exactPublicKey();
- Credential issuerCredential = CredentialSnapshots.copy(selectIssuerCredential(issuer, command.formatId()));
requireIssuerKeyBinding(issuer, issuerCredential, command.formatId(), "CREATE_INTERMEDIATE_REJECTED",
Optional.of(caId));
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.
*
*
+ *
+ * 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.
+ *
+ *
* @param command intermediate certificate issuance command; must not be
* {@code null}
* @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()),
"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(),
command.formatId(), "ISSUE_INTERMEDIATE_REJECTED", Optional.of(subject.caId()));
EncodedObject subjectSpki = subjectProof.exactPublicKey();
- Credential issuerCredential = CredentialSnapshots.copy(selectIssuerCredential(issuer, command.formatId()));
requireIssuerKeyBinding(issuer, issuerCredential, command.formatId(), "ISSUE_INTERMEDIATE_REJECTED",
Optional.of(subject.caId()));
AttributeSet authoritative = authoritativeIntermediateAttributes(command.attributes(), issuer,
@@ -669,17 +695,33 @@ public final class DefaultCaService implements CaService {
}
}
- private static Credential selectIssuerCredential(CaRecord issuer, FormatId formatId) {
- Instant now = Instant.now();
+ private Credential selectIssuerCredential(CaRecord issuer, FormatId formatId, CredentialUse use,
+ EffectiveCredentialStatusResolver.Evaluation evaluation) {
+ Credential lastRejected = null;
+ EffectiveCredentialStatus lastStatus = null;
for (Credential credential : issuer.caCredentials()) {
- if (credential != null && formatId.equals(credential.formatId())
- && credential.status() == CredentialStatus.ISSUED
- && !now.isBefore(credential.validity().notBefore())
- && !now.isAfter(credential.validity().notAfter())) {
+ if (credential == null || !formatId.equals(credential.formatId())) {
+ continue;
+ }
+ 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;
}
+ 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) {
diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java
index 36ac46c..ade925a 100644
--- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java
+++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java
@@ -62,6 +62,9 @@ import zeroecho.pki.api.ca.CaState;
import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialBundle;
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.IssueEndEntityCommand;
import zeroecho.pki.api.issuance.ReissueCommand;
@@ -98,9 +101,8 @@ import zeroecho.pki.spi.store.PkiStore;
*
* - the issuer CA must exist,
* - the issuer CA must be in {@link CaState#ACTIVE} state,
- * - the issuer CA must expose a currently valid
- * {@link CredentialStatus#ISSUED} credential for the active framework
- * {@link FormatId},
+ * - the issuer CA must expose an effectively usable credential for the active
+ * framework {@link FormatId},
* - issuer material required by the current X.509 runtime wiring must be
* present in issuance overrides before the backend is invoked,
* - 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 CredentialIssuerBackend issuerBackend;
private final AuditSink auditSink;
+ private final EffectiveCredentialStatusResolver statusResolver;
/**
* 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}
* @param auditSink required sink for safe rejection audit events; must not be
* {@code null}
+ * @param statusResolver authoritative runtime credential-status resolver; must
+ * not be {@code null}
* @throws NullPointerException if an argument is {@code null}
*/
public DefaultIssuanceService(PkiStore store, CredentialFramework framework,
- CredentialIssuerBackend issuerBackend, AuditSink auditSink) {
+ CredentialIssuerBackend issuerBackend, AuditSink auditSink,
+ EffectiveCredentialStatusResolver statusResolver) {
this.store = Objects.requireNonNull(store, "store");
this.framework = Objects.requireNonNull(framework, "framework");
this.issuerBackend = Objects.requireNonNull(issuerBackend, "issuerBackend");
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
* @throws NullPointerException if {@code command} is {@code null}
* @throws PkiException if the issuer CA does not exist, is not active,
- * has no credentials, no compatible issuer
- * current issued credential can be selected,
+ * has no credentials, no compatible effectively
+ * usable issuer credential can be selected, or
+ * current revocation state cannot be resolved,
* issuer material enrichment fails, backend
* issuance or result validation fails, or
* persistence of the validated leaf fails
@@ -204,7 +212,6 @@ public final class DefaultIssuanceService implements IssuanceService {
@Override
public CredentialBundle issueEndEntity(IssueEndEntityCommand command) {
Objects.requireNonNull(command, "command");
- VerifiedIssuanceCandidate candidate = verifyIssuanceCandidate(command);
CaRecord issuer = store.getCa(command.issuerCaId()).orElseThrow(() -> new PkiException("Issuer CA not found"));
if (issuer.state() != CaState.ACTIVE) {
@@ -214,7 +221,10 @@ public final class DefaultIssuanceService implements IssuanceService {
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(),
issuer.issuerKeyRef());
@@ -243,15 +253,10 @@ public final class DefaultIssuanceService implements IssuanceService {
* format.
*
*
- * The selected credential must match the requested {@link FormatId}, have
- * {@link CredentialStatus#ISSUED} status, and contain the current instant
- * within its inclusive validity interval. No status or validity fallback is
- * permitted.
- *
- *
- *
- * This method does not evaluate profile suitability or revocation information
- * external to {@link CredentialStatus}.
+ * The selected credential must match the requested {@link FormatId} and
+ * resolve as effectively usable against current revocation state and the
+ * operation's captured evaluation instant. Resolution failures abort
+ * selection.
*
*
* @param issuer issuer CA record containing candidate credentials; must not
@@ -261,24 +266,39 @@ public final class DefaultIssuanceService implements IssuanceService {
* @return selected issuer credential
* @throws NullPointerException if {@code issuer} or {@code formatId} is
* {@code null}
- * @throws PkiException if the issuer CA has no current issued
- * credential compatible with the requested format
+ * @throws PkiException if the issuer CA has no compatible effectively
+ * 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(formatId, "formatId");
- Instant now = Instant.now();
+ Credential lastRejected = null;
+ EffectiveCredentialStatus lastStatus = null;
for (Credential c : issuer.caCredentials()) {
- if (c == null) {
+ if (c == null || !formatId.equals(c.formatId())) {
continue;
}
- if (formatId.equals(c.formatId()) && c.status() == CredentialStatus.ISSUED
- && !now.isBefore(c.validity().notBefore()) && !now.isAfter(c.validity().notAfter())) {
+ EffectiveCredentialStatus status;
+ 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;
}
+ 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
* @throws NullPointerException if {@code command} is {@code null}
* @throws PkiException if the requested credential does not exist in
- * the store
+ * the store or is not currently usable
*/
@Override
public CredentialBundle buildBundle(BundleCommand command) {
Objects.requireNonNull(command, "command");
PkiId credId = command.credentialId();
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
// concerns.
return new CredentialBundle(leaf, List.of());
diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultRevocationService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultRevocationService.java
index ae7f5c2..aef8ed7 100644
--- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultRevocationService.java
+++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultRevocationService.java
@@ -1,288 +1,181 @@
/*******************************************************************************
* 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.List;
+import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId;
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.RevocationReason;
-import zeroecho.pki.api.revocation.RevokeCommand;
-import zeroecho.pki.api.revocation.RevokedRecord;
-import zeroecho.pki.api.revocation.UnholdCommand;
+import zeroecho.pki.api.revocation.RevocationTransition;
+import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.spi.store.PkiStore;
/**
- * Default implementation of {@link RevocationService}.
- *
- *
- * 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}.
- *
- *
- *
- * 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.
- *
- *
- * Persistence model
- *
- * - Each successful operation creates a new {@link RevokedRecord} timestamped
- * with the current service time.
- * - The resulting record is written to the configured {@link PkiStore}
- * through {@link PkiStore#putRevocation(RevokedRecord)}.
- * - Lookup and search operations read from the store and do not maintain an
- * internal cache.
- *
- *
- * Security considerations
- *
- * - This service does not modify the credential object itself; it records
- * revocation state separately.
- * - The trustworthiness of revocation status therefore depends on consumers
- * consulting the revocation store or derivative status objects such as CRLs or
- * OCSP-equivalent artifacts.
- * - Attributes carried in revocation commands are passed through to the
- * stored record unchanged and should therefore be governed upstream to avoid
- * leaking sensitive material.
- *
- *
- * Thread-safety
- *
- * 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.
- *
+ * Store-backed authoritative revocation transition service.
*/
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 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 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
- * persistence, and revocation lookup; must not be {@code null}
- * @throws NullPointerException if {@code store} is {@code null}
+ * @param store authoritative store
+ * @param clock authoritative clock
+ * @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.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.
- *
- *
- * 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.
- *
- *
- *
- * 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.
- *
- *
- * @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
- public RevokedRecord revoke(RevokeCommand command) {
- Objects.requireNonNull(command, "command");
- 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;
+ public RevocationJournal hold(RevocationCommand.Hold command) {
+ return transition(Objects.requireNonNull(command, "command"), "HOLD");
}
- /**
- * Records a certificate-hold entry for an existing credential.
- *
- *
- * This method is a specialization of revocation recording that always uses
- * {@link RevocationReason#CERTIFICATE_HOLD} as the persisted reason.
- *
- *
- *
- * The method does not verify whether the credential is already on hold or
- * whether a prior revocation history would make the hold semantically invalid.
- *
- *
- * @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
- public RevokedRecord hold(HoldCommand command) {
- Objects.requireNonNull(command, "command");
- 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;
+ public RevocationJournal unhold(RevocationCommand.Unhold command) {
+ return transition(Objects.requireNonNull(command, "command"), "UNHOLD");
}
- /**
- * Records a removal-from-CRL entry for an existing credential.
- *
- *
- * This method is a specialization of revocation recording that always uses
- * {@link RevocationReason#REMOVE_FROM_CRL} as the persisted reason.
- *
- *
- *
- * 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.
- *
- *
- * @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
- public RevokedRecord unhold(UnholdCommand command) {
- Objects.requireNonNull(command, "command");
- 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;
+ public RevocationJournal revokePermanently(RevocationCommand.RevokePermanently command) {
+ return transition(Objects.requireNonNull(command, "command"), "REVOKE_PERMANENTLY");
}
- /**
- * Resolves the current revocation record associated with a credential
- * identifier.
- *
- *
- * 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.
- *
- *
- * @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
- public Optional get(PkiId credentialId) {
+ @SuppressWarnings("PMD.AvoidCatchingGenericException")
+ public Optional get(PkiId 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.
- *
- *
- * Filtering is performed in memory over the revocation records returned by the
- * configured {@link PkiStore}. Only constraints present in the
- * {@link RevocationQuery} are applied.
- *
- *
- * Time filtering semantics
- *
- * - {@code revokedAfter}: records strictly earlier than the supplied instant
- * are excluded; records exactly at the supplied instant are retained.
- * - {@code revokedBefore}: records at or after the supplied instant are
- * excluded; only records strictly before the supplied instant are
- * retained.
- *
- *
- *
- * 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.
- *
- *
- * @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
- public List search(RevocationQuery query) {
+ @SuppressWarnings("PMD.AvoidCatchingGenericException")
+ public List search(RevocationQuery query) {
Objects.requireNonNull(query, "query");
- List all = store.listRevocations();
- return all.stream().filter(r -> {
- if (query.reason().isPresent() && query.reason().get() != r.reason()) {
- return false;
- }
- if (query.revokedAfter().isPresent() && r.revocationTime().isBefore(query.revokedAfter().get())) {
- return false;
- }
- if (query.revokedBefore().isPresent() && !r.revocationTime().isBefore(query.revokedBefore().get())) {
- return false;
- }
- if (query.issuerCaId().isPresent()) {
- Optional c = store.getCredential(r.credentialId());
- if (c.isEmpty()) {
- return false;
+ try {
+ return store.listRevocationJournals().stream().filter(journal -> matches(journal, query)).toList();
+ } 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;
+ }
+ if (query.revokedAfter().isPresent() && latest.time().isBefore(query.revokedAfter().get())) {
+ return false;
+ }
+ if (query.revokedBefore().isPresent() && !latest.time().isBefore(query.revokedBefore().get())) {
+ return false;
+ }
+ return query.issuerCaId().isEmpty() || store.getCredential(journal.credentialId())
+ .map(credential -> query.issuerCaId().get().equals(credential.issuerRef().caId())).orElse(false);
+ }
+
+ @SuppressWarnings("PMD.AvoidCatchingGenericException")
+ private Optional currentState(RevocationCommand command) {
+ try {
+ return store.getRevocationJournal(command.credentialId()).map(RevocationJournal::latest)
+ .map(transition -> transition.state().name());
+ } catch (RuntimeException ignored) {
+ return Optional.empty();
+ }
+ }
+
+ 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 currentState) {
+ Map 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 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);
}
- PkiId issuerId = c.get().issuerRef().caId();
- return query.issuerCaId().get().equals(issuerId);
}
- return true;
- }).toList();
+ }
+ 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);
}
}
diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java
index 77cbcfc..8a02dfa 100644
--- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java
+++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java
@@ -34,29 +34,40 @@
package zeroecho.pki.impl.core;
import java.math.BigInteger;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
-import java.util.logging.Level;
-import java.util.logging.Logger;
+import java.util.Set;
import org.bouncycastle.cert.X509CertificateHolder;
+import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.StatusObjectService;
-import zeroecho.pki.api.attr.AttributeId;
import zeroecho.pki.api.attr.AttributeValue;
import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.ca.CaState;
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.StatusObjectGenerateCommand;
import zeroecho.pki.api.status.StatusObjectQuery;
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.audit.AuditSink;
+import zeroecho.pki.spi.framework.CrlEntry;
import zeroecho.pki.spi.framework.CredentialFramework;
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
* to the active {@link CredentialFramework}. The store acts as the
* authoritative source of issuer CA state, issuer credentials, revocation
- * records, and previously generated status objects.
+ * journals, and previously generated status objects.
*
*
*
* The current runtime implementation primarily supports generation workflows
* that require issuer certificate material and issuer signing key indirection
* to be provided through status-object attributes. For X.509 CRL generation,
- * this class additionally derives revoked certificate serial numbers from the
- * revocation and credential records currently available in the store.
+ * this class derives structured CRL entries from authoritative journals and the
+ * referenced X.509 credentials.
*
*
* Persistence model
@@ -93,9 +104,9 @@ import zeroecho.pki.spi.store.PkiStore;
* - This service does not access private key material directly.
* - Issuer signing capability is conveyed only through
* {@link BcX509Attributes#ISSUER_KEYREF}.
- * - For CRL generation, malformed credentials and unresolved revocation
- * targets are silently ignored by the current implementation rather than
- * failing the entire generation request.
+ * - For CRL generation, every active journal and referenced target is
+ * validated before signing. Any unresolved or malformed state aborts generation
+ * with a stable redacted failure.
* - The correctness of the generated status object depends on the configured
* {@link CredentialFramework} matching the requested runtime format and
* honoring the supplied attributes.
@@ -109,23 +120,12 @@ import zeroecho.pki.spi.store.PkiStore;
*
*/
public final class DefaultStatusObjectService implements StatusObjectService {
- private static final Logger LOG = Logger.getLogger(DefaultStatusObjectService.class.getName());
-
- /**
- * Attribute identifier used to pass one revoked certificate serial number to
- * the current X.509 CRL backend wiring.
- *
- *
- * 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.
- *
- */
- private static final AttributeId CRL_REVOKED_SERIAL = new AttributeId("urn:zeroecho:pki:x509:crl:revoked");
+ private static final String CRL_GENERATION_FAILED = "CRL_GENERATION_FAILED";
private final PkiStore store;
private final CredentialFramework framework;
+ private final AuditSink auditSink;
+ private final EffectiveCredentialStatusResolver statusResolver;
/**
* 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}
* @param framework credential framework providing the format-specific status
* object generator; must not be {@code null}
- * @throws NullPointerException if {@code store} or {@code framework} is
- * {@code null}
+ * @param auditSink required sink for safe trust-rejection audit events
+ * @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.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 {
*
*
* 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
- * the last credential in {@link CaRecord#caCredentials()} as the effective
- * issuer credential for status-object generation.
+ * and must expose at least one usable credential matching the requested
+ * format. Candidates are evaluated newest-first against current revocation
+ * state and one authoritative evaluation time.
*
*
*
@@ -167,17 +171,17 @@ public final class DefaultStatusObjectService implements StatusObjectService {
*
*
*
- * When {@link StatusObjectType#CRL} is requested, the service additionally
- * scans all revocation records in the store, excludes entries with
- * {@code REMOVE_FROM_CRL}, resolves the referenced credentials, filters them to
- * the requested issuer CA, extracts X.509 serial numbers, and forwards serials
- * that fit into a signed 64-bit integer as repeated {@link #CRL_REVOKED_SERIAL}
- * attributes.
+ * When {@link StatusObjectType#CRL} is requested, the service validates every
+ * authoritative active journal before signing. Current hold and permanent
+ * states are transported with the exact positive X.509 serial, authoritative
+ * transition time, and explicit reason. Current {@code CLEAR} states are
+ * omitted.
*
*
*
- * Malformed credential payloads encountered during CRL input derivation are
- * ignored by the current implementation and do not abort generation.
+ * Missing targets, malformed credentials, duplicate serials, future
+ * transitions, corrupt journals, and store failures abort the complete CRL
+ * before generator invocation or persistence with a stable redacted error.
*
*
* @param command status object generation request; must not be {@code null}
@@ -200,46 +204,146 @@ public final class DefaultStatusObjectService implements StatusObjectService {
if (ca.caCredentials().isEmpty()) {
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 crlEntries = command.type() == StatusObjectType.CRL
+ ? collectCrlEntries(command.issuerCaId(), statusEvaluation.evaluationTime())
+ : List.of();
SimpleAttributeSet.Builder b = SimpleAttributeSet.builder();
b.putAll(command.attributes());
b.put(BcX509Attributes.ISSUER_CERT_DER, new AttributeValue.BytesValue(issuerCred.encoded().bytes()));
b.put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue(ca.issuerKeyRef().value()));
- if (command.type() == StatusObjectType.CRL) {
- List revs = store.listRevocations();
- for (RevokedRecord rr : revs) {
- if (rr.reason() == zeroecho.pki.api.revocation.RevocationReason.REMOVE_FROM_CRL) {
- continue;
- }
- Optional credOpt = store.getCredential(rr.credentialId());
- if (credOpt.isEmpty()) {
- continue;
- }
- Credential cred = credOpt.get();
- if (!command.issuerCaId().equals(cred.issuerRef().caId())) {
- continue;
- }
- try {
- X509CertificateHolder h = new X509CertificateHolder(cred.encoded().bytes()); // NOPMD
- BigInteger serial = h.getSerialNumber();
- if (serial.bitLength() <= 63) { // NOPMD
- b.put(CRL_REVOKED_SERIAL, new AttributeValue.IntegerValue(serial.longValue())); // NOPMD
- }
- } catch (Exception ex) {
- LOG.log(Level.FINE, "malformed credential ignored", ex);
- }
- }
- }
-
StatusObjectGenerateCommand wired = new StatusObjectGenerateCommand(command.issuerCaId(), command.type(),
command.formatId(), b.build());
- StatusObject obj = framework.statusObjectGenerator().generate(wired);
+ if (command.type() == StatusObjectType.CRL) {
+ return generateAndPersistCrl(wired, crlEntries);
+ }
+ StatusObject obj = framework.statusObjectGenerator().generate(wired, crlEntries);
store.putStatusObject(obj);
return obj;
}
+ // 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 entries) {
+ try {
+ StatusObject generated = framework.statusObjectGenerator().generate(command, entries);
+ store.putStatusObject(generated);
+ return generated;
+ } catch (RuntimeException exception) {
+ throw crlGenerationFailure();
+ }
+ }
+
+ // Store and parser failures may contain persisted material; the complete
+ // collection boundary deliberately replaces every cause with one stable code.
+ @SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
+ private List collectCrlEntries(PkiId issuerCaId, Instant evaluationTime) {
+ try {
+ List journals =
+ Objects.requireNonNull(store.listRevocationJournals(), "revocation journals");
+ List entries = new java.util.ArrayList<>();
+ Set 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 collectCrlEntry(PkiId issuerCaId, Instant evaluationTime,
+ RevocationJournal journal, Set 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 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");
+ }
+
/**
* Resolves the most recent status object of the requested type for a given
* issuer CA.
diff --git a/pki/src/main/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolver.java b/pki/src/main/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolver.java
new file mode 100644
index 0000000..08e1977
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolver.java
@@ -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.
+ *
+ *
+ * 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.
+ *
+ */
+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 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);
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialFramework.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialFramework.java
index 12ee1f8..2f66fb2 100644
--- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialFramework.java
+++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialFramework.java
@@ -278,12 +278,14 @@ public final class BcX509CredentialFramework implements CredentialFramework {
* status-object generator has been wired.
*
* @param command ignored command parameter
+ * @param crlEntries ignored structured CRL entries
* @return never returns normally
* @throws UnsupportedOperationException always
*/
@Override
public zeroecho.pki.api.status.StatusObject generate(
- zeroecho.pki.api.status.StatusObjectGenerateCommand command) {
+ zeroecho.pki.api.status.StatusObjectGenerateCommand command,
+ java.util.List crlEntries) {
throw new UnsupportedOperationException("X.509 status object generator not wired");
}
}
diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509StatusObjectGenerator.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509StatusObjectGenerator.java
index 0bd171d..97e21a6 100644
--- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509StatusObjectGenerator.java
+++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509StatusObjectGenerator.java
@@ -33,17 +33,19 @@
******************************************************************************/
package zeroecho.pki.impl.framework.x509.bc;
-import java.math.BigInteger;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.Instant;
+import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.HexFormat;
import java.util.List;
+import java.util.Objects;
import java.util.Optional;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.AuthorityKeyIdentifier;
+import org.bouncycastle.asn1.x509.CRLReason;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.cert.X509CRLHolder;
import org.bouncycastle.cert.X509CertificateHolder;
@@ -57,14 +59,14 @@ import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId;
-import zeroecho.pki.api.attr.AttributeId;
import zeroecho.pki.api.attr.AttributeSet;
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.StatusObjectGenerateCommand;
import zeroecho.pki.api.status.StatusObjectType;
import zeroecho.pki.impl.core.async.PkiSigningBus;
+import zeroecho.pki.spi.framework.CrlEntry;
import zeroecho.pki.spi.framework.StatusObjectGenerator;
/**
@@ -73,8 +75,8 @@ import zeroecho.pki.spi.framework.StatusObjectGenerator;
*
* The current implementation supports only generation of X.509 certificate
* revocation lists (CRLs). It assembles a version 2 CRL from issuer-side wiring
- * attributes, optional revoked-certificate serial numbers supplied through the
- * attribute set, and delegated signing performed via {@link PkiSigningBus}.
+ * attributes, structured {@link CrlEntry} values, and delegated signing
+ * performed via {@link PkiSigningBus}.
*
*
*
@@ -86,10 +88,7 @@ import zeroecho.pki.spi.framework.StatusObjectGenerator;
* {@link AttributeValue.BytesValue} containing the issuer certificate in DER
* form,
*
{@link BcX509Attributes#ISSUER_KEYREF} as an
- * {@link AttributeValue.StringValue} identifying the issuer signing key,
- * optionally one or more {@link #CRL_REVOKED_SERIAL} values as
- * {@link AttributeValue.IntegerValue} instances representing serial numbers of
- * revoked certificates.
+ * {@link AttributeValue.StringValue} identifying the issuer signing key.
*
*
*
@@ -105,10 +104,11 @@ import zeroecho.pki.spi.framework.StatusObjectGenerator;
*
*
Revoked-entry handling
*
- * Revoked certificate serial numbers are read from all values associated with
- * {@link #CRL_REVOKED_SERIAL}. Only {@link IntegerValue} entries with a
- * positive numeric value are included as CRL entries. Non-positive serials and
- * non-integer attribute values are ignored.
+ * Structured entries preserve the full positive {@code BigInteger} serial,
+ * authoritative transition instant, and exact supported revocation reason.
+ * X.509 {@code Date} carries millisecond precision; this generator deliberately
+ * truncates each transition instant to whole seconds only at the DER CRL-entry
+ * boundary.
*
*
* Update semantics
@@ -143,18 +143,6 @@ import zeroecho.pki.spi.framework.StatusObjectGenerator;
*/
public final class BcX509StatusObjectGenerator implements StatusObjectGenerator {
- /**
- * Attribute identifier used to carry one revoked certificate serial number for
- * CRL generation.
- *
- *
- * Multiple values associated with this identifier may be supplied in the input
- * {@link AttributeSet}. Each positive {@link IntegerValue} contributes one CRL
- * entry.
- *
- */
- private static final AttributeId CRL_REVOKED_SERIAL = new AttributeId("urn:zeroecho:pki:x509:crl:revoked");
-
private final PkiSigningBus signingBus;
private final String signatureAlgorithmId;
private final Duration signingTtl;
@@ -212,6 +200,7 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
*
*
* @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
* @throws IllegalArgumentException if {@code command} is {@code null}, if the
* requested status object type is unsupported,
@@ -223,8 +212,9 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
* if signing fails, or if CRL encoding fails
*/
@Override
- public StatusObject generate(StatusObjectGenerateCommand command) {
+ public StatusObject generate(StatusObjectGenerateCommand command, List crlEntries) {
validateCommandOrThrow(command);
+ List validatedEntries = validateEntries(crlEntries);
IssuerMaterial issuerMaterial = extractIssuerMaterialOrThrow(command.attributes());
Instant thisUpdate = Instant.now();
@@ -232,7 +222,7 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
Date thisUpdateDate = Date.from(thisUpdate);
X509v2CRLBuilder builder = newCrlBuilder(issuerMaterial.issuerHolder(), thisUpdateDate, nextUpdate);
- addRevokedEntries(builder, command.attributes(), thisUpdateDate);
+ addRevokedEntries(builder, validatedEntries);
addAuthorityKeyIdentifierOrThrow(builder, issuerMaterial.issuerHolder());
X509CRLHolder crl = buildSignedCrlOrThrow(builder, issuerMaterial.keyRef());
@@ -241,6 +231,11 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
return toStatusObject(command, crlDer, thisUpdate, nextUpdate);
}
+ private static List validateEntries(List crlEntries) {
+ Objects.requireNonNull(crlEntries, "crlEntries");
+ return List.copyOf(crlEntries);
+ }
+
/**
* Validates the high-level command contract for CRL generation.
*
@@ -318,36 +313,34 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
}
/**
- * Adds revoked-certificate entries to the CRL builder from framework
- * attributes.
+ * Adds validated structured revoked-certificate entries to the CRL builder.
*
- *
- * Only positive {@link IntegerValue} attribute values associated with
- * {@link #CRL_REVOKED_SERIAL} are added. Non-integer and non-positive values
- * are ignored.
- *
- *
- * @param builder target CRL builder; must not be {@code null}
- * @param attributes source attributes; must not be {@code null}
- * @param revocationDate date to use for all generated CRL entries; must not be
- * {@code null}
+ * @param builder target CRL builder; must not be {@code null}
+ * @param entries validated structured entries
*/
- private static void addRevokedEntries(X509v2CRLBuilder builder, AttributeSet attributes, Date revocationDate) {
- List revoked = attributes.getAll(CRL_REVOKED_SERIAL);
- for (AttributeValue value : revoked) {
- if (!(value instanceof IntegerValue)) {
- continue;
- }
-
- long serial = ((IntegerValue) value).value();
- if (serial <= 0) {
- continue;
- }
-
- builder.addCRLEntry(BigInteger.valueOf(serial), revocationDate, 0);
+ private static void addRevokedEntries(X509v2CRLBuilder builder, List entries) {
+ for (CrlEntry entry : entries) {
+ Instant encodedTime = entry.transitionTime().truncatedTo(ChronoUnit.SECONDS);
+ builder.addCRLEntry(entry.serialNumber(), Date.from(encodedTime), reasonCode(entry.reason()));
}
}
+ 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(
+ "REMOVE_FROM_CRL is not an active CRL entry");
+ case PRIVILEGE_WITHDRAWN -> CRLReason.privilegeWithdrawn;
+ case AA_COMPROMISE -> CRLReason.aACompromise;
+ };
+ }
+
/**
* Adds the authority key identifier extension to the CRL builder.
*
diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java
index 530bbec..56c5eb0 100644
--- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java
@@ -62,11 +62,14 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.ReentrantLock;
import java.util.logging.Level;
import java.util.logging.Logger;
+import java.util.stream.Stream;
import zeroecho.pki.api.EncodedObject;
+import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.ca.CaRecord;
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.publication.PublicationRecord;
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.impl.core.async.PkiSigningBus;
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
* anomalous behavior for audit and incident analysis.
*
- * Audit history for mutable entities: CA records,
- * profiles, and revocations are treated as "mutable but auditable": each update
- * appends an immutable history entry and then updates {@code current.bin}
- * atomically. This supports forensic reconstruction, and snapshot export ("time
- * travel") without mutating the store.
+ * Audit history for mutable entities: CA records and
+ * profiles append history before updating {@code current.bin}. Revocation state
+ * is instead one atomically replaced, internally ordered journal per
+ * credential.
*
* Deterministic behavior: filenames, ordering, and cleanup
* 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 ReentrantLock signingTimeLock;
private final ConcurrentMap signLocks;
+ private final ConcurrentMap revocationLocks;
+ private final AtomicBoolean durabilityUncertain;
private final StoreOwnership ownership;
@@ -188,6 +196,8 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
Objects.requireNonNull(root, "root");
this.clock = Objects.requireNonNull(clock, "clock");
this.signLocks = new ConcurrentHashMap<>();
+ this.revocationLocks = new ConcurrentHashMap<>();
+ this.durabilityUncertain = new AtomicBoolean();
this.signingTimeLock = new ReentrantLock();
this.paths = new FsPaths(root);
@@ -239,6 +249,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
* @throws IllegalStateException if export fails
*/
public void exportSnapshot(final Path targetRoot, final Instant at) {
+ requireStoreUsable();
Objects.requireNonNull(targetRoot, "targetRoot");
Objects.requireNonNull(at, "at");
new FsSnapshotExporter(this.options).exportSnapshot(this.paths.root(), targetRoot, at);
@@ -246,6 +257,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override
public void putCa(final CaRecord record) {
+ requireStoreUsable();
Objects.requireNonNull(record, "record");
PkiId caId = record.caId();
Path current = this.paths.caCurrent(caId);
@@ -256,6 +268,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override
public Optional getCa(final PkiId caId) {
+ requireStoreUsable();
Objects.requireNonNull(caId, "caId");
Path p = this.paths.caCurrent(caId);
return readOptional(p, FsCodec.CA_RECORD);
@@ -263,12 +276,14 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override
public List listCas() {
+ requireStoreUsable();
Path casRoot = this.paths.root().resolve("cas").resolve("by-id");
return listCurrentRecords(casRoot, FsCodec.CA_RECORD);
}
@Override
public void putCredential(final Credential credential) {
+ requireStoreUsable();
Objects.requireNonNull(credential, "credential");
PkiId id = credential.credentialId();
Path p = this.paths.credentialPath(id);
@@ -277,12 +292,14 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override
public Optional getCredential(final PkiId credentialId) {
+ requireStoreUsable();
Objects.requireNonNull(credentialId, "credentialId");
return readOptional(this.paths.credentialPath(credentialId), FsCodec.CREDENTIAL);
}
@Override
public void putRequest(final ParsedCertificationRequest request) {
+ requireStoreUsable();
Objects.requireNonNull(request, "request");
PkiId id = request.requestId();
writeOnce(this.paths.requestPath(id), FsCodec.encode(FsCodec.PARSED_REQUEST, request), "REQUEST",
@@ -291,34 +308,91 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override
public Optional getRequest(final PkiId requestId) {
+ requireStoreUsable();
Objects.requireNonNull(requestId, "requestId");
return readOptional(this.paths.requestPath(requestId), FsCodec.PARSED_REQUEST);
}
@Override
- public void putRevocation(final RevokedRecord record) {
- Objects.requireNonNull(record, "record");
- PkiId credId = record.credentialId();
- Path current = this.paths.revocationCurrent(credId);
-
- writeWithHistory(this.paths.revocationHistoryDir(credId), current, FsCodec.encode(FsCodec.REVOCATION, record),
- this.options.revocationHistoryPolicy(), "REVOCATION", FsUtil.safeId(credId));
+ // Store failures are intentionally replaced by one stable redacted boundary.
+ @SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
+ public RevocationJournal transitionRevocation(final RevocationCommand command, final Instant transitionTime) {
+ requireStoreUsable();
+ Objects.requireNonNull(command, "command");
+ Objects.requireNonNull(transitionTime, "transitionTime");
+ PkiId credentialId = command.credentialId();
+ RevocationLockEntry lock = acquireRevocationLock(credentialId);
+ try {
+ Optional 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 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
- public Optional getRevocation(final PkiId credentialId) {
+ public Optional getRevocationJournal(final PkiId credentialId) {
+ requireStoreUsable();
Objects.requireNonNull(credentialId, "credentialId");
- return readOptional(this.paths.revocationCurrent(credentialId), FsCodec.REVOCATION);
+ return readRevocationJournal(credentialId);
}
@Override
- public List listRevocations() {
+ // Listing failures are intentionally replaced by one stable redacted boundary.
+ @SuppressWarnings("PMD.PreserveStackTrace")
+ public List listRevocationJournals() {
+ requireStoreUsable();
Path root = this.paths.root().resolve("revocations").resolve("by-credential");
- return listCurrentRecords(root, FsCodec.REVOCATION);
+ if (!Files.isDirectory(root)) {
+ return List.of();
+ }
+ try (Stream directories = Files.list(root)) {
+ List 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
public void putStatusObject(final StatusObject object) {
+ requireStoreUsable();
Objects.requireNonNull(object, "object");
PkiId id = object.statusObjectId();
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
public Optional getStatusObject(final PkiId statusObjectId) {
+ requireStoreUsable();
Objects.requireNonNull(statusObjectId, "statusObjectId");
return readOptional(this.paths.statusObjectPath(statusObjectId), FsCodec.STATUS_OBJECT);
}
@Override
public List listStatusObjects(final PkiId issuerCaId) {
+ requireStoreUsable();
Objects.requireNonNull(issuerCaId, "issuerCaId");
// Deterministic but coarse: scan all and filter by issuer id.
@@ -351,6 +427,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override
public void putPublicationRecord(final PublicationRecord record) {
+ requireStoreUsable();
Objects.requireNonNull(record, "record");
PkiId id = record.publicationId();
writeOnce(this.paths.publicationPath(id), FsCodec.encode(FsCodec.PUBLICATION, record), "PUBLICATION",
@@ -359,12 +436,14 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override
public List listPublicationRecords() {
+ requireStoreUsable();
Path byId = this.paths.root().resolve("publications").resolve("by-id");
return listBinaryFiles(byId, FsCodec.PUBLICATION);
}
@Override
public void putProfile(final CertificateProfile profile) {
+ requireStoreUsable();
Objects.requireNonNull(profile, "profile");
String profileId = profile.profileId();
Path current = this.paths.profileCurrent(profileId);
@@ -376,6 +455,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override
public Optional getProfile(final String profileId) {
+ requireStoreUsable();
if (profileId == null || profileId.isBlank()) {
throw new IllegalArgumentException("profileId must not be null/blank");
}
@@ -384,12 +464,14 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override
public List listProfiles() {
+ requireStoreUsable();
Path root = this.paths.root().resolve("profiles").resolve("by-id");
return listCurrentRecords(root, FsCodec.CERTIFICATE_PROFILE);
}
@Override
public void putPolicyTrace(final PolicyTrace trace) {
+ requireStoreUsable();
Objects.requireNonNull(trace, "trace");
PkiId id = trace.decisionId();
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
public Optional getPolicyTrace(final PkiId decisionId) {
+ requireStoreUsable();
Objects.requireNonNull(decisionId, "decisionId");
return readOptional(this.paths.policyTracePath(decisionId), FsCodec.POLICY_TRACE);
}
@@ -408,6 +491,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override
public void putWorkflowState(final WorkflowStateRecord record) {
+ requireStoreUsable();
Objects.requireNonNull(record, "record");
PkiId opId = record.opId();
@@ -420,12 +504,14 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override
public Optional getWorkflowState(final PkiId opId) {
+ requireStoreUsable();
Objects.requireNonNull(opId, "opId");
return readOptional(this.paths.workflowCurrent(opId), FsCodec.WORKFLOW_STATE);
}
@Override
public void deleteWorkflowState(final PkiId opId) {
+ requireStoreUsable();
Objects.requireNonNull(opId, "opId");
Path current = this.paths.workflowCurrent(opId);
@@ -438,12 +524,14 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override
public List listWorkflowStates() {
+ requireStoreUsable();
// List by operation directory (workflows/by-op//current.bin)
return listCurrentRecords(this.paths.workflowRoot(), FsCodec.WORKFLOW_STATE);
}
@Override
public Instant signingNow() {
+ requireStoreUsable();
signingTimeLock.lock();
try {
long monotonic = Math.max(signingTimeWatermark.get(), clock.instant().toEpochMilli());
@@ -457,21 +545,25 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override
public String signingNamespace() {
+ requireStoreUsable();
return signingNamespace;
}
@Override
public Duration signingHorizon() {
+ requireStoreUsable();
return options.signingOperationHorizon();
}
@Override
public Duration signingPermittedSkew() {
+ requireStoreUsable();
return options.signingIdPermittedSkew();
}
@Override
public SignWorkflowStore.CreateResult createSignIntent(SignWorkflowStore.Record intent) {
+ requireStoreUsable();
Objects.requireNonNull(intent, "intent");
SignLockEntry lock = acquireSignLock(intent.submissionId());
try {
@@ -504,6 +596,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override
public Optional getSignRecord(PkiId submissionId) {
+ requireStoreUsable();
Objects.requireNonNull(submissionId, "submissionId");
SignLockEntry lock = acquireSignLock(submissionId);
try {
@@ -515,11 +608,12 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override
public List listSignRecords() {
+ requireStoreUsable();
Path root = paths.signWorkflowRoot();
if (!Files.isDirectory(root)) {
return List.of();
}
- try (java.util.stream.Stream directories = Files.list(root)) {
+ try (Stream directories = Files.list(root)) {
return directories.filter(Files::isDirectory)
.map(directory -> directory.resolve(FsPaths.CURRENT_FILE))
.filter(Files::isRegularFile)
@@ -535,6 +629,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override
public Optional tryClaimSign(PkiId submissionId, long expectedRevision,
Duration lease) {
+ requireStoreUsable();
requirePositive(lease, "lease");
SignLockEntry lock = acquireSignLock(submissionId);
try {
@@ -562,6 +657,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override
public Optional renewSignClaim(PkiId submissionId, long expectedRevision, long fence,
Duration lease) {
+ requireStoreUsable();
requirePositive(lease, "lease");
SignLockEntry lock = acquireSignLock(submissionId);
try {
@@ -587,6 +683,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
public Optional transitionSign(PkiId submissionId, long expectedRevision, long fence,
SignWorkflowStore.State target, Optional detailCode, Optional result,
Optional providerUpdatedAt) {
+ requireStoreUsable();
Objects.requireNonNull(target, "target");
Objects.requireNonNull(detailCode, "detailCode");
Objects.requireNonNull(result, "result");
@@ -622,6 +719,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override
public Optional retireSign(PkiId submissionId, long expectedRevision, long fence) {
+ requireStoreUsable();
SignLockEntry lock = acquireSignLock(submissionId);
try {
Optional optional = readSignRecord(submissionId);
@@ -645,6 +743,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override
public int purgeExpiredSignRecords() {
+ requireStoreUsable();
Path root = paths.signWorkflowRoot();
if (!Files.isDirectory(root)) {
return 0;
@@ -677,6 +776,12 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
this.ownership.close();
}
+ private void requireStoreUsable() {
+ if (durabilityUncertain.get()) {
+ throw new PkiException("Store durability unconfirmed: code=STORE_DURABILITY_UNCONFIRMED");
+ }
+ }
+
private SignLockEntry acquireSignLock(PkiId submissionId) {
SignLockEntry entry = signLocks.compute(submissionId, (ignored, 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 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 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 transitions = new ArrayList<>(
+ current.map(RevocationJournal::transitions).orElseGet(List::of));
+ Optional 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 readSignRecord(PkiId submissionId) {
Path path = paths.signWorkflowPath(submissionId);
if (!Files.exists(path)) {
@@ -995,6 +1209,14 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
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.
*
diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java
index 10d6e02..ba59130 100644
--- a/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java
@@ -77,8 +77,10 @@ import zeroecho.pki.api.publication.PublicationStatus;
import zeroecho.pki.api.publication.PublicationTarget;
import zeroecho.pki.api.publication.PublicationTargetType;
import zeroecho.pki.api.request.ParsedCertificationRequest;
+import zeroecho.pki.api.revocation.RevocationJournal;
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.StatusObjectType;
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_DURABILITY_POLICY_ENUM = 58;
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_BOOLEAN = 2;
@@ -248,6 +252,18 @@ final class FsCodec {
case 10 -> RevocationReason.AA_COMPROMISE;
default -> throw unknownEnum("RevocationReason", code);
});
+ private static final ValueSchema 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 STATUS_OBJECT_TYPE = enumSchema(TYPE_STATUS_OBJECT_TYPE_ENUM,
value -> switch (value) {
case CRL -> 1;
@@ -379,6 +395,13 @@ final class FsCodec {
private static final ValueSchema> OPTIONAL_INSTANT = optionalOf(INSTANT);
private static final ValueSchema> OPTIONAL_DURATION = optionalOf(DURATION);
private static final ValueSchema> OPTIONAL_ENCODED_OBJECT = optionalOf(ENCODED_OBJECT);
+ private static final ValueSchema> OPTIONAL_REVOCATION_REASON =
+ optionalOf(REVOCATION_REASON);
+ private static final ValueSchema REVOCATION_TRANSITION =
+ valueSchema(TYPE_REVOCATION_TRANSITION, FsCodec::writeRevocationTransition,
+ FsCodec::readRevocationTransition);
+ private static final ValueSchema> REVOCATION_TRANSITIONS =
+ listOf(REVOCATION_TRANSITION);
private static final ValueSchema CREDENTIAL_VALUE = valueSchema(TYPE_CREDENTIAL_RECORD,
FsCodec::writeCredential, FsCodec::readCredential);
@@ -390,8 +413,9 @@ final class FsCodec {
CREDENTIAL_VALUE);
/* package */ static final Schema PARSED_REQUEST = topLevel(TOP_PARSED_REQUEST,
"PARSED_REQUEST", valueSchema(101, FsCodec::writeParsedRequest, FsCodec::readParsedRequest));
- /* package */ static final Schema REVOCATION = topLevel(TOP_REVOCATION, "REVOCATION",
- valueSchema(102, FsCodec::writeRevocation, FsCodec::readRevocation));
+ /* package */ static final Schema REVOCATION_JOURNAL =
+ topLevel(TOP_REVOCATION, "REVOCATION_JOURNAL",
+ valueSchema(102, FsCodec::writeRevocationJournal, FsCodec::readRevocationJournal));
/* package */ static final Schema STATUS_OBJECT = topLevel(TOP_STATUS_OBJECT, "STATUS_OBJECT",
valueSchema(103, FsCodec::writeStatusObject, FsCodec::readStatusObject));
/* package */ static final Schema PUBLICATION = topLevel(TOP_PUBLICATION, "PUBLICATION",
@@ -411,7 +435,7 @@ final class FsCodec {
Map.entry(TOP_CA_RECORD, CA_RECORD),
Map.entry(TOP_CREDENTIAL, CREDENTIAL),
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_PUBLICATION, PUBLICATION),
Map.entry(TOP_CERTIFICATE_PROFILE, CERTIFICATE_PROFILE),
@@ -615,16 +639,33 @@ final class FsCodec {
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(INSTANT, value.revocationTime());
- writer.writeValue(REVOCATION_REASON, value.reason());
+ writer.writeValue(LONG, (long) RevocationJournal.CURRENT_VERSION);
+ 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());
}
- private static RevokedRecord readRevocation(Reader reader) throws IOException {
- return new RevokedRecord(reader.readValue(PKI_ID), reader.readValue(INSTANT),
- reader.readValue(REVOCATION_REASON), reader.readValue(ATTRIBUTE_SET));
+ private static RevocationTransition readRevocationTransition(Reader reader) throws IOException {
+ return new RevocationTransition(reader.readValue(LONG), reader.readValue(REVOCATION_STATE),
+ reader.readValue(INSTANT), reader.readValue(OPTIONAL_REVOCATION_REASON),
+ reader.readValue(ATTRIBUTE_SET));
}
private static void writeStatusObject(Writer writer, StatusObject value) throws IOException {
diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsOperations.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsOperations.java
index a601407..5353c79 100644
--- a/pki/src/main/java/zeroecho/pki/impl/fs/FsOperations.java
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsOperations.java
@@ -36,6 +36,7 @@ package zeroecho.pki.impl.fs;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
+import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.FileAlreadyExistsException;
@@ -157,6 +158,66 @@ final class FsOperations {
forceDirectoryBestEffort(parent);
}
+ /**
+ * Strictly persists one authoritative revocation journal image.
+ *
+ * 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.
+ *
+ * @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.
*
diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java
index a62dff1..ecf9d6d 100644
--- a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java
@@ -154,7 +154,7 @@ final class FsPaths {
}
// -------------------------------------------------------------------------
- // Revocations (mutable with history)
+ // Revocations (single authoritative journal)
// -------------------------------------------------------------------------
/* 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));
}
- /* default */ Path revocationCurrent(final PkiId credentialId) {
- return revocationDir(credentialId).resolve(CURRENT_FILE);
- }
-
- /* default */ Path revocationHistoryDir(final PkiId credentialId) {
- return revocationDir(credentialId).resolve(HISTORY_DIR);
+ /* default */ Path revocationJournal(final PkiId credentialId) {
+ return revocationDir(credentialId).resolve("journal.bin");
}
// -------------------------------------------------------------------------
diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java
index fa4aca1..560da6b 100644
--- a/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java
@@ -97,14 +97,13 @@ final class FsSnapshotExporter {
copyTreeIfExists(sourceRoot.resolve("policy"), targetRoot.resolve("policy"));
copyTreeIfExists(sourceRoot.resolve("publications"), targetRoot.resolve("publications"));
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,
this.options.caHistoryPolicy(), this.options.strictSnapshotExport());
reconstructMutableTree(sourceRoot.resolve("profiles"), targetRoot.resolve("profiles"), at,
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
reconstructMutableTree(sourceRoot.resolve("workflows"), targetRoot.resolve("workflows"), at,
this.options.workflowHistoryPolicy(), this.options.strictSnapshotExport());
diff --git a/pki/src/main/java/zeroecho/pki/spi/framework/CrlEntry.java b/pki/src/main/java/zeroecho/pki/spi/framework/CrlEntry.java
new file mode 100644
index 0000000..3767718
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/spi/framework/CrlEntry.java
@@ -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.
+ *
+ * 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.
+ *
+ * @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");
+ }
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/spi/framework/StatusObjectGenerator.java b/pki/src/main/java/zeroecho/pki/spi/framework/StatusObjectGenerator.java
index 9d89859..eaeac4f 100644
--- a/pki/src/main/java/zeroecho/pki/spi/framework/StatusObjectGenerator.java
+++ b/pki/src/main/java/zeroecho/pki/spi/framework/StatusObjectGenerator.java
@@ -33,6 +33,8 @@
******************************************************************************/
package zeroecho.pki.spi.framework;
+import java.util.List;
+
import zeroecho.pki.api.status.StatusObject;
import zeroecho.pki.api.status.StatusObjectGenerateCommand;
@@ -47,9 +49,11 @@ public interface StatusObjectGenerator {
* Generates a status object.
*
* @param command generation command
+ * @param crlEntries structured CRL entries; empty for non-CRL objects
* @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
*/
- StatusObject generate(StatusObjectGenerateCommand command);
+ StatusObject generate(StatusObjectGenerateCommand command, List crlEntries);
}
diff --git a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java
index 09a1c37..30840e7 100644
--- a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java
+++ b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java
@@ -33,6 +33,7 @@
******************************************************************************/
package zeroecho.pki.spi.store;
+import java.time.Instant;
import java.util.List;
import java.util.Optional;
@@ -44,7 +45,8 @@ import zeroecho.pki.api.policy.PolicyTrace;
import zeroecho.pki.api.profile.CertificateProfile;
import zeroecho.pki.api.publication.PublicationRecord;
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;
/**
@@ -154,31 +156,28 @@ public interface PkiStore extends SignWorkflowStore {
Optional getRequest(PkiId requestId);
/**
- * Persists or updates a revocation record.
+ * Atomically validates and appends one legal revocation transition.
*
- * @param record revocation record (never {@code null})
- * @throws NullPointerException if {@code record} is {@code null}
- * @throws IllegalStateException if persistence fails
+ * @param command trusted transition command
+ * @param transitionTime authoritative transition time
+ * @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})
- * @return revocation record if present
- * @throws NullPointerException if {@code credentialId} is {@code null}
- * @throws IllegalStateException if retrieval fails
+ * @param credentialId credential identifier
+ * @return journal when present
*/
- Optional getRevocation(PkiId credentialId);
+ Optional getRevocationJournal(PkiId credentialId);
/**
- * Lists all revocation records.
+ * Lists authoritative revocation journals.
*
- * @return list of revocation records (never {@code null})
- * @throws IllegalStateException if listing fails
+ * @return immutable journal list
*/
- List listRevocations();
+ List listRevocationJournals();
/**
* Persists a status object.
diff --git a/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java b/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java
index c5062fd..ea34075 100644
--- a/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java
+++ b/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java
@@ -35,6 +35,9 @@ package zeroecho.pki.e2e;
import static org.junit.jupiter.api.Assertions.assertEquals;
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 java.nio.file.Path;
@@ -42,9 +45,13 @@ import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.time.Duration;
import java.time.Instant;
+import java.util.ArrayList;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.cert.X509CRLHolder;
@@ -63,6 +70,7 @@ import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.IssuanceService;
import zeroecho.pki.api.KeyRef;
+import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.RevocationService;
import zeroecho.pki.api.StatusObjectService;
@@ -70,7 +78,15 @@ import zeroecho.pki.api.SubjectRef;
import zeroecho.pki.api.Validity;
import zeroecho.pki.api.attr.AttributeSet;
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.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.VerificationPolicy;
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.RequestStorePolicy;
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.StatusObjectGenerateCommand;
import zeroecho.pki.api.status.StatusObjectType;
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;
/**
@@ -91,6 +110,136 @@ import zeroecho.pki.testkit.PkiTestRuntime;
*/
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 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 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 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
void e2eRootIssueRevokeCrl(@TempDir Path tempDir) throws Exception {
System.out.println("e2eRootIssueRevokeCrl");
@@ -144,8 +293,8 @@ public final class PkiCoreE2eTest {
assertEquals("CN=Root", eeCert.getIssuer().toString());
assertEquals("CN=Alice", eeCert.getSubject().toString());
- revSvc.revoke(new RevokeCommand(bundle.credential().credentialId(), RevocationReason.KEY_COMPROMISE,
- emptyAttributes()));
+ revSvc.revokePermanently(new RevocationCommand.RevokePermanently(bundle.credential().credentialId(),
+ RevocationReason.KEY_COMPROMISE, emptyAttributes()));
StatusObject crl = stSvc.generate(new StatusObjectGenerateCommand(rootCaId, StatusObjectType.CRL,
runtime.framework().formatId(), emptyAttributes()));
@@ -159,6 +308,125 @@ public final class PkiCoreE2eTest {
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 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 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 {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
diff --git a/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java b/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java
index f5a8bf1..c5e375f 100644
--- a/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java
+++ b/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java
@@ -172,7 +172,7 @@ final class PkiProofGateE2eTest {
try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), keys)) {
CountingIssuerBackend counting = new CountingIssuerBackend(runtime.issuerBackend());
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"));
assertThrows(PkiException.class,
@@ -217,7 +217,7 @@ final class PkiProofGateE2eTest {
try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), Map.of())) {
CountingIssuerBackend counting = new CountingIssuerBackend(runtime.issuerBackend());
DefaultIssuanceService issuance = new DefaultIssuanceService(runtime.store(), runtime.framework(),
- counting, runtime.auditSink());
+ counting, runtime.auditSink(), runtime.statusResolver());
CaService caService = runtime.caService(counting);
ParsedCertificationRequest request = new ParsedCertificationRequest(new PkiId("csr:unsupported"),
runtime.framework().formatId(), new SubjectRef("CN=Unsupported"),
@@ -265,11 +265,15 @@ final class PkiProofGateE2eTest {
void endEntityGateRejectsMalformedTamperedAndSubstitutedRequests(@TempDir Path tempDir) throws Exception {
System.out.println("endEntityGateRejectsMalformedTamperedAndSubstitutedRequests");
+ KeyPair rootKey = generateRsa();
KeyPair subjectKey = generateRsa();
KeyPair otherKey = generateRsa();
+ KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:root");
KeyRef subjectKeyRef = new KeyRef("kref:v1:keyring:test:subject");
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");
ParsedCertificationRequest valid = parse(runtime, validCsr);
ParsedCertificationRequest pss = parse(runtime,
@@ -289,44 +293,44 @@ final class PkiProofGateE2eTest {
new zeroecho.pki.api.issuance.VerificationPolicy(true, Optional.empty()));
assertEquals(ProofOfPossessionStatus.FAILED, unsupportedProof.status());
- assertRejected(runtime, withAttributes(valid, new SimpleAttributeSet()), "CSR_MISSING");
- assertRejected(runtime, withCsr(valid, new byte[] { 0x01 }), "CSR_MALFORMED");
+ assertRejected(runtime, rootCaId, withAttributes(valid, new SimpleAttributeSet()), "CSR_MISSING");
+ assertRejected(runtime, rootCaId, withCsr(valid, new byte[] { 0x01 }), "CSR_MALFORMED");
byte[] tampered = csrDer(valid).clone();
tampered[tampered.length - 1] ^= 0x01;
ParsedCertificationRequest tamperedParsed = parse(runtime, new PKCS10CertificationRequest(tampered));
- assertRejected(runtime, tamperedParsed, "PROOF_FAILED");
+ assertRejected(runtime, rootCaId, tamperedParsed, "PROOF_FAILED");
ParsedCertificationRequest wrongSigner = parse(runtime,
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(),
valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(),
valid.attributes()),
"REQUEST_ID_MISMATCH");
- assertRejected(runtime,
+ assertRejected(runtime, rootCaId,
new ParsedCertificationRequest(valid.requestId(), valid.formatId(), new SubjectRef("CN=Other"),
valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(),
valid.attributes()),
"SUBJECT_MISMATCH");
- assertRejected(runtime,
+ assertRejected(runtime, rootCaId,
new ParsedCertificationRequest(valid.requestId(), valid.formatId(), valid.subjectRef(),
new EncodedObject(Encoding.DER, otherKey.getPublic().getEncoded()),
valid.requestedValidity(), valid.requestedProfileId(), valid.attributes()),
"SPKI_MISMATCH");
- assertRejected(runtime,
+ assertRejected(runtime, rootCaId,
new ParsedCertificationRequest(valid.requestId(), new FormatId("unsupported"), valid.subjectRef(),
valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(),
valid.attributes()),
"FORMAT_UNSUPPORTED");
byte[] maximum = new byte[1024 * 1024];
System.arraycopy(csrDer(valid), 0, maximum, 0, csrDer(valid).length);
- assertRejected(runtime, withCsr(valid, maximum), "CSR_MALFORMED");
- assertRejected(runtime, withCsr(valid, new byte[1024 * 1024 + 1]), "CSR_TOO_LARGE");
+ assertRejected(runtime, rootCaId, withCsr(valid, maximum), "CSR_MALFORMED");
+ 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);
assertEquals("ISSUE_END_ENTITY_REJECTED", last.action());
assertEquals("SYSTEM", last.principal().type());
@@ -341,7 +345,9 @@ final class PkiProofGateE2eTest {
void endEntityGateForcesRequiredPolicyAndRejectsEveryNonVerifiedStatus(@TempDir Path tempDir) throws Exception {
System.out.println("endEntityGateForcesRequiredPolicyAndRejectsEveryNonVerifiedStatus");
+ KeyPair rootKey = generateRsa();
KeyPair subjectKey = generateRsa();
+ KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:root");
KeyRef subjectKeyRef = new KeyRef("kref:v1:keyring:test:subject");
for (ProofOfPossessionStatus status : new ProofOfPossessionStatus[] {
ProofOfPossessionStatus.NOT_PRESENT,
@@ -350,17 +356,20 @@ final class PkiProofGateE2eTest {
AtomicBoolean required = new AtomicBoolean();
Path caseDir = tempDir.resolve(status.name());
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) -> {
required.set(policy.requireProofOfPossession());
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"));
- assertThrows(PkiException.class, () -> issue(runtime, parsed));
+ assertThrows(PkiException.class, () -> issue(runtime, rootCaId, parsed));
assertTrue(required.get());
assertEquals("PROOF_" + status.name(),
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(),
- runtime.framework(), throwingBackend, runtime.auditSink());
+ runtime.framework(), throwingBackend, runtime.auditSink(), runtime.statusResolver());
PkiException backendRejection = assertThrows(PkiException.class,
() -> throwingBackendService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject,
"default", Optional.empty(), new SimpleAttributeSet())));
@@ -678,7 +687,7 @@ final class PkiProofGateE2eTest {
}
};
DefaultIssuanceService service = new DefaultIssuanceService(runtime.store(), runtime.framework(),
- maliciousBackend, runtime.auditSink());
+ maliciousBackend, runtime.auditSink(), runtime.statusResolver());
assertThrows(PkiException.class,
() -> service.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, "default",
Optional.empty(), new SimpleAttributeSet())));
@@ -707,7 +716,7 @@ final class PkiProofGateE2eTest {
}
};
DefaultIssuanceService invalidSignatureService = new DefaultIssuanceService(runtime.store(),
- runtime.framework(), invalidSignatureBackend, runtime.auditSink());
+ runtime.framework(), invalidSignatureBackend, runtime.auditSink(), runtime.statusResolver());
assertThrows(PkiException.class,
() -> invalidSignatureService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject,
"default", Optional.empty(), new SimpleAttributeSet())));
@@ -727,7 +736,7 @@ final class PkiProofGateE2eTest {
}
};
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,
"default", Optional.empty(), new SimpleAttributeSet()));
byte[] expectedLeaf = returned.credential().encoded().bytes().clone();
@@ -763,6 +772,8 @@ final class PkiProofGateE2eTest {
"default", Optional.empty(), new SimpleAttributeSet())));
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());
AttributeSet hostileAttributes = new AttributeSet() {
@Override
@@ -789,7 +800,7 @@ final class PkiProofGateE2eTest {
DefaultIssuanceService failingAudit = new DefaultIssuanceService(runtime.store(), runtime.framework(),
runtime.issuerBackend(), event -> {
throw new IllegalStateException("DO_NOT_LOG_PAYLOAD_SENTINEL");
- });
+ }, runtime.statusResolver());
PkiException rejection = assertThrows(PkiException.class,
() -> failingAudit.issueEndEntity(new IssueEndEntityCommand(rootCaId, missing, "default",
Optional.empty(), new SimpleAttributeSet())));
@@ -1080,15 +1091,16 @@ final class PkiProofGateE2eTest {
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();
- assertThrows(PkiException.class, () -> issue(runtime, request));
+ assertThrows(PkiException.class, () -> issue(runtime, issuerCaId, request));
assertEquals(before + 1, runtime.auditSink().snapshot().size());
assertEquals(code, runtime.auditSink().snapshot().get(before).details().get("code"));
}
- private static void issue(PkiTestRuntime runtime, ParsedCertificationRequest request) {
- runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(new PkiId("ca:absent"), request, "default",
+ private static void issue(PkiTestRuntime runtime, PkiId issuerCaId, ParsedCertificationRequest request) {
+ runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(issuerCaId, request, "default",
Optional.empty(), new SimpleAttributeSet()));
}
diff --git a/pki/src/test/java/zeroecho/pki/impl/core/DefaultRevocationServiceTest.java b/pki/src/test/java/zeroecho/pki/impl/core/DefaultRevocationServiceTest.java
new file mode 100644
index 0000000..55b2d8a
--- /dev/null
+++ b/pki/src/test/java/zeroecho/pki/impl/core/DefaultRevocationServiceTest.java
@@ -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 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 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 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);
+ }
+}
diff --git a/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java b/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java
new file mode 100644
index 0000000..610dda7
--- /dev/null
+++ b/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java
@@ -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 reasons = activeReasons();
+ List serials = serials(reasons.size());
+ List 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 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 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 journals, Map 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 journals,
+ Map 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 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 activeReasons() {
+ return Arrays.stream(RevocationReason.values())
+ .filter(reason -> reason != RevocationReason.REMOVE_FROM_CRL)
+ .toList();
+ }
+
+ private static List serials(int size) {
+ List 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) {
+ }
+}
diff --git a/pki/src/test/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolverTest.java b/pki/src/test/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolverTest.java
new file mode 100644
index 0000000..d6608ac
--- /dev/null
+++ b/pki/src/test/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolverTest.java
@@ -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 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 revocation,
+ EffectiveCredentialStatus expected) {
+ assertEquals(expected, resolver(revocation).beginEvaluation().resolve(credential));
+ }
+
+ private static void assertResolutionFailure(Credential credential, Optional 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 revocation) {
+ return new StoreBackedEffectiveCredentialStatusResolver(store(id -> revocation, new AtomicInteger()),
+ Clock.fixed(NOW, ZoneOffset.UTC));
+ }
+
+ private static PkiStore store(Function> 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 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 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();
+ }
+ }
+}
diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java
index 50371ee..9704bb5 100644
--- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java
+++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java
@@ -78,8 +78,9 @@ import zeroecho.pki.api.publication.PublicationStatus;
import zeroecho.pki.api.publication.PublicationTarget;
import zeroecho.pki.api.publication.PublicationTargetType;
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.RevokedRecord;
import zeroecho.pki.api.status.StatusObject;
import zeroecho.pki.api.status.StatusObjectType;
import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy;
@@ -120,8 +121,6 @@ public final class FilesystemPkiStoreTest {
new FormatId("fmt-x509"), new SubjectRef("CN=request-all"),
new EncodedObject(Encoding.DER, new byte[] { 4, 5, 6 }), Optional.empty(),
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(),
StatusObjectType.CRL, now, Optional.of(now.plusSeconds(60L)),
new EncodedObject(Encoding.DER, new byte[] { 7, 8 }), attributes);
@@ -140,7 +139,8 @@ public final class FilesystemPkiStoreTest {
store.putCa(ca);
store.putCredential(credential);
store.putRequest(request);
- store.putRevocation(revocation);
+ RevocationJournal revocation = store.transitionRevocation(new RevocationCommand.RevokePermanently(
+ credential.credentialId(), RevocationReason.KEY_COMPROMISE, attributes), now);
store.putStatusObject(status);
store.putPublicationRecord(publication);
store.putProfile(profile);
@@ -152,7 +152,7 @@ public final class FilesystemPkiStoreTest {
store.getCredential(credential.credentialId()).orElseThrow().credentialId());
assertEquals(request.requestId(), store.getRequest(request.requestId()).orElseThrow().requestId());
assertEquals(revocation.credentialId(),
- store.getRevocation(revocation.credentialId()).orElseThrow().credentialId());
+ store.getRevocationJournal(revocation.credentialId()).orElseThrow().credentialId());
assertEquals(status.statusObjectId(),
store.getStatusObject(status.statusObjectId()).orElseThrow().statusObjectId());
assertEquals(publication.publicationId(), store.listPublicationRecords().get(0).publicationId());
@@ -213,30 +213,29 @@ public final class FilesystemPkiStoreTest {
}
@Test
- void revocationHistorySupportsOverwriteWithTrail() throws Exception {
- System.out.println("revocationHistorySupportsOverwriteWithTrail");
+ void revocationJournalPersistsLegalTransitions() throws Exception {
+ System.out.println("revocationJournalPersistsLegalTransitions");
Path root = tmp.resolve("store-revocation-history");
FsPkiStoreOptions options = FsPkiStoreOptions.defaults();
try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) {
- RevokedRecord r1 = TestObjects.minimalRevocation("cred-rev-1", Instant.EPOCH.plusSeconds(10L),
- RevocationReason.KEY_COMPROMISE);
- store.putRevocation(r1);
+ Credential credential = TestObjects.minimalCredential("SERIAL-REV", "profile-rev");
+ store.putCredential(credential);
+ 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(),
- r1.attributes());
- store.putRevocation(r2);
-
- Optional loaded = store.getRevocation(r1.credentialId());
+ Optional loaded = store.getRevocationJournal(credential.credentialId());
assertTrue(loaded.isPresent());
- assertEquals(r2.revocationTime(), loaded.get().revocationTime());
+ assertEquals(2L, loaded.get().latest().revision());
}
System.out.println("...store tree:");
dumpTree(root);
- System.out.println("revocationHistorySupportsOverwriteWithTrail...ok");
+ System.out.println("revocationJournalPersistsLegalTransitions...ok");
}
@Test
@@ -563,12 +562,6 @@ public final class FilesystemPkiStoreTest {
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() {
return new TestAttributeSet(List.of());
}
diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationJournalTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationJournalTest.java
new file mode 100644
index 0000000..42d7f6c
--- /dev/null
+++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationJournalTest.java
@@ -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 first = attemptHold(store, credential, barrier, executor);
+ CompletableFuture 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 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 blockedTransition = CompletableFuture.supplyAsync(() -> {
+ blockedStarted.countDown();
+ return transition(store, hold(blocked));
+ }, executor);
+ assertTrue(blockedStarted.await(5, TimeUnit.SECONDS));
+ CompletableFuture 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 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 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 runConcurrent(FilesystemPkiStore store, RevocationCommand firstCommand,
+ RevocationCommand secondCommand, ExecutorService executor) {
+ CyclicBarrier barrier = new CyclicBarrier(2);
+ CompletableFuture first = attemptTransition(store, firstCommand, barrier, executor);
+ CompletableFuture second = attemptTransition(store, secondCommand, barrier, executor);
+ return List.of(first.join(), second.join());
+ }
+
+ private static CompletableFuture 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 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 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));
+ }
+ }
+}
diff --git a/pki/src/test/java/zeroecho/pki/spi/framework/CrlEntryTest.java b/pki/src/test/java/zeroecho/pki/spi/framework/CrlEntryTest.java
new file mode 100644
index 0000000..550859d
--- /dev/null
+++ b/pki/src/test/java/zeroecho/pki/spi/framework/CrlEntryTest.java
@@ -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);
+ }
+}
diff --git a/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java b/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java
index f30fbbf..6e7849d 100644
--- a/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java
+++ b/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java
@@ -37,6 +37,7 @@ import java.io.IOException;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.PublicKey;
+import java.time.Clock;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
@@ -51,11 +52,13 @@ import zeroecho.pki.api.IssuanceService;
import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.RevocationService;
import zeroecho.pki.api.StatusObjectService;
+import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver;
import zeroecho.pki.impl.core.DefaultCaService;
import zeroecho.pki.impl.core.DefaultCertificationRequestService;
import zeroecho.pki.impl.core.DefaultIssuanceService;
import zeroecho.pki.impl.core.DefaultRevocationService;
import zeroecho.pki.impl.core.DefaultStatusObjectService;
+import zeroecho.pki.impl.core.StoreBackedEffectiveCredentialStatusResolver;
import zeroecho.pki.impl.core.async.PkiSigningBus;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
import zeroecho.pki.impl.audit.InMemoryAuditSink;
@@ -88,6 +91,7 @@ public final class PkiTestRuntime implements AutoCloseable {
private final CredentialFramework framework;
private final CredentialIssuerBackend issuerBackend;
+ private final EffectiveCredentialStatusResolver statusResolver;
private final CaService caService;
private final CertificationRequestService certificationRequestService;
@@ -107,17 +111,19 @@ public final class PkiTestRuntime implements AutoCloseable {
this.auditSink = new InMemoryAuditSink();
this.framework = framework;
this.issuerBackend = issuerBackend;
+ Clock clock = Clock.systemUTC();
+ this.statusResolver = new StoreBackedEffectiveCredentialStatusResolver(store, clock);
this.publicKeysByKeyRef = publicKeysByKeyRef;
this.publicKeyResolveHook = () -> {
};
this.certificationRequestService = new DefaultCertificationRequestService(store, framework);
- this.issuanceService = new DefaultIssuanceService(store, framework, issuerBackend, auditSink);
- this.revocationService = new DefaultRevocationService(store);
- this.statusObjectService = new DefaultStatusObjectService(store, framework);
+ this.issuanceService = new DefaultIssuanceService(store, framework, issuerBackend, auditSink, statusResolver);
+ this.revocationService = new DefaultRevocationService(store, clock, auditSink);
+ this.statusObjectService = new DefaultStatusObjectService(store, framework, auditSink, statusResolver);
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;
}
+ public EffectiveCredentialStatusResolver statusResolver() {
+ return statusResolver;
+ }
+
public CaService caService() {
return caService;
}
public CaService caService(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));
}
public CaService caService(CredentialIssuerBackend 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() {
@@ -299,6 +316,12 @@ public final class PkiTestRuntime implements AutoCloseable {
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() {
return revocationService;
}
@@ -307,6 +330,11 @@ public final class PkiTestRuntime implements AutoCloseable {
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.
*