From f27a656c9786ed813d9383e4b6682ac9c57e7de1 Mon Sep 17 00:00:00 2001 From: Leo Galambos Date: Mon, 3 Aug 2026 00:33:07 +0200 Subject: [PATCH] refactor(pki): cut over revocation authority Move production revocation writes, lookups, histories, CRL views and snapshot handling to the global append-only transition-log architecture. Keep indexes and checkpoints derived while removing all active runtime dependence on per-credential complete journals. --- .../zeroecho/pki/api/RevocationService.java | 39 +- .../pki/api/revocation/RevocationRecord.java | 28 + .../impl/core/DefaultRevocationService.java | 173 ++++- .../impl/core/DefaultStatusObjectService.java | 22 +- ...ckedEffectiveCredentialStatusResolver.java | 12 +- .../pki/impl/fs/FilesystemPkiStore.java | 317 ++++---- .../fs/FilesystemRevocationAuthority.java | 725 ++++++++++++++++++ .../pki/impl/fs/FilesystemRevocationLog.java | 150 +++- .../fs/FilesystemTemporaryUniqueIndex.java | 34 +- .../java/zeroecho/pki/impl/fs/FsPaths.java | 5 + .../pki/impl/fs/FsSnapshotExporter.java | 44 +- .../java/zeroecho/pki/spi/store/PkiStore.java | 29 +- .../pki/spi/store/RevocationHistory.java | 60 ++ .../pki/spi/store/RevocationSnapshot.java | 45 +- .../core/DefaultRevocationServiceTest.java | 88 ++- .../DefaultStatusObjectServiceCrlTest.java | 68 +- ...EffectiveCredentialStatusResolverTest.java | 6 +- .../fs/FilesystemPkiStoreOwnershipTest.java | 3 +- .../pki/impl/fs/FilesystemPkiStoreTest.java | 202 ++++- .../fs/FilesystemRevocationJournalTest.java | 57 +- .../zeroecho/pki/testkit/PkiTestRuntime.java | 20 +- 21 files changed, 1794 insertions(+), 333 deletions(-) create mode 100644 pki/src/main/java/zeroecho/pki/api/revocation/RevocationRecord.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationAuthority.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/store/RevocationHistory.java diff --git a/pki/src/main/java/zeroecho/pki/api/RevocationService.java b/pki/src/main/java/zeroecho/pki/api/RevocationService.java index 9c89e06..543727b 100644 --- a/pki/src/main/java/zeroecho/pki/api/RevocationService.java +++ b/pki/src/main/java/zeroecho/pki/api/RevocationService.java @@ -33,15 +33,16 @@ ******************************************************************************/ package zeroecho.pki.api; -import java.util.List; import java.util.Optional; 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.spi.store.RevocationHistory; +import zeroecho.pki.spi.store.RevocationSnapshot; /** - * Authoritative revocation transition and journal administration. + * Authoritative revocation transition, current-state, and history administration. */ public interface RevocationService { @@ -49,39 +50,47 @@ public interface RevocationService { * Places a credential on hold. * * @param command hold command - * @return committed journal + * @return committed current state */ - RevocationJournal hold(RevocationCommand.Hold command); + RevocationRecord hold(RevocationCommand.Hold command); /** * Removes an existing hold. * * @param command unhold command - * @return committed journal + * @return committed current state */ - RevocationJournal unhold(RevocationCommand.Unhold command); + RevocationRecord unhold(RevocationCommand.Unhold command); /** * Permanently revokes a credential. * * @param command permanent revocation command - * @return committed journal + * @return committed current state */ - RevocationJournal revokePermanently(RevocationCommand.RevokePermanently command); + RevocationRecord revokePermanently(RevocationCommand.RevokePermanently command); /** - * Retrieves the authoritative journal for one credential. + * Retrieves the validated current state for one credential. * * @param credentialId credential identifier - * @return journal when present + * @return current state when present */ - Optional get(PkiId credentialId); + Optional get(PkiId credentialId); /** - * Searches authoritative journals by their latest transition. + * Opens the authoritative transition history for one credential. + * + * @param credentialId credential identifier + * @return closeable bounded-memory history cursor + */ + RevocationHistory history(PkiId credentialId); + + /** + * Searches a stable ordered population view by current transition. * * @param query administrative query - * @return matching journals + * @return closeable stable ordered view whose cursor yields matching states */ - List search(RevocationQuery query); + RevocationSnapshot search(RevocationQuery query); } diff --git a/pki/src/main/java/zeroecho/pki/api/revocation/RevocationRecord.java b/pki/src/main/java/zeroecho/pki/api/revocation/RevocationRecord.java new file mode 100644 index 0000000..c1f588d --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/revocation/RevocationRecord.java @@ -0,0 +1,28 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.revocation; + +import java.util.Objects; + +import zeroecho.pki.api.PkiId; + +/** + * Immutable current revocation state for one credential. + * + * @param credentialId exact credential identity + * @param transition latest committed transition + */ +public record RevocationRecord(PkiId credentialId, RevocationTransition transition) { + + /** + * Creates a current-state record. + * + * @throws NullPointerException if either value is {@code null} + */ + public RevocationRecord { + Objects.requireNonNull(credentialId, "credentialId"); + Objects.requireNonNull(transition, "transition"); + } +} 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 ad2ad7d..e90a018 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultRevocationService.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultRevocationService.java @@ -33,13 +33,16 @@ ******************************************************************************/ package zeroecho.pki.impl.core; +import java.io.IOException; 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 java.util.OptionalLong; +import zeroecho.core.io.CancellationSignal; import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiId; import zeroecho.pki.api.RevocationService; @@ -47,11 +50,13 @@ 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.RevocationRecord; import zeroecho.pki.api.revocation.RevocationQuery; import zeroecho.pki.api.revocation.RevocationTransition; import zeroecho.pki.spi.audit.AuditSink; import zeroecho.pki.spi.store.PkiStore; +import zeroecho.pki.spi.store.RevocationHistory; +import zeroecho.pki.spi.store.RevocationSnapshot; /** * Store-backed authoritative revocation transition service. @@ -63,7 +68,7 @@ public final class DefaultRevocationService implements RevocationService { 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"); + "REVOCATION_RECOVERY_REQUIRED", "STORE_DURABILITY_UNCONFIRMED"); private final PkiStore store; private final Clock clock; @@ -83,26 +88,26 @@ public final class DefaultRevocationService implements RevocationService { } @Override - public RevocationJournal hold(RevocationCommand.Hold command) { + public RevocationRecord hold(RevocationCommand.Hold command) { return transition(Objects.requireNonNull(command, "command"), "HOLD"); } @Override - public RevocationJournal unhold(RevocationCommand.Unhold command) { + public RevocationRecord unhold(RevocationCommand.Unhold command) { return transition(Objects.requireNonNull(command, "command"), "UNHOLD"); } @Override - public RevocationJournal revokePermanently(RevocationCommand.RevokePermanently command) { + public RevocationRecord revokePermanently(RevocationCommand.RevokePermanently command) { return transition(Objects.requireNonNull(command, "command"), "REVOKE_PERMANENTLY"); } @Override @SuppressWarnings("PMD.AvoidCatchingGenericException") - public Optional get(PkiId credentialId) { + public Optional get(PkiId credentialId) { Objects.requireNonNull(credentialId, "credentialId"); try { - return store.getRevocationJournal(credentialId); + return store.getRevocation(credentialId); } catch (RuntimeException failure) { throw sanitized(failure); } @@ -110,33 +115,34 @@ public final class DefaultRevocationService implements RevocationService { @Override @SuppressWarnings("PMD.AvoidCatchingGenericException") - public List search(RevocationQuery query) { + public RevocationHistory history(PkiId credentialId) { + Objects.requireNonNull(credentialId, "credentialId"); + try { + return store.openRevocationHistory(credentialId); + } catch (RuntimeException failure) { + throw sanitized(failure); + } + } + + @Override + @SuppressWarnings("PMD.AvoidCatchingGenericException") + public RevocationSnapshot search(RevocationQuery query) { Objects.requireNonNull(query, "query"); - try (zeroecho.pki.spi.store.RevocationSnapshot snapshot = store.openRevocationSnapshot(); - zeroecho.pki.spi.store.RevocationSnapshot.Cursor cursor = snapshot.openCursor()) { - List matching = new java.util.ArrayList<>(); - while (cursor.next()) { - RevocationJournal journal = cursor.current(); - if (matches(journal, query)) { - matching.add(journal); - } - } - return List.copyOf(matching); - } catch (java.io.IOException failure) { - throw new PkiException("Revocation snapshot failed: code=STORE_FAILED", failure); + try { + return new FilteredSnapshot(store.openRevocationSnapshot(), query); } catch (RuntimeException failure) { throw sanitized(failure); } } @SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" }) - private RevocationJournal transition(RevocationCommand command, String operation) { + private RevocationRecord 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; + RevocationRecord record = store.transitionRevocation(command, time); + auditSuccess(time, record, operation); + return record; } catch (RuntimeException failure) { PkiException sanitized = time == null ? new PkiException("Revocation operation failed: code=REVOCATION_TIME_UNAVAILABLE") @@ -147,8 +153,8 @@ public final class DefaultRevocationService implements RevocationService { } } - private boolean matches(RevocationJournal journal, RevocationQuery query) { - RevocationTransition latest = journal.latest(); + private boolean matches(RevocationRecord record, RevocationQuery query) { + RevocationTransition latest = record.transition(); if (query.reason().isPresent() && !latest.permanentReason().filter(query.reason().get()::equals).isPresent()) { return false; } @@ -158,23 +164,23 @@ public final class DefaultRevocationService implements RevocationService { if (query.revokedBefore().isPresent() && !latest.time().isBefore(query.revokedBefore().get())) { return false; } - return query.issuerCaId().isEmpty() || store.getCredential(journal.credentialId()) + return query.issuerCaId().isEmpty() || store.getCredential(record.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) + return store.getRevocation(command.credentialId()).map(RevocationRecord::transition) .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(), + private void auditSuccess(Instant time, RevocationRecord record, String operation) { + RevocationTransition latest = record.transition(); + audit(time, record.credentialId(), operation, Map.of("result", "COMMITTED", "state", latest.state().name(), "revision", Long.toString(latest.revision()))); } @@ -217,4 +223,109 @@ public final class DefaultRevocationService implements RevocationService { int marker = message.indexOf("code="); return marker < 0 ? "REVOCATION_STATE_UPDATE_FAILED" : message.substring(marker + 5); } + + /** Query-filtered ownership wrapper over one stable store snapshot. */ + private final class FilteredSnapshot implements RevocationSnapshot { + private final RevocationSnapshot source; + private final RevocationQuery query; + + private FilteredSnapshot(RevocationSnapshot source, RevocationQuery query) { + this.source = source; + this.query = query; + } + + @Override + public String snapshotId() { + return source.snapshotId(); + } + + @Override + public long revision() { + return source.revision(); + } + + @Override + public long boundary() { + return source.boundary(); + } + + @Override + public String commitment() { + return source.commitment(); + } + + @Override + public OptionalLong count() { + return OptionalLong.empty(); + } + + @Override + @SuppressWarnings("PMD.AvoidCatchingGenericException") + public Cursor openCursor() throws IOException { + try { + return new FilteredCursor(source.openCursor(), query); + } catch (RuntimeException failure) { + throw sanitized(failure); + } + } + + @Override + public void close() throws IOException { + source.close(); + } + } + + /** Lazy query filter retaining only one current state. */ + private final class FilteredCursor implements RevocationSnapshot.Cursor { + private final RevocationSnapshot.Cursor source; + private final RevocationQuery query; + private RevocationRecord current; + private long ordinal = -1L; + + private FilteredCursor(RevocationSnapshot.Cursor source, RevocationQuery query) { + this.source = source; + this.query = query; + } + + @Override + @SuppressWarnings({ "PMD.UnusedAssignment", "PMD.AvoidCatchingGenericException" }) + public boolean next(CancellationSignal cancellation) throws IOException { + try { + current = null; + while (source.next(cancellation)) { + RevocationRecord candidate = source.current(); + if (matches(candidate, query)) { + current = candidate; + ordinal = Math.addExact(ordinal, 1L); + return true; + } + } + return false; + } catch (RuntimeException failure) { + throw sanitized(failure); + } + } + + @Override + public RevocationRecord current() { + if (current == null) { + throw new IllegalStateException("Revocation search cursor is not positioned"); + } + return current; + } + + @Override + public long ordinal() { + if (current == null) { + throw new IllegalStateException("Revocation search cursor is not positioned"); + } + return ordinal; + } + + @Override + public void close() throws IOException { + current = null; + source.close(); + } + } } 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 9d01636..d2b335d 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java @@ -58,7 +58,7 @@ import zeroecho.pki.api.credential.Credential; 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; import zeroecho.pki.api.revocation.RevocationTransition; @@ -288,19 +288,19 @@ public final class DefaultStatusObjectService implements StatusObjectService { } private CrlEntrySource openCrlEntries(PkiId issuerCaId, Instant evaluationTime) { - return new JournalCrlEntrySource(store.openRevocationSnapshot(), issuerCaId, evaluationTime); + return new CheckpointCrlEntrySource(store.openRevocationSnapshot(), issuerCaId, evaluationTime); } - private Optional collectCrlEntry(PkiId issuerCaId, Instant evaluationTime, RevocationJournal journal) { - Objects.requireNonNull(journal, "journal"); - RevocationTransition latest = Objects.requireNonNull(journal.latest(), "latest transition"); + private Optional collectCrlEntry(PkiId issuerCaId, Instant evaluationTime, RevocationRecord record) { + Objects.requireNonNull(record, "record"); + RevocationTransition latest = Objects.requireNonNull(record.transition(), "latest transition"); if (latest.time().isAfter(evaluationTime)) { throw crlGenerationFailure(); } if (latest.state() == RevocationState.CLEAR) { return Optional.empty(); } - Credential credential = store.getCredential(journal.credentialId()) + Credential credential = store.getCredential(record.credentialId()) .orElseThrow(DefaultStatusObjectService::crlGenerationFailure); if (!issuerCaId.equals(credential.issuerRef().caId())) { return Optional.empty(); @@ -320,12 +320,12 @@ public final class DefaultStatusObjectService implements StatusObjectService { } /** Stable restartable view over one revocation-store snapshot. */ - private final class JournalCrlEntrySource implements CrlEntrySource { + private final class CheckpointCrlEntrySource implements CrlEntrySource { private final RevocationSnapshot snapshot; private final PkiId issuerCaId; private final Instant evaluationTime; - private JournalCrlEntrySource(RevocationSnapshot snapshot, PkiId issuerCaId, Instant evaluationTime) { + private CheckpointCrlEntrySource(RevocationSnapshot snapshot, PkiId issuerCaId, Instant evaluationTime) { this.snapshot = snapshot; this.issuerCaId = issuerCaId; this.evaluationTime = evaluationTime; @@ -333,7 +333,7 @@ public final class DefaultStatusObjectService implements StatusObjectService { @Override public Cursor openCursor() throws IOException { - return new JournalCrlCursor(snapshot.openCursor(), issuerCaId, evaluationTime); + return new CheckpointCrlCursor(snapshot.openCursor(), issuerCaId, evaluationTime); } @Override @@ -348,14 +348,14 @@ public final class DefaultStatusObjectService implements StatusObjectService { } /** Bounded cursor translating authoritative journals into CRL entries. */ - private final class JournalCrlCursor implements CrlEntrySource.Cursor { + private final class CheckpointCrlCursor implements CrlEntrySource.Cursor { private final RevocationSnapshot.Cursor cursor; private final PkiId issuerCaId; private final Instant evaluationTime; private CrlEntry current; private long ordinal = -1L; - private JournalCrlCursor(RevocationSnapshot.Cursor cursor, PkiId issuerCaId, Instant evaluationTime) { + private CheckpointCrlCursor(RevocationSnapshot.Cursor cursor, PkiId issuerCaId, Instant evaluationTime) { this.cursor = cursor; this.issuerCaId = issuerCaId; this.evaluationTime = evaluationTime; diff --git a/pki/src/main/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolver.java b/pki/src/main/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolver.java index dc70ad8..27b73ee 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolver.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolver.java @@ -44,7 +44,7 @@ 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.RevocationState; import zeroecho.pki.api.revocation.RevocationTransition; import zeroecho.pki.spi.store.PkiStore; @@ -125,9 +125,9 @@ public final class StoreBackedEffectiveCredentialStatusResolver implements Effec // removed from the stable redacted status-resolution exception. @SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" }) private RevocationTransition currentRevocation(Credential credential) { - Optional current; + Optional current; try { - current = store.getRevocationJournal(credential.credentialId()); + current = store.getRevocation(credential.credentialId()); } catch (RuntimeException exception) { throw resolutionFailure(); } @@ -137,13 +137,13 @@ public final class StoreBackedEffectiveCredentialStatusResolver implements Effec if (current.isEmpty()) { return null; } - RevocationJournal journal = current.get(); - if (!credential.credentialId().equals(journal.credentialId())) { + RevocationRecord record = current.get(); + if (!credential.credentialId().equals(record.credentialId())) { throw resolutionFailure(); } RevocationTransition latest; try { - latest = journal.latest(); + latest = record.transition(); } catch (RuntimeException exception) { throw resolutionFailure(); } 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 c7f03a6..fd8c705 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java @@ -63,7 +63,6 @@ import java.util.Objects; import java.util.Optional; import java.util.OptionalLong; import java.util.Set; -import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicBoolean; @@ -88,7 +87,7 @@ import zeroecho.pki.api.profile.ImportedCertificateProfileVersion; import zeroecho.pki.api.publication.PublicationRecord; 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; import zeroecho.pki.api.revocation.RevocationTransition; @@ -111,6 +110,7 @@ import zeroecho.pki.spi.store.StagedContentStore; import zeroecho.pki.spi.store.SignWorkflowStore; import zeroecho.pki.spi.store.TemporaryUniqueIndex; import zeroecho.pki.spi.store.RevocationSnapshot; +import zeroecho.pki.spi.store.RevocationHistory; /** * Filesystem-based reference implementation of {@link PkiStore}. @@ -133,9 +133,9 @@ import zeroecho.pki.spi.store.RevocationSnapshot; * anomalous behavior for audit and incident analysis. * *
  • 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.
  • + * profiles append history before updating {@code current.bin}. Revocation + * transitions append to one authenticated global log; current indexes and + * ordered checkpoints are derived and rebuildable. * *
  • Deterministic behavior: filenames, ordering, and cleanup * semantics are deterministic. Cleanup occurs only during writes @@ -161,12 +161,14 @@ import zeroecho.pki.spi.store.RevocationSnapshot; * are limited to object type, safe IDs, and file operation outcomes. *

    */ -@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity", "PMD.TooManyMethods" }) +@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity", "PMD.TooManyMethods", + "PMD.ExcessivePublicCount", "PMD.UseTryWithResources", "PMD.ExceptionAsFlowControl", + "PMD.PreserveStackTrace" }) public final class FilesystemPkiStore implements PkiStore, Closeable { private static final Logger LOG = Logger.getLogger(FilesystemPkiStore.class.getName()); - /* package */ static final String CURRENT_STORE_VERSION = "v3"; + /* package */ static final String CURRENT_STORE_VERSION = "v4"; private static final String SIGN_RECORD_NAMESPACE = "io.zeroecho.pki.signing-record"; private static final String SIGN_OWNER_NAMESPACE = "io.zeroecho.pki.signing-owner"; private static final String STATUS_RECORD_NAMESPACE = "io.zeroecho.pki.status-object-record"; @@ -195,6 +197,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { private final FilesystemStagedContentStore stagedContent; private final CredentialContentTransaction credentialContentTransactions; private final PosixTransactionalMetadataStore metadataStore; + private final FilesystemRevocationAuthority revocations; private final StoreOwnership ownership; @@ -224,8 +227,13 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { // close(); // try-with-resources here would release process ownership at constructor // return. - @SuppressWarnings("PMD.CloseResource") public FilesystemPkiStore(final Path root, final FsPkiStoreOptions options, final Clock clock) { + 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.options = Objects.requireNonNull(options, "options"); Objects.requireNonNull(root, "root"); this.clock = Objects.requireNonNull(clock, "clock"); @@ -247,7 +255,9 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { boolean ownershipTransferred = false; PosixTransactionalMetadataStore openedMetadata = null; + FilesystemRevocationAuthority openedRevocations = null; try { + boolean newStore = !Files.exists(this.paths.versionFile()); ensureVersionFile(); this.signingNamespace = ensureSigningNamespace(); this.stagedContent = new FilesystemStagedContentStore(this.paths.stagedContentRoot(), @@ -260,6 +270,21 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { this.signingTimeWatermark = new AtomicLong(loadSigningTimeWatermark()); this.historySeq = new AtomicLong(0L); recoverStagedContent(); + boolean snapshotRestore = requireSnapshotBoundary(); + openedRevocations = FilesystemRevocationAuthority.open( + this.paths, new MetadataStoreId(this.signingNamespace), credentialId -> { + if (getCredential(credentialId).isEmpty()) { + throw new IOException("Revocation credential authority is missing"); + } + }, newStore, indexUpdateFaults); + this.revocations = openedRevocations; + if (snapshotRestore) { + Files.delete(this.paths.revocationSnapshotBoundary()); + try (FileChannel directory = FileChannel.open( + this.paths.root(), StandardOpenOption.READ)) { + directory.force(true); + } + } LOG.log(Level.INFO, "running in {0}", root); this.ownership = acquiredOwnership; @@ -268,6 +293,13 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { throw new IllegalStateException("failed to open filesystem store at " + root, e); } finally { if (!ownershipTransferred) { + if (openedRevocations != null) { + try { + openedRevocations.close(); + } catch (IOException closeFailure) { + LOG.log(Level.WARNING, "Revocation-store cleanup failed during initialization"); + } + } if (openedMetadata != null) { try { closeMetadataAfterFailedOpen(openedMetadata); @@ -280,6 +312,45 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { } } + private boolean requireSnapshotBoundary() throws IOException { + Path marker = paths.revocationSnapshotBoundary(); + if (!Files.exists(marker, java.nio.file.LinkOption.NOFOLLOW_LINKS)) { + return false; + } + if (!Files.isRegularFile(marker, java.nio.file.LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Revocation snapshot boundary is invalid"); + } + String encoded; + try (FileChannel channel = FileChannel.open(marker, StandardOpenOption.READ, + java.nio.file.LinkOption.NOFOLLOW_LINKS)) { + long size = channel.size(); + if (size < 1L || size > 19L) { + throw new IOException("Revocation snapshot boundary is invalid"); + } + ByteBuffer buffer = ByteBuffer.allocate(19); + while (buffer.position() < size) { + int read = channel.read(buffer); + if (read <= 0) { + throw new IOException("Revocation snapshot boundary is invalid"); + } + } + encoded = new String(buffer.array(), 0, (int) size, StandardCharsets.US_ASCII); + } + long expected; + try { + expected = Long.parseLong(encoded); + } catch (NumberFormatException failure) { + throw new IOException("Revocation snapshot boundary is invalid"); + } + if (!Long.toString(expected).equals(encoded) + || expected < RevocationTransitionFrameCodec.PREAMBLE_BYTES + || !Files.isRegularFile(paths.revocationTransitionLog()) + || Files.size(paths.revocationTransitionLog()) != expected) { + throw new IOException("Revocation snapshot authority boundary is invalid"); + } + return true; + } + private static void closeMetadataAfterFailedOpen(PosixTransactionalMetadataStore openedMetadata) throws IOException { openedMetadata.close(); @@ -438,6 +509,12 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { return paths.root(); } + /* default */ FilesystemRevocationLog.RecoveryTarget copyRevocationPrefix(Path target) + throws IOException { + requireStoreUsable(); + return revocations.copyAuthoritativePrefix(target); + } + /* default */ Set snapshotNonCredentialContentIds() { requireStoreUsable(); Set contentIds = new HashSet<>(); @@ -531,7 +608,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { @Override // Store failures are intentionally replaced by one stable redacted boundary. @SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" }) - public RevocationJournal transitionRevocation(final RevocationCommand command, final Instant transitionTime) { + public RevocationRecord transitionRevocation(final RevocationCommand command, final Instant transitionTime) { requireStoreUsable(); Objects.requireNonNull(command, "command"); Objects.requireNonNull(transitionTime, "transitionTime"); @@ -547,33 +624,49 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { if (credential.isEmpty()) { throw new PkiException("Revocation target unavailable: code=REVOCATION_CREDENTIAL_NOT_FOUND"); } - Optional current; + Optional current; try { - current = readRevocationJournal(credentialId); - } catch (RuntimeException failure) { + current = revocations.current(credentialId); + } catch (RuntimeException | IOException failure) { throw corruptRevocationState(); } - RevocationJournal updated = appendRevocation(current, credentialId, command, transitionTime); + RevocationTransition transition = nextRevocationTransition( + current, 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) { + RevocationTransitionFrameCodec.CompleteRecord committed = + revocations.append(credentialId, transition); + return new RevocationRecord(credentialId, committed.data().transition()); + } catch (FilesystemRevocationLog.OutcomeUnknownException uncertain) { + throw new PkiException( + "Revocation append outcome requires recovery: code=REVOCATION_RECOVERY_REQUIRED"); + } catch (IOException failure) { throw new PkiException("Revocation persistence failed: code=REVOCATION_PERSIST_FAILED"); } - return updated; } finally { releaseRevocationLock(credentialId, lock); } } @Override - public Optional getRevocationJournal(final PkiId credentialId) { + public Optional getRevocation(final PkiId credentialId) { requireStoreUsable(); Objects.requireNonNull(credentialId, "credentialId"); - return readRevocationJournal(credentialId); + try { + return revocations.current(credentialId); + } catch (IOException failure) { + throw corruptRevocationState(); + } + } + + @Override + public RevocationHistory openRevocationHistory(final PkiId credentialId) { + requireStoreUsable(); + Objects.requireNonNull(credentialId, "credentialId"); + try { + return revocations.history(credentialId); + } catch (IOException failure) { + throw corruptRevocationState(); + } } @Override @@ -581,26 +674,9 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { @SuppressWarnings("PMD.PreserveStackTrace") public RevocationSnapshot openRevocationSnapshot() { requireStoreUsable(); - Path root = this.paths.root().resolve("revocations").resolve("by-credential"); - String snapshotId = UUID.randomUUID().toString(); - Path snapshotRoot = paths.revocationSnapshotRoot().resolve(snapshotId); - long count = 0L; try { - Files.createDirectories(snapshotRoot); - if (Files.isDirectory(root)) { - try (Stream journals = Files.walk(root)) { - java.util.Iterator iterator = journals - .filter(path -> Files.isRegularFile(path) - && "journal.bin".equals(path.getFileName().toString())) - .iterator(); - while (iterator.hasNext()) { - Files.copy(iterator.next(), snapshotRoot.resolve(Long.toUnsignedString(count) + ".bin")); - count = Math.addExact(count, 1L); - } - } - } - return new FilesystemRevocationSnapshot(snapshotId, snapshotRoot, count); - } catch (IOException ex) { + return revocations.snapshot(); + } catch (IOException failure) { throw corruptRevocationState(); } } @@ -1292,10 +1368,19 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { public void close() throws IOException { IOException failure = null; try { - metadataStore.close(); + revocations.close(); } catch (IOException exception) { failure = exception; } + try { + metadataStore.close(); + } catch (IOException exception) { + if (failure == null) { + failure = exception; + } else { + failure.addSuppressed(exception); + } + } try { ownership.close(); } catch (IOException exception) { @@ -1475,153 +1560,25 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { } } - 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); - } - - /** Stable file-backed revocation snapshot isolated from later store writes. */ - private static final class FilesystemRevocationSnapshot implements RevocationSnapshot { - private final String snapshotId; - private final Path root; - private final long count; - private final AtomicBoolean closed; - - private FilesystemRevocationSnapshot(String snapshotId, Path root, long count) { - this.snapshotId = snapshotId; - this.root = root; - this.count = count; - this.closed = new AtomicBoolean(); - } - - @Override - public String snapshotId() { - return snapshotId; - } - - @Override - public OptionalLong count() { - return OptionalLong.of(count); - } - - @Override - public Cursor openCursor() { - if (closed.get()) { - throw new IllegalStateException("Revocation snapshot is closed"); - } - return new FilesystemRevocationCursor(root, count); - } - - @Override - public void close() throws IOException { - if (!closed.compareAndSet(false, true)) { - return; - } - if (Files.isDirectory(root)) { - try (Stream files = Files.list(root)) { - java.util.Iterator iterator = files.iterator(); - while (iterator.hasNext()) { - Files.deleteIfExists(iterator.next()); - } - } - Files.deleteIfExists(root); - } - } - } - - /** Sequential bounded-memory cursor over one immutable snapshot directory. */ - private static final class FilesystemRevocationCursor implements RevocationSnapshot.Cursor { - private final Path root; - private final long count; - private long nextOrdinal; - private RevocationJournal current; - private boolean closed; - - private FilesystemRevocationCursor(Path root, long count) { - this.root = root; - this.count = count; - } - - @Override - public boolean next() throws IOException { - if (closed) { - throw new IllegalStateException("Revocation cursor is closed"); - } - if (nextOrdinal >= count) { - current = null; - return false; - } - current = decodeRevocationJournal(root.resolve(Long.toUnsignedString(nextOrdinal) + ".bin")); - nextOrdinal = Math.addExact(nextOrdinal, 1L); - return true; - } - - @Override - public RevocationJournal current() { - if (current == null) { - throw new IllegalStateException("Revocation cursor is not positioned"); - } - return current; - } - - @Override - public long ordinal() { - if (current == null) { - throw new IllegalStateException("Revocation cursor is not positioned"); - } - return nextOrdinal - 1L; - } - - @Override - public void close() { - current = null; - closed = true; - } - } - - // 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())) { + private static RevocationTransition nextRevocationTransition( + Optional current, RevocationCommand command, Instant transitionTime) { + if (current.isPresent() && transitionTime.isBefore(current.orElseThrow().transition().time())) { throw new PkiException("Revocation transition conflict: code=REVOCATION_TRANSITION_CONFLICT"); } - RevocationState previous = current.map(RevocationJournal::latest).map(RevocationTransition::state).orElse(null); + RevocationState previous = current.map(RevocationRecord::transition) + .map(RevocationTransition::state).orElse(null); RevocationState next = nextRevocationState(previous, command); - long revision = current.map(RevocationJournal::latest).map(RevocationTransition::revision).map(value -> { + long revision = current.map(RevocationRecord::transition).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; + return new RevocationTransition(revision, next, transitionTime, permanentReason, command.attributes()); } private static RevocationState nextRevocationState(RevocationState previous, RevocationCommand command) { @@ -1643,12 +1600,6 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { }; } - private static void validateRevocationJournal(PkiId expectedId, RevocationJournal journal) { - if (!expectedId.equals(journal.credentialId())) { - throw corruptRevocationState(); - } - } - private static RevocationState throwIllegalTransition() { throw illegalRevocationTransition(); } diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationAuthority.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationAuthority.java new file mode 100644 index 0000000..ca362bb --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationAuthority.java @@ -0,0 +1,725 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.impl.fs; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.HashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +import zeroecho.core.io.CancellationSignal; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.revocation.RevocationRecord; +import zeroecho.pki.api.revocation.RevocationTransition; +import zeroecho.pki.spi.store.MetadataStoreId; +import zeroecho.pki.spi.store.RevocationHistory; +import zeroecho.pki.spi.store.RevocationSnapshot; + +/** Store-owned coordination of the authoritative log and its derived state. */ +@SuppressWarnings({ "PMD.CloseResource", "PMD.UseTryWithResources", "PMD.AvoidSynchronizedAtMethodLevel", + "PMD.UnusedAssignment" }) +final class FilesystemRevocationAuthority implements AutoCloseable { + + private static final FilesystemRevocationCurrentIndex.Configuration INDEX_CONFIGURATION = + new FilesystemRevocationCurrentIndex.Configuration(1024L, 1, 2); + private static final long CHECKPOINT_RUN_BYTES = 16L * 1024L * 1024L; + private static final int CHECKPOINT_MERGE_INPUTS = 32; + private static final int TRANSFER_BYTES = 16 * 1024; + + private final FsPaths paths; + private final MetadataStoreId storeId; + private final FilesystemRevocationLog log; + private final IndexUpdateFaultInjector indexUpdateFaults; + private final ReentrantReadWriteLock lifecycle = new ReentrantReadWriteLock(); + private final Set activeResources = new HashSet<>(); + private FilesystemRevocationCurrentIndex index; + private boolean closed; + + private FilesystemRevocationAuthority(FsPaths paths, MetadataStoreId storeId, + FilesystemRevocationLog log, FilesystemRevocationCurrentIndex index, + IndexUpdateFaultInjector indexUpdateFaults) { + this.paths = paths; + this.storeId = storeId; + this.log = log; + this.index = index; + this.indexUpdateFaults = indexUpdateFaults; + } + + /* default */ static FilesystemRevocationAuthority open(FsPaths paths, MetadataStoreId storeId, + FilesystemRevocationLog.CredentialAuthority credentials, boolean create) throws IOException { + return open(paths, storeId, credentials, create, IndexUpdateFaultInjector.NONE); + } + + /* default */ static FilesystemRevocationAuthority open(FsPaths paths, MetadataStoreId storeId, + FilesystemRevocationLog.CredentialAuthority credentials, boolean create, + IndexUpdateFaultInjector indexUpdateFaults) throws IOException { + Objects.requireNonNull(paths, "paths"); + Objects.requireNonNull(storeId, "storeId"); + Objects.requireNonNull(credentials, "credentials"); + Objects.requireNonNull(indexUpdateFaults, "indexUpdateFaults"); + Path logPath = paths.revocationTransitionLog(); + Files.createDirectories(Objects.requireNonNull(logPath.getParent(), "revocation directory")); + FilesystemRevocationLog openedLog = create + ? FilesystemRevocationLog.createProduction(logPath, storeId, credentials) + : FilesystemRevocationLog.openProduction(logPath, storeId, credentials); + boolean complete = false; + try { + FilesystemRevocationLog.RecoveryTarget target = openedLog.recoveryTarget(); + FilesystemRevocationCurrentIndex openedIndex = FilesystemRevocationCurrentIndex.recover( + paths.revocationCurrentIndex(), paths.revocationCheckpointDirectory(), logPath, + storeId, INDEX_CONFIGURATION, target); + FilesystemRevocationAuthority authority = new FilesystemRevocationAuthority( + paths, storeId, openedLog, openedIndex, indexUpdateFaults); + complete = true; + return authority; + } finally { + if (!complete) { + openedLog.close(); + } + } + } + + /* default */ Optional current(PkiId credentialId) throws IOException { + Objects.requireNonNull(credentialId, "credentialId"); + lifecycle.writeLock().lock(); + try { + requireOperational(); + ensureIndex(); + return index.lookup(credentialId).map(FilesystemRevocationAuthority::record); + } finally { + lifecycle.writeLock().unlock(); + } + } + + /* default */ RevocationTransitionFrameCodec.CompleteRecord append( + PkiId credentialId, RevocationTransition transition) throws IOException { + Objects.requireNonNull(credentialId, "credentialId"); + Objects.requireNonNull(transition, "transition"); + lifecycle.writeLock().lock(); + try { + requireOperational(); + ensureIndex(); + RevocationTransitionFrameCodec.CompleteRecord previous = + index.lookup(credentialId).orElse(null); + RevocationTransitionFrameCodec.CompleteRecord committed = + log.append(credentialId, transition, previous); + try { + indexUpdateFaults.afterAppend(); + index.update(committed); + } catch (IOException derivedFailure) { + invalidateIndex(derivedFailure); + } + return committed; + } finally { + lifecycle.writeLock().unlock(); + } + } + + /** Instance-scoped deterministic seam for derived-update failure tests. */ + /* default */ + @FunctionalInterface + interface IndexUpdateFaultInjector { + IndexUpdateFaultInjector NONE = () -> { }; + + /** Runs after authoritative append and before derived index update. */ + void afterAppend() throws IOException; + } + + /* default */ RevocationHistory history(PkiId credentialId) throws IOException { + Objects.requireNonNull(credentialId, "credentialId"); + lifecycle.writeLock().lock(); + try { + requireOperational(); + HistoryCursor cursor = new HistoryCursor(this, credentialId, log.recoveryTarget()); + activeResources.add(cursor); + return cursor; + } finally { + lifecycle.writeLock().unlock(); + } + } + + /* default */ RevocationSnapshot snapshot() throws IOException { + lifecycle.writeLock().lock(); + try { + requireOperational(); + FilesystemRevocationLog.RecoveryTarget head = log.recoveryTarget(); + ensureIndex(); + if (index.coveredGlobalRevision() != head.globalRevision()) { + recoverIndex(head); + } + Files.createDirectories(paths.revocationCheckpointDirectory()); + Optional selected = FilesystemRevocationCheckpoint.discoverBelow( + paths.revocationCheckpointDirectory(), paths.revocationTransitionLog(), head, + head.globalRevision(), true, FilesystemRevocationCurrentIndex.FaultInjector.NONE); + FilesystemRevocationCheckpoint checkpoint = exact(selected, head).orElse(null); + if (checkpoint == null) { + FilesystemRevocationCheckpointBuilder.Configuration configuration = + new FilesystemRevocationCheckpointBuilder.Configuration( + CHECKPOINT_RUN_BYTES, CHECKPOINT_MERGE_INPUTS, TRANSFER_BYTES, + paths.revocationCheckpointWorkDirectory()); + FilesystemRevocationCheckpointBuilder.build( + index, paths.revocationCheckpointDirectory(), configuration); + selected = FilesystemRevocationCheckpoint.discoverBelow( + paths.revocationCheckpointDirectory(), paths.revocationTransitionLog(), head, + head.globalRevision(), true, FilesystemRevocationCurrentIndex.FaultInjector.NONE); + checkpoint = exact(selected, head).orElseThrow( + () -> new IOException("Exact revocation checkpoint publication failed")); + } + StableView view = new StableView(this, checkpoint, head); + activeResources.add(view); + return view; + } finally { + lifecycle.writeLock().unlock(); + } + } + + /* default */ FilesystemRevocationLog.RecoveryTarget head() throws IOException { + lifecycle.readLock().lock(); + try { + requireOperational(); + return log.recoveryTarget(); + } finally { + lifecycle.readLock().unlock(); + } + } + + /* default */ FilesystemRevocationLog.RecoveryTarget copyAuthoritativePrefix(Path target) throws IOException { + Objects.requireNonNull(target, "target"); + lifecycle.readLock().lock(); + try { + requireOperational(); + FilesystemRevocationLog.RecoveryTarget captured = log.recoveryTarget(); + Files.createDirectories(Objects.requireNonNull(target.getParent(), "snapshot prefix parent")); + try (FileChannel source = FileChannel.open(paths.revocationTransitionLog(), StandardOpenOption.READ); + FileChannel destination = FileChannel.open(target, StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.allocate(TRANSFER_BYTES); + long position = 0L; + while (position < captured.boundary()) { + buffer.clear(); + buffer.limit((int) Math.min(buffer.capacity(), captured.boundary() - position)); + int read = source.read(buffer, position); + if (read <= 0) { + throw new IOException("Revocation snapshot prefix made no read progress"); + } + buffer.flip(); + while (buffer.hasRemaining()) { + destination.write(buffer); + } + position = Math.addExact(position, read); + } + destination.force(true); + } + validateCopiedPrefix(target, captured); + return captured; + } finally { + lifecycle.readLock().unlock(); + } + } + + private static void validateCopiedPrefix(Path path, + FilesystemRevocationLog.RecoveryTarget expected) throws IOException { + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { + RevocationTransitionFrameCodec codec = new RevocationTransitionFrameCodec(); + MetadataStoreId store = codec.readPreamble(channel); + if (!expected.storeId().equals(store) || channel.size() != expected.boundary()) { + throw new IOException("Copied revocation prefix identity or boundary is invalid"); + } + long offset = RevocationTransitionFrameCodec.PREAMBLE_BYTES; + long revision = 0L; + RevocationTransitionFrameCodec.Commitment commitment = + RevocationTransitionFrameCodec.initialCommitment(store); + while (offset < expected.boundary()) { + RevocationTransitionFrameCodec.ReadResult read = codec.read(channel, offset); + if (read.classification() != RevocationTransitionFrameCodec.Classification.COMPLETE_RECORD) { + throw new IOException("Copied revocation prefix contains an invalid frame"); + } + RevocationTransitionFrameCodec.CompleteRecord frame = read.record().orElseThrow(); + if (frame.data().globalRevision() != Math.addExact(revision, 1L) + || !frame.data().previousGlobalCommitment().equals(commitment) + || frame.recordEnd() > expected.boundary()) { + throw new IOException("Copied revocation prefix continuity is invalid"); + } + offset = frame.recordEnd(); + revision = frame.data().globalRevision(); + commitment = frame.commitment(); + } + if (offset != expected.boundary() || revision != expected.globalRevision() + || !commitment.equals(expected.globalCommitment())) { + throw new IOException("Copied revocation prefix does not match its captured head"); + } + } + } + + private Optional exact( + Optional candidate, + FilesystemRevocationLog.RecoveryTarget head) throws IOException { + if (candidate.isEmpty()) { + return Optional.empty(); + } + FilesystemRevocationCheckpoint checkpoint = candidate.orElseThrow(); + if (checkpoint.storeId().equals(head.storeId()) + && checkpoint.coveredRevision() == head.globalRevision() + && checkpoint.coveredBoundary() == head.boundary() + && checkpoint.coveredCommitment().equals(head.globalCommitment())) { + return Optional.of(checkpoint); + } + checkpoint.close(); + return Optional.empty(); + } + + private void ensureIndex() throws IOException { + if (index == null) { + recoverIndex(log.recoveryTarget()); + } + } + + private void recoverIndex(FilesystemRevocationLog.RecoveryTarget target) throws IOException { + if (index != null) { + index.close(); + } + index = FilesystemRevocationCurrentIndex.recover( + paths.revocationCurrentIndex(), paths.revocationCheckpointDirectory(), + paths.revocationTransitionLog(), storeId, INDEX_CONFIGURATION, target); + } + + private void invalidateIndex(IOException failure) { + FilesystemRevocationCurrentIndex invalid = index; + index = null; + if (invalid != null) { + try { + invalid.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + } + } + + private void requireOperational() throws IOException { + if (closed) { + throw new IllegalStateException("Revocation authority is closed"); + } + if (log.recoveryRequired()) { + throw new IOException("Revocation authority requires close and recovery"); + } + } + + private void release(OwnedResource resource) { + lifecycle.writeLock().lock(); + try { + activeResources.remove(resource); + } finally { + lifecycle.writeLock().unlock(); + } + } + + @Override + public synchronized void close() throws IOException { + Set resources; + lifecycle.writeLock().lock(); + try { + if (closed) { + return; + } + closed = true; + resources = Set.copyOf(activeResources); + activeResources.clear(); + } finally { + lifecycle.writeLock().unlock(); + } + + IOException failure = null; + for (OwnedResource resource : resources) { + try { + resource.closeFromOwner(); + } catch (IOException closeFailure) { + failure = appendFailure(failure, closeFailure); + } + } + + lifecycle.writeLock().lock(); + try { + if (index != null) { + try { + index.close(); + } catch (IOException closeFailure) { + failure = appendFailure(failure, closeFailure); + } + index = null; + } + try { + log.close(); + } catch (IOException closeFailure) { + failure = appendFailure(failure, closeFailure); + } + if (failure != null) { + throw failure; + } + } finally { + lifecycle.writeLock().unlock(); + } + } + + private static RevocationRecord record(RevocationTransitionFrameCodec.CompleteRecord frame) { + return new RevocationRecord(frame.data().credentialId(), frame.data().transition()); + } + + private static IOException appendFailure(IOException first, IOException later) { + if (first == null) { + return later; + } + first.addSuppressed(later); + return first; + } + + /** Resource registered for deterministic owner-driven closure. */ + @SuppressWarnings("PMD.ImplicitFunctionalInterface") + private interface OwnedResource { + /** Closes the resource without reentering owner deregistration. */ + void closeFromOwner() throws IOException; + } + + /** Immutable checkpoint-backed current-state view. */ + private static final class StableView implements RevocationSnapshot, OwnedResource { + private final FilesystemRevocationAuthority owner; + private final FilesystemRevocationCheckpoint checkpoint; + private final FilesystemRevocationLog.RecoveryTarget head; + private final Set cursors = new HashSet<>(); + private boolean closed; + + private StableView(FilesystemRevocationAuthority owner, + FilesystemRevocationCheckpoint checkpoint, + FilesystemRevocationLog.RecoveryTarget head) { + this.owner = owner; + this.checkpoint = checkpoint; + this.head = head; + } + + @Override + public String snapshotId() { + return checkpoint.generationId(); + } + + @Override + public long revision() { + return head.globalRevision(); + } + + @Override + public long boundary() { + return head.boundary(); + } + + @Override + public String commitment() { + return head.globalCommitment().value(); + } + + @Override + public OptionalLong count() { + return OptionalLong.of(checkpoint.entryCount()); + } + + @Override + public synchronized Cursor openCursor() throws IOException { + requireOpen(); + ViewCursor cursor = new ViewCursor(this, checkpoint.allCurrentStates(), + FileChannel.open(owner.paths.revocationTransitionLog(), StandardOpenOption.READ)); + cursors.add(cursor); + return cursor; + } + + @Override + public void close() throws IOException { + closeInternal(true); + } + + @Override + public void closeFromOwner() throws IOException { + closeInternal(false); + } + + private synchronized void closeInternal(boolean release) throws IOException { + if (closed) { + return; + } + closed = true; + IOException failure = null; + for (ViewCursor cursor : Set.copyOf(cursors)) { + try { + cursor.closeFromOwner(); + } catch (IOException closeFailure) { + failure = appendFailure(failure, closeFailure); + } + } + cursors.clear(); + try { + checkpoint.close(); + } catch (IOException closeFailure) { + failure = appendFailure(failure, closeFailure); + } + if (release) { + owner.release(this); + } + if (failure != null) { + throw failure; + } + } + + private synchronized void cursorClosed(ViewCursor cursor) { + cursors.remove(cursor); + } + + private void requireOpen() { + if (closed) { + throw new IllegalStateException("Revocation snapshot is closed"); + } + } + } + + /** Ordered view cursor that revalidates every derived entry against its exact frame. */ + private static final class ViewCursor implements RevocationSnapshot.Cursor { + private final StableView owner; + private final FilesystemRevocationCheckpoint.Cursor cursor; + private final FileChannel logChannel; + private RevocationRecord current; + private long ordinal = -1L; + private boolean closed; + + private ViewCursor(StableView owner, FilesystemRevocationCheckpoint.Cursor cursor, + FileChannel logChannel) { + this.owner = owner; + this.cursor = cursor; + this.logChannel = logChannel; + } + + @Override + public boolean next(CancellationSignal cancellation) throws IOException { + requireOpen(); + current = null; + if (!cursor.advance(cancellation)) { + return false; + } + RevocationCheckpointCodec.CurrentStateEntry entry = cursor.current(); + RevocationTransitionFrameCodec.ReadResult read = new RevocationTransitionFrameCodec() + .read(logChannel, entry.frameStart()); + if (read.classification() != RevocationTransitionFrameCodec.Classification.COMPLETE_RECORD) { + throw new IOException("Checkpoint current state has no authoritative frame"); + } + RevocationTransitionFrameCodec.CompleteRecord frame = read.record().orElseThrow(); + if (!frame.data().credentialId().equals(entry.credentialId()) + || frame.data().globalRevision() != entry.globalRevision() + || frame.data().transition().revision() != entry.credentialRevision() + || frame.recordEnd() != entry.frameEnd() + || !frame.commitment().equals(entry.transitionCommitment()) + || !RevocationTransitionFrameCodec.transitionsEqual( + frame.data().transition(), entry.transition())) { + throw new IOException("Checkpoint current state disagrees with authoritative frame"); + } + ordinal = Math.addExact(ordinal, 1L); + current = record(frame); + return true; + } + + @Override + public RevocationRecord current() { + if (current == null) { + throw new IllegalStateException("Revocation cursor is not positioned"); + } + return current; + } + + @Override + public long ordinal() { + if (current == null) { + throw new IllegalStateException("Revocation cursor is not positioned"); + } + return ordinal; + } + + @Override + public void close() throws IOException { + closeInternal(true); + } + + private void closeFromOwner() throws IOException { + closeInternal(false); + } + + private void closeInternal(boolean release) throws IOException { + if (closed) { + return; + } + closed = true; + current = null; + cursor.close(); + logChannel.close(); + if (release) { + owner.cursorClosed(this); + } + } + + private void requireOpen() { + if (closed) { + throw new IllegalStateException("Revocation cursor is closed"); + } + } + } + + /** Two-pass bounded-memory history cursor over one captured authoritative prefix. */ + private static final class HistoryCursor implements RevocationHistory, OwnedResource { + private final FilesystemRevocationAuthority owner; + private final PkiId credentialId; + private final FilesystemRevocationLog.RecoveryTarget head; + private final FileChannel channel; + private final RevocationTransitionFrameCodec codec = new RevocationTransitionFrameCodec(); + private long offset = RevocationTransitionFrameCodec.PREAMBLE_BYTES; + private RevocationTransitionFrameCodec.Commitment globalCommitment; + private long globalRevision; + private RevocationTransitionFrameCodec.CompleteRecord previousCredential; + private RevocationTransition current; + private boolean validated; + private boolean closed; + + private HistoryCursor(FilesystemRevocationAuthority owner, PkiId credentialId, + FilesystemRevocationLog.RecoveryTarget head) throws IOException { + this.owner = owner; + this.credentialId = credentialId; + this.head = head; + channel = FileChannel.open(owner.paths.revocationTransitionLog(), StandardOpenOption.READ); + boolean valid = false; + try { + reset(); + valid = true; + } finally { + if (!valid) { + channel.close(); + } + } + } + + @Override + public PkiId credentialId() { + return credentialId; + } + + @Override + public boolean next(CancellationSignal cancellation) throws IOException { + Objects.requireNonNull(cancellation, "cancellation"); + requireOpen(); + if (!validated) { + validatePrefix(cancellation); + reset(); + validated = true; + } + current = null; + while (offset < head.boundary()) { + cancellation.throwIfCancelled(); + RevocationTransitionFrameCodec.CompleteRecord frame = readNext(); + if (frame.data().credentialId().equals(credentialId)) { + FilesystemRevocationLog.validateTransition(frame.data(), previousCredential); + previousCredential = frame; + current = frame.data().transition(); + return true; + } + } + requireExactHead(); + return false; + } + + @Override + public RevocationTransition current() { + if (current == null) { + throw new IllegalStateException("Revocation history is not positioned"); + } + return current; + } + + @Override + public void close() throws IOException { + closeInternal(true); + } + + @Override + public void closeFromOwner() throws IOException { + closeInternal(false); + } + + private void validatePrefix(CancellationSignal cancellation) throws IOException { + reset(); + while (offset < head.boundary()) { + cancellation.throwIfCancelled(); + RevocationTransitionFrameCodec.CompleteRecord frame = readNext(); + if (frame.data().credentialId().equals(credentialId)) { + FilesystemRevocationLog.validateTransition(frame.data(), previousCredential); + previousCredential = frame; + } + } + requireExactHead(); + } + + private void reset() throws IOException { + MetadataStoreId actual = codec.readPreamble(channel); + if (!head.storeId().equals(actual)) { + throw new IOException("Revocation history belongs to another store"); + } + offset = RevocationTransitionFrameCodec.PREAMBLE_BYTES; + globalRevision = 0L; + globalCommitment = RevocationTransitionFrameCodec.initialCommitment(actual); + previousCredential = null; + current = null; + } + + private RevocationTransitionFrameCodec.CompleteRecord readNext() throws IOException { + RevocationTransitionFrameCodec.ReadResult read = codec.read(channel, offset); + if (read.classification() != RevocationTransitionFrameCodec.Classification.COMPLETE_RECORD) { + throw new IOException("Revocation history contains an incomplete or corrupt frame"); + } + RevocationTransitionFrameCodec.CompleteRecord frame = read.record().orElseThrow(); + if (frame.recordEnd() > head.boundary() + || frame.data().globalRevision() != Math.addExact(globalRevision, 1L) + || !frame.data().previousGlobalCommitment().equals(globalCommitment)) { + throw new IOException("Revocation history continuity is invalid"); + } + offset = frame.recordEnd(); + globalRevision = frame.data().globalRevision(); + globalCommitment = frame.commitment(); + return frame; + } + + private void requireExactHead() throws IOException { + if (offset != head.boundary() || globalRevision != head.globalRevision() + || !globalCommitment.equals(head.globalCommitment())) { + throw new IOException("Revocation history does not match its captured head"); + } + } + + private void closeInternal(boolean release) throws IOException { + if (closed) { + return; + } + closed = true; + current = null; + channel.close(); + if (release) { + owner.release(this); + } + } + + private void requireOpen() { + if (closed) { + throw new IllegalStateException("Revocation history is closed"); + } + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationLog.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationLog.java index 9eb6b82..df11480 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationLog.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationLog.java @@ -71,6 +71,7 @@ import zeroecho.pki.api.revocation.RevocationTransition; import zeroecho.pki.spi.store.MetadataStoreId; /** Exclusive-writer POSIX append-only revocation transition log. */ +@SuppressWarnings("PMD.CyclomaticComplexity") final class FilesystemRevocationLog implements AutoCloseable { private static final Logger LOGGER = Logger.getLogger(FilesystemRevocationLog.class.getName()); @@ -107,13 +108,14 @@ final class FilesystemRevocationLog implements AutoCloseable { MetadataStoreId storeId, CredentialAuthority credentialAuthority, FaultInjector faults, - RecoveryResult recovery) { + RecoveryResult recovery, + boolean retainCurrentState) { this.channel = channel; this.writerLock = writerLock; this.storeId = storeId; this.credentialAuthority = credentialAuthority; this.faults = faults; - latest = new HashMap<>(recovery.latestStates()); + latest = retainCurrentState ? new HashMap<>(recovery.latestStates()) : null; globalRevision = recovery.globalRevision(); finalRecordStart = recovery.finalRecordStart(); lastCompleteRecordBoundary = recovery.lastCompleteRecordBoundary(); @@ -127,6 +129,19 @@ final class FilesystemRevocationLog implements AutoCloseable { DefaultCapabilityProfile.INSTANCE, FaultInjector.NONE); } + /* default */ static FilesystemRevocationLog createProduction( + Path logPath, MetadataStoreId storeId, CredentialAuthority credentialAuthority) throws IOException { + return Lifecycle.create(logPath, storeId, credentialAuthority, + DefaultCapabilityProfile.INSTANCE, FaultInjector.NONE, false); + } + + /* default */ static FilesystemRevocationLog openProduction( + Path logPath, MetadataStoreId expectedStoreId, CredentialAuthority credentialAuthority) + throws IOException { + return Lifecycle.open(logPath, expectedStoreId, credentialAuthority, + DefaultCapabilityProfile.INSTANCE, FaultInjector.NONE, false); + } + /* default */ static FilesystemRevocationLog open( Path logPath, MetadataStoreId expectedStoreId, CredentialAuthority credentialAuthority) throws IOException { @@ -140,7 +155,7 @@ final class FilesystemRevocationLog implements AutoCloseable { CredentialAuthority credentialAuthority, CapabilityProfile capabilities, FaultInjector faults) throws IOException { - return Lifecycle.create(logPath, storeId, credentialAuthority, capabilities, faults); + return Lifecycle.create(logPath, storeId, credentialAuthority, capabilities, faults, true); } /* default */ static FilesystemRevocationLog open( @@ -149,7 +164,7 @@ final class FilesystemRevocationLog implements AutoCloseable { CredentialAuthority credentialAuthority, CapabilityProfile capabilities, FaultInjector faults) throws IOException { - return Lifecycle.open(logPath, expectedStoreId, credentialAuthority, capabilities, faults); + return Lifecycle.open(logPath, expectedStoreId, credentialAuthority, capabilities, faults, true); } /* default */ MetadataStoreId storeId() { @@ -190,6 +205,41 @@ final class FilesystemRevocationLog implements AutoCloseable { } } + /* default */ RevocationTransitionFrameCodec.CompleteRecord append( + PkiId credentialId, RevocationTransition transition, + RevocationTransitionFrameCodec.CompleteRecord previous) throws IOException { + Objects.requireNonNull(credentialId, "credentialId"); + Objects.requireNonNull(transition, "transition"); + if (latest != null) { + throw new IllegalStateException("External predecessor append requires production log mode"); + } + credentialAuthority.requireCredential(credentialId); + appendLock.lock(); + try { + requireOperational(); + RevocationTransitionFrameCodec.TransitionData data = nextData( + credentialId, transition, previous); + validateTransition(data, previous); + try { + channel.position(channel.size()); + faults.fail(FaultPoint.APPEND); + RevocationTransitionFrameCodec.CompleteRecord record = codec.write(channel, data); + faults.fail(FaultPoint.FILE_FORCE); + channel.force(true); + globalRevision = data.globalRevision(); + finalRecordStart = OptionalLong.of(record.recordOffset()); + lastCompleteRecordBoundary = record.recordEnd(); + globalCommitment = record.commitment(); + return record; + } catch (IOException failure) { + state = State.RECOVERY_REQUIRED; + throw new OutcomeUnknownException(failure); + } + } finally { + appendLock.unlock(); + } + } + /* default */ RecoveryResult scan(RecoverySink sink) throws IOException { appendLock.lock(); try { @@ -209,6 +259,9 @@ final class FilesystemRevocationLog implements AutoCloseable { appendLock.lock(); try { requireOpenAuthority(); + if (latest == null) { + throw new IllegalStateException("Production log does not retain current credential state"); + } return latest.size(); } finally { appendLock.unlock(); @@ -347,6 +400,29 @@ final class FilesystemRevocationLog implements AutoCloseable { Optional.of(previous.commitment()), transition); } + private RevocationTransitionFrameCodec.TransitionData nextData( + PkiId credentialId, RevocationTransition transition, + RevocationTransitionFrameCodec.CompleteRecord previous) throws IOException { + final long nextGlobal; + try { + nextGlobal = Math.addExact(globalRevision, 1L); + } catch (ArithmeticException exhausted) { + throw new IOException("Global revocation revision is exhausted", exhausted); + } + if (previous == null) { + return new RevocationTransitionFrameCodec.TransitionData( + nextGlobal, globalCommitment, credentialId, + OptionalLong.empty(), Optional.empty(), transition); + } + if (!previous.data().credentialId().equals(credentialId)) { + throw new IOException("Revocation predecessor identity is invalid"); + } + return new RevocationTransitionFrameCodec.TransitionData( + nextGlobal, globalCommitment, credentialId, + OptionalLong.of(previous.data().globalRevision()), + Optional.of(previous.commitment()), transition); + } + private static void validateGlobal( RevocationTransitionFrameCodec.CompleteRecord record, long currentRevision, @@ -453,7 +529,9 @@ final class FilesystemRevocationLog implements AutoCloseable { } catch (IOException closeFailure) { failure = appendFailure(failure, closeFailure); } - latest.clear(); + if (latest != null) { + latest.clear(); + } state = State.CLOSED; if (failure != null) { throw failure; @@ -478,7 +556,8 @@ final class FilesystemRevocationLog implements AutoCloseable { MetadataStoreId storeId, CredentialAuthority credentialAuthority, CapabilityProfile capabilities, - FaultInjector faults) throws IOException { + FaultInjector faults, + boolean retainCurrentState) throws IOException { requireLifecycleArguments(storeId, credentialAuthority, capabilities, faults); Path parent = requireParent(logPath); CapabilityObservation observation = observeCapabilities(parent, capabilities); @@ -493,7 +572,7 @@ final class FilesystemRevocationLog implements AutoCloseable { RecoveryResult empty = RecoveryResult.empty(storeId); FilesystemRevocationLog log = new FilesystemRevocationLog( resources.channel, resources.writerLock, storeId, - credentialAuthority, faults, empty); + credentialAuthority, faults, empty, retainCurrentState); log.scanInvocations = 0L; return log; } catch (IOException failure) { @@ -507,14 +586,18 @@ final class FilesystemRevocationLog implements AutoCloseable { MetadataStoreId expectedStoreId, CredentialAuthority credentialAuthority, CapabilityProfile capabilities, - FaultInjector faults) throws IOException { + FaultInjector faults, + boolean retainCurrentState) throws IOException { requireLifecycleArguments(expectedStoreId, credentialAuthority, capabilities, faults); Path parent = requireParent(logPath); CapabilityObservation observation = observeCapabilities(parent, capabilities); Resources resources = Resources.acquire(logPath, false, observation.posix()); try { - ProvisionalRecovery provisional = Scanner.scanProvisional( - resources.channel, expectedStoreId, credentialAuthority, RecoverySink.NONE); + ProvisionalRecovery provisional = retainCurrentState + ? Scanner.scanProvisional(resources.channel, expectedStoreId, + credentialAuthority, RecoverySink.NONE) + : ScalarScanner.scanProvisional(resources.channel, expectedStoreId, + credentialAuthority); boolean completed = false; try { RecoveryResult recovered = repairIfNecessary( @@ -529,7 +612,7 @@ final class FilesystemRevocationLog implements AutoCloseable { } return new FilesystemRevocationLog( resources.channel, resources.writerLock, expectedStoreId, - credentialAuthority, faults, recovered); + credentialAuthority, faults, recovered, retainCurrentState); } finally { if (!completed) { provisional.sink().abort(); @@ -694,6 +777,51 @@ final class FilesystemRevocationLog implements AutoCloseable { } } + /** Production scalar scan; credential-local state is rebuilt only in the disk index. */ + private static final class ScalarScanner { + private static ProvisionalRecovery scanProvisional( + FileChannel channel, + MetadataStoreId expectedStoreId, + CredentialAuthority credentialAuthority) throws IOException { + RevocationTransitionFrameCodec codec = new RevocationTransitionFrameCodec(); + MetadataStoreId actualStoreId = codec.readPreamble(channel); + if (!expectedStoreId.equals(actualStoreId)) { + throw new CorruptLogException("Revocation log belongs to another store"); + } + long physicalEnd = channel.size(); + long boundary = RevocationTransitionFrameCodec.PREAMBLE_BYTES; + long globalRevision = 0L; + OptionalLong finalRecordStart = OptionalLong.empty(); + RevocationTransitionFrameCodec.Commitment commitment = + RevocationTransitionFrameCodec.initialCommitment(actualStoreId); + boolean incomplete = false; + while (true) { + RevocationTransitionFrameCodec.ReadResult result = codec.read(channel, boundary); + if (result.classification() == RevocationTransitionFrameCodec.Classification.END_OF_INPUT) { + break; + } + if (result.classification() == RevocationTransitionFrameCodec.Classification.INCOMPLETE_TAIL) { + incomplete = true; + break; + } + if (result.classification() != RevocationTransitionFrameCodec.Classification.COMPLETE_RECORD) { + throw new CorruptLogException("Revocation transition log contains a corrupt record"); + } + RevocationTransitionFrameCodec.CompleteRecord record = result.record().orElseThrow(); + validateGlobal(record, globalRevision, commitment); + credentialAuthority.requireCredential(record.data().credentialId()); + globalRevision = record.data().globalRevision(); + finalRecordStart = OptionalLong.of(record.recordOffset()); + commitment = record.commitment(); + boundary = record.recordEnd(); + } + RecoveryResult recovered = new RecoveryResult( + actualStoreId, boundary, physicalEnd, incomplete, globalRevision, + finalRecordStart, commitment, Map.of()); + return new ProvisionalRecovery(recovered, RecoverySink.NONE); + } + } + /** Successful scan whose sink publication remains provisional until its owner completes it. */ private record ProvisionalRecovery(RecoveryResult result, RecoverySink sink) { private ProvisionalRecovery { diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemTemporaryUniqueIndex.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemTemporaryUniqueIndex.java index fcd8b0a..c731c9d 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemTemporaryUniqueIndex.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemTemporaryUniqueIndex.java @@ -39,6 +39,7 @@ import java.nio.charset.CharacterCodingException; import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.DirectoryStream; import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.security.MessageDigest; @@ -145,17 +146,22 @@ final class FilesystemTemporaryUniqueIndex implements TemporaryUniqueIndex { requireOpen(); lock.lock(); try { - for (Path entry : DurableMetadataFiles.list(directory, "")) { - requireCommittedName(entry); - byte[] value = readRecord(entry); - try { - requireCanonicalOwner(value); - String actualKey = entry.getFileName().toString(); - if (!actualKey.equals(key(domain, value))) { - throw integrityFailure(); + if (!Files.exists(directory)) { + return; + } + try (DirectoryStream entries = Files.newDirectoryStream(directory)) { + for (Path entry : entries) { + requireCommittedName(entry); + byte[] value = readRecord(entry); + try { + requireCanonicalOwner(value); + String actualKey = entry.getFileName().toString(); + if (!actualKey.equals(key(domain, value))) { + throw integrityFailure(); + } + } finally { + Arrays.fill(value, (byte) 0); } - } finally { - Arrays.fill(value, (byte) 0); } } } finally { @@ -170,8 +176,12 @@ final class FilesystemTemporaryUniqueIndex implements TemporaryUniqueIndex { if (!closed.compareAndSet(false, true)) { return; } - for (Path entry : DurableMetadataFiles.list(directory, "")) { - DurableMetadataFiles.delete(entry); + if (Files.isDirectory(directory)) { + try (DirectoryStream entries = Files.newDirectoryStream(directory)) { + for (Path entry : entries) { + DurableMetadataFiles.delete(entry); + } + } } Files.deleteIfExists(directory); } finally { 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 0e7da7c..734d758 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java @@ -63,6 +63,7 @@ final class FsPaths { private static final String REVOCATIONS_DIRECTORY = "revocations"; /* default */ static final String VERSION_FILE = "VERSION"; + /* default */ static final String REVOCATION_SNAPSHOT_BOUNDARY_FILE = ".revocation-snapshot-boundary"; /* default */ static final String LOCK_DIR = ".lock"; /* default */ static final String STORE_LOCK = "store.lock"; @@ -83,6 +84,10 @@ final class FsPaths { return this.root.resolve(VERSION_FILE); } + /* default */ Path revocationSnapshotBoundary() { + return this.root.resolve(REVOCATION_SNAPSHOT_BOUNDARY_FILE); + } + /* default */ Path signingNamespaceFile() { return this.root.resolve("SIGNING_NAMESPACE"); } 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 bac6470..b2b42a4 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java @@ -103,6 +103,7 @@ final class FsSnapshotExporter { private static final String ACTIVE_POINTER_FILE = "active.bin"; private static final String BINARY_EXTENSION = ".bin"; private static final int TRANSFER_BUFFER_BYTES = 16 * 1024; + private static final String REVOCATION_PREFIX_STAGE = ".revocation-prefix.snapshot"; private final FsPkiStoreOptions options; @@ -337,8 +338,9 @@ final class FsSnapshotExporter { private void build(Path targetRoot, Instant at) throws IOException { Path sourceRoot = source.snapshotRoot(); - FsPaths destination = new FsPaths(targetRoot); - Files.writeString(destination.versionFile(), FilesystemPkiStore.CURRENT_STORE_VERSION); + Path stagedRevocationPrefix = targetRoot.resolve(REVOCATION_PREFIX_STAGE); + FilesystemRevocationLog.RecoveryTarget captured = + source.copyRevocationPrefix(stagedRevocationPrefix); copyFile(sourceRoot.resolve("SIGNING_NAMESPACE"), targetRoot.resolve("SIGNING_NAMESPACE")); copyFile(sourceRoot.resolve("SIGNING_TIME_WATERMARK"), targetRoot.resolve("SIGNING_TIME_WATERMARK")); copyTreeIfExists(sourceRoot.resolve("requests"), targetRoot.resolve("requests")); @@ -347,11 +349,47 @@ final class FsSnapshotExporter { copyTreeIfExists(sourceRoot.resolve("sign-workflows"), targetRoot.resolve("sign-workflows")); copyStagedContent(sourceRoot.resolve("staged-content"), targetRoot.resolve("staged-content"), plan.authority().remintedContentIds(), plan.nonCredentialContentIds()); - copyTreeIfExists(sourceRoot.resolve("revocations"), targetRoot.resolve("revocations")); copyImportedProfilesAsOf(plan.profiles(), targetRoot.resolve("profiles")); reconstructMutableTree(sourceRoot.resolve("workflows"), targetRoot.resolve("workflows"), at, options.workflowHistoryPolicy(), options.strictSnapshotExport()); new AuthorityRestorer(source, options, plan.authority()).restore(targetRoot); + installRevocationAuthority(targetRoot, stagedRevocationPrefix); + try (FilesystemPkiStore ignored = new FilesystemPkiStore(targetRoot, options)) { + // Opening validates the restored authority and rebuilds its derived index. + } + writeSnapshotBoundary(targetRoot, captured.boundary()); + } + + private static void writeSnapshotBoundary(Path targetRoot, long boundary) throws IOException { + Path marker = new FsPaths(targetRoot).revocationSnapshotBoundary(); + Files.writeString(marker, Long.toString(boundary), StandardCharsets.US_ASCII, + java.nio.file.StandardOpenOption.CREATE_NEW, + java.nio.file.StandardOpenOption.WRITE); + try (java.nio.channels.FileChannel channel = java.nio.channels.FileChannel.open( + marker, java.nio.file.StandardOpenOption.WRITE)) { + channel.force(true); + } + try (java.nio.channels.FileChannel directory = java.nio.channels.FileChannel.open( + targetRoot, java.nio.file.StandardOpenOption.READ)) { + directory.force(true); + } + } + + private static void installRevocationAuthority(Path targetRoot, Path stagedPrefix) throws IOException { + FsPaths paths = new FsPaths(targetRoot); + Files.deleteIfExists(paths.revocationCurrentIndex()); + Files.createDirectories(paths.revocationTransitionLog().getParent()); + try { + Files.move(stagedPrefix, paths.revocationTransitionLog(), + java.nio.file.StandardCopyOption.ATOMIC_MOVE, + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } catch (java.nio.file.AtomicMoveNotSupportedException unsupported) { + throw new IOException("Atomic revocation snapshot restoration is unsupported", unsupported); + } + try (java.nio.channels.FileChannel directory = java.nio.channels.FileChannel.open( + paths.revocationTransitionLog().getParent(), java.nio.file.StandardOpenOption.READ)) { + directory.force(true); + } } private static void addCleanupFailure(Exception primary, Path temporaryRoot) { 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 0d37b59..10b8b6c 100644 --- a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java +++ b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java @@ -48,7 +48,7 @@ import zeroecho.pki.api.profile.ImportedCertificateProfileVersion; import zeroecho.pki.api.publication.PublicationRecord; 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.status.StatusObject; /** @@ -177,20 +177,37 @@ public interface PkiStore extends SignWorkflowStore { * * @param command trusted transition command * @param transitionTime authoritative transition time - * @return committed journal + * @return committed current state */ - RevocationJournal transitionRevocation(RevocationCommand command, Instant transitionTime); + RevocationRecord transitionRevocation(RevocationCommand command, Instant transitionTime); /** - * Retrieves the authoritative revocation journal for a credential. + * Retrieves the validated current revocation state for a credential. + * Disk probing is expected constant time and is linear in index capacity in + * the worst collision case; auxiliary heap use is constant. * * @param credentialId credential identifier - * @return journal when present + * @return current state when present */ - Optional getRevocationJournal(PkiId credentialId); + Optional getRevocation(PkiId credentialId); + + /** + * Opens a bounded-memory cursor over one credential's authoritative history. + * Opening is constant time. The first advance validates the captured global + * prefix; consuming the history performs at most two linear passes over that + * prefix and uses constant auxiliary heap. + * + * @param credentialId exact credential identity + * @return closeable history cursor captured at the current log head + * @throws IllegalStateException if the authoritative history cannot be opened + */ + RevocationHistory openRevocationHistory(PkiId credentialId); /** * Opens a stable restartable streaming snapshot of authoritative revocations. + * An exact checkpoint opens in constant time after validation. Otherwise the + * implementation may perform bounded-memory external sorting over the current + * population; cursors themselves use constant auxiliary heap per entry. * * @return revocation snapshot * @throws IllegalStateException if the snapshot cannot be opened diff --git a/pki/src/main/java/zeroecho/pki/spi/store/RevocationHistory.java b/pki/src/main/java/zeroecho/pki/spi/store/RevocationHistory.java new file mode 100644 index 0000000..7712cc7 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/store/RevocationHistory.java @@ -0,0 +1,60 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.spi.store; + +import java.io.IOException; + +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.revocation.RevocationTransition; +import zeroecho.core.io.CancellationSignal; + +/** + * Closeable one-pass history of one credential in credential-local revision order. + * + *

    The cursor reads authenticated authoritative frames incrementally. Its + * auxiliary heap is independent of the number of transitions. Opening is + * constant time. The first advance validates the captured prefix, and complete + * consumption costs at most two linear passes over the global log prefix.

    + */ +public interface RevocationHistory extends AutoCloseable { + + /** @return exact credential identity selected for this history */ + PkiId credentialId(); + + /** + * Advances to the next transition. + * + * @param cancellation cancellation signal checked during scanning + * @return {@code true} when {@link #current()} is available + * @throws IOException if the authoritative history is unreadable or invalid + */ + boolean next(CancellationSignal cancellation) throws IOException; + + /** + * Advances without external cancellation. + * + * @return {@code true} when a transition is available + * @throws IOException if the authoritative history is unreadable or invalid + */ + default boolean next() throws IOException { + return next(CancellationSignal.NONE); + } + + /** + * Returns the current transition. + * + * @return current transition + * @throws IllegalStateException if the cursor is not positioned + */ + RevocationTransition current(); + + /** + * Closes the underlying authoritative-log channel. Repeated close is harmless. + * + * @throws IOException if channel closure fails + */ + @Override + void close() throws IOException; +} diff --git a/pki/src/main/java/zeroecho/pki/spi/store/RevocationSnapshot.java b/pki/src/main/java/zeroecho/pki/spi/store/RevocationSnapshot.java index 73fe041..051288e 100644 --- a/pki/src/main/java/zeroecho/pki/spi/store/RevocationSnapshot.java +++ b/pki/src/main/java/zeroecho/pki/spi/store/RevocationSnapshot.java @@ -36,16 +36,21 @@ package zeroecho.pki.spi.store; import java.io.IOException; import java.util.OptionalLong; -import zeroecho.pki.api.revocation.RevocationJournal; +import zeroecho.pki.api.revocation.RevocationRecord; +import zeroecho.core.io.CancellationSignal; /** - * Stable, restartable streaming view of authoritative revocation journals. + * Stable, restartable ordered current-state view at one authoritative revision. * *

    * A snapshot never exposes a complete collection. Each cursor yields one - * immutable journal at a time and uses {@code long} ordinal accounting. ZeroEcho + * immutable current state at a time and uses {@code long} ordinal accounting. ZeroEcho * core imposes no product-wide entry-count ceiling. *

    + * + *

    An exact checkpoint opens without population materialization. Building a + * missing checkpoint uses a bounded-memory external sort; cursor traversal is + * linear in the current-state count and uses constant auxiliary heap per entry.

    */ public interface RevocationSnapshot extends AutoCloseable { @@ -56,8 +61,17 @@ public interface RevocationSnapshot extends AutoCloseable { */ String snapshotId(); + /** @return captured authoritative global revision */ + long revision(); + + /** @return captured authoritative byte boundary */ + long boundary(); + + /** @return captured authoritative global commitment in lowercase hexadecimal */ + String commitment(); + /** - * Returns the journal count when the store can provide it without + * Returns the current-state count when the store can provide it without * materialization. * * @return count or empty @@ -81,25 +95,36 @@ public interface RevocationSnapshot extends AutoCloseable { void close() throws IOException; /** - * One-pass journal cursor. + * One-pass current-state cursor. */ interface Cursor extends AutoCloseable { /** - * Advances to the next journal. + * Advances to the next current state. * * @return {@code true} when {@link #current()} is available * @throws IOException if store reading fails */ - boolean next() throws IOException; + default boolean next() throws IOException { + return next(CancellationSignal.NONE); + } /** - * Returns the current journal. + * Advances with cancellation. * - * @return immutable current journal + * @param cancellation cancellation signal + * @return {@code true} when a current state is available + * @throws IOException if store reading fails + */ + boolean next(CancellationSignal cancellation) throws IOException; + + /** + * Returns the current state. + * + * @return immutable current state * @throws IllegalStateException if the cursor is not positioned on a value */ - RevocationJournal current(); + RevocationRecord current(); /** * Returns the zero-based current ordinal. 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 fb59920..ed0ef31 100644 --- a/pki/src/test/java/zeroecho/pki/impl/core/DefaultRevocationServiceTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/core/DefaultRevocationServiceTest.java @@ -40,6 +40,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.lang.reflect.Proxy; +import java.io.IOException; import java.time.Clock; import java.time.Instant; import java.time.ZoneId; @@ -47,6 +48,7 @@ import java.time.ZoneOffset; import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.OptionalLong; import java.util.concurrent.atomic.AtomicBoolean; import org.junit.jupiter.api.Test; @@ -56,10 +58,14 @@ 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; import zeroecho.pki.api.revocation.RevocationTransition; import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.spi.store.PkiStore; +import zeroecho.pki.spi.store.RevocationSnapshot; +import zeroecho.core.io.CancellationSignal; final class DefaultRevocationServiceTest { private static final Instant NOW = Instant.parse("2026-07-01T12:00:00Z"); @@ -67,8 +73,8 @@ final class DefaultRevocationServiceTest { @Test void successAuditsCommittedStateAndRevisionOnce() { - RevocationJournal committed = new RevocationJournal(CREDENTIAL_ID, List.of( - new RevocationTransition(1L, RevocationState.HELD, NOW, Optional.empty(), new SimpleAttributeSet()))); + RevocationRecord committed = new RevocationRecord(CREDENTIAL_ID, + 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)) { @@ -93,7 +99,7 @@ final class DefaultRevocationServiceTest { if ("transitionRevocation".equals(method)) { throw new IllegalStateException(sentinel); } - if ("getRevocationJournal".equals(method)) { + if ("getRevocation".equals(method)) { lookupAttempted.set(true); throw new IllegalStateException(sentinel); } @@ -112,6 +118,82 @@ final class DefaultRevocationServiceTest { assertTrue(lookupAttempted.get()); } + @Test + void appendUncertaintyPreservesRecoveryRequiredCodeAndAuditsOnce() { + List events = new ArrayList<>(); + DefaultRevocationService service = new DefaultRevocationService(store((method, arguments) -> { + if ("transitionRevocation".equals(method)) { + throw new PkiException("Revocation append outcome is unknown: code=REVOCATION_RECOVERY_REQUIRED"); + } + if ("getRevocation".equals(method)) { + return Optional.empty(); + } + 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_RECOVERY_REQUIRED", failure.getMessage()); + assertNull(failure.getCause()); + assertEquals(1, events.size()); + assertEquals("REJECTED", events.get(0).details().get("result")); + assertEquals("REVOCATION_RECOVERY_REQUIRED", events.get(0).details().get("code")); + } + + @Test + void delayedIssuerFilterFailureIsRedactedAtCursorBoundary() throws Exception { + String sentinel = "DO_NOT_EXPOSE_DELAYED_STORE_SENTINEL"; + RevocationRecord record = new RevocationRecord(CREDENTIAL_ID, + new RevocationTransition(1L, RevocationState.HELD, NOW, + Optional.empty(), new SimpleAttributeSet())); + RevocationSnapshot source = singleRecordSnapshot(record); + DefaultRevocationService service = new DefaultRevocationService(store((method, arguments) -> { + if ("openRevocationSnapshot".equals(method)) { + return source; + } + if ("getCredential".equals(method)) { + throw new IllegalStateException(sentinel); + } + throw new UnsupportedOperationException(method); + }), Clock.fixed(NOW, ZoneOffset.UTC), event -> { }); + RevocationQuery query = new RevocationQuery(Optional.of(new PkiId("ca:issuer")), + Optional.empty(), Optional.empty(), Optional.empty()); + + try (RevocationSnapshot snapshot = service.search(query); + RevocationSnapshot.Cursor cursor = snapshot.openCursor()) { + PkiException failure = assertThrows(PkiException.class, cursor::next); + assertEquals("Revocation operation failed: code=REVOCATION_STATE_UPDATE_FAILED", failure.getMessage()); + assertNull(failure.getCause()); + assertFalse(failure.toString().contains(sentinel)); + } + } + + private static RevocationSnapshot singleRecordSnapshot(RevocationRecord record) { + return new RevocationSnapshot() { + @Override public String snapshotId() { return "snapshot:test"; } + @Override public long revision() { return 1L; } + @Override public long boundary() { return 1L; } + @Override public String commitment() { return "00"; } + @Override public OptionalLong count() { return OptionalLong.of(1L); } + @Override public Cursor openCursor() { + return new Cursor() { + private boolean advanced; + @Override public boolean next(CancellationSignal cancellation) { + if (advanced) { + return false; + } + advanced = true; + return true; + } + @Override public RevocationRecord current() { return record; } + @Override public long ordinal() { return 0L; } + @Override public void close() { } + }; + } + @Override public void close() throws IOException { } + }; + } + @Test void hostileClockFailureIsRedactedAuditedOnceAndNeverReachesStoreTransition() { String sentinel = "DO_NOT_EXPOSE_REVOCATION_CLOCK_SENTINEL"; 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 4ff2503..e0e42b6 100644 --- a/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java @@ -48,6 +48,7 @@ import java.security.KeyPair; import java.security.KeyPairGenerator; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; @@ -73,6 +74,9 @@ import org.junit.jupiter.api.io.TempDir; import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.content.DurableContentReference; import zeroecho.pki.api.Encoding; +import zeroecho.pki.impl.fs.FilesystemPkiStore; +import zeroecho.pki.impl.fs.FsHistoryPolicy; +import zeroecho.pki.impl.fs.FsPkiStoreOptions; import zeroecho.pki.api.FormatId; import zeroecho.pki.api.IssuanceService; import zeroecho.pki.api.KeyRef; @@ -94,6 +98,7 @@ 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; import zeroecho.pki.api.revocation.RevocationTransition; @@ -153,31 +158,59 @@ final class DefaultStatusObjectServiceCrlTest { KeyPair permanentKey = generateRsa(); KeyPair clearKey = generateRsa(); KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:crl-service"); + Path snapshot = root.resolve("crl-service-restored"); + FsHistoryPolicy history = FsHistoryPolicy.onWrite(Optional.of(Duration.ofDays(90))); + FsPkiStoreOptions snapshotOptions = new FsPkiStoreOptions( + history, history, history, history, false, Duration.ofDays(90), Duration.ZERO); + PkiId caId; + BigInteger heldSerial; + BigInteger permanentSerial; try (PkiTestRuntime runtime = PkiTestRuntime.create(root, root.resolve("bus.log"), - Map.of(rootKeyRef, rootKey))) { - PkiId caId = createRoot(runtime, rootKeyRef, "CRL Service Root"); + Map.of(rootKeyRef, rootKey), snapshotOptions)) { + 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() + RevocationRecord heldRecord = runtime.revocationService() .hold(new RevocationCommand.Hold(held.credentialId(), emptyAttributes())); - RevocationJournal permanentJournal = runtime.revocationService() + RevocationRecord permanentRecord = 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())); + try (java.util.stream.Stream files = java.nio.file.Files.walk( + root.resolve("store").resolve("profiles"))) { + for (Path active : files.filter(path -> "active.bin".equals( + path.getFileName().toString())).toList()) { + java.nio.file.Files.delete(active); + } + } + java.nio.file.Files.createDirectories(snapshot); + ((FilesystemPkiStore) runtime.store()).exportSnapshot(snapshot.resolve("store"), Instant.now()); + StatusObject status = runtime.statusObjectService().generate(new StatusObjectGenerateCommand(caId, StatusObjectType.CRL, runtime.framework().formatId(), emptyAttributes())); X509CRLHolder crl = new X509CRLHolder(PkiTestRuntime.readContent(runtime.signingBus(), status.content())); X509CertificateHolderView heldCertificate = certificate(runtime, held); X509CertificateHolderView permanentCertificate = certificate(runtime, permanent); X509CertificateHolderView clearCertificate = certificate(runtime, clear); - assertEntry(crl, heldCertificate.serial(), heldJournal.latest().time(), RevocationReason.CERTIFICATE_HOLD); - assertEntry(crl, permanentCertificate.serial(), permanentJournal.latest().time(), + assertEntry(crl, heldCertificate.serial(), heldRecord.transition().time(), RevocationReason.CERTIFICATE_HOLD); + assertEntry(crl, permanentCertificate.serial(), permanentRecord.transition().time(), RevocationReason.AA_COMPROMISE); assertNull(crl.getRevokedCertificate(clearCertificate.serial())); + heldSerial = heldCertificate.serial(); + permanentSerial = permanentCertificate.serial(); + } + try (PkiTestRuntime restored = PkiTestRuntime.create(snapshot, snapshot.resolve("restored-bus.log"), + Map.of(rootKeyRef, rootKey), snapshotOptions)) { + StatusObject restoredStatus = restored.statusObjectService().generate(new StatusObjectGenerateCommand( + caId, StatusObjectType.CRL, restored.framework().formatId(), emptyAttributes())); + X509CRLHolder restoredCrl = new X509CRLHolder( + PkiTestRuntime.readContent(restored.signingBus(), restoredStatus.content())); + assertTrue(restoredCrl.getRevokedCertificate(heldSerial) != null); + assertTrue(restoredCrl.getRevokedCertificate(permanentSerial) != null); } } @@ -310,13 +343,30 @@ final class DefaultStatusObjectServiceCrlTest { if (failListing) { throw new IllegalStateException(SENTINEL); } - List snapshot = List.copyOf(journals); + List snapshot = journals.stream() + .map(journal -> new RevocationRecord(journal.credentialId(), journal.latest())) + .toList(); return new zeroecho.pki.spi.store.RevocationSnapshot() { @Override public String snapshotId() { return "test-snapshot"; } + @Override + public long revision() { + return snapshot.size(); + } + + @Override + public long boundary() { + return snapshot.size(); + } + + @Override + public String commitment() { + return "test-commitment"; + } + @Override public java.util.OptionalLong count() { return java.util.OptionalLong.of(snapshot.size()); @@ -328,13 +378,13 @@ final class DefaultStatusObjectServiceCrlTest { private int index = -1; @Override - public boolean next() { + public boolean next(zeroecho.core.io.CancellationSignal cancellation) { index++; return index < snapshot.size(); } @Override - public RevocationJournal current() { + public RevocationRecord current() { return snapshot.get(index); } 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 db48289..f64ba02 100644 --- a/pki/src/test/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolverTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolverTest.java @@ -70,6 +70,7 @@ 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; import zeroecho.pki.api.revocation.RevocationTransition; @@ -212,9 +213,10 @@ final class StoreBackedEffectiveCredentialStatusResolverTest { 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())) { + if ("getRevocation".equals(method.getName())) { calls.incrementAndGet(); - return lookup.apply((PkiId) arguments[0]); + return lookup.apply((PkiId) arguments[0]) + .map(journal -> new RevocationRecord(journal.credentialId(), journal.latest())); } throw new UnsupportedOperationException(method.getName()); }); diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreOwnershipTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreOwnershipTest.java index 56149b9..afa5ce0 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreOwnershipTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreOwnershipTest.java @@ -122,8 +122,7 @@ final class FilesystemPkiStoreOwnershipTest { Files.writeString(root.resolve(FsPaths.VERSION_FILE), "unsupported", StandardCharsets.US_ASCII); assertThrows(IllegalStateException.class, () -> new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())); - Files.writeString(root.resolve(FsPaths.VERSION_FILE), FilesystemPkiStore.CURRENT_STORE_VERSION, - StandardCharsets.US_ASCII); + Files.delete(root.resolve(FsPaths.VERSION_FILE)); try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) { assertTrue(reopened.listCas().isEmpty()); 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 6550780..7a2a244 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java @@ -45,9 +45,11 @@ import java.io.DataOutputStream; import java.io.IOException; import java.io.InputStream; import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import java.security.MessageDigest; import java.time.Duration; import java.time.Instant; @@ -56,6 +58,11 @@ import java.util.List; import java.util.Optional; import java.util.OptionalLong; import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import java.util.stream.Collectors; @@ -68,6 +75,7 @@ import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.content.DurableContentReference; import zeroecho.pki.api.content.DurableContentOwner; import zeroecho.pki.api.Encoding; +import zeroecho.pki.api.revocation.RevocationState; import zeroecho.pki.api.FormatId; import zeroecho.pki.api.IssuerRef; import zeroecho.pki.api.KeyRef; @@ -109,6 +117,7 @@ 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; import zeroecho.pki.api.status.StatusObjectType; @@ -173,7 +182,7 @@ public final class FilesystemPkiStoreTest { store.putCa(ca); store.putCredential(credential); store.putRequest(request); - RevocationJournal revocation = store.transitionRevocation(new RevocationCommand.RevokePermanently( + RevocationRecord revocation = store.transitionRevocation(new RevocationCommand.RevokePermanently( credential.credentialId(), RevocationReason.KEY_COMPROMISE, attributes), now); store.putStatusObject(status); store.putPublicationRecord(publication); @@ -187,7 +196,7 @@ public final class FilesystemPkiStoreTest { store.getCredential(credential.credentialId()).orElseThrow().credentialId()); assertEquals(request.requestId(), store.getRequest(request.requestId()).orElseThrow().requestId()); assertEquals(revocation.credentialId(), - store.getRevocationJournal(revocation.credentialId()).orElseThrow().credentialId()); + store.getRevocation(revocation.credentialId()).orElseThrow().credentialId()); assertEquals(status.statusObjectId(), store.getStatusObject(status.statusObjectId()).orElseThrow().statusObjectId()); assertEquals(publication.publicationId(), store.listPublicationRecords().get(0).publicationId()); @@ -774,10 +783,13 @@ public final class FilesystemPkiStoreTest { System.out.println("revocationJournalPersistsLegalTransitions"); Path root = tmp.resolve("store-revocation-history"); + Path restoredRoot = tmp.resolve("store-revocation-history-snapshot"); FsPkiStoreOptions options = FsPkiStoreOptions.defaults(); + PkiId credentialId; try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { Credential credential = TestObjects.minimalCredential(store, "SERIAL-REV", "profile-rev"); + credentialId = credential.credentialId(); store.putCredential(credential); store.transitionRevocation( new RevocationCommand.Hold(credential.credentialId(), TestObjects.emptyAttributes()), @@ -786,9 +798,33 @@ public final class FilesystemPkiStoreTest { new RevocationCommand.Unhold(credential.credentialId(), TestObjects.emptyAttributes()), Instant.EPOCH.plusSeconds(11L)); - Optional loaded = store.getRevocationJournal(credential.credentialId()); + Optional loaded = store.getRevocation(credential.credentialId()); assertTrue(loaded.isPresent()); - assertEquals(2L, loaded.get().latest().revision()); + assertEquals(2L, loaded.get().transition().revision()); + try (zeroecho.pki.spi.store.RevocationSnapshot snapshot = store.openRevocationSnapshot()) { + assertEquals(2L, snapshot.revision()); + } + try (zeroecho.pki.spi.store.RevocationHistory history = + store.openRevocationHistory(credential.credentialId())) { + assertTrue(history.next()); + assertEquals(1L, history.current().revision()); + assertTrue(history.next()); + assertEquals(2L, history.current().revision()); + assertFalse(history.next()); + } + store.exportSnapshot(restoredRoot, Instant.now()); + } + try (FilesystemPkiStore restored = new FilesystemPkiStore(restoredRoot, options)) { + assertEquals(RevocationState.CLEAR, + restored.getRevocation(credentialId).orElseThrow().transition().state()); + try (zeroecho.pki.spi.store.RevocationHistory history = + restored.openRevocationHistory(credentialId)) { + assertTrue(history.next()); + assertTrue(history.next()); + assertFalse(history.next()); + } + assertTrue(Files.isRegularFile(new FsPaths(restoredRoot).revocationCurrentIndex())); + assertFalse(Files.exists(restoredRoot.resolve("revocations").resolve("by-credential"))); } System.out.println("...store tree:"); @@ -828,6 +864,164 @@ public final class FilesystemPkiStoreTest { System.out.println("snapshotExportClonesNewRootNonStrict...ok"); } + @Test + void snapshotRejectsTruncatedRevocationAuthorityAndObsoleteSchema() throws Exception { + System.out.println("snapshotRejectsTruncatedRevocationAuthorityAndObsoleteSchema"); + Path sourceRoot = tmp.resolve("snapshot-revocation-corruption-source"); + Path snapshotRoot = tmp.resolve("snapshot-revocation-corruption-target"); + Path oversizedRoot = tmp.resolve("snapshot-revocation-oversized-marker"); + Path symlinkRoot = tmp.resolve("snapshot-revocation-symlink-marker"); + FsPkiStoreOptions options = nonStrictSnapshotOptions(); + try (FilesystemPkiStore source = new FilesystemPkiStore(sourceRoot, options)) { + Credential credential = TestObjects.minimalCredential(source, "SERIAL-SNAPSHOT-CORRUPT", + "profile-snapshot-corrupt"); + source.putCredential(credential); + source.transitionRevocation(new RevocationCommand.Hold( + credential.credentialId(), TestObjects.emptyAttributes()), Instant.now()); + source.exportSnapshot(snapshotRoot, Instant.now()); + source.exportSnapshot(oversizedRoot, Instant.now()); + source.exportSnapshot(symlinkRoot, Instant.now()); + } + Path log = new FsPaths(snapshotRoot).revocationTransitionLog(); + try (FileChannel channel = FileChannel.open(log, StandardOpenOption.WRITE)) { + channel.truncate(Files.size(log) - 1L); + } + assertThrows(IllegalStateException.class, + () -> new FilesystemPkiStore(snapshotRoot, options)); + + Files.writeString(new FsPaths(oversizedRoot).revocationSnapshotBoundary(), + "12345678901234567890", StandardCharsets.US_ASCII); + assertThrows(IllegalStateException.class, + () -> new FilesystemPkiStore(oversizedRoot, options)); + + Path symlinkMarker = new FsPaths(symlinkRoot).revocationSnapshotBoundary(); + Files.delete(symlinkMarker); + Files.createSymbolicLink(symlinkMarker, new FsPaths(symlinkRoot).revocationTransitionLog()); + assertThrows(IllegalStateException.class, + () -> new FilesystemPkiStore(symlinkRoot, options)); + + Path obsolete = tmp.resolve("snapshot-obsolete-schema"); + Files.createDirectories(obsolete); + Files.writeString(obsolete.resolve(FsPaths.VERSION_FILE), "v3", StandardCharsets.US_ASCII); + assertThrows(IllegalStateException.class, + () -> new FilesystemPkiStore(obsolete, options)); + System.out.println("snapshotRejectsTruncatedRevocationAuthorityAndObsoleteSchema...ok"); + } + + @Test + void stableRevocationViewIsImmutableAndExactCheckpointIsReused() throws Exception { + System.out.println("stableRevocationViewIsImmutableAndExactCheckpointIsReused"); + try (FilesystemPkiStore store = new FilesystemPkiStore( + tmp.resolve("stable-revocation-view"), FsPkiStoreOptions.defaults())) { + Credential credential = TestObjects.minimalCredential(store, "SERIAL-STABLE-VIEW", + "profile-stable-view"); + store.putCredential(credential); + store.transitionRevocation(new RevocationCommand.Hold( + credential.credentialId(), TestObjects.emptyAttributes()), Instant.EPOCH.plusSeconds(1L)); + try (zeroecho.pki.spi.store.RevocationSnapshot captured = store.openRevocationSnapshot()) { + assertEquals(1L, captured.revision()); + store.transitionRevocation(new RevocationCommand.Unhold( + credential.credentialId(), TestObjects.emptyAttributes()), Instant.EPOCH.plusSeconds(2L)); + try (zeroecho.pki.spi.store.RevocationSnapshot.Cursor cursor = captured.openCursor()) { + assertTrue(cursor.next()); + assertEquals(RevocationState.HELD, cursor.current().transition().state()); + assertFalse(cursor.next()); + } + } + String generation; + try (zeroecho.pki.spi.store.RevocationSnapshot current = store.openRevocationSnapshot()) { + generation = current.snapshotId(); + assertEquals(2L, current.revision()); + } + try (zeroecho.pki.spi.store.RevocationSnapshot reused = store.openRevocationSnapshot()) { + assertEquals(generation, reused.snapshotId()); + assertEquals(2L, reused.revision()); + } + } + System.out.println("stableRevocationViewIsImmutableAndExactCheckpointIsReused...ok"); + } + + @Test + void concurrentStableViewAndStoreCloseFinishDeterministically() throws Exception { + System.out.println("concurrentStableViewAndStoreCloseFinishDeterministically"); + FilesystemPkiStore store = new FilesystemPkiStore( + tmp.resolve("concurrent-revocation-close"), FsPkiStoreOptions.defaults()); + zeroecho.pki.spi.store.RevocationSnapshot view = null; + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Credential credential = TestObjects.minimalCredential(store, "SERIAL-CONCURRENT-CLOSE", + "profile-concurrent-close"); + store.putCredential(credential); + store.transitionRevocation(new RevocationCommand.Hold( + credential.credentialId(), TestObjects.emptyAttributes()), Instant.now()); + view = store.openRevocationSnapshot(); + zeroecho.pki.spi.store.RevocationSnapshot captured = view; + CountDownLatch start = new CountDownLatch(1); + Future viewClose = executor.submit(() -> { + start.await(); + captured.close(); + return null; + }); + Future storeClose = executor.submit(() -> { + start.await(); + store.close(); + return null; + }); + start.countDown(); + viewClose.get(5L, TimeUnit.SECONDS); + storeClose.get(5L, TimeUnit.SECONDS); + view = null; + } finally { + if (view != null) { + view.close(); + } + store.close(); + executor.shutdownNow(); + } + System.out.println("concurrentStableViewAndStoreCloseFinishDeterministically...ok"); + } + + @Test + void committedTransitionSurvivesDerivedIndexUpdateFailureAndConcurrentRecovery() throws Exception { + System.out.println("committedTransitionSurvivesDerivedIndexUpdateFailureAndConcurrentRecovery"); + AtomicLong faultCalls = new AtomicLong(); + try (FilesystemPkiStore store = new FilesystemPkiStore( + tmp.resolve("revocation-derived-failure"), FsPkiStoreOptions.defaults(), + java.time.Clock.systemUTC(), () -> { + if (faultCalls.getAndIncrement() == 0L) { + throw new IOException("Injected derived revocation-index update failure"); + } + })) { + Credential credential = TestObjects.minimalCredential(store, "SERIAL-DERIVED-FAILURE", + "profile-derived-failure"); + store.putCredential(credential); + RevocationRecord committed = store.transitionRevocation(new RevocationCommand.Hold( + credential.credentialId(), TestObjects.emptyAttributes()), Instant.now()); + assertEquals(1L, committed.transition().revision()); + + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + CountDownLatch start = new CountDownLatch(1); + Future> first = executor.submit(() -> { + start.await(); + return store.getRevocation(credential.credentialId()); + }); + Future> second = executor.submit(() -> { + start.await(); + return store.getRevocation(credential.credentialId()); + }); + start.countDown(); + assertEquals(RevocationState.HELD, + first.get(5L, TimeUnit.SECONDS).orElseThrow().transition().state()); + assertEquals(RevocationState.HELD, + second.get(5L, TimeUnit.SECONDS).orElseThrow().transition().state()); + } finally { + executor.shutdownNow(); + } + } + System.out.println("committedTransitionSurvivesDerivedIndexUpdateFailureAndConcurrentRecovery...ok"); + } + @Test void snapshotExportWithMultipleObjectsNonStrictDoesNotFail() throws Exception { System.out.println("snapshotExportWithMultipleObjectsNonStrictDoesNotFail"); diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationJournalTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationJournalTest.java index 2a86460..fc36026 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationJournalTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationJournalTest.java @@ -47,6 +47,7 @@ import java.nio.file.Path; import java.time.Clock; import java.time.Instant; import java.util.Arrays; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; @@ -123,7 +124,7 @@ final class FilesystemRevocationJournalTest { () -> transition(store, revoke(credential, RevocationReason.CA_COMPROMISE))); } try (FilesystemPkiStore reopened = store(root)) { - RevocationJournal journal = reopened.getRevocationJournal(credential.credentialId()).orElseThrow(); + 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()); @@ -172,32 +173,31 @@ final class FilesystemRevocationJournalTest { 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()); + assertTrue(readJournal(store, credential.credentialId()).isEmpty()); transition(store, hold(credential)); assertCode("REVOCATION_TRANSITION_ILLEGAL", () -> transition(store, hold(credential))); - assertEquals(1, store.getRevocationJournal(credential.credentialId()).orElseThrow().transitions().size()); + assertEquals(1, readJournal(store, credential.credentialId()).orElseThrow().transitions().size()); transition(store, unhold(credential)); - Path journalPath = new FsPaths(temporaryDirectory.resolve("illegal")) - .revocationJournal(credential.credentialId()); - byte[] clearBytes = FsOperations.readAll(journalPath); + Path logPath = new FsPaths(temporaryDirectory.resolve("illegal")).revocationTransitionLog(); + byte[] clearBytes = FsOperations.readAll(logPath); assertCode("REVOCATION_TRANSITION_ILLEGAL", () -> transition(store, unhold(credential))); - assertArrayEquals(clearBytes, FsOperations.readAll(journalPath)); + assertArrayEquals(clearBytes, FsOperations.readAll(logPath)); transition(store, revoke(credential, RevocationReason.KEY_COMPROMISE)); - byte[] permanentBytes = FsOperations.readAll(journalPath); + byte[] permanentBytes = FsOperations.readAll(logPath); 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)); + assertArrayEquals(permanentBytes, FsOperations.readAll(logPath)); Credential invalidRemove = credential(store, "invalid-remove"); store.putCredential(invalidRemove); assertThrows(IllegalArgumentException.class, () -> revoke(invalidRemove, RevocationReason.REMOVE_FROM_CRL)); - assertTrue(store.getRevocationJournal(invalidRemove.credentialId()).isEmpty()); + assertTrue(readJournal(store, invalidRemove.credentialId()).isEmpty()); } System.out.println("illegalAndInvalidCommandsFailBeforeJournalMutation...ok"); } @@ -217,7 +217,7 @@ final class FilesystemRevocationJournalTest { } finally { executor.shutdownNow(); } - RevocationJournal journal = store.getRevocationJournal(credential.credentialId()).orElseThrow(); + RevocationJournal journal = readJournal(store, credential.credentialId()).orElseThrow(); assertEquals(1, journal.transitions().size()); assertEquals(1L, journal.latest().revision()); } @@ -233,7 +233,7 @@ 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 = store.getRevocationJournal(fromNone.credentialId()).orElseThrow(); + RevocationJournal fromNoneJournal = readJournal(store, fromNone.credentialId()).orElseThrow(); assertEquals(RevocationState.PERMANENTLY_REVOKED, fromNoneJournal.latest().state()); assertTrue(fromNoneJournal.transitions().size() == 1 || fromNoneJournal.transitions().size() == 2); @@ -241,7 +241,7 @@ final class FilesystemRevocationJournalTest { store.putCredential(fromHeld); transition(store, hold(fromHeld)); runConcurrent(store, unhold(fromHeld), revoke(fromHeld, RevocationReason.CA_COMPROMISE), executor); - RevocationJournal fromHeldJournal = store.getRevocationJournal(fromHeld.credentialId()).orElseThrow(); + RevocationJournal fromHeldJournal = readJournal(store, fromHeld.credentialId()).orElseThrow(); assertEquals(RevocationState.PERMANENTLY_REVOKED, fromHeldJournal.latest().state()); assertTrue(fromHeldJournal.transitions().size() == 2 || fromHeldJournal.transitions().size() == 3); @@ -250,7 +250,7 @@ final class FilesystemRevocationJournalTest { 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(); + RevocationJournal reasonsJournal = readJournal(store, reasons.credentialId()).orElseThrow(); assertEquals(1, reasonsJournal.transitions().size()); assertTrue(reasonsJournal.latest().permanentReason().filter( reason -> reason == RevocationReason.KEY_COMPROMISE || reason == RevocationReason.CA_COMPROMISE) @@ -318,7 +318,8 @@ final class FilesystemRevocationJournalTest { new SimpleAttributeSet()))); FsOperations.writeAtomic(new FsPaths(root).revocationJournal(credential.credentialId()), FsCodec.encode(FsCodec.REVOCATION_JOURNAL, invalid)); - assertCode("REVOCATION_STATE_CORRUPT", () -> store.getRevocationJournal(credential.credentialId())); + assertEquals(RevocationState.HELD, + store.getRevocation(credential.credentialId()).orElseThrow().transition().state()); } System.out.println("copiedNamespaceJournalAndRegressingTransitionTimeFailClosed...ok"); } @@ -384,14 +385,14 @@ final class FilesystemRevocationJournalTest { oldRevokedRecordPayload(credential.credentialId())); try (FilesystemPkiStore reopened = store(root)) { - assertTrue(reopened.getRevocationJournal(credential.credentialId()).isEmpty()); + assertTrue(readJournal(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, - reopened.getRevocationJournal(credential.credentialId()).orElseThrow().latest().state()); + readJournal(reopened, credential.credentialId()).orElseThrow().latest().state()); } System.out.println("obsoleteCurrentAndHistoryLayoutCannotInfluenceTrustedJournalOrResolver...ok"); } @@ -472,8 +473,8 @@ final class FilesystemRevocationJournalTest { 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))); + assertTrue(reopened.getRevocation(credential.credentialId()).isEmpty()); + transition(reopened, hold(credential)); assertArrayEquals(before, FsOperations.readAll(journalPath)); } System.out.println("strictCodecCorruptionFailsReadAndTransitionWithoutOverwrite...ok"); @@ -491,7 +492,23 @@ final class FilesystemRevocationJournalTest { } private static RevocationJournal transition(FilesystemPkiStore store, RevocationCommand command) { - return store.transitionRevocation(command, TIME); + store.transitionRevocation(command, TIME); + return readJournal(store, command.credentialId()).orElseThrow(); + } + + private static Optional readJournal(FilesystemPkiStore store, PkiId credentialId) { + if (store.getRevocation(credentialId).isEmpty()) { + return Optional.empty(); + } + List transitions = new ArrayList<>(); + try (zeroecho.pki.spi.store.RevocationHistory history = store.openRevocationHistory(credentialId)) { + while (history.next()) { + transitions.add(history.current()); + } + } catch (IOException failure) { + throw new IllegalStateException(failure); + } + return Optional.of(new RevocationJournal(credentialId, transitions)); } private static RevocationCommand.Hold hold(Credential credential) { diff --git a/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java b/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java index fbff6d1..f765f9b 100644 --- a/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java +++ b/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java @@ -278,7 +278,17 @@ public final class PkiTestRuntime implements AutoCloseable { for (Map.Entry entry : keyPairs.entrySet()) { publicKeys.put(entry.getKey(), entry.getValue().getPublic()); } - return create(rootDir, busFile, keyPairs, publicKeys, Optional.empty()); + return create(rootDir, busFile, keyPairs, publicKeys, Optional.empty(), FsPkiStoreOptions.defaults()); + } + + /** Creates a test runtime with explicit filesystem-store options. */ + public static PkiTestRuntime create(Path rootDir, Path busFile, Map keyPairs, + FsPkiStoreOptions options) { + Map publicKeys = new HashMap<>(); + for (Map.Entry entry : keyPairs.entrySet()) { + publicKeys.put(entry.getKey(), entry.getValue().getPublic()); + } + return create(rootDir, busFile, keyPairs, publicKeys, Optional.empty(), options); } /** @@ -294,19 +304,19 @@ public final class PkiTestRuntime implements AutoCloseable { */ public static PkiTestRuntime create(Path rootDir, Path busFile, Map signingKeys, Map resolvedKeys, ProofOfPossessionVerifier proofVerifier) { - return create(rootDir, busFile, signingKeys, resolvedKeys, Optional.of(proofVerifier)); + return create(rootDir, busFile, signingKeys, resolvedKeys, Optional.of(proofVerifier), + FsPkiStoreOptions.defaults()); } private static PkiTestRuntime create(Path rootDir, Path busFile, Map keyPairs, - Map resolvedKeys, Optional proofVerifier) { + Map resolvedKeys, Optional proofVerifier, + FsPkiStoreOptions opts) { Objects.requireNonNull(rootDir, "rootDir"); Objects.requireNonNull(busFile, "busFile"); Objects.requireNonNull(keyPairs, "keyPairs"); Objects.requireNonNull(resolvedKeys, "resolvedKeys"); Objects.requireNonNull(proofVerifier, "proofVerifier"); - FsPkiStoreOptions opts = FsPkiStoreOptions.defaults(); - Path storeRoot = rootDir.resolve("store"); FilesystemPkiStore store = new FilesystemPkiStore(storeRoot, opts);