diff --git a/pki/src/main/java/zeroecho/pki/api/revocation/RevocationCommand.java b/pki/src/main/java/zeroecho/pki/api/revocation/RevocationCommand.java index a85ec8d..c2bbe3e 100644 --- a/pki/src/main/java/zeroecho/pki/api/revocation/RevocationCommand.java +++ b/pki/src/main/java/zeroecho/pki/api/revocation/RevocationCommand.java @@ -68,7 +68,7 @@ public sealed interface RevocationCommand /** Validates and snapshots the command. */ public Hold { Objects.requireNonNull(credentialId, "credentialId"); - attributes = RevocationJournal.snapshot(attributes); + attributes = RevocationTransition.snapshotAttributes(attributes); } } @@ -82,7 +82,7 @@ public sealed interface RevocationCommand /** Validates and snapshots the command. */ public Unhold { Objects.requireNonNull(credentialId, "credentialId"); - attributes = RevocationJournal.snapshot(attributes); + attributes = RevocationTransition.snapshotAttributes(attributes); } } @@ -102,7 +102,7 @@ public sealed interface RevocationCommand if (reason == RevocationReason.CERTIFICATE_HOLD || reason == RevocationReason.REMOVE_FROM_CRL) { throw new IllegalArgumentException("reason must be permanent"); } - attributes = RevocationJournal.snapshot(attributes); + attributes = RevocationTransition.snapshotAttributes(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 deleted file mode 100644 index 54f58c3..0000000 --- a/pki/src/main/java/zeroecho/pki/api/revocation/RevocationJournal.java +++ /dev/null @@ -1,199 +0,0 @@ -/******************************************************************************* - * 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.revocation; - -import java.time.Instant; -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 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 index a8a28b7..ef65aae 100644 --- a/pki/src/main/java/zeroecho/pki/api/revocation/RevocationState.java +++ b/pki/src/main/java/zeroecho/pki/api/revocation/RevocationState.java @@ -34,7 +34,7 @@ package zeroecho.pki.api.revocation; /** - * Effective state recorded by an authoritative revocation journal. + * Effective state recorded by the authoritative global revocation log. */ public enum RevocationState { /** Credential has no effective revocation restriction. */ diff --git a/pki/src/main/java/zeroecho/pki/api/revocation/RevocationTransition.java b/pki/src/main/java/zeroecho/pki/api/revocation/RevocationTransition.java index c57f72a..c6c4550 100644 --- a/pki/src/main/java/zeroecho/pki/api/revocation/RevocationTransition.java +++ b/pki/src/main/java/zeroecho/pki/api/revocation/RevocationTransition.java @@ -34,12 +34,19 @@ package zeroecho.pki.api.revocation; import java.time.Instant; +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 zeroecho.pki.api.attr.AttributeId; import zeroecho.pki.api.attr.AttributeSet; +import zeroecho.pki.api.attr.AttributeValue; /** - * One committed transition in an authoritative revocation journal. + * One committed transition in the authoritative global revocation log. * * @param revision positive contiguous revision * @param state resulting state @@ -57,6 +64,76 @@ public record RevocationTransition(long revision, RevocationState state, Instant if (revision <= 0L || state == null || time == null || permanentReason == null || attributes == null) { throw new IllegalArgumentException("Invalid revocation transition"); } - attributes = RevocationJournal.snapshot(attributes); + attributes = snapshotAttributes(attributes); + } + + // Defensive snapshotting necessarily allocates immutable entries while walking + // the caller-owned attribute collection. + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + /* default */ static AttributeSet snapshotAttributes(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 a transition or command. */ + private record SnapshotEntry(AttributeId id, List values) { + } + + /** Immutable defensive implementation for revocation 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 + // transition-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/impl/core/DefaultStatusObjectService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java index d2b335d..ff6bd08 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java @@ -89,15 +89,15 @@ import zeroecho.pki.spi.store.RevocationSnapshot; * 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 - * journals, and previously generated status objects. + * transitions, 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 derives structured CRL entries from authoritative journals and the - * referenced X.509 credentials. + * this class derives structured CRL entries from a stable ordered current-state + * snapshot and the referenced X.509 credentials. *

* *

Persistence model

@@ -114,7 +114,7 @@ import zeroecho.pki.spi.store.RevocationSnapshot; *
  • This service does not access private key material directly.
  • *
  • Issuer signing capability is conveyed only through * {@link BcX509Attributes#ISSUER_KEYREF}.
  • - *
  • For CRL generation, every active journal and referenced target is + *
  • For CRL generation, every selected current state 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 @@ -184,7 +184,7 @@ public final class DefaultStatusObjectService implements StatusObjectService { * *

    * When {@link StatusObjectType#CRL} is requested, the service validates every - * authoritative active journal before signing. Current hold and permanent + * current state from one stable authoritative revision before signing. 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. @@ -192,7 +192,7 @@ public final class DefaultStatusObjectService implements StatusObjectService { * *

    * Missing targets, malformed credentials, duplicate serials, future - * transitions, corrupt journals, and store failures abort the complete CRL + * transitions, corrupt derived state, and store failures abort the complete CRL * before generator invocation or persistence with a stable redacted error. *

    * @@ -347,7 +347,7 @@ public final class DefaultStatusObjectService implements StatusObjectService { } } - /** Bounded cursor translating authoritative journals into CRL entries. */ + /** Bounded cursor translating stable validated current states into CRL entries. */ private final class CheckpointCrlCursor implements CrlEntrySource.Cursor { private final RevocationSnapshot.Cursor cursor; private final PkiId issuerCaId; 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 fd8c705..1e15080 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java @@ -231,9 +231,15 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { this(root, options, clock, FilesystemRevocationAuthority.IndexUpdateFaultInjector.NONE); } - @SuppressWarnings("PMD.CloseResource") /* package */ FilesystemPkiStore(final Path root, final FsPkiStoreOptions options, final Clock clock, final FilesystemRevocationAuthority.IndexUpdateFaultInjector indexUpdateFaults) { + this(root, options, clock, indexUpdateFaults, false); + } + + @SuppressWarnings("PMD.CloseResource") + private FilesystemPkiStore(final Path root, final FsPkiStoreOptions options, final Clock clock, + final FilesystemRevocationAuthority.IndexUpdateFaultInjector indexUpdateFaults, + final boolean snapshotAssembly) { this.options = Objects.requireNonNull(options, "options"); Objects.requireNonNull(root, "root"); this.clock = Objects.requireNonNull(clock, "clock"); @@ -258,6 +264,9 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { FilesystemRevocationAuthority openedRevocations = null; try { boolean newStore = !Files.exists(this.paths.versionFile()); + if (newStore && !snapshotAssembly) { + rejectNonEmptyUnversionedStore(); + } ensureVersionFile(); this.signingNamespace = ensureSigningNamespace(); this.stagedContent = new FilesystemStagedContentStore(this.paths.stagedContentRoot(), @@ -312,6 +321,12 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { } } + /* package */ static FilesystemPkiStore openSnapshotAssembly(final Path root, + final FsPkiStoreOptions options) { + return new FilesystemPkiStore(root, options, Clock.systemUTC(), + FilesystemRevocationAuthority.IndexUpdateFaultInjector.NONE, true); + } + private boolean requireSnapshotBoundary() throws IOException { Path marker = paths.revocationSnapshotBoundary(); if (!Files.exists(marker, java.nio.file.LinkOption.NOFOLLOW_LINKS)) { @@ -2320,6 +2335,20 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { } } + private void rejectNonEmptyUnversionedStore() throws IOException { + Path lockDirectory = this.paths.lockFile().getParent(); + try (Stream entries = Files.list(this.paths.root())) { + if (entries.anyMatch(entry -> !entry.equals(lockDirectory))) { + throw new IllegalStateException("unversioned store is not empty"); + } + } + try (Stream entries = Files.list(lockDirectory)) { + if (entries.anyMatch(entry -> !entry.equals(this.paths.lockFile()))) { + throw new IllegalStateException("unversioned store is not empty"); + } + } + } + private Optional readOptional(final Path path, final FsCodec.Schema schema) { try { if (!Files.exists(path)) { 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 392718f..5a6187c 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java @@ -87,10 +87,6 @@ import zeroecho.pki.api.publication.PublicationTargetType; import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.request.SubjectAlternativeName; import zeroecho.pki.api.request.SubjectRdn; -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.StatusObjectType; import zeroecho.pki.spi.store.StagedContentStore; @@ -128,7 +124,6 @@ final class FsCodec { private static final int TOP_CA_RECORD = 1; private static final int TOP_CREDENTIAL = 2; private static final int TOP_PARSED_REQUEST = 3; - private static final int TOP_REVOCATION = 4; private static final int TOP_STATUS_OBJECT = 5; private static final int TOP_PUBLICATION = 6; private static final int TOP_POLICY_TRACE = 8; @@ -163,14 +158,11 @@ final class FsCodec { private static final int TYPE_CA_KIND_ENUM = 51; private static final int TYPE_CA_STATE_ENUM = 52; private static final int TYPE_CREDENTIAL_STATUS_ENUM = 53; - private static final int TYPE_REVOCATION_REASON_ENUM = 54; private static final int TYPE_STATUS_OBJECT_TYPE_ENUM = 55; private static final int TYPE_PUBLICATION_TARGET_TYPE_ENUM = 56; 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 TYPE_SUBJECT_RDN_TYPE_ENUM = 62; private static final int TYPE_SAN_TYPE_ENUM = 63; private static final int TYPE_SUBJECT_RDN = 65; @@ -245,42 +237,6 @@ final class FsCodec { case 3 -> CredentialStatus.EXPIRED; default -> throw unknownEnum("CredentialStatus", code); }); - private static final ValueSchema REVOCATION_REASON = enumSchema(TYPE_REVOCATION_REASON_ENUM, - value -> switch (value) { - 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; - }, code -> switch (code) { - case 1 -> RevocationReason.UNSPECIFIED; - case 2 -> RevocationReason.KEY_COMPROMISE; - case 3 -> RevocationReason.CA_COMPROMISE; - case 4 -> RevocationReason.AFFILIATION_CHANGED; - case 5 -> RevocationReason.SUPERSEDED; - case 6 -> RevocationReason.CESSATION_OF_OPERATION; - case 7 -> RevocationReason.CERTIFICATE_HOLD; - case 8 -> RevocationReason.REMOVE_FROM_CRL; - case 9 -> RevocationReason.PRIVILEGE_WITHDRAWN; - 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; @@ -440,11 +396,6 @@ final class FsCodec { private static final ValueSchema> OPTIONAL_STRING = optionalOf(STRING); private static final ValueSchema> OPTIONAL_INSTANT = optionalOf(INSTANT); 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); @@ -455,8 +406,6 @@ final class FsCodec { /* package */ static final Schema CREDENTIAL = topLevel(TOP_CREDENTIAL, "CREDENTIAL", 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_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", @@ -475,7 +424,7 @@ final class FsCodec { private static final Map> TOP_LEVEL_SCHEMAS = Map.ofEntries(Map.entry(TOP_CA_RECORD, CA_RECORD), Map.entry(TOP_CREDENTIAL, CREDENTIAL), Map.entry(TOP_PARSED_REQUEST, PARSED_REQUEST), - Map.entry(TOP_REVOCATION, REVOCATION_JOURNAL), Map.entry(TOP_STATUS_OBJECT, STATUS_OBJECT), + Map.entry(TOP_STATUS_OBJECT, STATUS_OBJECT), Map.entry(TOP_PUBLICATION, PUBLICATION), Map.entry(TOP_POLICY_TRACE, POLICY_TRACE), Map.entry(TOP_WORKFLOW_STATE, WORKFLOW_STATE), Map.entry(TOP_SIGN_WORKFLOW_RECORD, SIGN_WORKFLOW_RECORD), Map.entry(TOP_PROFILE_VERSION, PROFILE_VERSION), Map.entry(TOP_ACTIVE_PROFILE_REF, ACTIVE_PROFILE_REF)); @@ -739,35 +688,6 @@ final class FsCodec { reader.readValue(BOOLEAN), reader.readValue(ATTRIBUTE_SET)); } - private static void writeRevocationJournal(Writer writer, RevocationJournal value) throws IOException { - writer.writeValue(PKI_ID, value.credentialId()); - 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 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 { writer.writeValue(PKI_ID, value.statusObjectId()); writer.writeValue(FORMAT_ID, value.formatId()); 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 87cc25e..f709f61 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsOperations.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsOperations.java @@ -214,17 +214,17 @@ final class FsOperations { } /** - * Strictly persists one authoritative revocation journal image. + * Strictly persists one complete atomic file 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. + * image is then uncertain. *

    * - * @param target journal target - * @param data complete encoded journal + * @param target file target + * @param data complete encoded image * @throws IOException on a pre-commit persistence failure * @throws DurabilityUncertainException after a committed move whose directory * force failed 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 734d758..8e8e6f2 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java @@ -167,19 +167,9 @@ final class FsPaths { } // ------------------------------------------------------------------------- - // Revocations (single authoritative journal) + // Revocations (global log authority and derived structures) // ------------------------------------------------------------------------- - /* default */ Path revocationDir(final PkiId credentialId) { - Objects.requireNonNull(credentialId, "credentialId"); - return this.root.resolve(REVOCATIONS_DIRECTORY).resolve("by-credential") - .resolve(FsUtil.safeId(credentialId)); - } - - /* default */ Path revocationJournal(final PkiId credentialId) { - return revocationDir(credentialId).resolve("journal.bin"); - } - /* default */ Path revocationTransitionLog() { return this.root.resolve(REVOCATIONS_DIRECTORY).resolve("transitions.log"); } @@ -200,10 +190,6 @@ final class FsPaths { return this.root.resolve(REVOCATIONS_DIRECTORY).resolve("current-state.idx.lock"); } - /* default */ Path revocationSnapshotRoot() { - return this.root.resolve("revocation-snapshots"); - } - // ------------------------------------------------------------------------- // Policy traces (immutable .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 b2b42a4..2bb3e53 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java @@ -416,7 +416,7 @@ final class FsSnapshotExporter { private void restore(Path targetRoot) throws IOException { Set transferred = new HashSet<>(); - try (FilesystemPkiStore target = new FilesystemPkiStore(targetRoot, options)) { + try (FilesystemPkiStore target = FilesystemPkiStore.openSnapshotAssembly(targetRoot, options)) { for (Map.Entry entry : authority.credentials().entrySet()) { if (persistCredential(target, entry.getValue())) { transferred.add(entry.getKey()); diff --git a/pki/src/test/java/zeroecho/pki/impl/core/DefaultRevocationServiceTest.java b/pki/src/test/java/zeroecho/pki/impl/core/DefaultRevocationServiceTest.java index ed0ef31..f0d35f6 100644 --- a/pki/src/test/java/zeroecho/pki/impl/core/DefaultRevocationServiceTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/core/DefaultRevocationServiceTest.java @@ -57,7 +57,6 @@ 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.RevocationRecord; import zeroecho.pki.api.revocation.RevocationQuery; import zeroecho.pki.api.revocation.RevocationState; diff --git a/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java b/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java index e0e42b6..da6a7fa 100644 --- a/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java @@ -97,7 +97,6 @@ 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.RevocationRecord; import zeroecho.pki.api.revocation.RevocationReason; import zeroecho.pki.api.revocation.RevocationState; @@ -225,12 +224,12 @@ final class DefaultStatusObjectServiceCrlTest { StatusObjectGenerateCommand command = new StatusObjectGenerateCommand(caId, StatusObjectType.CRL, runtime.framework().formatId(), emptyAttributes()); - assertCrlFailure(runtime, command, List.of(journal(new PkiId("credential:missing"), RevocationState.HELD, + assertCrlFailure(runtime, command, List.of(record(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.content()); assertCrlFailure( - runtime, command, List.of(journal(wrongFormat.credentialId(), RevocationState.HELD, + runtime, command, List.of(record(wrongFormat.credentialId(), RevocationState.HELD, EVALUATION_TIME.minusSeconds(1), Optional.empty())), Map.of(wrongFormat.credentialId(), wrongFormat), false); @@ -240,14 +239,14 @@ final class DefaultStatusObjectServiceCrlTest { Credential wrongEncoding = copy(template, "wrong-encoding", BcX509CredentialFramework.FORMAT_ID, wrongEncodingContent); assertCrlFailure( - runtime, command, List.of(journal(wrongEncoding.credentialId(), RevocationState.HELD, + runtime, command, List.of(record(wrongEncoding.credentialId(), RevocationState.HELD, EVALUATION_TIME.minusSeconds(1), Optional.empty())), Map.of(wrongEncoding.credentialId(), wrongEncoding), false); Credential malformed = copy(template, "malformed", BcX509CredentialFramework.FORMAT_ID, runtime.stageCredential(new byte[] { 1, 2, 3 })); assertCrlFailure( - runtime, command, List.of(journal(malformed.credentialId(), RevocationState.HELD, + runtime, command, List.of(record(malformed.credentialId(), RevocationState.HELD, EVALUATION_TIME.minusSeconds(1), Optional.empty())), Map.of(malformed.credentialId(), malformed), false); @@ -256,16 +255,16 @@ final class DefaultStatusObjectServiceCrlTest { Credential duplicateTwo = copy(template, "duplicate-two", BcX509CredentialFramework.FORMAT_ID, template.content()); assertCrlFailure(runtime, command, - List.of(journal(duplicateOne.credentialId(), RevocationState.HELD, EVALUATION_TIME.minusSeconds(1), + List.of(record(duplicateOne.credentialId(), RevocationState.HELD, EVALUATION_TIME.minusSeconds(1), Optional.empty()), - journal(duplicateTwo.credentialId(), RevocationState.PERMANENTLY_REVOKED, + record(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.content()); assertCrlFailure( - runtime, command, List.of(journal(future.credentialId(), RevocationState.HELD, + runtime, command, List.of(record(future.credentialId(), RevocationState.HELD, EVALUATION_TIME.plusSeconds(1), Optional.empty())), Map.of(future.credentialId(), future), false); @@ -320,10 +319,10 @@ final class DefaultStatusObjectServiceCrlTest { } private static void assertCrlFailure(PkiTestRuntime runtime, StatusObjectGenerateCommand command, - List journals, Map credentials, boolean failListing) { + List records, Map credentials, boolean failListing) { int signCount = runtime.submittedSignCount(); int statusCount = runtime.store().listStatusObjects(command.issuerCaId()).size(); - PkiStore view = storeView(runtime.store(), journals, credentials, failListing); + PkiStore view = storeView(runtime.store(), records, credentials, failListing); DefaultStatusObjectService service = new DefaultStatusObjectService(view, runtime.framework(), runtime.auditSink(), usableResolver(), runtime.signingBus().authority()); @@ -335,7 +334,7 @@ final class DefaultStatusObjectServiceCrlTest { assertEquals(statusCount, runtime.store().listStatusObjects(command.issuerCaId()).size()); } - private static PkiStore storeView(PkiStore delegate, List journals, + private static PkiStore storeView(PkiStore delegate, List records, Map credentials, boolean failListing) { return (PkiStore) Proxy.newProxyInstance(PkiStore.class.getClassLoader(), new Class[] { PkiStore.class }, (proxy, method, arguments) -> { @@ -343,9 +342,7 @@ final class DefaultStatusObjectServiceCrlTest { if (failListing) { throw new IllegalStateException(SENTINEL); } - List snapshot = journals.stream() - .map(journal -> new RevocationRecord(journal.credentialId(), journal.latest())) - .toList(); + List snapshot = List.copyOf(records); return new zeroecho.pki.spi.store.RevocationSnapshot() { @Override public String snapshotId() { @@ -475,10 +472,10 @@ final class DefaultStatusObjectServiceCrlTest { }; } - private static RevocationJournal journal(PkiId credentialId, RevocationState state, Instant time, + private static RevocationRecord record(PkiId credentialId, RevocationState state, Instant time, Optional reason) { - return new RevocationJournal(credentialId, - List.of(new RevocationTransition(1L, state, time, reason, emptyAttributes()))); + return new RevocationRecord(credentialId, + new RevocationTransition(1L, state, time, reason, emptyAttributes())); } private static Credential copy(Credential template, String suffix, FormatId formatId, diff --git a/pki/src/test/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolverTest.java b/pki/src/test/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolverTest.java index f64ba02..651bf32 100644 --- a/pki/src/test/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolverTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolverTest.java @@ -69,7 +69,6 @@ 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.RevocationRecord; import zeroecho.pki.api.revocation.RevocationReason; import zeroecho.pki.api.revocation.RevocationState; @@ -122,13 +121,13 @@ final class StoreBackedEffectiveCredentialStatusResolverTest { } @Test - void futureAndMismatchedJournalsFailClosed() { + void futureAndMismatchedRecordsFailClosed() { 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()))); + RevocationRecord mismatch = new RevocationRecord(new PkiId("credential:other"), + new RevocationTransition(1L, RevocationState.PERMANENTLY_REVOKED, NOW, + Optional.of(RevocationReason.KEY_COMPROMISE), new SimpleAttributeSet())); assertResolutionFailure(credential, Optional.of(mismatch)); } @@ -193,50 +192,47 @@ final class StoreBackedEffectiveCredentialStatusResolverTest { assertFalse(event.details().toString().contains(sentinel)); } - private static void assertStatus(Credential credential, Optional revocation, + 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) { + 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) { + 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) { + private static PkiStore store(Function> lookup, AtomicInteger calls) { return (PkiStore) Proxy.newProxyInstance(PkiStore.class.getClassLoader(), new Class[] { PkiStore.class }, (proxy, method, arguments) -> { if ("getRevocation".equals(method.getName())) { calls.incrementAndGet(); - return lookup.apply((PkiId) arguments[0]) - .map(journal -> new RevocationRecord(journal.credentialId(), journal.latest())); + return lookup.apply((PkiId) arguments[0]); } throw new UnsupportedOperationException(method.getName()); }); } - private static Optional revocation(Credential credential, RevocationReason reason, + 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())))); + return Optional.of(new RevocationRecord(credential.credentialId(), + 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())))); + return Optional.of(new RevocationRecord(credential.credentialId(), + new RevocationTransition(1L, state, time, permanentReason, new SimpleAttributeSet()))); } private Credential credential(String suffix, CredentialStatus status, Instant notBefore, Instant notAfter) { 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 7a2a244..96f1f5a 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java @@ -116,7 +116,6 @@ 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.RevocationRecord; import zeroecho.pki.api.revocation.RevocationReason; import zeroecho.pki.api.status.StatusObject; @@ -779,8 +778,8 @@ public final class FilesystemPkiStoreTest { } @Test - void revocationJournalPersistsLegalTransitions() throws Exception { - System.out.println("revocationJournalPersistsLegalTransitions"); + void globalRevocationLogPersistsStreamingHistory() throws Exception { + System.out.println("globalRevocationLogPersistsStreamingHistory"); Path root = tmp.resolve("store-revocation-history"); Path restoredRoot = tmp.resolve("store-revocation-history-snapshot"); @@ -830,7 +829,7 @@ public final class FilesystemPkiStoreTest { System.out.println("...store tree:"); dumpTree(root); - System.out.println("revocationJournalPersistsLegalTransitions...ok"); + System.out.println("globalRevocationLogPersistsStreamingHistory...ok"); } @Test diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationJournalTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationAuthorityTest.java similarity index 56% rename from pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationJournalTest.java rename to pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationAuthorityTest.java index fc36026..acfc20c 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationJournalTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationAuthorityTest.java @@ -39,10 +39,10 @@ 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; @@ -57,14 +57,10 @@ 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.FormatId; import zeroecho.pki.api.IssuerRef; import zeroecho.pki.api.PkiException; @@ -79,27 +75,13 @@ 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; +final class FilesystemRevocationAuthorityTest { private static final Instant TIME = Instant.parse("2026-07-01T12:00:00Z"); private static final AttributeId NOTE = new AttributeId("test.note"); @@ -124,10 +106,10 @@ final class FilesystemRevocationJournalTest { () -> transition(store, revoke(credential, RevocationReason.CA_COMPROMISE))); } try (FilesystemPkiStore reopened = store(root)) { - RevocationJournal journal = readJournal(reopened, credential.credentialId()).orElseThrow(); - assertEquals(4, journal.transitions().size()); - assertEquals(RevocationState.PERMANENTLY_REVOKED, journal.latest().state()); - assertEquals(RevocationReason.KEY_COMPROMISE, journal.latest().permanentReason().orElseThrow()); + CredentialHistory history = readHistory(reopened, credential.credentialId()).orElseThrow(); + assertEquals(4, history.transitions().size()); + assertEquals(RevocationState.PERMANENTLY_REVOKED, history.latest().state()); + assertEquals(RevocationReason.KEY_COMPROMISE, history.latest().permanentReason().orElseThrow()); } System.out.println("legalTransitionsAreContiguousDurableAndPermanentIsTerminal...ok"); } @@ -143,28 +125,28 @@ final class FilesystemRevocationJournalTest { 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()); + CredentialHistory directHistory = transition(store, revoke(direct, RevocationReason.KEY_COMPROMISE)); + assertEquals(List.of(RevocationState.PERMANENTLY_REVOKED), states(directHistory)); + assertEquals(1L, directHistory.latest().revision()); transition(store, hold(fromClear)); transition(store, unhold(fromClear)); - RevocationJournal clearJournal = transition(store, revoke(fromClear, RevocationReason.CA_COMPROMISE)); + CredentialHistory clearHistory = transition(store, revoke(fromClear, RevocationReason.CA_COMPROMISE)); assertEquals(List.of(RevocationState.HELD, RevocationState.CLEAR, RevocationState.PERMANENTLY_REVOKED), - states(clearJournal)); - assertEquals(3L, clearJournal.latest().revision()); + states(clearHistory)); + assertEquals(3L, clearHistory.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()); + CredentialHistory heldHistory = transition(store, revoke(fromHeld, RevocationReason.SUPERSEDED)); + assertEquals(List.of(RevocationState.HELD, RevocationState.PERMANENTLY_REVOKED), states(heldHistory)); + assertEquals(2L, heldHistory.latest().revision()); } System.out.println("everyLegalPermanentTransitionIsAccepted...ok"); } @Test - void illegalAndInvalidCommandsFailBeforeJournalMutation() throws Exception { - System.out.println("illegalAndInvalidCommandsFailBeforeJournalMutation"); + void illegalAndInvalidCommandsFailBeforeHistoryMutation() throws Exception { + System.out.println("illegalAndInvalidCommandsFailBeforeHistoryMutation"); try (FilesystemPkiStore store = store(temporaryDirectory.resolve("illegal"))) { Credential unknown = credential(store, "unknown"); assertCode("REVOCATION_CREDENTIAL_NOT_FOUND", () -> transition(store, hold(unknown))); @@ -173,11 +155,11 @@ final class FilesystemRevocationJournalTest { store.putCredential(credential); assertCode("REVOCATION_TRANSITION_ILLEGAL", () -> transition(store, unhold(credential))); assertThrows(IllegalArgumentException.class, () -> revoke(credential, RevocationReason.CERTIFICATE_HOLD)); - assertTrue(readJournal(store, credential.credentialId()).isEmpty()); + assertTrue(readHistory(store, credential.credentialId()).isEmpty()); transition(store, hold(credential)); assertCode("REVOCATION_TRANSITION_ILLEGAL", () -> transition(store, hold(credential))); - assertEquals(1, readJournal(store, credential.credentialId()).orElseThrow().transitions().size()); + assertEquals(1, readHistory(store, credential.credentialId()).orElseThrow().transitions().size()); transition(store, unhold(credential)); Path logPath = new FsPaths(temporaryDirectory.resolve("illegal")).revocationTransitionLog(); @@ -197,9 +179,9 @@ final class FilesystemRevocationJournalTest { Credential invalidRemove = credential(store, "invalid-remove"); store.putCredential(invalidRemove); assertThrows(IllegalArgumentException.class, () -> revoke(invalidRemove, RevocationReason.REMOVE_FROM_CRL)); - assertTrue(readJournal(store, invalidRemove.credentialId()).isEmpty()); + assertTrue(readHistory(store, invalidRemove.credentialId()).isEmpty()); } - System.out.println("illegalAndInvalidCommandsFailBeforeJournalMutation...ok"); + System.out.println("illegalAndInvalidCommandsFailBeforeHistoryMutation...ok"); } @Test @@ -217,9 +199,9 @@ final class FilesystemRevocationJournalTest { } finally { executor.shutdownNow(); } - RevocationJournal journal = readJournal(store, credential.credentialId()).orElseThrow(); - assertEquals(1, journal.transitions().size()); - assertEquals(1L, journal.latest().revision()); + CredentialHistory history = readHistory(store, credential.credentialId()).orElseThrow(); + assertEquals(1, history.transitions().size()); + assertEquals(1L, history.latest().revision()); } System.out.println("sameCredentialConcurrentHoldHasExactlyOneCommittedWinner...ok"); } @@ -233,26 +215,26 @@ final class FilesystemRevocationJournalTest { Credential fromNone = credential(store, "hold-vs-permanent"); store.putCredential(fromNone); runConcurrent(store, hold(fromNone), revoke(fromNone, RevocationReason.KEY_COMPROMISE), executor); - RevocationJournal fromNoneJournal = readJournal(store, fromNone.credentialId()).orElseThrow(); - assertEquals(RevocationState.PERMANENTLY_REVOKED, fromNoneJournal.latest().state()); - assertTrue(fromNoneJournal.transitions().size() == 1 || fromNoneJournal.transitions().size() == 2); + CredentialHistory fromNoneHistory = readHistory(store, fromNone.credentialId()).orElseThrow(); + assertEquals(RevocationState.PERMANENTLY_REVOKED, fromNoneHistory.latest().state()); + assertTrue(fromNoneHistory.transitions().size() == 1 || fromNoneHistory.transitions().size() == 2); Credential fromHeld = credential(store, "unhold-vs-permanent"); store.putCredential(fromHeld); transition(store, hold(fromHeld)); runConcurrent(store, unhold(fromHeld), revoke(fromHeld, RevocationReason.CA_COMPROMISE), executor); - RevocationJournal fromHeldJournal = readJournal(store, fromHeld.credentialId()).orElseThrow(); - assertEquals(RevocationState.PERMANENTLY_REVOKED, fromHeldJournal.latest().state()); - assertTrue(fromHeldJournal.transitions().size() == 2 || fromHeldJournal.transitions().size() == 3); + CredentialHistory fromHeldHistory = readHistory(store, fromHeld.credentialId()).orElseThrow(); + assertEquals(RevocationState.PERMANENTLY_REVOKED, fromHeldHistory.latest().state()); + assertTrue(fromHeldHistory.transitions().size() == 2 || fromHeldHistory.transitions().size() == 3); Credential reasons = credential(store, "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 = readJournal(store, reasons.credentialId()).orElseThrow(); - assertEquals(1, reasonsJournal.transitions().size()); - assertTrue(reasonsJournal.latest().permanentReason().filter( + CredentialHistory reasonsHistory = readHistory(store, reasons.credentialId()).orElseThrow(); + assertEquals(1, reasonsHistory.transitions().size()); + assertTrue(reasonsHistory.latest().permanentReason().filter( reason -> reason == RevocationReason.KEY_COMPROMISE || reason == RevocationReason.CA_COMPROMISE) .isPresent()); } finally { @@ -280,12 +262,12 @@ final class FilesystemRevocationJournalTest { ExecutorService executor = Executors.newFixedThreadPool(2); CountDownLatch blockedStarted = new CountDownLatch(1); try { - CompletableFuture blockedTransition = CompletableFuture.supplyAsync(() -> { + CompletableFuture blockedTransition = CompletableFuture.supplyAsync(() -> { blockedStarted.countDown(); return transition(store, hold(blocked)); }, executor); assertTrue(blockedStarted.await(5, TimeUnit.SECONDS)); - CompletableFuture independentTransition = CompletableFuture + CompletableFuture independentTransition = CompletableFuture .supplyAsync(() -> transition(store, hold(independent)), executor); assertEquals(RevocationState.HELD, independentTransition.get(5, TimeUnit.SECONDS).latest().state()); assertFalse(blockedTransition.isDone()); @@ -303,8 +285,8 @@ final class FilesystemRevocationJournalTest { } @Test - void copiedNamespaceJournalAndRegressingTransitionTimeFailClosed() throws Exception { - System.out.println("copiedNamespaceJournalAndRegressingTransitionTimeFailClosed"); + void regressingTransitionTimeFailsClosed() throws Exception { + System.out.println("regressingTransitionTimeFailsClosed"); Path root = temporaryDirectory.resolve("corrupt"); try (FilesystemPkiStore store = store(root)) { Credential credential = credential(store, "corrupt"); @@ -313,19 +295,15 @@ final class FilesystemRevocationJournalTest { 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)); assertEquals(RevocationState.HELD, store.getRevocation(credential.credentialId()).orElseThrow().transition().state()); } - System.out.println("copiedNamespaceJournalAndRegressingTransitionTimeFailClosed...ok"); + System.out.println("regressingTransitionTimeFailsClosed...ok"); } @Test void transitionMetadataDefensivelySnapshotsByteValues() { + System.out.println("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(), @@ -336,65 +314,62 @@ final class FilesystemRevocationJournalTest { first[1] = 9; byte[] second = ((AttributeValue.BytesValue) transition.attributes().get(NOTE).orElseThrow()).value(); assertArrayEquals(new byte[] { 1, 2, 3 }, second); + System.out.println("transitionMetadataDefensivelySnapshotsByteValues...ok"); } @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 { - System.out.println("obsoleteCurrentAndHistoryLayoutCannotInfluenceTrustedJournalOrResolver"); + void obsoleteCurrentAndHistoryLayoutCannotInfluenceTrustedHistoryOrResolver() throws Exception { + System.out.println("obsoleteCurrentAndHistoryLayoutCannotInfluenceTrustedHistoryOrResolver"); Path root = temporaryDirectory.resolve("obsolete-layout"); + Path snapshot = temporaryDirectory.resolve("obsolete-layout-snapshot"); Credential credential; try (FilesystemPkiStore store = store(root)) { credential = credential(store, "obsolete-layout"); store.putCredential(credential); } - Path legacyDirectory = new FsPaths(root).revocationDir(credential.credentialId()); + Path legacyDirectory = root.resolve("revocations").resolve("by-credential") + .resolve(FsUtil.safeId(credential.credentialId())); FsOperations.ensureDir(legacyDirectory.resolve("history")); - FsOperations.writeAtomic(legacyDirectory.resolve("current.bin"), - oldRevokedRecordPayload(credential.credentialId())); + FsOperations.writeAtomic(legacyDirectory.resolve("current.bin"), new byte[] { 0x01, 0x02 }); FsOperations.writeAtomic(legacyDirectory.resolve("history").resolve("legacy.bin"), - oldRevokedRecordPayload(credential.credentialId())); + new byte[] { 0x03, 0x04 }); + Path legacyJournal = legacyDirectory.resolve("journal.bin"); + byte[] legacyJournalBytes = { 0x05, 0x06 }; + FsOperations.writeAtomic(legacyJournal, legacyJournalBytes); try (FilesystemPkiStore reopened = store(root)) { - assertTrue(readJournal(reopened, credential.credentialId()).isEmpty()); + assertTrue(readHistory(reopened, 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, - readJournal(reopened, credential.credentialId()).orElseThrow().latest().state()); + readHistory(reopened, credential.credentialId()).orElseThrow().latest().state()); + reopened.exportSnapshot(snapshot, TIME); } - System.out.println("obsoleteCurrentAndHistoryLayoutCannotInfluenceTrustedJournalOrResolver...ok"); + assertArrayEquals(legacyJournalBytes, Files.readAllBytes(legacyJournal)); + assertFalse(Files.exists(snapshot.resolve("revocations").resolve("by-credential"))); + System.out.println("obsoleteCurrentAndHistoryLayoutCannotInfluenceTrustedHistoryOrResolver...ok"); + } + + @Test + void unversionedLegacyJournalStoreIsRejected() throws Exception { + System.out.println("unversionedLegacyJournalStoreIsRejected"); + Path root = temporaryDirectory.resolve("unversioned-legacy"); + Path legacyJournal = root.resolve("revocations").resolve("by-credential").resolve("credential-legacy") + .resolve("journal.bin"); + Files.createDirectories(legacyJournal.getParent()); + Files.write(legacyJournal, new byte[] { 0x01, 0x02 }); + Path legacyCredential = root.resolve("credentials").resolve("by-id").resolve("credential-legacy.bin"); + Files.createDirectories(legacyCredential.getParent()); + Files.write(legacyCredential, new byte[] { 0x03, 0x04 }); + + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> store(root)); + assertTrue(failure.getMessage().contains("unversioned store is not empty")); + assertFalse(Files.exists(root.resolve(FsPaths.VERSION_FILE))); + assertArrayEquals(new byte[] { 0x01, 0x02 }, Files.readAllBytes(legacyJournal)); + System.out.println("unversionedLegacyJournalStoreIsRejected...ok"); } @Test @@ -455,48 +430,23 @@ final class FilesystemRevocationJournalTest { }, executor); } - private void assertPersistedCorruptionRejected(CorruptionCase corruption) throws Exception { - System.out.println("strictCodecCorruptionFailsReadAndTransitionWithoutOverwrite[" + corruption.description - + "]"); - String suffix = "corrupt-" + corruption.name().toLowerCase(java.util.Locale.ROOT); - Path root = temporaryDirectory.resolve(suffix); - Credential credential; - Path journalPath; - byte[] corrupt; - try (FilesystemPkiStore store = store(root)) { - credential = credential(store, suffix); - 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); - assertTrue(reopened.getRevocation(credential.credentialId()).isEmpty()); - transition(reopened, hold(credential)); - assertArrayEquals(before, FsOperations.readAll(journalPath)); - } - System.out.println("strictCodecCorruptionFailsReadAndTransitionWithoutOverwrite...ok"); - } - - private static List states(RevocationJournal journal) { - return journal.transitions().stream().map(RevocationTransition::state).toList(); + private static List states(CredentialHistory history) { + return history.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()); + CredentialHistory history = transition(store, command); + assertEquals(revision, history.latest().revision()); + assertEquals(state, history.latest().state()); } - private static RevocationJournal transition(FilesystemPkiStore store, RevocationCommand command) { + private static CredentialHistory transition(FilesystemPkiStore store, RevocationCommand command) { store.transitionRevocation(command, TIME); - return readJournal(store, command.credentialId()).orElseThrow(); + return readHistory(store, command.credentialId()).orElseThrow(); } - private static Optional readJournal(FilesystemPkiStore store, PkiId credentialId) { + private static Optional readHistory(FilesystemPkiStore store, PkiId credentialId) { if (store.getRevocation(credentialId).isEmpty()) { return Optional.empty(); } @@ -508,7 +458,7 @@ final class FilesystemRevocationJournalTest { } catch (IOException failure) { throw new IllegalStateException(failure); } - return Optional.of(new RevocationJournal(credentialId, transitions)); + return Optional.of(new CredentialHistory(credentialId, transitions)); } private static RevocationCommand.Hold hold(Credential credential) { @@ -542,171 +492,9 @@ final class FilesystemRevocationJournalTest { 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)); + private record CredentialHistory(PkiId credentialId, List transitions) { + private RevocationTransition latest() { + return transitions.get(transitions.size() - 1); } } }