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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user