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.
This commit is contained in:
2026-08-03 00:33:07 +02:00
parent 6d775b3817
commit f27a656c97
21 changed files with 1794 additions and 333 deletions

View File

@@ -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<RevocationJournal> get(PkiId credentialId);
Optional<RevocationRecord> 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<RevocationJournal> search(RevocationQuery query);
RevocationSnapshot search(RevocationQuery query);
}

View File

@@ -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");
}
}

View File

@@ -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<String> SAFE_STORE_CODES = List.of("REVOCATION_CREDENTIAL_NOT_FOUND",
"REVOCATION_TRANSITION_ILLEGAL", "REVOCATION_TERMINAL", "REVOCATION_TRANSITION_CONFLICT",
"REVOCATION_STATE_CORRUPT", "REVOCATION_PERSIST_FAILED", "REVOCATION_DURABILITY_UNCONFIRMED",
"STORE_DURABILITY_UNCONFIRMED");
"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<RevocationJournal> get(PkiId credentialId) {
public Optional<RevocationRecord> 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<RevocationJournal> 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<RevocationJournal> 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<String> 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();
}
}
}

View File

@@ -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<CrlEntry> collectCrlEntry(PkiId issuerCaId, Instant evaluationTime, RevocationJournal journal) {
Objects.requireNonNull(journal, "journal");
RevocationTransition latest = Objects.requireNonNull(journal.latest(), "latest transition");
private Optional<CrlEntry> 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;

View File

@@ -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<RevocationJournal> current;
Optional<RevocationRecord> 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();
}

View File

@@ -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.
*
* <li><strong>Audit history for mutable entities:</strong> CA records and
* profiles append history before updating {@code current.bin}. Revocation state
* is instead one atomically replaced, internally ordered journal per
* credential.</li>
* 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.</li>
*
* <li><strong>Deterministic behavior:</strong> 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.
* </p>
*/
@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<String> snapshotNonCredentialContentIds() {
requireStoreUsable();
Set<String> 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<RevocationJournal> current;
Optional<RevocationRecord> 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<RevocationJournal> getRevocationJournal(final PkiId credentialId) {
public Optional<RevocationRecord> 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<Path> journals = Files.walk(root)) {
java.util.Iterator<Path> 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<RevocationJournal> readRevocationJournal(PkiId credentialId) {
Path path = paths.revocationJournal(credentialId);
if (!Files.exists(path)) {
return Optional.empty();
}
RevocationJournal journal = decodeRevocationJournal(path);
validateRevocationJournal(credentialId, journal);
return Optional.of(journal);
}
/** 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<Path> files = Files.list(root)) {
java.util.Iterator<Path> 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<RevocationJournal> current, PkiId credentialId,
RevocationCommand command, Instant transitionTime) {
if (current.isPresent() && transitionTime.isBefore(current.get().latest().time())) {
private static RevocationTransition nextRevocationTransition(
Optional<RevocationRecord> 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<RevocationTransition> transitions = new ArrayList<>(
current.map(RevocationJournal::transitions).orElseGet(List::of));
Optional<RevocationReason> permanentReason = command instanceof RevocationCommand.RevokePermanently revoke
? Optional.of(revoke.reason())
: Optional.empty();
transitions
.add(new RevocationTransition(revision, next, transitionTime, permanentReason, command.attributes()));
RevocationJournal journal = new RevocationJournal(credentialId, transitions);
validateRevocationJournal(credentialId, journal);
return journal;
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();
}

View File

@@ -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<OwnedResource> 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<RevocationRecord> 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<FilesystemRevocationCheckpoint> 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<FilesystemRevocationCheckpoint> exact(
Optional<FilesystemRevocationCheckpoint> 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<OwnedResource> 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<ViewCursor> 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");
}
}
}
}

View File

@@ -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 {

View File

@@ -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<Path> 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<Path> entries = Files.newDirectoryStream(directory)) {
for (Path entry : entries) {
DurableMetadataFiles.delete(entry);
}
}
}
Files.deleteIfExists(directory);
} finally {

View File

@@ -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");
}

View File

@@ -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) {

View File

@@ -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<RevocationJournal> getRevocationJournal(PkiId credentialId);
Optional<RevocationRecord> 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

View File

@@ -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.
*
* <p>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.</p>
*/
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;
}

View File

@@ -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.
*
* <p>
* 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.
* </p>
*
* <p>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.</p>
*/
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.

View File

@@ -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<AuditEvent> 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<AuditEvent> 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";

View File

@@ -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<Path> 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<RevocationJournal> snapshot = List.copyOf(journals);
List<RevocationRecord> 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);
}

View File

@@ -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<PkiId, Optional<RevocationJournal>> lookup, AtomicInteger calls) {
return (PkiStore) Proxy.newProxyInstance(PkiStore.class.getClassLoader(), new Class<?>[] { PkiStore.class },
(proxy, method, arguments) -> {
if ("getRevocationJournal".equals(method.getName())) {
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());
});

View File

@@ -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());

View File

@@ -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<RevocationJournal> loaded = store.getRevocationJournal(credential.credentialId());
Optional<RevocationRecord> 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<Optional<RevocationRecord>> first = executor.submit(() -> {
start.await();
return store.getRevocation(credential.credentialId());
});
Future<Optional<RevocationRecord>> 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");

View File

@@ -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<Boolean> results = runConcurrent(store, revoke(reasons, RevocationReason.KEY_COMPROMISE),
revoke(reasons, RevocationReason.CA_COMPROMISE), executor);
assertEquals(1L, results.stream().filter(Boolean::booleanValue).count());
RevocationJournal reasonsJournal = store.getRevocationJournal(reasons.credentialId()).orElseThrow();
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<RevocationJournal> readJournal(FilesystemPkiStore store, PkiId credentialId) {
if (store.getRevocation(credentialId).isEmpty()) {
return Optional.empty();
}
List<RevocationTransition> 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) {

View File

@@ -278,7 +278,17 @@ public final class PkiTestRuntime implements AutoCloseable {
for (Map.Entry<KeyRef, KeyPair> 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<KeyRef, KeyPair> keyPairs,
FsPkiStoreOptions options) {
Map<KeyRef, PublicKey> publicKeys = new HashMap<>();
for (Map.Entry<KeyRef, KeyPair> 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<KeyRef, KeyPair> signingKeys,
Map<KeyRef, PublicKey> 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<KeyRef, KeyPair> keyPairs,
Map<KeyRef, PublicKey> resolvedKeys, Optional<ProofOfPossessionVerifier> proofVerifier) {
Map<KeyRef, PublicKey> resolvedKeys, Optional<ProofOfPossessionVerifier> 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);