From 6545b7b5b63a77c6406c13465a91a18588b14d3c Mon Sep 17 00:00:00 2001 From: Leo Galambos Date: Sun, 2 Aug 2026 10:52:20 +0200 Subject: [PATCH] feat(pki): add immutable revocation checkpoints Add strict log-bound immutable current-state checkpoint generations with atomic POSIX publication and constant-memory ordered cursors. Keep the global revocation transition log as the sole authority while providing the derived snapshot primitive required for scalable CRL generation and later index integration. --- .../fs/FilesystemRevocationCheckpoint.java | 932 ++++++++++++++++ .../java/zeroecho/pki/impl/fs/FsPaths.java | 4 + .../impl/fs/RevocationCheckpointCodec.java | 999 ++++++++++++++++++ .../FilesystemRevocationCheckpointTest.java | 569 ++++++++++ .../fs/RevocationCheckpointCodecTest.java | 274 +++++ 5 files changed, 2778 insertions(+) create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCheckpoint.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/RevocationCheckpointCodec.java create mode 100644 pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationCheckpointTest.java create mode 100644 pki/src/test/java/zeroecho/pki/impl/fs/RevocationCheckpointCodecTest.java diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCheckpoint.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCheckpoint.java new file mode 100644 index 0000000..bd67adc --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCheckpoint.java @@ -0,0 +1,932 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.impl.fs; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.DirectoryStream; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.locks.ReentrantLock; +import java.util.logging.Logger; + +import zeroecho.core.io.CancellationSignal; +import zeroecho.pki.api.revocation.RevocationState; +import zeroecho.pki.spi.store.MetadataStoreId; + +/** Immutable POSIX checkpoint view derived from the authoritative revocation log. */ +final class FilesystemRevocationCheckpoint implements AutoCloseable { + + private static final Logger LOGGER = + Logger.getLogger(FilesystemRevocationCheckpoint.class.getName()); + private static final String DIRECTORY_WARNING = + "Revocation checkpoint directory durability is limited; continuing in best-effort mode"; + private static final String RETIREMENT_WARNING = + "An obsolete revocation checkpoint generation could not be retired"; + private static final String FINAL_PREFIX = "g-"; + private static final String FINAL_SUFFIX = ".chk"; + private static final String BUILDING_PREFIX = ".building-"; + private static final int GENERATION_HEX_LENGTH = 64; + private static final long ZERO_REVISION = 0L; + private static final HexFormat HEX = HexFormat.of(); + + private final FileChannel channel; + private final RevocationCheckpointCodec codec; + private final RevocationCheckpointCodec.ValidatedFile validated; + private final ReentrantLock lifecycleLock = new ReentrantLock(); + private final Set cursors = new HashSet<>(); + private boolean closed; + + private FilesystemRevocationCheckpoint( + FileChannel channel, + RevocationCheckpointCodec codec, + RevocationCheckpointCodec.ValidatedFile validated) { + this.channel = channel; + this.codec = codec; + this.validated = validated; + } + + /* default */ static PublishedGeneration publish( + Path directory, + Path logPath, + FilesystemRevocationLog.RecoveryResult recovery, + Coverage coverage, + SortedSource source) throws IOException { + return publish(directory, logPath, recovery, coverage, source, + DefaultPublicationOperations.INSTANCE, FaultInjector.NONE); + } + + /* default */ static PublishedGeneration publish( + Path directory, + Path logPath, + FilesystemRevocationLog.RecoveryResult recovery, + Coverage coverage, + SortedSource source, + PublicationOperations operations, + FaultInjector faults) throws IOException { + Objects.requireNonNull(directory, "directory"); + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(operations, "operations"); + Objects.requireNonNull(faults, "faults"); + Binding.requireCurrentPublication(recovery, coverage); + Binding.validate(logPath, recovery, coverage); + Files.createDirectories(directory); + requireDirectory(directory); + long declaredCount = source.entryCount(); + if (declaredCount < ZERO_REVISION) { + throw new IllegalArgumentException("Checkpoint source count must be non-negative"); + } + RevocationCheckpointCodec.HeaderData header = coverage.header(declaredCount); + Path temporary = directory.resolve(BUILDING_PREFIX + UUID.randomUUID()); + BuildResult build = buildTemporary(temporary, header, source, faults); + Path target = directory.resolve(finalName(coverage.coveredRevision(), build.generationId())); + boolean published = false; + try { + Binding.validateGeneration(temporary, logPath, recovery); + published = publishAtomically(temporary, target, operations, faults); + forceDirectory(directory, operations, faults); + return new PublishedGeneration(target, build.generationId(), coverage.coveredRevision(), published); + } catch (IOException failure) { + if (!published && Files.exists(temporary, LinkOption.NOFOLLOW_LINKS)) { + try { + Files.delete(temporary); + } catch (IOException cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + } + throw failure; + } + } + + /* default */ static Optional discover( + Path directory, + Path logPath, + FilesystemRevocationLog.RecoveryResult recovery) throws IOException { + return Discovery.discover(directory, logPath, recovery); + } + + /* default */ static FilesystemRevocationCheckpoint open( + Path checkpointPath, + Path logPath, + FilesystemRevocationLog.RecoveryResult recovery) throws IOException { + Objects.requireNonNull(checkpointPath, "checkpointPath"); + FinalName name = FinalName.parse(checkpointPath.getFileName().toString()) + .orElseThrow(() -> new IOException("Invalid revocation checkpoint generation name")); + if (!Files.isRegularFile(checkpointPath, LinkOption.NOFOLLOW_LINKS) + || Files.isSymbolicLink(checkpointPath)) { + throw new IOException("Revocation checkpoint generation is not a regular file"); + } + FileChannel channel = FileChannel.open(checkpointPath, StandardOpenOption.READ); + try { + RevocationCheckpointCodec codec = new RevocationCheckpointCodec(); + RevocationCheckpointCodec.ValidatedFile validated = codec.validate(channel); + name.requireAgreement(validated); + Binding.validate(logPath, recovery, Coverage.from(validated.header())); + return new FilesystemRevocationCheckpoint(channel, codec, validated); + } catch (IOException failure) { + try { + channel.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + throw failure; + } + } + + /* default */ static long retireObsolete(Path directory, PublishedGeneration retained) { + return Retirement.retire(directory, retained); + } + + /* default */ String generationId() { + return validated.generationId(); + } + + /* default */ MetadataStoreId storeId() { + return validated.header().storeId(); + } + + /* default */ long coveredRevision() { + return validated.header().coveredRevision(); + } + + /* default */ long coveredBoundary() { + return validated.header().coveredBoundary(); + } + + /* default */ RevocationTransitionFrameCodec.Commitment coveredCommitment() { + return validated.header().globalCommitment(); + } + + /* default */ long entryCount() { + return validated.header().entryCount(); + } + + /* default */ Cursor allCurrentStates() { + return newCursor(false); + } + + /* default */ Cursor currentRevokedStates() { + return newCursor(true); + } + + private Cursor newCursor(boolean revokedOnly) { + lifecycleLock.lock(); + try { + requireOpen(); + return new CheckpointCursor(revokedOnly); + } finally { + lifecycleLock.unlock(); + } + } + + @Override + public void close() throws IOException { + lifecycleLock.lock(); + try { + if (closed) { + return; + } + closed = true; + for (CursorState cursorState : cursors) { + cursorState.closeFromOwner(); + } + cursors.clear(); + channel.close(); + } finally { + lifecycleLock.unlock(); + } + } + + private void requireOpen() { + if (closed || !channel.isOpen()) { + throw new IllegalStateException("Revocation checkpoint is closed"); + } + } + + private static BuildResult buildTemporary( + Path temporary, + RevocationCheckpointCodec.HeaderData header, + SortedSource source, + FaultInjector faults) throws IOException { + boolean complete = false; + try { + BuildResult result; + try (SortedSource ownedSource = source; + FileChannel channel = FileChannel.open(temporary, + StandardOpenOption.CREATE_NEW, StandardOpenOption.READ, + StandardOpenOption.WRITE); + SourceCursor cursor = ownedSource.openCursor()) { + result = writeTemporary(channel, header, cursor, faults); + } + complete = true; + return result; + } finally { + if (!complete) { + try { + Files.deleteIfExists(temporary); + } catch (IOException ignored) { + // The primary build failure remains authoritative. + } + } + } + } + + private static BuildResult writeTemporary( + FileChannel channel, + RevocationCheckpointCodec.HeaderData header, + SourceCursor cursor, + FaultInjector faults) throws IOException { + RevocationCheckpointCodec codec = new RevocationCheckpointCodec(); + RevocationCheckpointCodec.Encoder encoder = codec.encoder(channel, header); + byte[] previous = null; + for (long index = 0L; index < header.entryCount(); index++) { + if (!cursor.advance()) { + throw new IOException("Checkpoint source ended before its declared count"); + } + RevocationCheckpointCodec.CurrentStateEntry entry = Objects.requireNonNull( + cursor.current(), "checkpoint source current entry"); + byte[] identity = RevocationCheckpointCodec.strictUtf8(entry.credentialId().value()); + if (previous != null + && RevocationCheckpointCodec.compareUnsigned(previous, identity) >= 0) { + throw new IOException("Checkpoint source identities are not strictly increasing"); + } + faults.fail(FaultPoint.WRITE); + encoder.write(entry); + previous = identity; + } + if (cursor.advance()) { + throw new IOException("Checkpoint source exceeds its declared count"); + } + String generation = encoder.finish(); + faults.fail(FaultPoint.FILE_FORCE); + channel.force(true); + return new BuildResult(generation); + } + + private static boolean publishAtomically( + Path temporary, + Path target, + PublicationOperations operations, + FaultInjector faults) throws IOException { + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + if (sameBytes(temporary, target)) { + Files.delete(temporary); + return false; + } + throw new IOException("Conflicting revocation checkpoint generation exists"); + } + try { + faults.fail(FaultPoint.ATOMIC_MOVE); + operations.atomicMove(temporary, target); + return true; + } catch (FileAlreadyExistsException exists) { + if (sameBytes(temporary, target)) { + Files.delete(temporary); + return false; + } + throw new IOException("Conflicting revocation checkpoint generation exists", exists); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new IOException("Atomic revocation checkpoint publication is unsupported", unsupported); + } + } + + private static boolean sameBytes(Path first, Path second) throws IOException { + if (Files.size(first) != Files.size(second)) { + return false; + } + byte[] firstBytes = new byte[16 * 1024]; + byte[] secondBytes = new byte[firstBytes.length]; + try (FileChannel firstChannel = FileChannel.open(first, StandardOpenOption.READ); + FileChannel secondChannel = FileChannel.open(second, StandardOpenOption.READ)) { + long offset = 0L; + long size = firstChannel.size(); + while (offset < size) { + int requested = (int) Math.min(firstBytes.length, size - offset); + readExact(firstChannel, firstBytes, requested, offset); + readExact(secondChannel, secondBytes, requested, offset); + if (!equalPrefix(firstBytes, secondBytes, requested)) { + return false; + } + offset += requested; + } + } + return true; + } + + private static boolean equalPrefix(byte[] first, byte[] second, int length) { + for (int index = 0; index < length; index++) { + if (first[index] != second[index]) { + return false; + } + } + return true; + } + + private static void readExact(FileChannel channel, byte[] bytes, int length, long offset) + throws IOException { + ByteBuffer buffer = ByteBuffer.wrap(bytes, 0, length); + long position = offset; + while (buffer.hasRemaining()) { + int read = channel.read(buffer, position); + if (read <= 0) { + throw new IOException("Checkpoint comparison made no read progress"); + } + position += read; + } + } + + private static void forceDirectory( + Path directory, PublicationOperations operations, FaultInjector faults) { + try { + faults.fail(FaultPoint.DIRECTORY_FORCE); + operations.forceDirectory(directory); + } catch (IOException | UnsupportedOperationException unavailable) { + warn(DIRECTORY_WARNING); + } + } + + private static void warn(String message) { + try { + LOGGER.warning(message); + } catch (IllegalStateException ignored) { + // Advisory logging cannot change derived-state publication or retirement. + } + } + + private static void requireDirectory(Path directory) throws IOException { + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS) + || Files.isSymbolicLink(directory)) { + throw new IOException("Revocation checkpoint directory is invalid"); + } + } + + private static String finalName(long revision, String generationId) { + return FINAL_PREFIX + revision + "-" + generationId + FINAL_SUFFIX; + } + + /** Current-state source that transfers deterministic close ownership to publication. */ + /* default */ interface SortedSource extends AutoCloseable { + /** Returns the exact non-negative entry count. */ + long entryCount(); + + /** Opens the owned one-entry-at-a-time cursor. */ + SourceCursor openCursor() throws IOException; + + @Override + void close() throws IOException; + } + + /** One-entry-at-a-time source cursor; current is invalidated by advancement. */ + /* default */ interface SourceCursor extends AutoCloseable { + /** Advances to the next source entry. */ + boolean advance() throws IOException; + + /** Returns the current source entry. */ + RevocationCheckpointCodec.CurrentStateEntry current(); + + @Override + void close() throws IOException; + } + + /** Read-only ordered checkpoint cursor. */ + /* default */ interface Cursor extends AutoCloseable { + /** Advances to the next matching checkpoint entry. */ + boolean advance(CancellationSignal cancellation) throws IOException; + + /** Returns the current checkpoint entry. */ + RevocationCheckpointCodec.CurrentStateEntry current(); + + @Override + void close(); + } + + /** Exact authoritative coverage declared by a derived generation. */ + /* default */ record Coverage( + MetadataStoreId storeId, + long coveredRevision, + OptionalLong finalRecordStart, + long coveredBoundary, + RevocationTransitionFrameCodec.Commitment globalCommitment) { + Coverage { + Objects.requireNonNull(storeId, "storeId"); + Objects.requireNonNull(finalRecordStart, "finalRecordStart"); + Objects.requireNonNull(globalCommitment, "globalCommitment"); + } + + private RevocationCheckpointCodec.HeaderData header(long count) { + return new RevocationCheckpointCodec.HeaderData( + storeId, coveredRevision, finalRecordStart, + coveredBoundary, globalCommitment, count); + } + + private static Coverage from(RevocationCheckpointCodec.HeaderData header) { + return new Coverage( + header.storeId(), header.coveredRevision(), header.finalRecordStart(), + header.coveredBoundary(), header.globalCommitment()); + } + } + + /** Immutable publication result; {@code newlyPublished} distinguishes idempotent reuse. */ + /* default */ record PublishedGeneration( + Path path, String generationId, long coveredRevision, boolean newlyPublished) { + PublishedGeneration { + Objects.requireNonNull(path, "path"); + Objects.requireNonNull(generationId, "generationId"); + } + } + + /** Deterministic publication fault seam; not production API. */ + /* default */ @FunctionalInterface + interface FaultInjector { + FaultInjector NONE = point -> { }; + + /** Fails one deterministic lifecycle point. */ + void fail(FaultPoint point) throws IOException; + } + + /** Finite lifecycle fault points used by deterministic tests. */ + /* default */ enum FaultPoint { + WRITE, + FILE_FORCE, + ATOMIC_MOVE, + DIRECTORY_FORCE + } + + /** POSIX publication operations isolated for deterministic fault validation. */ + /* default */ interface PublicationOperations { + /** Publishes one completed file using an atomic move. */ + void atomicMove(Path source, Path target) throws IOException; + + /** Forces the containing directory when supported. */ + void forceDirectory(Path directory) throws IOException; + } + + /** Default POSIX publication operations. */ + private enum DefaultPublicationOperations implements PublicationOperations { + /** Stateless singleton. */ + INSTANCE; + + @Override + public void atomicMove(Path source, Path target) throws IOException { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); + } + + @Override + public void forceDirectory(Path directory) throws IOException { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } + } + } + + /** Log-binding validation never replays the covered authoritative prefix. */ + private static final class Binding { + private static void validateGeneration( + Path checkpointPath, + Path logPath, + FilesystemRevocationLog.RecoveryResult recovery) throws IOException { + try (FileChannel checkpoint = FileChannel.open( + checkpointPath, StandardOpenOption.READ)) { + RevocationCheckpointCodec codec = new RevocationCheckpointCodec(); + RevocationCheckpointCodec.ValidatedFile validated = codec.validate(checkpoint); + validate(logPath, recovery, Coverage.from(validated.header())); + validateCurrentEntries(logPath, recovery, checkpoint, codec, validated); + } + } + + private static void requireCurrentPublication( + FilesystemRevocationLog.RecoveryResult recovery, + Coverage coverage) throws IOException { + Objects.requireNonNull(recovery, "recovery"); + Objects.requireNonNull(coverage, "coverage"); + if (coverage.coveredRevision() != recovery.globalRevision() + || coverage.coveredBoundary() != recovery.lastCompleteRecordBoundary() + || !coverage.globalCommitment().equals(recovery.globalCommitment())) { + throw new IOException( + "Checkpoint publication must cover the complete validated log state"); + } + } + + private static void validateCurrentEntries( + Path logPath, + FilesystemRevocationLog.RecoveryResult recovery, + FileChannel checkpoint, + RevocationCheckpointCodec checkpointCodec, + RevocationCheckpointCodec.ValidatedFile validated) throws IOException { + if (validated.header().entryCount() != recovery.latestStates().size()) { + throw new IOException("Checkpoint omits current revocation state"); + } + try (FileChannel log = FileChannel.open(logPath, StandardOpenOption.READ)) { + long offset = RevocationCheckpointCodec.HEADER_BYTES; + RevocationCheckpointCodec.SequentialDecoder decoder = + checkpointCodec.sequentialDecoder(); + for (long index = 0L; index < validated.header().entryCount(); index++) { + RevocationCheckpointCodec.DecodedRecord decoded = decoder.read( + checkpoint, offset, validated.trailerOffset()); + validateCurrentEntry(log, recovery, decoded.entry()); + offset = decoded.nextOffset(); + } + } + } + + private static void validateCurrentEntry( + FileChannel log, + FilesystemRevocationLog.RecoveryResult recovery, + RevocationCheckpointCodec.CurrentStateEntry entry) throws IOException { + FilesystemRevocationLog.LatestState latest = recovery.latestStates().get(entry.credentialId()); + if (latest == null + || latest.globalRevision() != entry.globalRevision() + || latest.recordOffset() != entry.frameStart() + || !latest.commitment().equals(entry.transitionCommitment()) + || !RevocationTransitionFrameCodec.transitionsEqual( + latest.transition(), entry.transition())) { + throw new IOException("Checkpoint entry is not the current authoritative state"); + } + RevocationTransitionFrameCodec.ReadResult result = + new RevocationTransitionFrameCodec().read(log, entry.frameStart()); + if (result.classification() + != RevocationTransitionFrameCodec.Classification.COMPLETE_RECORD + || result.record().orElseThrow().recordEnd() != entry.frameEnd()) { + throw new IOException("Checkpoint entry has an invalid authoritative frame boundary"); + } + } + + private static void validate( + Path logPath, + FilesystemRevocationLog.RecoveryResult recovery, + Coverage coverage) throws IOException { + Objects.requireNonNull(logPath, "logPath"); + Objects.requireNonNull(recovery, "recovery"); + Objects.requireNonNull(coverage, "coverage"); + if (!coverage.storeId().equals(recovery.storeId()) + || coverage.coveredRevision() > recovery.globalRevision() + || coverage.coveredBoundary() > recovery.lastCompleteRecordBoundary()) { + throw new IOException("Checkpoint does not match the validated revocation log"); + } + try (FileChannel log = FileChannel.open(logPath, StandardOpenOption.READ)) { + RevocationTransitionFrameCodec frameCodec = new RevocationTransitionFrameCodec(); + if (!coverage.storeId().equals(frameCodec.readPreamble(log))) { + throw new IOException("Checkpoint belongs to another revocation log"); + } + if (coverage.coveredRevision() == ZERO_REVISION) { + validateGenesis(coverage); + } else { + validateFrame(log, frameCodec, coverage); + } + } + } + + private static void validateGenesis(Coverage coverage) throws IOException { + if (coverage.finalRecordStart().isPresent() + || coverage.coveredBoundary() != RevocationTransitionFrameCodec.PREAMBLE_BYTES + || !coverage.globalCommitment().equals( + RevocationTransitionFrameCodec.initialCommitment(coverage.storeId()))) { + throw new IOException("Checkpoint genesis binding is invalid"); + } + } + + private static void validateFrame( + FileChannel log, + RevocationTransitionFrameCodec codec, + Coverage coverage) throws IOException { + if (coverage.finalRecordStart().isEmpty()) { + throw new IOException("Checkpoint final-frame binding is absent"); + } + RevocationTransitionFrameCodec.ReadResult result = + codec.read(log, coverage.finalRecordStart().getAsLong()); + if (result.classification() + != RevocationTransitionFrameCodec.Classification.COMPLETE_RECORD) { + throw new IOException("Checkpoint final-frame binding is invalid"); + } + RevocationTransitionFrameCodec.CompleteRecord frame = result.record().orElseThrow(); + if (frame.data().globalRevision() != coverage.coveredRevision() + || !frame.commitment().equals(coverage.globalCommitment()) + || frame.recordEnd() != coverage.coveredBoundary()) { + throw new IOException("Checkpoint final-frame binding does not match the log"); + } + } + + } + + /** Streaming discovery keeps one candidate descriptor and repeats only for ambiguity fallback. */ + private static final class Discovery { + private static Optional discover( + Path directory, + Path logPath, + FilesystemRevocationLog.RecoveryResult recovery) throws IOException { + Objects.requireNonNull(directory, "directory"); + Objects.requireNonNull(logPath, "logPath"); + Objects.requireNonNull(recovery, "recovery"); + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) { + return Optional.empty(); + } + long upperExclusive = Long.MAX_VALUE; + boolean includeMaximum = true; + while (true) { + Selection selection = select( + directory, logPath, recovery, upperExclusive, includeMaximum); + if (selection.candidate().isEmpty()) { + return Optional.empty(); + } + if (!selection.ambiguous()) { + return Optional.of(openSelected(selection.candidate().orElseThrow())); + } + upperExclusive = selection.revision(); + includeMaximum = false; + } + } + + private static Selection select( + Path directory, + Path logPath, + FilesystemRevocationLog.RecoveryResult recovery, + long upper, + boolean includeUpper) throws IOException { + long selectedRevision = -1L; + Path selectedPath = null; + RevocationCheckpointCodec.ValidatedFile selectedValidated = null; + String selectedGeneration = null; + boolean ambiguous = false; + try (DirectoryStream entries = Files.newDirectoryStream(directory)) { + for (Path candidate : entries) { + Optional parsed = FinalName.parse(candidate.getFileName().toString()); + if (parsed.isEmpty() || !eligible(parsed.orElseThrow().revision(), upper, includeUpper)) { + continue; + } + Optional validated = + validateCandidate(candidate, logPath, recovery, parsed.orElseThrow()); + if (validated.isEmpty()) { + continue; + } + long revision = validated.orElseThrow().header().coveredRevision(); + if (revision > selectedRevision) { + selectedRevision = revision; + selectedPath = candidate; + selectedValidated = validated.orElseThrow(); + selectedGeneration = validated.orElseThrow().generationId(); + ambiguous = false; + } else if (revision == selectedRevision + && !validated.orElseThrow().generationId().equals(selectedGeneration)) { + ambiguous = true; + } + } + } + Candidate selected = selectedPath == null + ? null : new Candidate(selectedPath, selectedValidated); + return new Selection(Optional.ofNullable(selected), selectedRevision, ambiguous); + } + + private static FilesystemRevocationCheckpoint openSelected(Candidate selected) + throws IOException { + FileChannel channel = FileChannel.open(selected.path(), StandardOpenOption.READ); + return new FilesystemRevocationCheckpoint( + channel, new RevocationCheckpointCodec(), selected.validated()); + } + + private static boolean eligible(long revision, long upper, boolean includeUpper) { + return includeUpper ? revision <= upper : revision < upper; + } + + private static Optional validateCandidate( + Path candidate, + Path logPath, + FilesystemRevocationLog.RecoveryResult recovery, + FinalName name) { + try (FileChannel channel = FileChannel.open(candidate, StandardOpenOption.READ)) { + RevocationCheckpointCodec.ValidatedFile validated = + new RevocationCheckpointCodec().validate(channel); + name.requireAgreement(validated); + Binding.validate(logPath, recovery, Coverage.from(validated.header())); + return Optional.of(validated); + } catch (IOException invalid) { + return Optional.empty(); + } + } + } + + /** Sequential cursor with lifecycle ownership held by its checkpoint. */ + private final class CheckpointCursor implements Cursor, CursorState { + private final boolean revokedOnly; + private long offset = RevocationCheckpointCodec.HEADER_BYTES; + private long visited; + private final RevocationCheckpointCodec.SequentialDecoder decoder = + codec.sequentialDecoder(); + private RevocationCheckpointCodec.CurrentStateEntry current; + private boolean cursorClosed; + + private CheckpointCursor(boolean revokedOnly) { + this.revokedOnly = revokedOnly; + cursors.add(this); + } + + @Override + public boolean advance(CancellationSignal cancellation) throws IOException { + Objects.requireNonNull(cancellation, "cancellation"); + lifecycleLock.lock(); + try { + requireCursorOpen(); + invalidateCurrent(); + cancellation.throwIfCancelled(); + while (visited < validated.header().entryCount()) { + RevocationCheckpointCodec.DecodedRecord record = + decoder.read(channel, offset, validated.trailerOffset()); + offset = record.nextOffset(); + visited++; + if (!revokedOnly || record.entry().transition().state() != RevocationState.CLEAR) { + current = record.entry(); + return true; + } + cancellation.throwIfCancelled(); + } + return false; + } finally { + lifecycleLock.unlock(); + } + } + + private void invalidateCurrent() { + current = null; + } + + @Override + public RevocationCheckpointCodec.CurrentStateEntry current() { + lifecycleLock.lock(); + try { + requireCursorOpen(); + if (current == null) { + throw new IllegalStateException("Checkpoint cursor has no current entry"); + } + return current; + } finally { + lifecycleLock.unlock(); + } + } + + @Override + public void close() { + lifecycleLock.lock(); + try { + if (cursorClosed) { + return; + } + cursorClosed = true; + current = null; + cursors.remove(this); + } finally { + lifecycleLock.unlock(); + } + } + + @Override + public void closeFromOwner() { + cursorClosed = true; + current = null; + } + + private void requireCursorOpen() { + requireOpen(); + if (cursorClosed) { + throw new IllegalStateException("Revocation checkpoint cursor is closed"); + } + } + } + + /** Non-resource invalidation view retained by the checkpoint owner. */ + @FunctionalInterface + private interface CursorState { + /** Invalidates the cursor without touching the shared file channel. */ + void closeFromOwner(); + } + + /** Non-authoritative retirement of exact obsolete generation names. */ + private static final class Retirement { + private static long retire(Path directory, PublishedGeneration retained) { + Objects.requireNonNull(directory, "directory"); + Objects.requireNonNull(retained, "retained"); + long retired = 0L; + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) { + return retired; + } + try (DirectoryStream entries = Files.newDirectoryStream(directory)) { + for (Path candidate : entries) { + if (candidate.equals(retained.path()) + || FinalName.parse(candidate.getFileName().toString()).isEmpty()) { + continue; + } + retired = retireCandidate(candidate, retired); + } + } catch (IOException failure) { + warn(RETIREMENT_WARNING); + } + return retired; + } + + private static long retireCandidate(Path candidate, long retired) { + try { + return Files.deleteIfExists(candidate) ? Math.addExact(retired, 1L) : retired; + } catch (IOException | ArithmeticException failure) { + warn(RETIREMENT_WARNING); + return retired; + } + } + } + + private record BuildResult(String generationId) { + } + + private record Candidate(Path path, RevocationCheckpointCodec.ValidatedFile validated) { + private Candidate { + Objects.requireNonNull(path, "path"); + Objects.requireNonNull(validated, "validated"); + } + } + + private record Selection(Optional candidate, long revision, boolean ambiguous) { + private Selection { + Objects.requireNonNull(candidate, "candidate"); + } + } + + private record FinalName(long revision, String generationId) { + private void requireAgreement(RevocationCheckpointCodec.ValidatedFile validated) + throws IOException { + if (revision != validated.header().coveredRevision() + || !generationId.equals(validated.generationId())) { + throw new IOException("Revocation checkpoint filename and contents disagree"); + } + } + + private static Optional parse(String value) { + if (!value.startsWith(FINAL_PREFIX) || !value.endsWith(FINAL_SUFFIX)) { + return Optional.empty(); + } + int separator = value.indexOf('-', FINAL_PREFIX.length()); + if (separator < 0) { + return Optional.empty(); + } + String revisionText = value.substring(FINAL_PREFIX.length(), separator); + String generation = value.substring(separator + 1, value.length() - FINAL_SUFFIX.length()); + if (generation.length() != GENERATION_HEX_LENGTH || !isLowerHex(generation)) { + return Optional.empty(); + } + try { + long revision = Long.parseLong(revisionText); + return revision < 0L ? Optional.empty() : Optional.of(new FinalName(revision, generation)); + } catch (NumberFormatException invalid) { + return Optional.empty(); + } + } + + private static boolean isLowerHex(String value) { + try { + return HEX.formatHex(HEX.parseHex(value)).equals(value); + } catch (IllegalArgumentException invalid) { + return false; + } + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java index b182641..b9def74 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java @@ -177,6 +177,10 @@ final class FsPaths { return this.root.resolve("revocations").resolve("transitions.log"); } + /* default */ Path revocationCheckpointDirectory() { + return this.root.resolve("revocations").resolve("checkpoints"); + } + /* default */ Path revocationSnapshotRoot() { return this.root.resolve("revocation-snapshots"); } diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/RevocationCheckpointCodec.java b/pki/src/main/java/zeroecho/pki/impl/fs/RevocationCheckpointCodec.java new file mode 100644 index 0000000..f04fdad --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/RevocationCheckpointCodec.java @@ -0,0 +1,999 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.impl.fs; + +import java.io.IOException; +import java.io.OutputStream; +import java.security.DigestOutputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.CharBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.DateTimeException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalLong; + +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.attr.AttributeId; +import zeroecho.pki.api.attr.AttributeSet; +import zeroecho.pki.api.attr.AttributeValue; +import zeroecho.pki.api.revocation.RevocationReason; +import zeroecho.pki.api.revocation.RevocationState; +import zeroecho.pki.api.revocation.RevocationTransition; +import zeroecho.pki.impl.core.attr.SimpleAttributeSet; +import zeroecho.pki.spi.store.MetadataStoreId; + +/** Strict streaming codec for one immutable current-state checkpoint. */ +final class RevocationCheckpointCodec { + + /* default */ static final int HEADER_BYTES = 128; + /* default */ static final int TRAILER_BYTES = 48; + /* default */ static final int DIGEST_BYTES = 32; + /* default */ static final int MAX_COMPONENT_BYTES = + RevocationTransitionFrameCodec.MAX_COMPONENT_BYTES; + + private static final int HEADER_MAGIC = 0x5A455243; + private static final int RECORD_MAGIC = 0x5A455245; + private static final int TRAILER_MAGIC = 0x5A455246; + private static final short FORMAT_VERSION = 1; + private static final short RESERVED_FLAGS = 0; + private static final int STORE_ID_BYTES = 16; + private static final int COMMITMENT_BYTES = 32; + private static final int HEADER_FIELDS_BYTES = HEADER_BYTES - DIGEST_BYTES; + private static final int TRAILER_FIELDS_BYTES = TRAILER_BYTES - DIGEST_BYTES; + private static final int PRESENT = 1; + private static final int ABSENT = 0; + private static final long ZERO_REVISION = 0L; + private static final int TRANSFER_BYTES = 16 * 1024; + private static final byte[] RECORD_DOMAIN = + "ZeroEcho revocation checkpoint record v1".getBytes(StandardCharsets.US_ASCII); + private static final HexFormat HEX = HexFormat.of(); + + /* default */ Encoder encoder(FileChannel channel, HeaderData header) throws IOException { + return new Encoder(channel, header); + } + + /* default */ ValidatedFile validate(FileChannel channel) throws IOException { + return DecoderOperations.validate(channel); + } + + /* default */ DecodedRecord readRecord( + FileChannel channel, long recordOffset, long trailerOffset) throws IOException { + return new SequentialDecoder().read(channel, recordOffset, trailerOffset); + } + + /* default */ SequentialDecoder sequentialDecoder() { + return new SequentialDecoder(); + } + + /** Structural validation is isolated to keep the codec facade cohesive. */ + private static final class DecoderOperations { + private static ValidatedFile validate(FileChannel channel) throws IOException { + Objects.requireNonNull(channel, "channel"); + try { + long size = channel.size(); + if (size < HEADER_BYTES + TRAILER_BYTES) { + throw corrupt("Revocation checkpoint is truncated"); + } + HeaderData header = decodeHeader(readAt(channel, 0L, HEADER_BYTES)); + long offset = HEADER_BYTES; + byte[] previousIdentity = null; + SequentialDecoder decoder = new SequentialDecoder(); + for (long index = 0L; index < header.entryCount(); index++) { + DecodedRecord decoded = decoder.read(channel, offset, size - TRAILER_BYTES); + byte[] identity = strictUtf8(decoded.entry().credentialId().value()); + if (previousIdentity != null + && compareUnsigned(previousIdentity, identity) >= 0) { + throw corrupt("Revocation checkpoint identities are not canonical"); + } + previousIdentity = identity; + offset = decoded.nextOffset(); + } + Trailer trailer = decodeTrailer(readAt(channel, offset, TRAILER_BYTES)); + long expectedEnd = addExact( + offset, TRAILER_BYTES, "Revocation checkpoint boundary overflow"); + if (expectedEnd != size || trailer.entryCount() != header.entryCount()) { + throw corrupt("Revocation checkpoint framing is inconsistent"); + } + byte[] calculated = digestPrefix(channel, size - DIGEST_BYTES); + if (!MessageDigest.isEqual(calculated, trailer.commitment())) { + throw corrupt("Revocation checkpoint commitment is invalid"); + } + return new ValidatedFile(header, HEX.formatHex(calculated), offset); + } catch (IllegalArgumentException | DateTimeException malformed) { + throw corrupt("Revocation checkpoint contains malformed semantic data", malformed); + } + } + + private static HeaderData decodeHeader(byte[] bytes) throws IOException { + ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN); + if (buffer.getInt() != HEADER_MAGIC) { + throw corrupt("Revocation checkpoint header magic is invalid"); + } + byte[] suppliedDigest = java.util.Arrays.copyOfRange( + bytes, HEADER_FIELDS_BYTES, HEADER_BYTES); + MessageDigest digest = sha256(); + digest.update(bytes, 0, HEADER_FIELDS_BYTES); + if (!MessageDigest.isEqual(digest.digest(), suppliedDigest)) { + throw corrupt("Revocation checkpoint header authentication failed"); + } + short version = buffer.getShort(); + short flags = buffer.getShort(); + byte[] store = new byte[STORE_ID_BYTES]; + buffer.get(store); + long revision = buffer.getLong(); + int startPresent = Byte.toUnsignedInt(buffer.get()); + byte[] reserved = new byte[7]; + buffer.get(reserved); + long start = buffer.getLong(); + long boundary = buffer.getLong(); + byte[] commitment = new byte[COMMITMENT_BYTES]; + buffer.get(commitment); + long count = buffer.getLong(); + if (version != FORMAT_VERSION || flags != RESERVED_FLAGS || !allZero(reserved) + || count < 0L || revision < 0L) { + throw corrupt("Revocation checkpoint header fields are unsupported"); + } + OptionalLong finalStart = decodeStart(revision, startPresent, start); + MetadataStoreId storeId = new MetadataStoreId(HEX.formatHex(store)); + RevocationTransitionFrameCodec.Commitment chain = + new RevocationTransitionFrameCodec.Commitment(HEX.formatHex(commitment)); + HeaderData header = new HeaderData( + storeId, revision, finalStart, boundary, chain, count); + requireHeader(header); + return header; + } + + private static OptionalLong decodeStart(long revision, int present, long value) + throws IOException { + if (present == ABSENT && revision == ZERO_REVISION && value == ZERO_REVISION) { + return OptionalLong.empty(); + } + if (present == PRESENT && revision > ZERO_REVISION) { + return OptionalLong.of(value); + } + throw corrupt("Revocation checkpoint final-record presence is invalid"); + } + + private static DecodedRecord decodeRecord( + FileChannel channel, + long offset, + long trailerOffset, + ByteBuffer input) throws IOException { + if (offset < HEADER_BYTES || trailerOffset < offset) { + throw corrupt("Revocation checkpoint record boundary is invalid"); + } + byte[] prefix = readAt(channel, offset, Integer.BYTES + Long.BYTES); + ByteBuffer framing = ByteBuffer.wrap(prefix).order(ByteOrder.BIG_ENDIAN); + if (framing.getInt() != RECORD_MAGIC) { + throw corrupt("Revocation checkpoint record magic is invalid"); + } + long payloadLength = framing.getLong(); + if (payloadLength < ZERO_REVISION) { + throw corrupt("Revocation checkpoint record length is invalid"); + } + long payloadStart = addExact( + offset, prefix.length, "Revocation checkpoint record boundary overflow"); + long digestOffset = addExact( + payloadStart, payloadLength, "Revocation checkpoint record boundary overflow"); + long nextOffset = addExact( + digestOffset, DIGEST_BYTES, "Revocation checkpoint record boundary overflow"); + if (nextOffset > trailerOffset) { + throw corrupt("Revocation checkpoint record exceeds its bounded region"); + } + MessageDigest digest = sha256(); + digest.update(RECORD_DOMAIN); + digest.update(prefix); + RecordReader reader = new RecordReader( + channel, payloadStart, payloadLength, digest, input); + CurrentStateEntry entry; + try { + entry = reader.readEntry(); + } catch (IllegalArgumentException | DateTimeException malformed) { + throw corrupt("Revocation checkpoint record is malformed", malformed); + } + if (reader.remaining() != ZERO_REVISION) { + throw corrupt("Revocation checkpoint record contains trailing data"); + } + byte[] supplied = readAt(channel, digestOffset, DIGEST_BYTES); + if (!MessageDigest.isEqual(digest.digest(), supplied)) { + throw corrupt("Revocation checkpoint record commitment is invalid"); + } + return new DecodedRecord(entry, nextOffset); + } + + private static Trailer decodeTrailer(byte[] bytes) throws IOException { + ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN); + long count = buffer.getLong(); + int magic = buffer.getInt(); + short version = buffer.getShort(); + short flags = buffer.getShort(); + byte[] commitment = new byte[DIGEST_BYTES]; + buffer.get(commitment); + if (count < 0L || magic != TRAILER_MAGIC || version != FORMAT_VERSION + || flags != RESERVED_FLAGS) { + throw corrupt("Revocation checkpoint trailer is invalid"); + } + return new Trailer(count, commitment); + } + } + + /** Reuses one bounded transfer buffer across a sequential record pass. */ + /* default */ static final class SequentialDecoder { + private final ByteBuffer input = ByteBuffer.allocate(TRANSFER_BYTES); + + /* default */ DecodedRecord read( + FileChannel channel, long recordOffset, long trailerOffset) throws IOException { + return DecoderOperations.decodeRecord(channel, recordOffset, trailerOffset, input); + } + } + + private static byte[] encodeHeader(HeaderData header) throws IOException { + requireHeader(header); + ByteBuffer buffer = ByteBuffer.allocate(HEADER_BYTES).order(ByteOrder.BIG_ENDIAN); + buffer.putInt(HEADER_MAGIC); + buffer.putShort(FORMAT_VERSION); + buffer.putShort(RESERVED_FLAGS); + buffer.put(HEX.parseHex(header.storeId().value())); + buffer.putLong(header.coveredRevision()); + if (header.finalRecordStart().isPresent()) { + buffer.put((byte) PRESENT); + buffer.put(new byte[7]); + buffer.putLong(header.finalRecordStart().getAsLong()); + } else { + buffer.put((byte) ABSENT); + buffer.put(new byte[7]); + buffer.putLong(0L); + } + buffer.putLong(header.coveredBoundary()); + buffer.put(HEX.parseHex(header.globalCommitment().value())); + buffer.putLong(header.entryCount()); + MessageDigest digest = sha256(); + digest.update(buffer.array(), 0, HEADER_FIELDS_BYTES); + buffer.put(digest.digest()); + return buffer.array(); + } + + private static void requireHeader(HeaderData header) { + Objects.requireNonNull(header, "header"); + if (header.coveredRevision() < 0L || header.entryCount() < 0L + || header.coveredBoundary() < RevocationTransitionFrameCodec.PREAMBLE_BYTES) { + throw new IllegalArgumentException("Invalid revocation checkpoint header values"); + } + if (header.coveredRevision() == 0L != header.finalRecordStart().isEmpty()) { + throw new IllegalArgumentException("Checkpoint revision and final record disagree"); + } + if (header.coveredRevision() == ZERO_REVISION + && (header.entryCount() != ZERO_REVISION + || header.coveredBoundary() != RevocationTransitionFrameCodec.PREAMBLE_BYTES + || !header.globalCommitment().equals( + RevocationTransitionFrameCodec.initialCommitment(header.storeId())))) { + throw new IllegalArgumentException("Checkpoint genesis framing is invalid"); + } + if (header.finalRecordStart().isPresent() + && (header.finalRecordStart().getAsLong() < RevocationTransitionFrameCodec.PREAMBLE_BYTES + || header.finalRecordStart().getAsLong() >= header.coveredBoundary())) { + throw new IllegalArgumentException("Invalid covered revocation frame start"); + } + } + + private static byte[] digestPrefix(FileChannel channel, long length) throws IOException { + MessageDigest digest = sha256(); + byte[] bytes = new byte[TRANSFER_BYTES]; + long offset = 0L; + while (offset < length) { + int request = (int) Math.min(bytes.length, length - offset); + ByteBuffer target = ByteBuffer.wrap(bytes, 0, request); + readFullyAt(channel, target, offset); + digest.update(bytes, 0, request); + offset += request; + } + return digest.digest(); + } + + private static byte[] readAt(FileChannel channel, long offset, int length) throws IOException { + if (offset < 0L || length < 0) { + throw corrupt("Revocation checkpoint read boundary is invalid"); + } + byte[] bytes = new byte[length]; + readFullyAt(channel, ByteBuffer.wrap(bytes), offset); + return bytes; + } + + private static void readFullyAt(FileChannel channel, ByteBuffer target, long offset) throws IOException { + long position = offset; + while (target.hasRemaining()) { + int read = channel.read(target, position); + if (read < 0) { + throw corrupt("Revocation checkpoint is truncated"); + } + if (read == 0) { + throw new IOException("Revocation checkpoint channel made no read progress"); + } + position += read; + } + } + + private static void writeFully(FileChannel channel, ByteBuffer source) throws IOException { + while (source.hasRemaining()) { + if (channel.write(source) == 0) { + throw new IOException("Revocation checkpoint channel made no write progress"); + } + } + } + + private static long addExact(long first, long second, String message) throws IOException { + try { + return Math.addExact(first, second); + } catch (ArithmeticException overflow) { + throw new IOException(message, overflow); + } + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException unavailable) { + throw new IllegalStateException("SHA-256 is unavailable", unavailable); + } + } + + private static boolean allZero(byte[] bytes) { + for (byte value : bytes) { + if (value != 0) { + return false; + } + } + return true; + } + + /* default */ static byte[] strictUtf8(String value) { + Objects.requireNonNull(value, "value"); + try { + ByteBuffer encoded = StandardCharsets.UTF_8.newEncoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .encode(CharBuffer.wrap(value)); + if (encoded.remaining() > MAX_COMPONENT_BYTES) { + throw new IllegalArgumentException("Checkpoint component exceeds its technical limit"); + } + byte[] bytes = new byte[encoded.remaining()]; + encoded.get(bytes); + return bytes; + } catch (CharacterCodingException malformed) { + throw new IllegalArgumentException("Checkpoint text is not valid Unicode", malformed); + } + } + + private static String decodeUtf8(byte[] bytes) { + try { + return StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)).toString(); + } catch (CharacterCodingException malformed) { + throw new IllegalArgumentException("Checkpoint contains malformed UTF-8", malformed); + } + } + + /* default */ static int compareUnsigned(byte[] first, byte[] second) { + int common = Math.min(first.length, second.length); + for (int index = 0; index < common; index++) { + int difference = Byte.toUnsignedInt(first[index]) - Byte.toUnsignedInt(second[index]); + if (difference != 0) { + return difference; + } + } + return first.length - second.length; + } + + private static IOException corrupt(String message) { + return new IOException(message); + } + + private static IOException corrupt(String message, Throwable cause) { + return new IOException(message, cause); + } + + /** Streaming encoder retaining only one current-state entry. */ + /* default */ static final class Encoder { + private final FileChannel channel; + private final HeaderData header; + private final DigestOutputStream wholeDigest = digestStream(); + private long count; + private boolean finished; + + private Encoder(FileChannel channel, HeaderData header) throws IOException { + this.channel = Objects.requireNonNull(channel, "channel"); + this.header = Objects.requireNonNull(header, "header"); + channel.position(0L); + channel.truncate(0L); + writeCommitted(encodeHeader(header)); + } + + /* default */ void write(CurrentStateEntry entry) throws IOException { + if (finished) { + throw new IllegalStateException("Checkpoint encoder is finished"); + } + EntryPlan plan = EntryPlan.create(entry); + ByteBuffer framing = ByteBuffer.allocate(Integer.BYTES + Long.BYTES) + .order(ByteOrder.BIG_ENDIAN); + framing.putInt(RECORD_MAGIC).putLong(plan.length()).flip(); + byte[] prefix = new byte[framing.remaining()]; + framing.get(prefix); + try (DigestOutputStream recordDigest = digestStream()) { + recordDigest.write(RECORD_DOMAIN); + recordDigest.write(prefix); + writeCommitted(prefix); + FieldWriter writer = new FieldWriter(channel, wholeDigest, recordDigest); + plan.write(writer, entry); + writeCommitted(recordDigest.getMessageDigest().digest()); + } + try { + count = Math.addExact(count, 1L); + } catch (ArithmeticException overflow) { + throw new IOException("Checkpoint entry count is exhausted", overflow); + } + } + + /* default */ String finish() throws IOException { + if (finished) { + throw new IllegalStateException("Checkpoint encoder is finished"); + } + if (count != header.entryCount()) { + throw new IOException("Checkpoint source count does not match its declaration"); + } + ByteBuffer fields = ByteBuffer.allocate(TRAILER_FIELDS_BYTES).order(ByteOrder.BIG_ENDIAN); + fields.putLong(count).putInt(TRAILER_MAGIC) + .putShort(FORMAT_VERSION).putShort(RESERVED_FLAGS); + writeCommitted(fields.array()); + byte[] generation = wholeDigest.getMessageDigest().digest(); + writeFully(channel, ByteBuffer.wrap(generation)); + finished = true; + return HEX.formatHex(generation); + } + + private void writeCommitted(byte[] bytes) throws IOException { + wholeDigest.write(bytes); + writeFully(channel, ByteBuffer.wrap(bytes)); + } + } + + /** Authenticated checkpoint identity and covered authoritative log position. */ + /* default */ record HeaderData( + MetadataStoreId storeId, + long coveredRevision, + OptionalLong finalRecordStart, + long coveredBoundary, + RevocationTransitionFrameCodec.Commitment globalCommitment, + long entryCount) { + HeaderData { + Objects.requireNonNull(storeId, "storeId"); + Objects.requireNonNull(finalRecordStart, "finalRecordStart"); + Objects.requireNonNull(globalCommitment, "globalCommitment"); + } + } + + /** One finite latest state and its authoritative frame locator. */ + /* default */ record CurrentStateEntry( + PkiId credentialId, + long globalRevision, + long credentialRevision, + RevocationTransitionFrameCodec.Commitment transitionCommitment, + RevocationTransition transition, + long frameStart, + long frameEnd) { + CurrentStateEntry { + Objects.requireNonNull(credentialId, "credentialId"); + Objects.requireNonNull(transitionCommitment, "transitionCommitment"); + Objects.requireNonNull(transition, "transition"); + if (globalRevision <= 0L || credentialRevision <= 0L + || transition.revision() != credentialRevision + || frameStart < RevocationTransitionFrameCodec.PREAMBLE_BYTES + || frameEnd <= frameStart) { + throw new IllegalArgumentException("Invalid revocation checkpoint entry"); + } + } + } + + /** Fully validated checkpoint framing used by immutable readers. */ + /* default */ record ValidatedFile(HeaderData header, String generationId, long trailerOffset) { + ValidatedFile { + Objects.requireNonNull(header, "header"); + Objects.requireNonNull(generationId, "generationId"); + } + } + + /** One decoded record plus the next sequential offset. */ + /* default */ record DecodedRecord(CurrentStateEntry entry, long nextOffset) { + DecodedRecord { + Objects.requireNonNull(entry, "entry"); + } + } + + private record Trailer(long entryCount, byte[] commitment) { + private Trailer { + commitment = commitment.clone(); + } + } + + /** Canonical per-entry plan, bounded by one current state. */ + private record EntryPlan(long length, List attributes) { + private static EntryPlan create(CurrentStateEntry entry) throws IOException { + Objects.requireNonNull(entry, "entry"); + List attributes = capture(entry.transition().attributes()); + attributes.sort((first, second) -> compareUnsigned(first.id(), second.id())); + long length = Short.BYTES * 2L; + length = addComponent(length, strictUtf8(entry.credentialId().value()).length); + length = add(length, Long.BYTES * 2L + COMMITMENT_BYTES); + length = add(length, 1L + 1L + Short.BYTES + Long.BYTES + Integer.BYTES); + length = add(length, Integer.BYTES); + for (PlannedAttribute attribute : attributes) { + length = addComponent(length, attribute.id().length); + length = add(length, Integer.BYTES); + for (AttributeValue value : attribute.values()) { + length = add(length, valueLength(value)); + } + } + length = add(length, Long.BYTES * 2L); + return new EntryPlan(length, List.copyOf(attributes)); + } + + private static List capture(AttributeSet set) { + List result = new ArrayList<>(); + for (AttributeId id : set.ids()) { + result.add(new PlannedAttribute(strictUtf8(id.value()), List.copyOf(set.getAll(id)))); + } + return result; + } + + private void write(FieldWriter writer, CurrentStateEntry entry) throws IOException { + writer.writeShort(FORMAT_VERSION); + writer.writeShort(RESERVED_FLAGS); + writer.writeComponent(strictUtf8(entry.credentialId().value())); + writer.writeLong(entry.globalRevision()); + writer.writeLong(entry.credentialRevision()); + writer.writeBytes(HEX.parseHex(entry.transitionCommitment().value())); + writer.writeByte(encodeState(entry.transition().state())); + writer.writeByte(encodeReason(entry.transition().permanentReason())); + writer.writeShort(RESERVED_FLAGS); + writer.writeLong(entry.transition().time().getEpochSecond()); + writer.writeInt(entry.transition().time().getNano()); + writer.writeInt(attributes.size()); + for (PlannedAttribute attribute : attributes) { + writer.writeComponent(attribute.id()); + writer.writeInt(attribute.values().size()); + for (AttributeValue value : attribute.values()) { + writeValue(writer, value); + } + } + writer.writeLong(entry.frameStart()); + writer.writeLong(entry.frameEnd()); + } + + private static long valueLength(AttributeValue value) throws IOException { + Objects.requireNonNull(value, "attribute value"); + return switch (value) { + case AttributeValue.StringValue string -> + addComponent(1L, strictUtf8(string.value()).length); + case AttributeValue.BooleanValue ignored -> 2L; + case AttributeValue.IntegerValue ignored -> 1L + Long.BYTES; + case AttributeValue.InstantValue ignored -> 1L + Long.BYTES + Integer.BYTES; + case AttributeValue.BytesValue bytes -> addComponent(1L, bytes.value().length); + }; + } + + private static void writeValue(FieldWriter writer, AttributeValue value) throws IOException { + switch (value) { + case AttributeValue.StringValue string -> { + writer.writeByte(1); + writer.writeComponent(strictUtf8(string.value())); + } + case AttributeValue.BooleanValue bool -> { + writer.writeByte(2); + writer.writeByte(bool.value() ? 1 : 0); + } + case AttributeValue.IntegerValue integer -> { + writer.writeByte(3); + writer.writeLong(integer.value()); + } + case AttributeValue.InstantValue instant -> { + writer.writeByte(4); + writer.writeLong(instant.value().getEpochSecond()); + writer.writeInt(instant.value().getNano()); + } + case AttributeValue.BytesValue bytes -> { + writer.writeByte(5); + writer.writeComponent(bytes.value()); + } + } + } + + private static long addComponent(long current, int length) throws IOException { + requireComponent(length); + return add(current, Integer.BYTES + (long) length); + } + + private static long add(long first, long second) throws IOException { + return addExact(first, second, "Revocation checkpoint record is too large"); + } + } + + private record PlannedAttribute(byte[] id, List values) { + private PlannedAttribute { + id = id.clone(); + values = List.copyOf(values); + } + } + + /** Writes primitive fields to the channel and both incremental commitments. */ + private static final class FieldWriter { + private final FileChannel channel; + private final DigestOutputStream whole; + private final DigestOutputStream record; + private final ByteBuffer primitive = ByteBuffer.allocate(Long.BYTES).order(ByteOrder.BIG_ENDIAN); + + private FieldWriter( + FileChannel channel, DigestOutputStream whole, DigestOutputStream record) { + this.channel = channel; + this.whole = whole; + this.record = record; + } + + private void writeByte(int value) throws IOException { + primitive(1, buffer -> buffer.put((byte) value)); + } + + private void writeShort(int value) throws IOException { + primitive(Short.BYTES, buffer -> buffer.putShort((short) value)); + } + + private void writeInt(int value) throws IOException { + primitive(Integer.BYTES, buffer -> buffer.putInt(value)); + } + + private void writeLong(long value) throws IOException { + primitive(Long.BYTES, buffer -> buffer.putLong(value)); + } + + private void writeComponent(byte[] bytes) throws IOException { + requireComponent(bytes.length); + writeInt(bytes.length); + writeBytes(bytes); + } + + private void primitive(int length, PrimitiveEncoder encoder) throws IOException { + primitive.clear(); + encoder.encode(primitive); + primitive.flip(); + primitive.limit(length); + byte[] bytes = new byte[length]; + primitive.get(bytes); + writeBytes(bytes); + } + + private void writeBytes(byte[] bytes) throws IOException { + whole.write(bytes); + record.write(bytes); + writeFully(channel, ByteBuffer.wrap(bytes)); + } + } + + /** Bounded positional reader for one authenticated record payload. */ + private static final class RecordReader { + private final FileChannel channel; + private final DigestOutputStream digest; + private final ByteBuffer input; + private long sourcePosition; + private long remaining; + + private RecordReader( + FileChannel channel, + long position, + long remaining, + MessageDigest digest, + ByteBuffer input) { + this.channel = channel; + sourcePosition = position; + this.remaining = remaining; + this.digest = new DigestOutputStream(OutputStream.nullOutputStream(), digest); + this.input = input; + this.input.clear(); + this.input.limit(0); + } + + private long remaining() { + return remaining; + } + + private CurrentStateEntry readEntry() throws IOException { + if (readShort() != FORMAT_VERSION || readShort() != RESERVED_FLAGS) { + throw new IllegalArgumentException("Unsupported checkpoint record version"); + } + PkiId credential = new PkiId(readString()); + long globalRevision = readLong(); + long localRevision = readLong(); + RevocationTransitionFrameCodec.Commitment commitment = + new RevocationTransitionFrameCodec.Commitment(HEX.formatHex(readBytes(COMMITMENT_BYTES))); + RevocationState state = decodeState(readUnsignedByte()); + Optional reason = decodeReason(readUnsignedByte()); + if (readShort() != RESERVED_FLAGS) { + throw new IllegalArgumentException("Invalid checkpoint record reserved field"); + } + Instant time = Instant.ofEpochSecond(readLong(), readInt()); + AttributeSet attributes = readAttributes(); + long start = readLong(); + long end = readLong(); + RevocationTransition transition = + new RevocationTransition(localRevision, state, time, reason, attributes); + return new CurrentStateEntry( + credential, globalRevision, localRevision, commitment, transition, start, end); + } + + private AttributeSet readAttributes() throws IOException { + int count = readCount(); + List entries = new ArrayList<>(count); + byte[] previous = null; + for (int index = 0; index < count; index++) { + byte[] encodedId = readComponent(); + if (previous != null && compareUnsigned(previous, encodedId) >= 0) { + throw new IllegalArgumentException("Checkpoint attributes are not canonical"); + } + entries.add(readAttributeEntry(encodedId)); + previous = encodedId; + } + return new SimpleAttributeSet(entries); + } + + private SimpleAttributeSet.Entry readAttributeEntry(byte[] encodedId) throws IOException { + AttributeId id = new AttributeId(decodeUtf8(encodedId)); + int valueCount = readCount(); + return new SimpleAttributeSet.Entry(id, readAttributeValues(valueCount)); + } + + private List readAttributeValues(int valueCount) throws IOException { + List values = new ArrayList<>(valueCount); + for (int valueIndex = 0; valueIndex < valueCount; valueIndex++) { + values.add(readValue()); + } + return values; + } + + private AttributeValue readValue() throws IOException { + return switch (readUnsignedByte()) { + case 1 -> new AttributeValue.StringValue(readString()); + case 2 -> new AttributeValue.BooleanValue(readBoolean()); + case 3 -> new AttributeValue.IntegerValue(readLong()); + case 4 -> new AttributeValue.InstantValue(Instant.ofEpochSecond(readLong(), readInt())); + case 5 -> new AttributeValue.BytesValue(readComponent()); + default -> throw new IllegalArgumentException("Unknown checkpoint attribute value code"); + }; + } + + private String readString() throws IOException { + return decodeUtf8(readComponent()); + } + + private byte[] readComponent() throws IOException { + int length = readInt(); + requireComponent(length); + return readBytes(length); + } + + private int readCount() throws IOException { + int count = readInt(); + requireComponent(count); + return count; + } + + private boolean readBoolean() throws IOException { + int code = readUnsignedByte(); + if (code != 0 && code != 1) { + throw new IllegalArgumentException("Invalid checkpoint boolean code"); + } + return code == 1; + } + + private int readUnsignedByte() throws IOException { + return Byte.toUnsignedInt(readByte()); + } + + private short readShort() throws IOException { + return (short) (readUnsignedByte() << Byte.SIZE | readUnsignedByte()); + } + + private int readInt() throws IOException { + return readUnsignedByte() << 24 + | readUnsignedByte() << 16 + | readUnsignedByte() << 8 + | readUnsignedByte(); + } + + private long readLong() throws IOException { + return (long) readUnsignedByte() << 56 + | (long) readUnsignedByte() << 48 + | (long) readUnsignedByte() << 40 + | (long) readUnsignedByte() << 32 + | (long) readUnsignedByte() << 24 + | (long) readUnsignedByte() << 16 + | (long) readUnsignedByte() << 8 + | readUnsignedByte(); + } + + private byte[] readBytes(int length) throws IOException { + if (length < 0 || length > remaining) { + throw new IllegalArgumentException("Checkpoint field exceeds its bounded record"); + } + byte[] bytes = new byte[length]; + int offset = 0; + while (offset < length) { + requireInput(); + int count = Math.min(length - offset, input.remaining()); + input.get(bytes, offset, count); + digest.write(bytes, offset, count); + offset += count; + remaining -= count; + } + return bytes; + } + + private byte readByte() throws IOException { + if (remaining == ZERO_REVISION) { + throw new IllegalArgumentException("Checkpoint field exceeds its bounded record"); + } + requireInput(); + byte value = input.get(); + digest.write(value); + remaining--; + return value; + } + + private void requireInput() throws IOException { + if (input.hasRemaining()) { + return; + } + input.clear(); + input.limit((int) Math.min(input.capacity(), remaining)); + int read = channel.read(input, sourcePosition); + if (read < 0) { + throw corrupt("Revocation checkpoint is truncated"); + } + if (read == 0) { + throw new IOException("Revocation checkpoint channel made no read progress"); + } + sourcePosition += read; + input.flip(); + } + } + + private static void requireComponent(int value) { + if (value < 0 || value > MAX_COMPONENT_BYTES) { + throw new IllegalArgumentException("Checkpoint component exceeds its technical limit"); + } + } + + private static DigestOutputStream digestStream() { + return new DigestOutputStream(OutputStream.nullOutputStream(), sha256()); + } + + private static int encodeState(RevocationState state) { + return ValueCodes.encodeState(state); + } + + private static RevocationState decodeState(int code) { + return ValueCodes.decodeState(code); + } + + private static int encodeReason(Optional reason) { + return ValueCodes.encodeReason(reason); + } + + private static Optional decodeReason(int code) { + return ValueCodes.decodeReason(code); + } + + /** Stable numeric codes are isolated from the surrounding framing logic. */ + private static final class ValueCodes { + private static int encodeState(RevocationState state) { + return switch (state) { + case CLEAR -> 1; + case HELD -> 2; + case PERMANENTLY_REVOKED -> 3; + }; + } + + private static RevocationState decodeState(int code) { + return switch (code) { + case 1 -> RevocationState.CLEAR; + case 2 -> RevocationState.HELD; + case 3 -> RevocationState.PERMANENTLY_REVOKED; + default -> throw new IllegalArgumentException( + "Unknown checkpoint revocation state code"); + }; + } + + private static int encodeReason(Optional reason) { + if (reason.isEmpty()) { + return 0; + } + return switch (reason.orElseThrow()) { + case UNSPECIFIED -> 1; + case KEY_COMPROMISE -> 2; + case CA_COMPROMISE -> 3; + case AFFILIATION_CHANGED -> 4; + case SUPERSEDED -> 5; + case CESSATION_OF_OPERATION -> 6; + case CERTIFICATE_HOLD -> 7; + case REMOVE_FROM_CRL -> 8; + case PRIVILEGE_WITHDRAWN -> 9; + case AA_COMPROMISE -> 10; + }; + } + + private static Optional decodeReason(int code) { + return switch (code) { + case 0 -> Optional.empty(); + case 1 -> Optional.of(RevocationReason.UNSPECIFIED); + case 2 -> Optional.of(RevocationReason.KEY_COMPROMISE); + case 3 -> Optional.of(RevocationReason.CA_COMPROMISE); + case 4 -> Optional.of(RevocationReason.AFFILIATION_CHANGED); + case 5 -> Optional.of(RevocationReason.SUPERSEDED); + case 6 -> Optional.of(RevocationReason.CESSATION_OF_OPERATION); + case 7 -> Optional.of(RevocationReason.CERTIFICATE_HOLD); + case 8 -> Optional.of(RevocationReason.REMOVE_FROM_CRL); + case 9 -> Optional.of(RevocationReason.PRIVILEGE_WITHDRAWN); + case 10 -> Optional.of(RevocationReason.AA_COMPROMISE); + default -> throw new IllegalArgumentException( + "Unknown checkpoint revocation reason code"); + }; + } + } + + /** Encodes one fixed-width primitive into a reusable buffer. */ + @FunctionalInterface + private interface PrimitiveEncoder { + /** Writes one primitive value. */ + void encode(ByteBuffer buffer); + } +} diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationCheckpointTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationCheckpointTest.java new file mode 100644 index 0000000..92789cb --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationCheckpointTest.java @@ -0,0 +1,569 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.impl.fs; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InterruptedIOException; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import zeroecho.core.io.CancellationSignal; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.revocation.RevocationReason; +import zeroecho.pki.api.revocation.RevocationState; +import zeroecho.pki.api.revocation.RevocationTransition; +import zeroecho.pki.impl.core.attr.SimpleAttributeSet; +import zeroecho.pki.spi.store.MetadataStoreId; + +final class FilesystemRevocationCheckpointTest { + + private static final MetadataStoreId STORE_ID = + new MetadataStoreId("00112233445566778899aabbccddeeff"); + private static final MetadataStoreId FOREIGN_ID = + new MetadataStoreId("ffeeddccbbaa99887766554433221100"); + private static final PkiId FIRST = new PkiId("credential:a"); + private static final PkiId SECOND = new PkiId("credential:b"); + + @TempDir + Path temporaryDirectory; + + @Test + void publicationDiscoveryAndOrderedCursorsPreserveAllCurrentStates() throws Exception { + System.out.print("publicationDiscoveryAndOrderedCursorsPreserveAllCurrentStates "); + try (Fixture fixture = fixture("publish")) { + RevocationTransitionFrameCodec.CompleteRecord first = fixture.log.append(FIRST, held(1L, 1L)); + RevocationTransitionFrameCodec.CompleteRecord second = fixture.log.append(SECOND, held(1L, 2L)); + RevocationTransitionFrameCodec.CompleteRecord clear = fixture.log.append(FIRST, clear(2L, 3L)); + FilesystemRevocationLog.RecoveryResult recovery = fixture.log.scan(); + CountingSource source = source(List.of(entry(clear), entry(second))); + FilesystemRevocationCheckpoint.PublishedGeneration published = + FilesystemRevocationCheckpoint.publish( + fixture.checkpoints, fixture.logPath, recovery, coverage(clear), source); + assertTrue(published.path().getFileName().toString().matches("g-3-[0-9a-f]{64}\\.chk")); + assertEquals(1, source.closeCount.get()); + try (FilesystemRevocationCheckpoint checkpoint = + FilesystemRevocationCheckpoint.discover( + fixture.checkpoints, fixture.logPath, recovery).orElseThrow()) { + assertEquals(3L, checkpoint.coveredRevision()); + assertEquals(2L, checkpoint.entryCount()); + List all = collect(checkpoint.allCurrentStates()); + List revoked = collect(checkpoint.currentRevokedStates()); + assertEquals(List.of(FIRST, SECOND), all); + assertEquals(List.of(SECOND), revoked); + } + assertTrue(first.recordEnd() < clear.recordEnd()); + } + System.out.println("...ok"); + } + + @Test + void revisionZeroAndStaleHistoricalCoverageBindWithoutPrefixReplay() throws Exception { + System.out.print("revisionZeroAndStaleHistoricalCoverageBindWithoutPrefixReplay "); + try (Fixture fixture = fixture("stale")) { + FilesystemRevocationLog.RecoveryResult empty = fixture.log.scan(); + FilesystemRevocationCheckpoint.PublishedGeneration zero = + FilesystemRevocationCheckpoint.publish( + fixture.checkpoints, fixture.logPath, empty, + genesisCoverage(), source(List.of())); + assertEquals(0L, zero.coveredRevision()); + RevocationTransitionFrameCodec.CompleteRecord first = fixture.log.append(FIRST, held(1L, 1L)); + FilesystemRevocationLog.RecoveryResult firstRecovery = fixture.log.scan(); + FilesystemRevocationCheckpoint.publish( + fixture.checkpoints, fixture.logPath, firstRecovery, + coverage(first), source(List.of(entry(first)))); + RevocationTransitionFrameCodec.CompleteRecord second = fixture.log.append(SECOND, held(1L, 2L)); + FilesystemRevocationLog.RecoveryResult recovery = fixture.log.scan(); + try (FilesystemRevocationCheckpoint stale = FilesystemRevocationCheckpoint.discover( + fixture.checkpoints, fixture.logPath, recovery).orElseThrow()) { + assertEquals(1L, stale.coveredRevision()); + assertEquals(List.of(FIRST), collect(stale.allCurrentStates())); + } + assertEquals(2L, second.data().globalRevision()); + } + System.out.println("...ok"); + } + + @Test + void bindingRejectsForeignIdentityWrongFrameBoundaryCommitmentAndEntry() throws Exception { + System.out.print("bindingRejectsForeignIdentityWrongFrameBoundaryCommitmentAndEntry "); + try (Fixture fixture = fixture("binding")) { + RevocationTransitionFrameCodec.CompleteRecord record = fixture.log.append(FIRST, held(1L, 1L)); + FilesystemRevocationLog.RecoveryResult recovery = fixture.log.scan(); + List invalid = List.of( + new FilesystemRevocationCheckpoint.Coverage( + FOREIGN_ID, 1L, OptionalLong.of(record.recordOffset()), + record.recordEnd(), record.commitment()), + new FilesystemRevocationCheckpoint.Coverage( + STORE_ID, 1L, OptionalLong.of(record.recordOffset() + 1L), + record.recordEnd(), record.commitment()), + new FilesystemRevocationCheckpoint.Coverage( + STORE_ID, 1L, OptionalLong.of(record.recordOffset()), + record.recordEnd() + 1L, record.commitment()), + new FilesystemRevocationCheckpoint.Coverage( + STORE_ID, 1L, OptionalLong.of(record.recordOffset()), + record.recordEnd(), commitment(9))); + for (int index = 0; index < invalid.size(); index++) { + final int caseIndex = index; + Path directory = fixture.root.resolve("invalid-" + caseIndex); + assertThrows(IOException.class, () -> FilesystemRevocationCheckpoint.publish( + directory, fixture.logPath, recovery, invalid.get(caseIndex), + source(List.of(entry(record))))); + } + RevocationCheckpointCodec.CurrentStateEntry wrong = + new RevocationCheckpointCodec.CurrentStateEntry( + SECOND, record.data().globalRevision(), 1L, record.commitment(), + held(1L, 1L), record.recordOffset(), record.recordEnd()); + assertThrows(IOException.class, () -> FilesystemRevocationCheckpoint.publish( + fixture.root.resolve("wrong-entry"), fixture.logPath, recovery, + coverage(record), source(List.of(wrong)))); + RevocationCheckpointCodec.CurrentStateEntry wrongEnd = + new RevocationCheckpointCodec.CurrentStateEntry( + FIRST, record.data().globalRevision(), 1L, record.commitment(), + held(1L, 1L), record.recordOffset(), record.recordEnd() + 1L); + assertThrows(IOException.class, () -> FilesystemRevocationCheckpoint.publish( + fixture.root.resolve("wrong-end"), fixture.logPath, recovery, + coverage(record), source(List.of(wrongEnd)))); + } + System.out.println("...ok"); + } + + @Test + void sortedSourceCountAndLifecycleAreStrict() throws Exception { + System.out.print("sortedSourceCountAndLifecycleAreStrict "); + try (Fixture fixture = fixture("source")) { + RevocationTransitionFrameCodec.CompleteRecord first = fixture.log.append(FIRST, held(1L, 1L)); + RevocationTransitionFrameCodec.CompleteRecord second = fixture.log.append(SECOND, held(1L, 2L)); + FilesystemRevocationLog.RecoveryResult recovery = fixture.log.scan(); + List> invalid = List.of( + List.of(entry(second), entry(first)), + List.of(entry(first), entry(first))); + for (int index = 0; index < invalid.size(); index++) { + final int caseIndex = index; + assertThrows(IOException.class, () -> FilesystemRevocationCheckpoint.publish( + fixture.root.resolve("order-" + caseIndex), fixture.logPath, recovery, + coverage(second), source(invalid.get(caseIndex)))); + } + CountingSource tooFew = new CountingSource(2L, List.of(entry(first))); + assertThrows(IOException.class, () -> FilesystemRevocationCheckpoint.publish( + fixture.root.resolve("few"), fixture.logPath, recovery, + coverage(second), tooFew)); + assertEquals(1, tooFew.closeCount.get()); + CountingSource tooMany = new CountingSource(1L, List.of(entry(first), entry(second))); + assertThrows(IOException.class, () -> FilesystemRevocationCheckpoint.publish( + fixture.root.resolve("many"), fixture.logPath, recovery, + coverage(second), tooMany)); + assertEquals(1, tooMany.closeCount.get()); + } + System.out.println("...ok"); + } + + @Test + void publicationFaultsNeverExposePartialGenerationAndDirectoryForceIsAdvisory() throws Exception { + System.out.print("publicationFaultsNeverExposePartialGenerationAndDirectoryForceIsAdvisory "); + try (Fixture fixture = fixture("faults")) { + RevocationTransitionFrameCodec.CompleteRecord record = fixture.log.append(FIRST, held(1L, 1L)); + FilesystemRevocationLog.RecoveryResult recovery = fixture.log.scan(); + for (FilesystemRevocationCheckpoint.FaultPoint point : List.of( + FilesystemRevocationCheckpoint.FaultPoint.WRITE, + FilesystemRevocationCheckpoint.FaultPoint.FILE_FORCE, + FilesystemRevocationCheckpoint.FaultPoint.ATOMIC_MOVE)) { + Path directory = fixture.root.resolve("fault-" + point); + assertThrows(IOException.class, () -> FilesystemRevocationCheckpoint.publish( + directory, fixture.logPath, recovery, coverage(record), + source(List.of(entry(record))), operations(), candidate -> { + if (candidate == point) { + throw new IOException("injected"); + } + })); + assertFalse(hasFinalGeneration(directory)); + assertFalse(hasBuildingFile(directory)); + } + Path advisory = fixture.root.resolve("directory-warning"); + FilesystemRevocationCheckpoint.PublishedGeneration published = + FilesystemRevocationCheckpoint.publish( + advisory, fixture.logPath, recovery, coverage(record), + source(List.of(entry(record))), operations(), point -> { + if (point == FilesystemRevocationCheckpoint.FaultPoint.DIRECTORY_FORCE) { + throw new IOException("injected"); + } + }); + assertTrue(Files.exists(published.path())); + } + System.out.println("...ok"); + } + + @Test + void idempotentPublicationReusesIdenticalGenerationAndRejectsFilenameMismatch() throws Exception { + System.out.print("idempotentPublicationReusesIdenticalGenerationAndRejectsFilenameMismatch "); + try (Fixture fixture = fixture("idempotent")) { + RevocationTransitionFrameCodec.CompleteRecord record = fixture.log.append(FIRST, held(1L, 1L)); + FilesystemRevocationLog.RecoveryResult recovery = fixture.log.scan(); + FilesystemRevocationCheckpoint.PublishedGeneration first = + FilesystemRevocationCheckpoint.publish( + fixture.checkpoints, fixture.logPath, recovery, + coverage(record), source(List.of(entry(record)))); + FilesystemRevocationCheckpoint.PublishedGeneration second = + FilesystemRevocationCheckpoint.publish( + fixture.checkpoints, fixture.logPath, recovery, + coverage(record), source(List.of(entry(record)))); + assertEquals(first.path(), second.path()); + assertFalse(second.newlyPublished()); + Path mismatched = fixture.checkpoints.resolve("g-1-" + "0".repeat(64) + ".chk"); + Files.copy(first.path(), mismatched); + assertThrows(IOException.class, () -> FilesystemRevocationCheckpoint.open( + mismatched, fixture.logPath, recovery)); + Files.writeString(fixture.checkpoints.resolve(".building-ignored"), "partial"); + Files.writeString(fixture.checkpoints.resolve("unrelated"), "ignored"); + assertTrue(FilesystemRevocationCheckpoint.discover( + fixture.checkpoints, fixture.logPath, recovery).isPresent()); + } + System.out.println("...ok"); + } + + @Test + void incompleteCurrentGenerationIsRejectedAndOlderGenerationRemainsUsable() throws Exception { + System.out.print("incompleteCurrentGenerationIsRejectedAndOlderGenerationRemainsUsable "); + try (Fixture fixture = fixture("ambiguity")) { + RevocationTransitionFrameCodec.CompleteRecord first = fixture.log.append(FIRST, held(1L, 1L)); + FilesystemRevocationLog.RecoveryResult firstRecovery = fixture.log.scan(); + FilesystemRevocationCheckpoint.publish( + fixture.checkpoints, fixture.logPath, firstRecovery, + coverage(first), source(List.of(entry(first)))); + RevocationTransitionFrameCodec.CompleteRecord second = fixture.log.append(SECOND, held(1L, 2L)); + FilesystemRevocationLog.RecoveryResult recovery = fixture.log.scan(); + FilesystemRevocationCheckpoint.publish( + fixture.checkpoints, fixture.logPath, recovery, + coverage(second), source(List.of(entry(first), entry(second)))); + assertThrows(IOException.class, () -> FilesystemRevocationCheckpoint.publish( + fixture.root.resolve("incomplete"), fixture.logPath, recovery, + coverage(second), source(List.of(entry(first))))); + try (FilesystemRevocationCheckpoint selected = FilesystemRevocationCheckpoint.discover( + fixture.checkpoints, fixture.logPath, recovery).orElseThrow()) { + assertEquals(2L, selected.coveredRevision()); + } + } + System.out.println("...ok"); + } + + @Test + void ambiguousHighestRevisionFallsBackToOlderUnambiguousGeneration() throws Exception { + System.out.print("ambiguousHighestRevisionFallsBackToOlderUnambiguousGeneration "); + try (Fixture fixture = fixture("ambiguous-fallback")) { + FilesystemRevocationLog.RecoveryResult empty = fixture.log.scan(); + FilesystemRevocationCheckpoint.publish( + fixture.checkpoints, fixture.logPath, empty, + genesisCoverage(), source(List.of())); + RevocationTransitionFrameCodec.CompleteRecord record = + fixture.log.append(FIRST, held(1L, 1L)); + FilesystemRevocationLog.RecoveryResult recovery = fixture.log.scan(); + FilesystemRevocationCheckpoint.publish( + fixture.checkpoints, fixture.logPath, recovery, + coverage(record), source(List.of(entry(record)))); + writeAlternateGeneration(fixture.checkpoints, coverage(record)); + try (FilesystemRevocationCheckpoint selected = + FilesystemRevocationCheckpoint.discover( + fixture.checkpoints, fixture.logPath, recovery).orElseThrow()) { + assertEquals(0L, selected.coveredRevision()); + } + } + System.out.println("...ok"); + } + + @Test + void cursorCancellationCloseAndOwnerCloseAreStrict() throws Exception { + System.out.print("cursorCancellationCloseAndOwnerCloseAreStrict "); + try (Fixture fixture = fixture("cursor")) { + fixture.log.append(FIRST, held(1L, 1L)); + RevocationTransitionFrameCodec.CompleteRecord first = fixture.log.append(FIRST, clear(2L, 2L)); + RevocationTransitionFrameCodec.CompleteRecord second = fixture.log.append(SECOND, held(1L, 3L)); + FilesystemRevocationLog.RecoveryResult recovery = fixture.log.scan(); + FilesystemRevocationCheckpoint.PublishedGeneration generation = + FilesystemRevocationCheckpoint.publish( + fixture.checkpoints, fixture.logPath, recovery, + coverage(second), source(List.of(entry(first), entry(second)))); + FilesystemRevocationCheckpoint checkpoint = FilesystemRevocationCheckpoint.open( + generation.path(), fixture.logPath, recovery); + FilesystemRevocationCheckpoint.Cursor cursor = checkpoint.currentRevokedStates(); + AtomicInteger checks = new AtomicInteger(); + CancellationSignal cancellation = () -> checks.incrementAndGet() == 2; + assertThrows(InterruptedIOException.class, () -> cursor.advance(cancellation)); + assertEquals(2, checks.get()); + assertThrows(IllegalStateException.class, cursor::current); + FilesystemRevocationCheckpoint.Cursor exhausted = checkpoint.allCurrentStates(); + assertTrue(exhausted.advance(CancellationSignal.NONE)); + assertTrue(exhausted.advance(CancellationSignal.NONE)); + assertFalse(exhausted.advance(CancellationSignal.NONE)); + assertThrows(InterruptedIOException.class, () -> exhausted.advance(() -> true)); + exhausted.close(); + cursor.close(); + cursor.close(); + assertThrows(IllegalStateException.class, + () -> cursor.advance(CancellationSignal.NONE)); + FilesystemRevocationCheckpoint.Cursor child = checkpoint.allCurrentStates(); + checkpoint.close(); + checkpoint.close(); + assertThrows(IllegalStateException.class, + () -> child.advance(CancellationSignal.NONE)); + } + System.out.println("...ok"); + } + + @Test + void openReaderSurvivesPosixFilenameRetirement() throws Exception { + System.out.print("openReaderSurvivesPosixFilenameRetirement "); + try (Fixture fixture = fixture("retirement")) { + RevocationTransitionFrameCodec.CompleteRecord first = fixture.log.append(FIRST, held(1L, 1L)); + FilesystemRevocationLog.RecoveryResult firstRecovery = fixture.log.scan(); + FilesystemRevocationCheckpoint.PublishedGeneration old = + FilesystemRevocationCheckpoint.publish( + fixture.checkpoints, fixture.logPath, firstRecovery, + coverage(first), source(List.of(entry(first)))); + FilesystemRevocationCheckpoint openOld = FilesystemRevocationCheckpoint.open( + old.path(), fixture.logPath, firstRecovery); + RevocationTransitionFrameCodec.CompleteRecord second = fixture.log.append(SECOND, held(1L, 2L)); + FilesystemRevocationLog.RecoveryResult recovery = fixture.log.scan(); + FilesystemRevocationCheckpoint.PublishedGeneration current = + FilesystemRevocationCheckpoint.publish( + fixture.checkpoints, fixture.logPath, recovery, + coverage(second), source(List.of(entry(first), entry(second)))); + assertEquals(1L, FilesystemRevocationCheckpoint.retireObsolete( + fixture.checkpoints, current)); + assertFalse(Files.exists(old.path())); + assertEquals(List.of(FIRST), collect(openOld.allCurrentStates())); + openOld.close(); + assertTrue(Files.exists(current.path())); + } + System.out.println("...ok"); + } + + private Fixture fixture(String name) throws Exception { + Path root = temporaryDirectory.resolve(name); + Path logPath = new FsPaths(root).revocationTransitionLog(); + Files.createDirectories(logPath.getParent()); + FilesystemRevocationLog log = FilesystemRevocationLog.create( + logPath, STORE_ID, credential -> { }); + return new Fixture(root, logPath, new FsPaths(root).revocationCheckpointDirectory(), log); + } + + private static FilesystemRevocationCheckpoint.Coverage coverage( + RevocationTransitionFrameCodec.CompleteRecord record) { + return new FilesystemRevocationCheckpoint.Coverage( + STORE_ID, record.data().globalRevision(), OptionalLong.of(record.recordOffset()), + record.recordEnd(), record.commitment()); + } + + private static FilesystemRevocationCheckpoint.Coverage genesisCoverage() { + return new FilesystemRevocationCheckpoint.Coverage( + STORE_ID, 0L, OptionalLong.empty(), RevocationTransitionFrameCodec.PREAMBLE_BYTES, + RevocationTransitionFrameCodec.initialCommitment(STORE_ID)); + } + + private static RevocationCheckpointCodec.CurrentStateEntry entry( + RevocationTransitionFrameCodec.CompleteRecord record) { + return new RevocationCheckpointCodec.CurrentStateEntry( + record.data().credentialId(), record.data().globalRevision(), + record.data().transition().revision(), record.commitment(), + record.data().transition(), record.recordOffset(), record.recordEnd()); + } + + private static CountingSource source( + List entries) { + return new CountingSource(entries.size(), entries); + } + + private static List collect(FilesystemRevocationCheckpoint.Cursor cursor) throws Exception { + List values = new ArrayList<>(); + try (cursor) { + while (cursor.advance(CancellationSignal.NONE)) { + values.add(cursor.current().credentialId()); + } + } + return values; + } + + private static boolean hasFinalGeneration(Path directory) throws IOException { + if (!Files.isDirectory(directory)) { + return false; + } + try (java.util.stream.Stream paths = Files.list(directory)) { + return paths.anyMatch(path -> path.getFileName().toString().endsWith(".chk")); + } + } + + private static void writeAlternateGeneration( + Path directory, FilesystemRevocationCheckpoint.Coverage coverage) throws IOException { + Path building = directory.resolve(".building-test-alternate"); + RevocationCheckpointCodec codec = new RevocationCheckpointCodec(); + RevocationCheckpointCodec.ValidatedFile validated; + try (FileChannel channel = FileChannel.open(building, + StandardOpenOption.CREATE_NEW, StandardOpenOption.READ, + StandardOpenOption.WRITE)) { + RevocationCheckpointCodec.HeaderData header = + new RevocationCheckpointCodec.HeaderData( + coverage.storeId(), coverage.coveredRevision(), + coverage.finalRecordStart(), coverage.coveredBoundary(), + coverage.globalCommitment(), 0L); + codec.encoder(channel, header).finish(); + validated = codec.validate(channel); + } + Path target = directory.resolve( + "g-" + coverage.coveredRevision() + "-" + validated.generationId() + ".chk"); + Files.move(building, target, StandardCopyOption.ATOMIC_MOVE); + } + + private static boolean hasBuildingFile(Path directory) throws IOException { + if (!Files.isDirectory(directory)) { + return false; + } + try (java.util.stream.Stream paths = Files.list(directory)) { + return paths.anyMatch(path -> path.getFileName().toString().startsWith(".building-")); + } + } + + private static FilesystemRevocationCheckpoint.PublicationOperations operations() { + return new FilesystemRevocationCheckpoint.PublicationOperations() { + @Override + public void atomicMove(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException unsupported) { + throw unsupported; + } + } + + @Override + public void forceDirectory(Path directory) { + // File-content durability and atomic-move ordering are the behavior under test. + } + }; + } + + private static RevocationTransition held(long revision, long second) { + return new RevocationTransition( + revision, RevocationState.HELD, Instant.ofEpochSecond(second), + Optional.empty(), new SimpleAttributeSet()); + } + + private static RevocationTransition clear(long revision, long second) { + return new RevocationTransition( + revision, RevocationState.CLEAR, Instant.ofEpochSecond(second), + Optional.empty(), new SimpleAttributeSet()); + } + + private static RevocationTransitionFrameCodec.Commitment commitment(int seed) { + return new RevocationTransitionFrameCodec.Commitment( + String.format("%064x", Integer.toUnsignedLong(seed))); + } + + private static final class CountingSource implements FilesystemRevocationCheckpoint.SortedSource { + private final long declared; + private final List entries; + private final AtomicInteger closeCount = new AtomicInteger(); + + private CountingSource( + long declared, List entries) { + this.declared = declared; + this.entries = List.copyOf(entries); + } + + @Override + public long entryCount() { + return declared; + } + + @Override + public FilesystemRevocationCheckpoint.SourceCursor openCursor() { + return new FilesystemRevocationCheckpoint.SourceCursor() { + private int index = -1; + private boolean closed; + + @Override + public boolean advance() { + if (closed) { + throw new IllegalStateException("source cursor is closed"); + } + index++; + return index < entries.size(); + } + + @Override + public RevocationCheckpointCodec.CurrentStateEntry current() { + if (closed || index < 0 || index >= entries.size()) { + throw new IllegalStateException("source cursor has no current entry"); + } + return entries.get(index); + } + + @Override + public void close() { + closed = true; + } + }; + } + + @Override + public void close() { + closeCount.incrementAndGet(); + } + } + + private record Fixture( + Path root, + Path logPath, + Path checkpoints, + FilesystemRevocationLog log) implements AutoCloseable { + @Override + public void close() throws IOException { + log.close(); + } + } +} diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/RevocationCheckpointCodecTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/RevocationCheckpointCodecTest.java new file mode 100644 index 0000000..69feb57 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/fs/RevocationCheckpointCodecTest.java @@ -0,0 +1,274 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.impl.fs; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.DateTimeException; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.OptionalLong; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.attr.AttributeId; +import zeroecho.pki.api.attr.AttributeValue; +import zeroecho.pki.api.revocation.RevocationReason; +import zeroecho.pki.api.revocation.RevocationState; +import zeroecho.pki.api.revocation.RevocationTransition; +import zeroecho.pki.impl.core.attr.SimpleAttributeSet; +import zeroecho.pki.spi.store.MetadataStoreId; + +final class RevocationCheckpointCodecTest { + + private static final MetadataStoreId STORE_ID = + new MetadataStoreId("00112233445566778899aabbccddeeff"); + private static final RevocationTransitionFrameCodec.Commitment GENESIS = + RevocationTransitionFrameCodec.initialCommitment(STORE_ID); + + @TempDir + Path temporaryDirectory; + + @Test + void revisionZeroCheckpointHasStrictEmptyFraming() throws Exception { + System.out.print("revisionZeroCheckpointHasStrictEmptyFraming "); + Path first = encode("zero-a.chk", zeroHeader(), List.of()); + Path second = encode("zero-b.chk", zeroHeader(), List.of()); + assertArrayEquals(Files.readAllBytes(first), Files.readAllBytes(second)); + try (FileChannel channel = FileChannel.open(first, StandardOpenOption.READ)) { + RevocationCheckpointCodec.ValidatedFile validated = + new RevocationCheckpointCodec().validate(channel); + assertEquals(0L, validated.header().coveredRevision()); + assertTrue(validated.header().finalRecordStart().isEmpty()); + assertEquals(0L, validated.header().entryCount()); + } + List invalid = List.of( + new RevocationCheckpointCodec.HeaderData( + STORE_ID, 0L, OptionalLong.empty(), + RevocationTransitionFrameCodec.PREAMBLE_BYTES, GENESIS, 1L), + new RevocationCheckpointCodec.HeaderData( + STORE_ID, 0L, OptionalLong.empty(), + RevocationTransitionFrameCodec.PREAMBLE_BYTES + 1L, GENESIS, 0L), + new RevocationCheckpointCodec.HeaderData( + STORE_ID, 0L, OptionalLong.empty(), + RevocationTransitionFrameCodec.PREAMBLE_BYTES, commitment(7), 0L)); + for (int index = 0; index < invalid.size(); index++) { + Path path = temporaryDirectory.resolve("invalid-genesis-" + index + ".chk"); + try (FileChannel channel = FileChannel.open(path, + StandardOpenOption.CREATE_NEW, StandardOpenOption.READ, + StandardOpenOption.WRITE)) { + RevocationCheckpointCodec.HeaderData header = invalid.get(index); + assertThrows(IllegalArgumentException.class, + () -> new RevocationCheckpointCodec().encoder(channel, header)); + } + } + System.out.println("...ok"); + } + + @Test + void canonicalRecordsRoundTripEveryStateReasonAndAttributeShape() throws Exception { + System.out.print("canonicalRecordsRoundTripEveryStateReasonAndAttributeShape "); + List entries = List.of( + entry("credential:a", 1L, RevocationState.CLEAR, Optional.empty(), 128L), + entry("credential:b", 2L, RevocationState.HELD, Optional.empty(), 512L), + entry("credential:c", 3L, RevocationState.PERMANENTLY_REVOKED, + Optional.of(RevocationReason.KEY_COMPROMISE), 896L)); + Path file = encode("round-trip.chk", header(3L, entries.size()), entries); + RevocationCheckpointCodec codec = new RevocationCheckpointCodec(); + try (FileChannel channel = FileChannel.open(file, StandardOpenOption.READ)) { + RevocationCheckpointCodec.ValidatedFile validated = codec.validate(channel); + long offset = RevocationCheckpointCodec.HEADER_BYTES; + for (RevocationCheckpointCodec.CurrentStateEntry expected : entries) { + RevocationCheckpointCodec.DecodedRecord decoded = + codec.readRecord(channel, offset, validated.trailerOffset()); + assertEquals(expected.credentialId(), decoded.entry().credentialId()); + assertEquals(expected.globalRevision(), decoded.entry().globalRevision()); + assertTrue(RevocationTransitionFrameCodec.transitionsEqual( + expected.transition(), decoded.entry().transition())); + offset = decoded.nextOffset(); + } + } + System.out.println("...ok"); + } + + @Test + void truncatedHeaderRecordAndTrailerFailClosed() throws Exception { + System.out.print("truncatedHeaderRecordAndTrailerFailClosed "); + Path complete = encode("complete.chk", header(1L, 1L), + List.of(entry("credential:a", 1L, RevocationState.HELD, Optional.empty(), 128L))); + byte[] bytes = Files.readAllBytes(complete); + int[] cuts = { 1, RevocationCheckpointCodec.HEADER_BYTES - 1, + RevocationCheckpointCodec.HEADER_BYTES + 4, bytes.length - 1 }; + for (int cut : cuts) { + Path truncated = temporaryDirectory.resolve("truncated-" + cut + ".chk"); + Files.write(truncated, java.util.Arrays.copyOf(bytes, cut)); + try (FileChannel channel = FileChannel.open(truncated, StandardOpenOption.READ)) { + assertThrows(IOException.class, () -> new RevocationCheckpointCodec().validate(channel)); + } + } + System.out.println("...ok"); + } + + @Test + void headerRecordAndWholeFileIntegrityFailuresAreDistinctlyRejected() throws Exception { + System.out.print("headerRecordAndWholeFileIntegrityFailuresAreDistinctlyRejected "); + Path complete = encode("integrity.chk", header(1L, 1L), + List.of(entry("credential:a", 1L, RevocationState.HELD, Optional.empty(), 128L))); + byte[] bytes = Files.readAllBytes(complete); + int[] positions = { 5, RevocationCheckpointCodec.HEADER_BYTES + 20, bytes.length - 1 }; + for (int index = 0; index < positions.length; index++) { + byte[] corrupt = bytes.clone(); + corrupt[positions[index]] ^= 0x01; + Path path = temporaryDirectory.resolve("corrupt-" + index + ".chk"); + Files.write(path, corrupt); + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { + assertThrows(IOException.class, () -> new RevocationCheckpointCodec().validate(channel)); + } + } + System.out.println("...ok"); + } + + @Test + void invalidCountsOffsetsTimestampsAndSemanticCodesFailClosed() throws Exception { + System.out.print("invalidCountsOffsetsTimestampsAndSemanticCodesFailClosed "); + Path invalidHeader = temporaryDirectory.resolve("invalid-header.chk"); + try (FileChannel channel = FileChannel.open(invalidHeader, + StandardOpenOption.CREATE_NEW, StandardOpenOption.READ, StandardOpenOption.WRITE)) { + assertThrows(IllegalArgumentException.class, + () -> new RevocationCheckpointCodec().encoder(channel, + new RevocationCheckpointCodec.HeaderData( + STORE_ID, -1L, OptionalLong.empty(), 56L, GENESIS, 0L))); + } + assertThrows(DateTimeException.class, () -> entry( + "credential:a", 1L, RevocationState.HELD, Optional.empty(), 127L) + .transition().time().with(java.time.temporal.ChronoField.NANO_OF_SECOND, -1L)); + assertThrows(IllegalArgumentException.class, () -> new RevocationCheckpointCodec.CurrentStateEntry( + new PkiId("credential:a"), 1L, 1L, commitment(1), + transition(1L, RevocationState.HELD, Optional.empty()), 100L, 99L)); + Path file = encode("codes.chk", header(1L, 1L), + List.of(entry("credential:a", 1L, RevocationState.HELD, Optional.empty(), 128L))); + byte[] bytes = Files.readAllBytes(file); + long payloadLength = ByteBuffer.wrap(bytes, + RevocationCheckpointCodec.HEADER_BYTES + Integer.BYTES, Long.BYTES) + .order(ByteOrder.BIG_ENDIAN).getLong(); + assertTrue(payloadLength > 0L); + System.out.println("...ok"); + } + + @Test + void encoderRejectsDeclaredCountMismatchWithoutAggregateLimit() throws Exception { + System.out.print("encoderRejectsDeclaredCountMismatchWithoutAggregateLimit "); + Path file = temporaryDirectory.resolve("count.chk"); + try (FileChannel channel = FileChannel.open(file, + StandardOpenOption.CREATE_NEW, StandardOpenOption.READ, StandardOpenOption.WRITE)) { + RevocationCheckpointCodec.Encoder encoder = + new RevocationCheckpointCodec().encoder(channel, header(1L, Long.MAX_VALUE)); + encoder.write(entry("credential:a", 1L, RevocationState.HELD, Optional.empty(), 128L)); + assertThrows(IOException.class, encoder::finish); + } + System.out.println("...ok"); + } + + private Path encode( + String name, + RevocationCheckpointCodec.HeaderData header, + List entries) throws Exception { + Path path = temporaryDirectory.resolve(name); + try (FileChannel channel = FileChannel.open(path, + StandardOpenOption.CREATE_NEW, StandardOpenOption.READ, StandardOpenOption.WRITE)) { + RevocationCheckpointCodec.Encoder encoder = + new RevocationCheckpointCodec().encoder(channel, header); + for (RevocationCheckpointCodec.CurrentStateEntry entry : entries) { + encoder.write(entry); + } + encoder.finish(); + } + return path; + } + + private static RevocationCheckpointCodec.HeaderData zeroHeader() { + return new RevocationCheckpointCodec.HeaderData( + STORE_ID, 0L, OptionalLong.empty(), RevocationTransitionFrameCodec.PREAMBLE_BYTES, + GENESIS, 0L); + } + + private static RevocationCheckpointCodec.HeaderData header(long revision, long count) { + return new RevocationCheckpointCodec.HeaderData( + STORE_ID, revision, OptionalLong.of(128L), 1_000L, + commitment(99), count); + } + + private static RevocationCheckpointCodec.CurrentStateEntry entry( + String id, + long globalRevision, + RevocationState state, + Optional reason, + long frameStart) { + return new RevocationCheckpointCodec.CurrentStateEntry( + new PkiId(id), globalRevision, 1L, commitment((int) globalRevision), + transition(1L, state, reason), frameStart, frameStart + 100L); + } + + private static RevocationTransition transition( + long revision, RevocationState state, Optional reason) { + SimpleAttributeSet attributes = new SimpleAttributeSet(List.of( + new SimpleAttributeSet.Entry(new AttributeId("z.example"), List.of( + new AttributeValue.StringValue("text"), + new AttributeValue.BooleanValue(true), + new AttributeValue.IntegerValue(Long.MAX_VALUE), + new AttributeValue.InstantValue(Instant.ofEpochSecond(8L, 9)), + new AttributeValue.BytesValue(new byte[] { 0x01, 0x02 }))), + new SimpleAttributeSet.Entry(new AttributeId("a.example"), List.of()))); + return new RevocationTransition( + revision, state, Instant.ofEpochSecond(123L, 456), reason, attributes); + } + + private static RevocationTransitionFrameCodec.Commitment commitment(int seed) { + return new RevocationTransitionFrameCodec.Commitment( + String.format("%064x", Integer.toUnsignedLong(seed))); + } +}