From f06b25fa3976dca78efb300a7e42b4e7395fff68 Mon Sep 17 00:00:00 2001 From: Leo Galambos Date: Thu, 30 Jul 2026 10:11:26 +0200 Subject: [PATCH] security(pki): enforce authoritative revocation state --- .../zeroecho/pki/api/ImportExportService.java | 12 - .../zeroecho/pki/api/RevocationService.java | 89 +-- .../pki/api/credential/Credential.java | 6 +- .../pki/api/credential/CredentialStatus.java | 5 +- .../CredentialUse.java} | 46 +- .../EffectiveCredentialStatus.java} | 51 +- .../EffectiveCredentialStatusResolver.java} | 72 +- .../pki/api/credential/package-info.java | 4 +- .../pki/api/revocation/RevocationCommand.java | 79 ++ .../pki/api/revocation/RevocationJournal.java | 170 +++++ .../pki/api/revocation/RevocationState.java | 17 + .../api/revocation/RevocationTransition.java | 33 + .../core/CredentialTrustAudit.java} | 65 +- .../pki/impl/core/DefaultCaService.java | 64 +- .../pki/impl/core/DefaultIssuanceService.java | 89 ++- .../impl/core/DefaultRevocationService.java | 373 ++++------ .../impl/core/DefaultStatusObjectService.java | 236 ++++-- ...ckedEffectiveCredentialStatusResolver.java | 200 +++++ .../x509/bc/BcX509CredentialFramework.java | 4 +- .../x509/bc/BcX509StatusObjectGenerator.java | 97 ++- .../pki/impl/fs/FilesystemPkiStore.java | 258 ++++++- .../java/zeroecho/pki/impl/fs/FsCodec.java | 61 +- .../zeroecho/pki/impl/fs/FsOperations.java | 61 ++ .../java/zeroecho/pki/impl/fs/FsPaths.java | 10 +- .../pki/impl/fs/FsSnapshotExporter.java | 5 +- .../zeroecho/pki/spi/framework/CrlEntry.java | 45 ++ .../spi/framework/StatusObjectGenerator.java | 8 +- .../java/zeroecho/pki/spi/store/PkiStore.java | 31 +- .../java/zeroecho/pki/e2e/PkiCoreE2eTest.java | 274 ++++++- .../zeroecho/pki/e2e/PkiProofGateE2eTest.java | 64 +- .../core/DefaultRevocationServiceTest.java | 135 ++++ .../DefaultStatusObjectServiceCrlTest.java | 488 +++++++++++++ ...EffectiveCredentialStatusResolverTest.java | 284 ++++++++ .../pki/impl/fs/FilesystemPkiStoreTest.java | 39 +- .../fs/FilesystemRevocationJournalTest.java | 688 ++++++++++++++++++ .../pki/spi/framework/CrlEntryTest.java | 59 ++ .../zeroecho/pki/testkit/PkiTestRuntime.java | 40 +- 37 files changed, 3563 insertions(+), 699 deletions(-) rename pki/src/main/java/zeroecho/pki/api/{revocation/UnholdCommand.java => credential/CredentialUse.java} (69%) rename pki/src/main/java/zeroecho/pki/api/{revocation/HoldCommand.java => credential/EffectiveCredentialStatus.java} (70%) rename pki/src/main/java/zeroecho/pki/api/{revocation/RevokeCommand.java => credential/EffectiveCredentialStatusResolver.java} (51%) create mode 100644 pki/src/main/java/zeroecho/pki/api/revocation/RevocationCommand.java create mode 100644 pki/src/main/java/zeroecho/pki/api/revocation/RevocationJournal.java create mode 100644 pki/src/main/java/zeroecho/pki/api/revocation/RevocationState.java create mode 100644 pki/src/main/java/zeroecho/pki/api/revocation/RevocationTransition.java rename pki/src/main/java/zeroecho/pki/{api/revocation/RevokedRecord.java => impl/core/CredentialTrustAudit.java} (53%) create mode 100644 pki/src/main/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolver.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/framework/CrlEntry.java create mode 100644 pki/src/test/java/zeroecho/pki/impl/core/DefaultRevocationServiceTest.java create mode 100644 pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java create mode 100644 pki/src/test/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolverTest.java create mode 100644 pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationJournalTest.java create mode 100644 pki/src/test/java/zeroecho/pki/spi/framework/CrlEntryTest.java 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. *