diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationLog.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationLog.java new file mode 100644 index 0000000..354650f --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationLog.java @@ -0,0 +1,952 @@ +/******************************************************************************* + * 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.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.channels.OverlappingFileLockException; +import java.nio.file.FileStore; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.OpenOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.locks.ReentrantLock; +import java.util.logging.Logger; + +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.spi.store.MetadataStoreId; + +/** Exclusive-writer POSIX append-only revocation transition log. */ +final class FilesystemRevocationLog implements AutoCloseable { + + private static final Logger LOGGER = Logger.getLogger(FilesystemRevocationLog.class.getName()); + private static final String CAPABILITY_WARNING = + "POSIX revocation-log creation durability is limited; continuing in best-effort mode"; + private static final Set LOCAL_FILE_SYSTEMS = + Set.of("apfs", "btrfs", "ext2", "ext3", "ext4", "tmpfs", "ufs", "xfs", "zfs"); + private static final Set OWNER_ONLY = + PosixFilePermissions.fromString("rw-------"); + + private final FileChannel channel; + private final FileLock writerLock; + private final MetadataStoreId storeId; + private final CredentialAuthority credentialAuthority; + private final FaultInjector faults; + private final ReentrantLock appendLock = new ReentrantLock(); + private final RevocationTransitionFrameCodec codec = new RevocationTransitionFrameCodec(); + private final Map latest; + private long globalRevision; + private RevocationTransitionFrameCodec.Commitment globalCommitment; + private long scanInvocations; + private State state = State.OPEN; + + private FilesystemRevocationLog( + FileChannel channel, + FileLock writerLock, + MetadataStoreId storeId, + CredentialAuthority credentialAuthority, + FaultInjector faults, + RecoveryResult recovery) { + this.channel = channel; + this.writerLock = writerLock; + this.storeId = storeId; + this.credentialAuthority = credentialAuthority; + this.faults = faults; + latest = new HashMap<>(recovery.latestStates()); + globalRevision = recovery.globalRevision(); + globalCommitment = recovery.globalCommitment(); + scanInvocations = 1L; + } + + /* default */ static FilesystemRevocationLog create( + Path logPath, MetadataStoreId storeId, CredentialAuthority credentialAuthority) throws IOException { + return create(logPath, storeId, credentialAuthority, + DefaultCapabilityProfile.INSTANCE, FaultInjector.NONE); + } + + /* default */ static FilesystemRevocationLog open( + Path logPath, MetadataStoreId expectedStoreId, CredentialAuthority credentialAuthority) + throws IOException { + return open(logPath, expectedStoreId, credentialAuthority, + DefaultCapabilityProfile.INSTANCE, FaultInjector.NONE); + } + + /* default */ static FilesystemRevocationLog create( + Path logPath, + MetadataStoreId storeId, + CredentialAuthority credentialAuthority, + CapabilityProfile capabilities, + FaultInjector faults) throws IOException { + return Lifecycle.create(logPath, storeId, credentialAuthority, capabilities, faults); + } + + /* default */ static FilesystemRevocationLog open( + Path logPath, + MetadataStoreId expectedStoreId, + CredentialAuthority credentialAuthority, + CapabilityProfile capabilities, + FaultInjector faults) throws IOException { + return Lifecycle.open(logPath, expectedStoreId, credentialAuthority, capabilities, faults); + } + + /* default */ MetadataStoreId storeId() { + return storeId; + } + + /* default */ RevocationTransitionFrameCodec.CompleteRecord append( + PkiId credentialId, RevocationTransition transition) throws IOException { + Objects.requireNonNull(credentialId, "credentialId"); + Objects.requireNonNull(transition, "transition"); + // Credential authority validation performs no log I/O and is intentionally + // outside the global append/force critical section. + credentialAuthority.requireCredential(credentialId); + appendLock.lock(); + try { + requireOperational(); + RevocationTransitionFrameCodec.TransitionData data = nextData(credentialId, transition); + TransitionRules.validate(data, latest.get(credentialId)); + try { + channel.position(channel.size()); + faults.fail(FaultPoint.APPEND); + RevocationTransitionFrameCodec.CompleteRecord record = codec.write(channel, data); + faults.fail(FaultPoint.FILE_FORCE); + channel.force(true); + LatestState next = latestState(record); + latest.put(credentialId, next); + globalRevision = data.globalRevision(); + globalCommitment = record.commitment(); + return record; + } catch (IOException failure) { + state = State.RECOVERY_REQUIRED; + throw new OutcomeUnknownException(failure); + } + } finally { + appendLock.unlock(); + } + } + + /* default */ RecoveryResult scan(RecoverySink sink) throws IOException { + appendLock.lock(); + try { + requireOpenAuthority(); + scanInvocations++; + return scanChannel(channel, storeId, credentialAuthority, sink); + } finally { + appendLock.unlock(); + } + } + + /* default */ RecoveryResult scan() throws IOException { + return scan(RecoverySink.NONE); + } + + /* default */ int activeCredentialCount() throws IOException { + appendLock.lock(); + try { + requireOpenAuthority(); + return latest.size(); + } finally { + appendLock.unlock(); + } + } + + /* default */ long scanInvocationCount() { + appendLock.lock(); + try { + return scanInvocations; + } finally { + appendLock.unlock(); + } + } + + /* default */ long currentGlobalRevision() throws IOException { + appendLock.lock(); + try { + requireOpenAuthority(); + return globalRevision; + } finally { + appendLock.unlock(); + } + } + + /* default */ boolean recoveryRequired() { + appendLock.lock(); + try { + return state == State.RECOVERY_REQUIRED; + } finally { + appendLock.unlock(); + } + } + + /* default */ static RecoveryResult scanChannel( + FileChannel channel, + MetadataStoreId expectedStoreId, + CredentialAuthority credentialAuthority, + RecoverySink sink) throws IOException { + return Scanner.scan(channel, expectedStoreId, credentialAuthority, sink); + } + + private RevocationTransitionFrameCodec.TransitionData nextData( + PkiId credentialId, RevocationTransition transition) throws IOException { + LatestState previous = latest.get(credentialId); + final long nextGlobal; + try { + nextGlobal = Math.addExact(globalRevision, 1L); + } catch (ArithmeticException exhausted) { + throw new IOException("Global revocation revision is exhausted", exhausted); + } + if (previous == null) { + return new RevocationTransitionFrameCodec.TransitionData( + nextGlobal, globalCommitment, credentialId, + java.util.OptionalLong.empty(), java.util.Optional.empty(), transition); + } + return new RevocationTransitionFrameCodec.TransitionData( + nextGlobal, globalCommitment, credentialId, + java.util.OptionalLong.of(previous.globalRevision()), + java.util.Optional.of(previous.commitment()), transition); + } + + private static void validateGlobal( + RevocationTransitionFrameCodec.CompleteRecord record, + long currentRevision, + RevocationTransitionFrameCodec.Commitment currentCommitment) throws CorruptLogException { + final long expected; + try { + expected = Math.addExact(currentRevision, 1L); + } catch (ArithmeticException exhausted) { + throw new CorruptLogException("Global revocation revision is exhausted", exhausted); + } + if (record.data().globalRevision() != expected + || !record.data().previousGlobalCommitment().equals(currentCommitment)) { + throw new CorruptLogException("Revocation global history chain is invalid"); + } + } + + private static LatestState latestState(RevocationTransitionFrameCodec.CompleteRecord record) { + return new LatestState( + record.data().globalRevision(), record.commitment(), + record.data().transition(), record.recordOffset()); + } + + private static void repairTail(FileChannel channel, long boundary, FaultInjector faults) throws IOException { + faults.fail(FaultPoint.TAIL_TRUNCATE); + channel.truncate(boundary); + faults.fail(FaultPoint.TAIL_FORCE); + channel.force(true); + faults.fail(FaultPoint.TAIL_VERIFY); + if (channel.size() != boundary) { + throw new IOException("Revocation log tail repair verification failed"); + } + channel.position(boundary); + } + + private static Path requireParent(Path logPath) throws IOException { + Objects.requireNonNull(logPath, "logPath"); + Path parent = logPath.getParent(); + if (parent == null || logPath.getFileName() == null + || !Files.isDirectory(parent, LinkOption.NOFOLLOW_LINKS) + || Files.isSymbolicLink(parent)) { + throw new IOException("Revocation log target has no trusted regular parent directory"); + } + return parent; + } + + private static CapabilityObservation observeCapabilities( + Path parent, CapabilityProfile capabilities) { + boolean posix = false; + boolean local = false; + boolean limited = false; + try { + posix = capabilities.posixAvailable(parent); + if (!posix) { + limited = true; + } + } catch (IOException unavailable) { + limited = true; + } + try { + local = capabilities.localFileSystem(parent); + if (!local) { + limited = true; + } + } catch (IOException unavailable) { + limited = true; + } + return new CapabilityObservation(posix, local, limited); + } + + private void requireOperational() throws IOException { + requireOpenAuthority(); + if (state == State.RECOVERY_REQUIRED) { + throw new IOException("Revocation log requires close and recovery"); + } + } + + private void requireOpenAuthority() throws IOException { + if (state == State.CLOSED || !channel.isOpen()) { + throw new IllegalStateException("Revocation log is closed"); + } + if (!writerLock.isValid()) { + state = State.RECOVERY_REQUIRED; + throw new IOException("Revocation log writer authority is invalid"); + } + } + + @Override + public void close() throws IOException { + appendLock.lock(); + try { + if (state == State.CLOSED) { + return; + } + IOException failure = null; + try { + if (writerLock.isValid()) { + writerLock.release(); + } + } catch (IOException releaseFailure) { + failure = releaseFailure; + } + try { + channel.close(); + } catch (IOException closeFailure) { + failure = appendFailure(failure, closeFailure); + } + latest.clear(); + state = State.CLOSED; + if (failure != null) { + throw failure; + } + } finally { + appendLock.unlock(); + } + } + + private static IOException appendFailure(IOException first, IOException later) { + if (first == null) { + return later; + } + first.addSuppressed(later); + return first; + } + + /** Isolates create and reopen mechanics from the retained writer authority. */ + private static final class Lifecycle { + private static FilesystemRevocationLog create( + Path logPath, + MetadataStoreId storeId, + CredentialAuthority credentialAuthority, + CapabilityProfile capabilities, + FaultInjector faults) throws IOException { + requireLifecycleArguments(storeId, credentialAuthority, capabilities, faults); + Path parent = requireParent(logPath); + CapabilityObservation observation = observeCapabilities(parent, capabilities); + Resources resources = Resources.acquire(logPath, true, observation.posix()); + try { + writeNewPreamble(resources.channel, storeId, faults); + boolean parentForced = forceParent(capabilities, parent); + boolean limited = observation.limited() || !parentForced; + if (limited) { + warnLimitedDurability(); + } + RecoveryResult empty = RecoveryResult.empty(storeId); + FilesystemRevocationLog log = new FilesystemRevocationLog( + resources.channel, resources.writerLock, storeId, + credentialAuthority, faults, empty); + log.scanInvocations = 0L; + return log; + } catch (IOException failure) { + resources.closeAfterFailure(failure); + throw failure; + } + } + + private static FilesystemRevocationLog open( + Path logPath, + MetadataStoreId expectedStoreId, + CredentialAuthority credentialAuthority, + CapabilityProfile capabilities, + FaultInjector faults) throws IOException { + requireLifecycleArguments(expectedStoreId, credentialAuthority, capabilities, faults); + Path parent = requireParent(logPath); + CapabilityObservation observation = observeCapabilities(parent, capabilities); + Resources resources = Resources.acquire(logPath, false, observation.posix()); + try { + ProvisionalRecovery provisional = Scanner.scanProvisional( + resources.channel, expectedStoreId, credentialAuthority, RecoverySink.NONE); + boolean completed = false; + try { + RecoveryResult recovered = repairIfNecessary( + resources.channel, provisional.result(), faults); + resources.channel.position(recovered.lastCompleteRecordBoundary()); + faults.fail(FaultPoint.OPEN_FORCE); + resources.channel.force(true); + provisional.sink().complete(); + completed = true; + if (observation.limited()) { + warnLimitedDurability(); + } + return new FilesystemRevocationLog( + resources.channel, resources.writerLock, expectedStoreId, + credentialAuthority, faults, recovered); + } finally { + if (!completed) { + provisional.sink().abort(); + } + } + } catch (IOException failure) { + resources.closeAfterFailure(failure); + throw failure; + } + } + + private static void requireLifecycleArguments( + MetadataStoreId storeId, + CredentialAuthority credentialAuthority, + CapabilityProfile capabilities, + FaultInjector faults) { + Objects.requireNonNull(storeId, "storeId"); + Objects.requireNonNull(credentialAuthority, "credentialAuthority"); + Objects.requireNonNull(capabilities, "capabilities"); + Objects.requireNonNull(faults, "faults"); + } + + private static void writeNewPreamble( + FileChannel channel, MetadataStoreId storeId, FaultInjector faults) throws IOException { + RevocationTransitionFrameCodec codec = new RevocationTransitionFrameCodec(); + channel.position(0L); + codec.writePreamble(channel, storeId); + faults.fail(FaultPoint.FILE_FORCE); + channel.force(true); + } + + private static boolean forceParent(CapabilityProfile capabilities, Path parent) { + try { + capabilities.forceParent(parent); + return true; + } catch (IOException | UnsupportedOperationException unavailable) { + return false; + } + } + + private static void warnLimitedDurability() { + try { + LOGGER.warning(CAPABILITY_WARNING); + } catch (IllegalStateException ignored) { + // Advisory logging failure cannot invalidate an otherwise usable log. + } + } + + private static RecoveryResult repairIfNecessary( + FileChannel channel, RecoveryResult recovered, FaultInjector faults) throws IOException { + if (!recovered.incompleteTail()) { + return recovered; + } + repairTail(channel, recovered.lastCompleteRecordBoundary(), faults); + return recovered.afterTailRepair(); + } + } + + /** One-pass scanner retaining only the latest finite state per credential. */ + private static final class Scanner { + private static RecoveryResult scan( + FileChannel channel, + MetadataStoreId expectedStoreId, + CredentialAuthority credentialAuthority, + RecoverySink sink) throws IOException { + ProvisionalRecovery provisional = scanProvisional( + channel, expectedStoreId, credentialAuthority, sink); + boolean completed = false; + try { + provisional.sink().complete(); + completed = true; + return provisional.result(); + } finally { + if (!completed) { + provisional.sink().abort(); + } + } + } + + private static ProvisionalRecovery scanProvisional( + FileChannel channel, + MetadataStoreId expectedStoreId, + CredentialAuthority credentialAuthority, + RecoverySink sink) throws IOException { + Objects.requireNonNull(channel, "channel"); + Objects.requireNonNull(expectedStoreId, "expectedStoreId"); + Objects.requireNonNull(credentialAuthority, "credentialAuthority"); + Objects.requireNonNull(sink, "sink"); + RevocationTransitionFrameCodec codec = new RevocationTransitionFrameCodec(); + MetadataStoreId actualStoreId = codec.readPreamble(channel); + if (!expectedStoreId.equals(actualStoreId)) { + throw corrupt("Revocation log belongs to another store"); + } + ScanState state = new ScanState(actualStoreId, channel.size()); + boolean scanned = false; + try { + RecoveryResult result = scanRecords(channel, credentialAuthority, sink, codec, state); + scanned = true; + return new ProvisionalRecovery(result, sink); + } finally { + if (!scanned) { + sink.abort(); + } + } + } + + private static RecoveryResult scanRecords( + FileChannel channel, + CredentialAuthority credentialAuthority, + RecoverySink sink, + RevocationTransitionFrameCodec codec, + ScanState state) throws IOException { + while (true) { + RevocationTransitionFrameCodec.ReadResult result = codec.read(channel, state.boundary); + switch (result.classification()) { + case END_OF_INPUT: + return finish(state, false); + case INCOMPLETE_TAIL: + return finish(state, true); + case CORRUPT_RECORD: + throw corrupt("Revocation transition log contains a corrupt record"); + case COMPLETE_RECORD: + accept(result.record().orElseThrow(), credentialAuthority, sink, state); + break; + } + } + } + + private static void accept( + RevocationTransitionFrameCodec.CompleteRecord record, + CredentialAuthority credentialAuthority, + RecoverySink sink, + ScanState state) throws IOException { + LatestState previous = state.states.get(record.data().credentialId()); + try { + validateGlobal(record, state.globalRevision, state.globalCommitment); + TransitionRules.validate(record.data(), previous); + } catch (IllegalArgumentException failure) { + throw new CorruptLogException( + "Revocation transition log violates semantic invariants", failure); + } + if (previous == null) { + credentialAuthority.requireCredential(record.data().credentialId()); + } + sink.accept(record); + state.states.put(record.data().credentialId(), latestState(record)); + state.globalRevision = record.data().globalRevision(); + state.globalCommitment = record.commitment(); + state.boundary = record.recordEnd(); + } + + private static RecoveryResult finish(ScanState state, boolean incomplete) { + return new RecoveryResult( + state.storeId, state.boundary, state.physicalEnd, incomplete, + state.globalRevision, state.globalCommitment, state.states); + } + + private static CorruptLogException corrupt(String message) { + return new CorruptLogException(message); + } + } + + /** Successful scan whose sink publication remains provisional until its owner completes it. */ + private record ProvisionalRecovery(RecoveryResult result, RecoverySink sink) { + private ProvisionalRecovery { + Objects.requireNonNull(result, "result"); + Objects.requireNonNull(sink, "sink"); + } + } + + /** Exact existing revocation state-machine rules without synthetic history allocation. */ + private static final class TransitionRules { + private static void validate( + RevocationTransitionFrameCodec.TransitionData data, LatestState previous) { + RevocationTransition transition = data.transition(); + if (previous == null) { + validateFirst(data, transition); + return; + } + long expectedLocal = nextLocalRevision(previous.transition().revision()); + if (transition.revision() != expectedLocal + || data.previousCredentialGlobalRevision().isEmpty() + || data.previousCredentialGlobalRevision().getAsLong() != previous.globalRevision() + || data.previousCredentialCommitment().isEmpty() + || !data.previousCredentialCommitment().orElseThrow().equals(previous.commitment())) { + throw new IllegalArgumentException("Credential revocation history chain is invalid"); + } + validateSuccessor(previous.transition(), transition); + } + + private static void validateFirst( + RevocationTransitionFrameCodec.TransitionData data, + RevocationTransition transition) { + if (transition.revision() != 1L + || data.previousCredentialGlobalRevision().isPresent() + || data.previousCredentialCommitment().isPresent() + || !hasValidReason(transition) + || transition.state() != RevocationState.HELD + && transition.state() != RevocationState.PERMANENTLY_REVOKED) { + throw new IllegalArgumentException( + "First credential revocation transition is not canonical"); + } + } + + private static long nextLocalRevision(long current) { + try { + return Math.addExact(current, 1L); + } catch (ArithmeticException exhausted) { + throw new IllegalArgumentException( + "Credential revocation revision is exhausted", exhausted); + } + } + + private static void validateSuccessor( + RevocationTransition previous, RevocationTransition current) { + if (current.time().isBefore(previous.time()) || !hasValidReason(current)) { + throw new IllegalArgumentException("Revocation transition is not legal"); + } + boolean legal = switch (previous.state()) { + case CLEAR -> current.state() == RevocationState.HELD + || current.state() == RevocationState.PERMANENTLY_REVOKED; + case HELD -> current.state() == RevocationState.CLEAR + || current.state() == RevocationState.PERMANENTLY_REVOKED; + case PERMANENTLY_REVOKED -> false; + }; + if (!legal) { + throw new IllegalArgumentException("Revocation transition is not legal"); + } + } + + private static boolean hasValidReason(RevocationTransition transition) { + if (transition.state() != RevocationState.PERMANENTLY_REVOKED) { + return transition.permanentReason().isEmpty(); + } + return transition.permanentReason() + .filter(reason -> reason != RevocationReason.CERTIFICATE_HOLD + && reason != RevocationReason.REMOVE_FROM_CRL) + .isPresent(); + } + } + + /** Mutable startup-only scalar state used by the single sequential scan. */ + private static final class ScanState { + private final MetadataStoreId storeId; + private final long physicalEnd; + private final Map states = new HashMap<>(); + private long boundary = RevocationTransitionFrameCodec.PREAMBLE_BYTES; + private long globalRevision; + private RevocationTransitionFrameCodec.Commitment globalCommitment; + + private ScanState(MetadataStoreId storeId, long physicalEnd) { + this.storeId = storeId; + this.physicalEnd = physicalEnd; + globalCommitment = RevocationTransitionFrameCodec.initialCommitment(storeId); + } + } + + /** Latest finite state retained for one credential, never its history. */ + /* default */ record LatestState( + long globalRevision, + RevocationTransitionFrameCodec.Commitment commitment, + RevocationTransition transition, + long recordOffset) { + LatestState { + Objects.requireNonNull(commitment, "commitment"); + Objects.requireNonNull(transition, "transition"); + if (globalRevision <= 0L || recordOffset < RevocationTransitionFrameCodec.PREAMBLE_BYTES) { + throw new IllegalArgumentException("Invalid latest revocation state"); + } + } + + @Override + public boolean equals(Object candidate) { + if (this == candidate) { + return true; + } + if (!(candidate instanceof LatestState other)) { + return false; + } + return globalRevision == other.globalRevision + && recordOffset == other.recordOffset + && commitment.equals(other.commitment) + && RevocationTransitionFrameCodec.transitionsEqual(transition, other.transition); + } + + @Override + public int hashCode() { + return Objects.hash(globalRevision, commitment, + RevocationTransitionFrameCodec.transitionHash(transition), recordOffset); + } + } + + /** Immutable result of one bounded-memory sequential recovery pass. */ + /* default */ record RecoveryResult( + MetadataStoreId storeId, + long lastCompleteRecordBoundary, + long physicalEnd, + boolean incompleteTail, + long globalRevision, + RevocationTransitionFrameCodec.Commitment globalCommitment, + Map latestStates) { + RecoveryResult { + Objects.requireNonNull(storeId, "storeId"); + Objects.requireNonNull(globalCommitment, "globalCommitment"); + latestStates = Map.copyOf(latestStates); + if (lastCompleteRecordBoundary < RevocationTransitionFrameCodec.PREAMBLE_BYTES + || physicalEnd < lastCompleteRecordBoundary || globalRevision < 0L) { + throw new IllegalArgumentException("Invalid revocation recovery boundaries"); + } + } + + private static RecoveryResult empty(MetadataStoreId storeId) { + return new RecoveryResult( + storeId, RevocationTransitionFrameCodec.PREAMBLE_BYTES, + RevocationTransitionFrameCodec.PREAMBLE_BYTES, false, 0L, + RevocationTransitionFrameCodec.initialCommitment(storeId), Map.of()); + } + + private RecoveryResult afterTailRepair() { + return new RecoveryResult( + storeId, lastCompleteRecordBoundary, lastCompleteRecordBoundary, + false, globalRevision, globalCommitment, latestStates); + } + } + + /** Provisional replay sink that can discard accepted records after later corruption. */ + /* default */ interface RecoverySink { + RecoverySink NONE = new RecoverySink() { + @Override + public void accept(RevocationTransitionFrameCodec.CompleteRecord record) { + // The default sink intentionally retains no historical record. + } + + @Override + public void complete() { + // No provisional external state requires publication. + } + + @Override + public void abort() { + // No provisional external state requires rollback. + } + }; + + /** Accepts one validated record provisionally during sequential replay. */ + void accept(RevocationTransitionFrameCodec.CompleteRecord record) throws IOException; + + /** Publishes all provisionally accepted records after a valid scan. */ + void complete() throws IOException; + + /** Discards provisionally accepted records after a failed scan. */ + void abort(); + } + + /** Store-local authority check performed once per distinct recovered credential. */ + /* default */ + @FunctionalInterface + interface CredentialAuthority { + /** Requires the exact credential to belong to the owning filesystem store. */ + void requireCredential(PkiId credentialId) throws IOException; + } + + /** Deterministic package-private append and durability fault boundaries. */ + /* default */ enum FaultPoint { + APPEND, + FILE_FORCE, + OPEN_FORCE, + TAIL_TRUNCATE, + TAIL_FORCE, + TAIL_VERIFY + } + + /** Deterministic fault injection seam; it is not a production extension point. */ + /* default */ + @FunctionalInterface + interface FaultInjector { + FaultInjector NONE = point -> { }; + + /** Fails one selected append, force, or recovery boundary. */ + void fail(FaultPoint point) throws IOException; + } + + /** Package-private capability observations for advisory durability behavior. */ + /* default */ interface CapabilityProfile { + /** Reports whether owner-only POSIX creation attributes are available. */ + boolean posixAvailable(Path parent) throws IOException; + + /** Reports whether the parent uses a recognized local filesystem. */ + boolean localFileSystem(Path parent) throws IOException; + + /** Attempts to force the parent directory after exclusive creation. */ + void forceParent(Path parent) throws IOException; + } + + /** Caller-visible append uncertainty requiring close and scanner recovery. */ + /* default */ static final class OutcomeUnknownException extends IOException { + private static final long serialVersionUID = -1593021845236499276L; + + private OutcomeUnknownException(IOException cause) { + super("Revocation transition append outcome requires recovery", cause); + } + } + + /** Checked corruption result; corrupt complete bytes are never tail-repaired. */ + /* default */ static final class CorruptLogException extends IOException { + private static final long serialVersionUID = -5000565696885871383L; + + private CorruptLogException(String message) { + super(message); + } + + private CorruptLogException(String message, Throwable cause) { + super(message, cause); + } + } + + /** Live lifecycle of the retained writer authority. */ + private enum State { + OPEN, + RECOVERY_REQUIRED, + CLOSED + } + + /** Default observations for the active Java filesystem provider. */ + private enum DefaultCapabilityProfile implements CapabilityProfile { + INSTANCE; + + @Override + public boolean posixAvailable(Path parent) throws IOException { + return Files.getFileStore(parent).supportsFileAttributeView("posix"); + } + + @Override + public boolean localFileSystem(Path parent) throws IOException { + FileStore store = Files.getFileStore(parent); + return LOCAL_FILE_SYSTEMS.contains(store.type().toLowerCase(Locale.ROOT)); + } + + @Override + public void forceParent(Path parent) throws IOException { + try (FileChannel directory = FileChannel.open(parent, StandardOpenOption.READ)) { + directory.force(true); + } + } + } + + /** Immutable advisory capability observation. */ + private record CapabilityObservation(boolean posix, boolean local, boolean limited) { + } + + /** Owns the channel and lock until their authority transfers to an opened log. */ + private static final class Resources { + private final FileChannel channel; + private final FileLock writerLock; + + private Resources(FileChannel channel, FileLock writerLock) { + this.channel = channel; + this.writerLock = writerLock; + } + + private static Resources acquire(Path logPath, boolean create, boolean posix) throws IOException { + if (!create && (Files.isSymbolicLink(logPath) + || !Files.isRegularFile(logPath, LinkOption.NOFOLLOW_LINKS))) { + throw new IOException("Revocation log entry is not a regular file"); + } + Set options = new HashSet<>(); + options.add(StandardOpenOption.READ); + options.add(StandardOpenOption.WRITE); + options.add(LinkOption.NOFOLLOW_LINKS); + if (create) { + options.add(StandardOpenOption.CREATE_NEW); + } + FileAttribute[] attributes = posix + ? new FileAttribute[] { PosixFilePermissions.asFileAttribute(OWNER_ONLY) } + : new FileAttribute[0]; + FileChannel opened = FileChannel.open(logPath, options, attributes); + try { + return new Resources(opened, acquireLock(opened)); + } catch (IOException failure) { + try { + opened.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + throw failure; + } + } + + private static FileLock acquireLock(FileChannel channel) throws IOException { + final FileLock lock; + try { + lock = channel.tryLock(); + } catch (OverlappingFileLockException unavailable) { + throw new IOException("Revocation log writer lock is unavailable", unavailable); + } + if (lock == null) { + throw new IOException("Revocation log writer lock is unavailable"); + } + return lock; + } + + private void closeAfterFailure(IOException primary) { + try { + if (writerLock.isValid()) { + writerLock.release(); + } + } catch (IOException releaseFailure) { + primary.addSuppressed(releaseFailure); + } + try { + channel.close(); + } catch (IOException closeFailure) { + primary.addSuppressed(closeFailure); + } + } + } +} 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 1f93958..b182641 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java @@ -173,6 +173,10 @@ final class FsPaths { return revocationDir(credentialId).resolve("journal.bin"); } + /* default */ Path revocationTransitionLog() { + return this.root.resolve("revocations").resolve("transitions.log"); + } + /* default */ Path revocationSnapshotRoot() { return this.root.resolve("revocation-snapshots"); } diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/RevocationTransitionFrameCodec.java b/pki/src/main/java/zeroecho/pki/impl/fs/RevocationTransitionFrameCodec.java new file mode 100644 index 0000000..cc3522b --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/RevocationTransitionFrameCodec.java @@ -0,0 +1,1104 @@ +/******************************************************************************* + * 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.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.CharBuffer; +import java.nio.channels.SeekableByteChannel; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.DigestOutputStream; +import java.security.NoSuchAlgorithmException; +import java.time.DateTimeException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; + +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.attr.AttributeId; +import zeroecho.pki.api.attr.AttributeSet; +import zeroecho.pki.api.attr.AttributeValue; +import zeroecho.pki.api.revocation.RevocationReason; +import zeroecho.pki.api.revocation.RevocationState; +import zeroecho.pki.api.revocation.RevocationTransition; +import zeroecho.pki.spi.store.MetadataStoreId; + +/** Strict bounded-memory codec for one authenticated revocation transition. */ +final class RevocationTransitionFrameCodec { + + /* default */ static final int PREAMBLE_BYTES = 56; + /* default */ static final int HEADER_BYTES = 88; + /* default */ static final int COMMITMENT_BYTES = 32; + /* default */ static final int MAX_COMPONENT_BYTES = 256 * 1024; + + private static final int PREAMBLE_MAGIC = 0x5A455250; + private static final int RECORD_MAGIC = 0x5A455254; + private static final short STRUCTURAL_VERSION = 1; + private static final short SEMANTIC_VERSION = 1; + private static final short RESERVED_FLAGS = 0; + private static final int PREAMBLE_FIELDS_BYTES = PREAMBLE_BYTES - COMMITMENT_BYTES; + private static final int HEADER_FIELDS_BYTES = HEADER_BYTES - COMMITMENT_BYTES; + private static final int STORE_ID_BYTES = 16; + private static final int TRANSFER_BYTES = 16 * 1024; + private static final long MINIMUM_GLOBAL_REVISION = 1L; + private static final long NO_REMAINING_PAYLOAD = 0L; + private static final int LINK_PRESENT = 1; + private static final long FIRST_CREDENTIAL_REVISION = 1L; + private static final int BOOLEAN_FALSE = 0; + private static final int BOOLEAN_TRUE = 1; + private static final byte[] RECORD_DOMAIN = "ZeroEcho revocation transition record v1" + .getBytes(StandardCharsets.US_ASCII); + private static final HexFormat LOWERCASE_HEX = HexFormat.of(); + private static final byte[] INITIAL_CHAIN_DOMAIN = "ZeroEcho revocation log initial chain v1" + .getBytes(StandardCharsets.US_ASCII); + + /* default */ void writePreamble(SeekableByteChannel channel, MetadataStoreId storeId) throws IOException { + Objects.requireNonNull(channel, "channel"); + Objects.requireNonNull(storeId, "storeId"); + writeFully(channel, ByteBuffer.wrap(encodePreamble(storeId))); + } + + private static byte[] encodePreamble(MetadataStoreId storeId) { + byte[] rawStoreId = decodeStoreId(storeId); + ByteBuffer preamble = ByteBuffer.allocate(PREAMBLE_BYTES).order(ByteOrder.BIG_ENDIAN); + preamble.putInt(PREAMBLE_MAGIC); + preamble.putShort(STRUCTURAL_VERSION); + preamble.putShort(RESERVED_FLAGS); + preamble.put(rawStoreId); + MessageDigest digest = newSha256(); + digest.update(preamble.array(), 0, PREAMBLE_FIELDS_BYTES); + preamble.put(digest.digest()); + return preamble.array(); + } + + /* default */ MetadataStoreId readPreamble(SeekableByteChannel channel) throws IOException { + Objects.requireNonNull(channel, "channel"); + channel.position(0L); + ByteBuffer preamble = ByteBuffer.allocate(PREAMBLE_BYTES).order(ByteOrder.BIG_ENDIAN); + if (readAvailable(channel, preamble) != PREAMBLE_BYTES) { + throw new IOException("Revocation log preamble is incomplete"); + } + byte[] encoded = preamble.array(); + ByteBuffer fields = ByteBuffer.wrap(encoded).order(ByteOrder.BIG_ENDIAN); + if (fields.getInt() != PREAMBLE_MAGIC) { + throw new IOException("Revocation log preamble is corrupt"); + } + byte[] suppliedDigest = new byte[COMMITMENT_BYTES]; + System.arraycopy(encoded, PREAMBLE_FIELDS_BYTES, suppliedDigest, 0, COMMITMENT_BYTES); + MessageDigest digest = newSha256(); + digest.update(encoded, 0, PREAMBLE_FIELDS_BYTES); + if (!MessageDigest.isEqual(digest.digest(), suppliedDigest)) { + throw new IOException("Revocation log preamble is corrupt"); + } + short version = fields.getShort(); + short flags = fields.getShort(); + byte[] rawStoreId = new byte[STORE_ID_BYTES]; + fields.get(rawStoreId); + if (version != STRUCTURAL_VERSION || flags != RESERVED_FLAGS) { + throw new IOException("Revocation log preamble is unsupported"); + } + return new MetadataStoreId(LOWERCASE_HEX.formatHex(rawStoreId)); + } + + /* default */ CompleteRecord write(SeekableByteChannel channel, TransitionData data) throws IOException { + Objects.requireNonNull(channel, "channel"); + Objects.requireNonNull(data, "data"); + requireTransitionData(data); + PayloadPlan plan = PayloadPlan.create(data); + long recordOffset = channel.position(); + long recordEnd = checkedRecordEnd(recordOffset, plan.length()); + byte[] header = encodeHeader(data, plan.length()); + MessageDigest recordDigest = newSha256(); + recordDigest.update(RECORD_DOMAIN); + recordDigest.update(header); + writeFully(channel, ByteBuffer.wrap(header)); + DigestingWriter writer = new DigestingWriter(channel, recordDigest); + plan.write(writer, data); + Commitment commitment = new Commitment(LOWERCASE_HEX.formatHex(recordDigest.digest())); + writeFully(channel, ByteBuffer.wrap(commitment.bytes())); + return new CompleteRecord(data, recordOffset, recordEnd, commitment); + } + + /* default */ ReadResult read(SeekableByteChannel channel, long recordOffset) throws IOException { + return WireOperations.read(channel, recordOffset); + } + + private static TransitionData decodePayload(Header header, BoundedReader reader) throws IOException { + return SemanticOperations.decodePayload(header, reader); + } + + private static byte[] encodeHeader(TransitionData data, long payloadLength) { + ByteBuffer header = ByteBuffer.allocate(HEADER_BYTES).order(ByteOrder.BIG_ENDIAN); + header.putInt(RECORD_MAGIC); + header.putShort(STRUCTURAL_VERSION); + header.putShort(RESERVED_FLAGS); + header.putLong(data.globalRevision()); + header.put(data.previousGlobalCommitment().bytes()); + header.putLong(payloadLength); + MessageDigest digest = newSha256(); + digest.update(header.array(), 0, HEADER_FIELDS_BYTES); + header.put(digest.digest()); + return header.array(); + } + + private static long checkedRecordEnd(long offset, long payloadLength) throws IOException { + try { + return Math.addExact(Math.addExact(offset, HEADER_BYTES), + Math.addExact(payloadLength, COMMITMENT_BYTES)); + } catch (ArithmeticException overflow) { + throw new IOException("Revocation record boundaries exceed the supported range", overflow); + } + } + + private static void requireTransitionData(TransitionData data) { + SemanticOperations.requireTransitionData(data); + } + + private static byte[] decodeStoreId(MetadataStoreId storeId) { + return LOWERCASE_HEX.parseHex(storeId.value()); + } + + private static byte[] encodeUtf8(String value) { + return SemanticOperations.encodeUtf8(value); + } + + private static String decodeStrictUtf8(byte[] encoded) { + return SemanticOperations.decodeStrictUtf8(encoded); + } + + private static int compareUnsigned(byte[] first, byte[] second) { + return SemanticOperations.compareUnsigned(first, second); + } + + private static MessageDigest newSha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException unavailable) { + throw new IllegalStateException("SHA-256 is unavailable", unavailable); + } + } + + /* default */ static Commitment initialCommitment(MetadataStoreId storeId) { + Objects.requireNonNull(storeId, "storeId"); + MessageDigest digest = newSha256(); + digest.update(INITIAL_CHAIN_DOMAIN); + digest.update(encodePreamble(storeId)); + return new Commitment(LOWERCASE_HEX.formatHex(digest.digest())); + } + + private static int readAvailable(SeekableByteChannel channel, ByteBuffer destination) throws IOException { + int total = 0; + while (destination.hasRemaining()) { + int read = channel.read(destination); + if (read < 0) { + return total; + } + if (read == 0) { + throw new IOException("Revocation log channel made no read progress"); + } + total += read; + } + return total; + } + + private static void readFully(SeekableByteChannel channel, ByteBuffer destination) throws IOException { + if (readAvailable(channel, destination) != destination.capacity()) { + throw new IOException("Revocation record changed during bounded decoding"); + } + } + + private static void writeFully(SeekableByteChannel channel, ByteBuffer source) throws IOException { + while (source.hasRemaining()) { + if (channel.write(source) == 0) { + throw new IOException("Revocation log channel made no write progress"); + } + } + } + + /** Immutable lowercase hexadecimal SHA-256 commitment. */ + /* default */ record Commitment(String value) { + Commitment { + Objects.requireNonNull(value, "value"); + if (!value.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException("Revocation commitment must be canonical SHA-256 hexadecimal"); + } + } + + private byte[] bytes() { + return LOWERCASE_HEX.parseHex(value); + } + + } + + /** Finite semantic input for one transition record. */ + /* default */ record TransitionData( + long globalRevision, + Commitment previousGlobalCommitment, + PkiId credentialId, + OptionalLong previousCredentialGlobalRevision, + Optional previousCredentialCommitment, + RevocationTransition transition) { + TransitionData { + Objects.requireNonNull(previousGlobalCommitment, "previousGlobalCommitment"); + Objects.requireNonNull(credentialId, "credentialId"); + Objects.requireNonNull(previousCredentialGlobalRevision, "previousCredentialGlobalRevision"); + Objects.requireNonNull(previousCredentialCommitment, "previousCredentialCommitment"); + Objects.requireNonNull(transition, "transition"); + } + + @Override + public boolean equals(Object candidate) { + if (this == candidate) { + return true; + } + if (!(candidate instanceof TransitionData other)) { + return false; + } + return globalRevision == other.globalRevision + && previousGlobalCommitment.equals(other.previousGlobalCommitment) + && credentialId.equals(other.credentialId) + && previousCredentialGlobalRevision.equals(other.previousCredentialGlobalRevision) + && previousCredentialCommitment.equals(other.previousCredentialCommitment) + && transitionsEqual(transition, other.transition); + } + + @Override + public int hashCode() { + return Objects.hash(globalRevision, previousGlobalCommitment, credentialId, + previousCredentialGlobalRevision, previousCredentialCommitment, + transitionHash(transition)); + } + } + + /** One fully authenticated record and its durable byte boundaries. */ + /* default */ record CompleteRecord( + TransitionData data, long recordOffset, long recordEnd, Commitment commitment) { + CompleteRecord { + Objects.requireNonNull(data, "data"); + Objects.requireNonNull(commitment, "commitment"); + if (recordOffset < PREAMBLE_BYTES || recordEnd <= recordOffset) { + throw new IllegalArgumentException("Invalid revocation record boundaries"); + } + } + } + + /** Structural scanner classifications independent of semantic log ordering. */ + /* default */ enum Classification { + END_OF_INPUT, + INCOMPLETE_TAIL, + COMPLETE_RECORD, + CORRUPT_RECORD + } + + /** Strict structural result containing a record only for complete input. */ + /* default */ record ReadResult(Classification classification, Optional record) { + ReadResult { + Objects.requireNonNull(classification, "classification"); + Objects.requireNonNull(record, "record"); + if (classification == Classification.COMPLETE_RECORD != record.isPresent()) { + throw new IllegalArgumentException("Revocation frame result and record presence disagree"); + } + } + + private static ReadResult endOfInput() { + return new ReadResult(Classification.END_OF_INPUT, Optional.empty()); + } + + private static ReadResult incompleteTail() { + return new ReadResult(Classification.INCOMPLETE_TAIL, Optional.empty()); + } + + private static ReadResult corruptRecord() { + return new ReadResult(Classification.CORRUPT_RECORD, Optional.empty()); + } + + private static ReadResult completeRecord(CompleteRecord record) { + return new ReadResult(Classification.COMPLETE_RECORD, Optional.of(record)); + } + } + + /** Authenticated fixed-header fields trusted before any payload positioning. */ + private record Header(long globalRevision, Commitment previousGlobalCommitment, long payloadLength) { + } + + /** One canonical attribute captured once before any record byte is written. */ + private record PlannedAttribute(byte[] encodedId, List values) { + private PlannedAttribute { + Objects.requireNonNull(encodedId, "encodedId"); + Objects.requireNonNull(values, "values"); + values = List.copyOf(values); + } + } + + /** Bounded canonical payload plan; it retains finite values, never encoded record chunks. */ + private record PayloadPlan(long length, List attributes) { + private static PayloadPlan create(TransitionData data) throws IOException { + List attributes = captureAttributes(data.transition().attributes()); + attributes.sort((first, second) -> compareUnsigned(first.encodedId(), second.encodedId())); + requireCount(attributes.size()); + long length = 2L + 2L; + length = addComponent(length, encodeUtf8(data.credentialId().value()).length); + length = add(length, Long.BYTES + 1L); + if (data.previousCredentialGlobalRevision().isPresent()) { + length = add(length, Long.BYTES + COMMITMENT_BYTES); + } + length = add(length, Long.BYTES + Integer.BYTES + 1L + 1L + Short.BYTES + Integer.BYTES); + for (PlannedAttribute attribute : attributes) { + length = addComponent(length, attribute.encodedId().length); + List values = attribute.values(); + length = add(length, Integer.BYTES); + for (AttributeValue value : values) { + length = add(length, attributeLength(value)); + } + } + return new PayloadPlan(length, List.copyOf(attributes)); + } + + private static List captureAttributes(AttributeSet source) { + List captured = new ArrayList<>(); + for (AttributeId id : source.ids()) { + List values = List.copyOf(source.getAll(id)); + requireCount(values.size()); + captured.add(new PlannedAttribute(encodeUtf8(id.value()), values)); + } + return captured; + } + + private void write(DigestingWriter writer, TransitionData data) throws IOException { + writer.writeShort(SEMANTIC_VERSION); + writer.writeShort(RESERVED_FLAGS); + writer.writeComponent(encodeUtf8(data.credentialId().value())); + writer.writeLong(data.transition().revision()); + if (data.previousCredentialGlobalRevision().isPresent()) { + writer.writeByte(1); + writer.writeLong(data.previousCredentialGlobalRevision().getAsLong()); + writer.writeBytes(data.previousCredentialCommitment().orElseThrow().bytes()); + } else { + writer.writeByte(0); + } + writer.writeLong(data.transition().time().getEpochSecond()); + writer.writeInt(data.transition().time().getNano()); + writer.writeByte(encodeState(data.transition().state())); + writer.writeByte(encodeReason(data.transition().permanentReason())); + writer.writeShort(RESERVED_FLAGS); + writer.writeInt(attributes.size()); + for (PlannedAttribute attribute : attributes) { + writer.writeComponent(attribute.encodedId()); + List values = attribute.values(); + writer.writeInt(values.size()); + for (AttributeValue value : values) { + writeAttributeValue(writer, value); + } + } + } + + private static long attributeLength(AttributeValue value) throws IOException { + Objects.requireNonNull(value, "attribute value"); + return switch (value) { + case AttributeValue.StringValue stringValue -> + addComponent(1L, encodeUtf8(stringValue.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 bytesValue -> { + requireComponentLength(bytesValue.value().length); + yield 1L + Integer.BYTES + bytesValue.value().length; + } + }; + } + + private static void writeAttributeValue(DigestingWriter writer, AttributeValue value) throws IOException { + switch (value) { + case AttributeValue.StringValue stringValue -> { + writer.writeByte(1); + writer.writeComponent(encodeUtf8(stringValue.value())); + } + case AttributeValue.BooleanValue booleanValue -> { + writer.writeByte(2); + writer.writeByte(booleanValue.value() ? 1 : 0); + } + case AttributeValue.IntegerValue integerValue -> { + writer.writeByte(3); + writer.writeLong(integerValue.value()); + } + case AttributeValue.InstantValue instantValue -> { + writer.writeByte(4); + writer.writeLong(instantValue.value().getEpochSecond()); + writer.writeInt(instantValue.value().getNano()); + } + case AttributeValue.BytesValue bytesValue -> { + writer.writeByte(5); + writer.writeComponent(bytesValue.value()); + } + } + } + + private static long addComponent(long current, int componentLength) throws IOException { + requireComponentLength(componentLength); + return add(current, Integer.BYTES + (long) componentLength); + } + + private static long add(long first, long second) throws IOException { + try { + return Math.addExact(first, second); + } catch (ArithmeticException overflow) { + throw new IOException("Revocation transition payload is too large", overflow); + } + } + + private static void requireComponentLength(int length) { + if (length < 0 || length > MAX_COMPONENT_BYTES) { + throw new IllegalArgumentException("Revocation transition component exceeds its technical limit"); + } + } + + private static void requireCount(int count) { + if (count < 0 || count > MAX_COMPONENT_BYTES) { + throw new IllegalArgumentException("Revocation transition collection exceeds its technical limit"); + } + } + } + + /** Writes canonical fields while incrementally committing every payload byte. */ + private static final class DigestingWriter { + private final SeekableByteChannel channel; + private final DigestOutputStream digest; + private final ByteBuffer primitive = ByteBuffer.allocate(Long.BYTES).order(ByteOrder.BIG_ENDIAN); + + private DigestingWriter(SeekableByteChannel channel, MessageDigest digest) { + this.channel = channel; + this.digest = new DigestOutputStream(OutputStream.nullOutputStream(), digest); + } + + private void writeByte(int value) throws IOException { + writePrimitive(1, buffer -> buffer.put((byte) value)); + } + + private void writeShort(int value) throws IOException { + writePrimitive(Short.BYTES, buffer -> buffer.putShort((short) value)); + } + + private void writeInt(int value) throws IOException { + writePrimitive(Integer.BYTES, buffer -> buffer.putInt(value)); + } + + private void writeLong(long value) throws IOException { + writePrimitive(Long.BYTES, buffer -> buffer.putLong(value)); + } + + private void writeComponent(byte[] value) throws IOException { + PayloadPlan.requireComponentLength(value.length); + writeInt(value.length); + writeBytes(value); + } + + private void writeBytes(byte[] value) throws IOException { + digest.write(value); + writeFully(channel, ByteBuffer.wrap(value)); + } + + private void writePrimitive(int bytes, PrimitiveEncoder encoder) throws IOException { + primitive.clear(); + encoder.encode(primitive); + primitive.flip(); + primitive.limit(bytes); + byte[] encoded = new byte[bytes]; + primitive.get(encoded); + writeBytes(encoded); + } + } + + /** Reads only the authenticated payload region and commits bytes incrementally. */ + private static final class BoundedReader { + private final SeekableByteChannel channel; + private final DigestOutputStream digest; + private final byte[] transfer = new byte[TRANSFER_BYTES]; + private long remaining; + + private BoundedReader(SeekableByteChannel channel, long remaining, MessageDigest digest) { + this.channel = channel; + this.remaining = remaining; + this.digest = new DigestOutputStream(OutputStream.nullOutputStream(), digest); + } + + private long remaining() { + return remaining; + } + + private int readUnsignedByte() throws IOException { + return Byte.toUnsignedInt(readBytes(1)[0]); + } + + private short readShort() throws IOException { + return ByteBuffer.wrap(readBytes(Short.BYTES)).order(ByteOrder.BIG_ENDIAN).getShort(); + } + + private int readInt() throws IOException { + return ByteBuffer.wrap(readBytes(Integer.BYTES)).order(ByteOrder.BIG_ENDIAN).getInt(); + } + + private long readLong() throws IOException { + return ByteBuffer.wrap(readBytes(Long.BYTES)).order(ByteOrder.BIG_ENDIAN).getLong(); + } + + private boolean readStrictBoolean() throws IOException { + int value = readUnsignedByte(); + if (value != BOOLEAN_FALSE && value != BOOLEAN_TRUE) { + throw new IllegalArgumentException("Invalid revocation boolean value"); + } + return value == BOOLEAN_TRUE; + } + + private int readBoundedCount() throws IOException { + int count = readInt(); + PayloadPlan.requireCount(count); + return count; + } + + private String readStrictString() throws IOException { + return decodeStrictUtf8(readComponent()); + } + + private byte[] readComponent() throws IOException { + int length = readInt(); + PayloadPlan.requireComponentLength(length); + return readBytes(length); + } + + private byte[] readBytes(int length) throws IOException { + if (length < 0 || length > remaining) { + throw new IllegalArgumentException("Revocation payload field exceeds its bounded region"); + } + byte[] result = new byte[length]; + int copied = 0; + while (copied < length) { + int requested = Math.min(transfer.length, length - copied); + ByteBuffer target = ByteBuffer.wrap(transfer, 0, requested); + if (readAvailable(channel, target) != requested) { + throw new IOException("Revocation payload changed during bounded decoding"); + } + digest.write(transfer, 0, requested); + System.arraycopy(transfer, 0, result, copied, requested); + copied += requested; + remaining -= requested; + } + return result; + } + } + + /** Encodes one primitive into the reusable fixed-width scratch buffer. */ + @FunctionalInterface + private interface PrimitiveEncoder { + /** Encodes one fixed-width primitive into the reusable buffer. */ + void encode(ByteBuffer buffer); + } + + /** One canonical decoded attribute and the bytes used for order validation. */ + private record DecodedAttribute(byte[] encodedId, AttributeId id, List values) { + /** Retains one bounded component and one immutable semantic entry. */ + private DecodedAttribute { + Objects.requireNonNull(encodedId, "encodedId"); + Objects.requireNonNull(id, "id"); + values = List.copyOf(values); + } + } + + /** Map-backed decode input keeps transition snapshot construction linear in attribute count. */ + private static final class DecodedAttributeSet implements AttributeSet { + private final Map> attributes; + + private DecodedAttributeSet(Map> attributes) { + this.attributes = Map.copyOf(attributes); + } + + @Override + public Set ids() { + return attributes.keySet(); + } + + @Override + public Optional get(AttributeId id) { + List values = getAll(id); + return values.isEmpty() ? Optional.empty() : Optional.of(values.get(0)); + } + + @Override + public List getAll(AttributeId id) { + Objects.requireNonNull(id, "id"); + return attributes.getOrDefault(id, List.of()); + } + } + + /** Structural record reader that authenticates boundaries before semantic decoding. */ + private static final class WireOperations { + private static ReadResult read(SeekableByteChannel channel, long recordOffset) throws IOException { + Objects.requireNonNull(channel, "channel"); + if (recordOffset < PREAMBLE_BYTES) { + throw new IllegalArgumentException("Revocation record offset precedes the log preamble"); + } + channel.position(recordOffset); + ByteBuffer headerBuffer = ByteBuffer.allocate(HEADER_BYTES).order(ByteOrder.BIG_ENDIAN); + int headerRead = readAvailable(channel, headerBuffer); + if (headerRead == 0) { + return ReadResult.endOfInput(); + } + if (headerRead != HEADER_BYTES) { + return ReadResult.incompleteTail(); + } + byte[] header = headerBuffer.array(); + Header decoded = decodeHeader(header); + if (decoded == null) { + return ReadResult.corruptRecord(); + } + Long recordEnd = recordEnd(recordOffset, decoded.payloadLength()); + if (recordEnd == null) { + return ReadResult.corruptRecord(); + } + if (recordEnd > channel.size()) { + return ReadResult.incompleteTail(); + } + return decodeComplete(channel, recordOffset, recordEnd, header, decoded); + } + + private static ReadResult decodeComplete( + SeekableByteChannel channel, + long recordOffset, + long recordEnd, + byte[] header, + Header decoded) throws IOException { + MessageDigest recordDigest = newSha256(); + recordDigest.update(RECORD_DOMAIN); + recordDigest.update(header); + BoundedReader reader = new BoundedReader(channel, decoded.payloadLength(), recordDigest); + try { + TransitionData data = decodePayload(decoded, reader); + if (reader.remaining() != NO_REMAINING_PAYLOAD) { + return ReadResult.corruptRecord(); + } + byte[] suppliedCommitment = new byte[COMMITMENT_BYTES]; + readFully(channel, ByteBuffer.wrap(suppliedCommitment)); + byte[] calculated = recordDigest.digest(); + if (!MessageDigest.isEqual(calculated, suppliedCommitment)) { + return ReadResult.corruptRecord(); + } + Commitment commitment = new Commitment(LOWERCASE_HEX.formatHex(suppliedCommitment)); + return ReadResult.completeRecord( + new CompleteRecord(data, recordOffset, recordEnd, commitment)); + } catch (IllegalArgumentException | DateTimeException malformed) { + return ReadResult.corruptRecord(); + } + } + + private static Header decodeHeader(byte[] encoded) { + ByteBuffer fields = ByteBuffer.wrap(encoded).order(ByteOrder.BIG_ENDIAN); + if (fields.getInt() != RECORD_MAGIC) { + return null; + } + byte[] suppliedDigest = new byte[COMMITMENT_BYTES]; + System.arraycopy(encoded, HEADER_FIELDS_BYTES, suppliedDigest, 0, COMMITMENT_BYTES); + MessageDigest digest = newSha256(); + digest.update(encoded, 0, HEADER_FIELDS_BYTES); + if (!MessageDigest.isEqual(digest.digest(), suppliedDigest)) { + return null; + } + short version = fields.getShort(); + short flags = fields.getShort(); + long globalRevision = fields.getLong(); + byte[] previous = new byte[COMMITMENT_BYTES]; + fields.get(previous); + long payloadLength = fields.getLong(); + if (version != STRUCTURAL_VERSION || flags != RESERVED_FLAGS + || globalRevision < MINIMUM_GLOBAL_REVISION || payloadLength < 0L) { + return null; + } + return new Header(globalRevision, + new Commitment(LOWERCASE_HEX.formatHex(previous)), payloadLength); + } + + private static Long recordEnd(long recordOffset, long payloadLength) { + try { + return checkedRecordEnd(recordOffset, payloadLength); + } catch (IOException invalidBoundary) { + return null; + } + } + } + + /** Strict semantic payload handling isolated from structural frame authentication. */ + private static final class SemanticOperations { + private static TransitionData decodePayload(Header header, BoundedReader reader) throws IOException { + if (reader.readShort() != SEMANTIC_VERSION || reader.readShort() != RESERVED_FLAGS) { + throw new IllegalArgumentException("Unsupported revocation transition payload"); + } + PkiId credentialId = new PkiId(reader.readStrictString()); + long localRevision = reader.readLong(); + CredentialLink link = readCredentialLink(reader); + Instant time = Instant.ofEpochSecond(reader.readLong(), reader.readInt()); + RevocationState state = decodeState(reader.readUnsignedByte()); + Optional reason = decodeReason(reader.readUnsignedByte()); + if (reader.readShort() != RESERVED_FLAGS) { + throw new IllegalArgumentException("Invalid revocation transition reserved field"); + } + AttributeSet attributes = readAttributes(reader); + RevocationTransition transition = new RevocationTransition( + localRevision, state, time, reason, attributes); + TransitionData data = new TransitionData( + header.globalRevision(), header.previousGlobalCommitment(), credentialId, + link.globalRevision(), link.commitment(), transition); + requireTransitionData(data); + return data; + } + + private static CredentialLink readCredentialLink(BoundedReader reader) throws IOException { + int linked = reader.readUnsignedByte(); + if (linked == 0) { + return new CredentialLink(OptionalLong.empty(), Optional.empty()); + } + if (linked != LINK_PRESENT) { + throw new IllegalArgumentException("Invalid revocation credential link marker"); + } + long previousRevision = reader.readLong(); + byte[] previousCommitment = reader.readBytes(COMMITMENT_BYTES); + return new CredentialLink(OptionalLong.of(previousRevision), Optional.of( + new Commitment(LOWERCASE_HEX.formatHex(previousCommitment)))); + } + + private static AttributeSet readAttributes(BoundedReader reader) throws IOException { + int count = reader.readBoundedCount(); + Map> entries = new LinkedHashMap<>(count); + byte[] previousId = null; + for (int index = 0; index < count; index++) { + DecodedAttribute decoded = readAttribute(reader, previousId); + entries.put(decoded.id(), decoded.values()); + previousId = decoded.encodedId(); + } + return new DecodedAttributeSet(entries); + } + + private static DecodedAttribute readAttribute( + BoundedReader reader, byte[] previousId) throws IOException { + byte[] encodedId = reader.readComponent(); + if (previousId != null && compareUnsigned(previousId, encodedId) >= 0) { + throw new IllegalArgumentException("Revocation attributes are not canonical"); + } + AttributeId id = new AttributeId(decodeStrictUtf8(encodedId)); + int valueCount = reader.readBoundedCount(); + List values = readAttributeValues(reader, valueCount); + return new DecodedAttribute(encodedId, id, values); + } + + private static List readAttributeValues( + BoundedReader reader, int valueCount) throws IOException { + List values = new ArrayList<>(valueCount); + for (int valueIndex = 0; valueIndex < valueCount; valueIndex++) { + values.add(readAttributeValue(reader)); + } + return values; + } + + private static AttributeValue readAttributeValue(BoundedReader reader) throws IOException { + return switch (reader.readUnsignedByte()) { + case 1 -> new AttributeValue.StringValue(reader.readStrictString()); + case 2 -> new AttributeValue.BooleanValue(reader.readStrictBoolean()); + case 3 -> new AttributeValue.IntegerValue(reader.readLong()); + case 4 -> new AttributeValue.InstantValue( + Instant.ofEpochSecond(reader.readLong(), reader.readInt())); + case 5 -> new AttributeValue.BytesValue(reader.readComponent()); + default -> throw new IllegalArgumentException("Unknown revocation attribute value code"); + }; + } + + private static RevocationState decodeState(int code) { + return ValueCodes.decodeState(code); + } + + private static Optional decodeReason(int code) { + return ValueCodes.decodeReason(code); + } + + private static void requireTransitionData(TransitionData data) { + if (data.globalRevision() < MINIMUM_GLOBAL_REVISION) { + throw new IllegalArgumentException("Global revocation revision must be positive"); + } + requireIntrinsicTransition(data.transition()); + boolean previousRevision = data.previousCredentialGlobalRevision().isPresent(); + boolean previousCommitment = data.previousCredentialCommitment().isPresent(); + if (previousRevision != previousCommitment + || previousRevision && data.previousCredentialGlobalRevision().getAsLong() <= 0L) { + throw new IllegalArgumentException("Revocation credential history link is invalid"); + } + if (data.transition().revision() == FIRST_CREDENTIAL_REVISION && previousRevision + || data.transition().revision() > FIRST_CREDENTIAL_REVISION && !previousRevision) { + throw new IllegalArgumentException( + "Revocation credential revision and history link disagree"); + } + } + + private static void requireIntrinsicTransition(RevocationTransition transition) { + if (transition.state() == RevocationState.PERMANENTLY_REVOKED) { + RevocationReason reason = transition.permanentReason().orElseThrow( + () -> new IllegalArgumentException( + "Permanent revocation requires a permanent reason")); + if (reason == RevocationReason.CERTIFICATE_HOLD + || reason == RevocationReason.REMOVE_FROM_CRL) { + throw new IllegalArgumentException( + "Permanent revocation requires a permanent reason"); + } + } else if (transition.permanentReason().isPresent()) { + throw new IllegalArgumentException( + "Non-permanent revocation state cannot carry a permanent reason"); + } + } + + private static byte[] encodeUtf8(String value) { + final byte[] encoded; + try { + ByteBuffer buffer = StandardCharsets.UTF_8.newEncoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .encode(CharBuffer.wrap(value)); + encoded = new byte[buffer.remaining()]; + buffer.get(encoded); + } catch (CharacterCodingException malformed) { + throw new IllegalArgumentException( + "Revocation transition text is not valid Unicode", malformed); + } + if (encoded.length > MAX_COMPONENT_BYTES) { + throw new IllegalArgumentException( + "Revocation transition component exceeds its technical limit"); + } + return encoded; + } + + private static String decodeStrictUtf8(byte[] encoded) { + try { + CharBuffer decoded = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(encoded)); + return decoded.toString(); + } catch (CharacterCodingException malformed) { + throw new IllegalArgumentException( + "Revocation transition contains malformed UTF-8", malformed); + } + } + + private 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 int encodeState(RevocationState state) { + return ValueCodes.encodeState(state); + } + + private static int encodeReason(Optional reason) { + return ValueCodes.encodeReason(reason); + } + + } + + /** Semantic equality for immutable decoded transition metadata. */ + private static final class SemanticEquality { + private static boolean transitionsEqual( + RevocationTransition first, RevocationTransition second) { + return first.revision() == second.revision() + && first.state() == second.state() + && first.time().equals(second.time()) + && first.permanentReason().equals(second.permanentReason()) + && attributesEqual(first.attributes(), second.attributes()); + } + + private static int transitionHash(RevocationTransition transition) { + return Objects.hash(transition.revision(), transition.state(), transition.time(), + transition.permanentReason(), attributeHash(transition.attributes())); + } + + private static boolean attributesEqual(AttributeSet first, AttributeSet second) { + Set firstIds = first.ids(); + Set secondIds = second.ids(); + if (!firstIds.equals(secondIds)) { + return false; + } + for (AttributeId id : firstIds) { + if (!attributeListsEqual(first.getAll(id), second.getAll(id))) { + return false; + } + } + return true; + } + + private static boolean attributeListsEqual( + List first, List second) { + if (first.size() != second.size()) { + return false; + } + for (int index = 0; index < first.size(); index++) { + if (!attributeValuesEqual(first.get(index), second.get(index))) { + return false; + } + } + return true; + } + + private static boolean attributeValuesEqual(AttributeValue first, AttributeValue second) { + if (first instanceof AttributeValue.BytesValue firstBytes + && second instanceof AttributeValue.BytesValue secondBytes) { + return java.util.Arrays.equals(firstBytes.value(), secondBytes.value()); + } + return first.equals(second); + } + + private static int attributeHash(AttributeSet attributes) { + List ids = new ArrayList<>(attributes.ids()); + ids.sort(Comparator.comparing(AttributeId::value)); + int hash = 1; + for (AttributeId id : ids) { + hash = 31 * hash + id.hashCode(); + hash = attributeValuesHash(hash, attributes.getAll(id)); + } + return hash; + } + + private static int attributeValuesHash(int initial, List values) { + int hash = initial; + for (AttributeValue value : values) { + hash = 31 * hash + attributeValueHash(value); + } + return hash; + } + + private static int attributeValueHash(AttributeValue value) { + if (value instanceof AttributeValue.BytesValue bytesValue) { + return java.util.Arrays.hashCode(bytesValue.value()); + } + return value.hashCode(); + } + } + + /** Closed stable numeric codes for revocation state and reason values. */ + private static final class ValueCodes { + 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 revocation state code"); + }; + } + + 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 revocation reason code"); + }; + } + + private static int encodeState(RevocationState state) { + return switch (state) { + case CLEAR -> 1; + case HELD -> 2; + case PERMANENTLY_REVOKED -> 3; + }; + } + + 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; + }; + } + } + + /** Optional same-credential history link decoded from one payload. */ + private record CredentialLink( + OptionalLong globalRevision, Optional commitment) { + } + + private static int encodeState(RevocationState state) { + return SemanticOperations.encodeState(state); + } + + private static int encodeReason(Optional reason) { + return SemanticOperations.encodeReason(reason); + } + + /* default */ static boolean transitionsEqual(RevocationTransition first, RevocationTransition second) { + return SemanticEquality.transitionsEqual(first, second); + } + + /* default */ static int transitionHash(RevocationTransition transition) { + return SemanticEquality.transitionHash(transition); + } +} diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationLogTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationLogTest.java new file mode 100644 index 0000000..761b1ba --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationLogTest.java @@ -0,0 +1,625 @@ +/******************************************************************************* + * 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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +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.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +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 FilesystemRevocationLogTest { + + private static final MetadataStoreId STORE_ID = + new MetadataStoreId("00112233445566778899aabbccddeeff"); + private static final MetadataStoreId FOREIGN_STORE_ID = + new MetadataStoreId("ffeeddccbbaa99887766554433221100"); + private static final PkiId FIRST = new PkiId("credential:first"); + private static final PkiId SECOND = new PkiId("credential:second"); + private static final Set AUTHORIZED = Set.of(FIRST, SECOND); + + @TempDir + Path temporaryDirectory; + + @Test + void newLogReopenIdentityAndExclusiveWriterLifecycleAreStrict() throws Exception { + System.out.print("newLogReopenIdentityAndExclusiveWriterLifecycleAreStrict "); + Path path = logPath("lifecycle"); + FilesystemRevocationLog created = FilesystemRevocationLog.create( + path, STORE_ID, authority()); + assertEquals(STORE_ID, created.storeId()); + assertEquals(new FsPaths(temporaryDirectory.resolve("lifecycle")).revocationTransitionLog(), path); + assertThrows(IOException.class, + () -> FilesystemRevocationLog.open(path, STORE_ID, authority())); + created.close(); + created.close(); + assertThrows(IllegalStateException.class, created::scan); + try (FilesystemRevocationLog reopened = FilesystemRevocationLog.open( + path, STORE_ID, authority())) { + assertEquals(STORE_ID, reopened.storeId()); + assertEquals(0L, reopened.scan().globalRevision()); + } + try (FilesystemRevocationLog reopened = FilesystemRevocationLog.open( + path, STORE_ID, authority())) { + assertEquals(STORE_ID, reopened.storeId()); + } + System.out.println("...ok"); + } + + @Test + void firstAndInterleavedCredentialTransitionsRecoverDeterministically() throws Exception { + System.out.print("firstAndInterleavedCredentialTransitionsRecoverDeterministically "); + Path path = logPath("interleaved"); + try (FilesystemRevocationLog log = FilesystemRevocationLog.create(path, STORE_ID, authority())) { + assertEquals(1L, log.append(FIRST, held(1L, 10L)).data().globalRevision()); + assertEquals(2L, log.append(SECOND, held(1L, 20L)).data().globalRevision()); + assertEquals(3L, log.append(FIRST, clear(2L, 21L)).data().globalRevision()); + assertEquals(4L, log.append(FIRST, held(3L, 22L)).data().globalRevision()); + assertEquals(5L, log.append(FIRST, permanent(4L, 23L)).data().globalRevision()); + assertEquals(2, log.activeCredentialCount()); + assertEquals(0L, log.scanInvocationCount()); + } + AtomicInteger authorityCalls = new AtomicInteger(); + FilesystemRevocationLog.CredentialAuthority counting = credentialId -> { + if (!AUTHORIZED.contains(credentialId)) { + throw new IOException("foreign credential"); + } + authorityCalls.incrementAndGet(); + }; + try (FilesystemRevocationLog reopened = FilesystemRevocationLog.open( + path, STORE_ID, counting)) { + FilesystemRevocationLog.RecoveryResult first = reopened.scan(); + FilesystemRevocationLog.RecoveryResult second = reopened.scan(); + assertEquals(first, second); + assertEquals(5L, first.globalRevision()); + assertEquals(2, first.latestStates().size()); + assertFalse(first.incompleteTail()); + assertEquals(first.physicalEnd(), first.lastCompleteRecordBoundary()); + } + assertEquals(6, authorityCalls.get()); + System.out.println("...ok"); + } + + @Test + void existingRevocationLegalityAndTimeRulesRemainStrict() throws Exception { + System.out.print("existingRevocationLegalityAndTimeRulesRemainStrict "); + Path path = logPath("rules"); + try (FilesystemRevocationLog log = FilesystemRevocationLog.create(path, STORE_ID, authority())) { + assertThrows(IllegalArgumentException.class, () -> log.append(FIRST, clear(1L, 1L))); + log.append(FIRST, held(1L, 10L)); + assertThrows(IllegalArgumentException.class, () -> log.append(FIRST, held(2L, 11L))); + assertThrows(IllegalArgumentException.class, () -> log.append(FIRST, clear(2L, 9L))); + assertThrows(IllegalArgumentException.class, () -> log.append(FIRST, + new RevocationTransition(2L, RevocationState.CLEAR, Instant.ofEpochSecond(11L), + Optional.of(RevocationReason.REMOVE_FROM_CRL), new SimpleAttributeSet()))); + log.append(FIRST, permanent(2L, 12L)); + assertThrows(IllegalArgumentException.class, () -> log.append(FIRST, clear(3L, 13L))); + assertEquals(2L, log.scan().globalRevision()); + } + System.out.println("...ok"); + } + + @Test + void wrongRevisionAndCommitmentChainsFailClosed() throws Exception { + System.out.print("wrongRevisionAndCommitmentChainsFailClosed "); + List> invalid = List.of( + List.of(firstData(2L, FIRST, held(1L, 1L))), + List.of(firstData(1L, FIRST, held(1L, 1L)), + linkedData(3L, FIRST, clear(2L, 2L), 1L, + RevocationTransitionFrameCodec.initialCommitment(STORE_ID), + RevocationTransitionFrameCodec.initialCommitment(STORE_ID))), + List.of(firstData(1L, FIRST, held(1L, 1L)), + linkedData(2L, FIRST, clear(3L, 2L), 1L, + RevocationTransitionFrameCodec.initialCommitment(STORE_ID), + RevocationTransitionFrameCodec.initialCommitment(STORE_ID))), + List.of(new RevocationTransitionFrameCodec.TransitionData( + 1L, new RevocationTransitionFrameCodec.Commitment("1".repeat(64)), FIRST, + OptionalLong.empty(), Optional.empty(), held(1L, 1L)))); + for (int index = 0; index < invalid.size(); index++) { + Path path = rawLog("invalid-" + index, invalid.get(index)); + assertThrows(FilesystemRevocationLog.CorruptLogException.class, + () -> FilesystemRevocationLog.open(path, STORE_ID, authority())); + } + + Path validPath = logPath("wrong-local-link"); + RevocationTransitionFrameCodec codec = new RevocationTransitionFrameCodec(); + try (FileChannel channel = rawChannel(validPath)) { + codec.writePreamble(channel, STORE_ID); + RevocationTransitionFrameCodec.CompleteRecord first = codec.write( + channel, firstData(1L, FIRST, held(1L, 1L))); + codec.write(channel, linkedData(2L, FIRST, clear(2L, 2L), 99L, + first.commitment(), first.commitment())); + } + assertThrows(FilesystemRevocationLog.CorruptLogException.class, + () -> FilesystemRevocationLog.open(validPath, STORE_ID, authority())); + System.out.println("...ok"); + } + + @Test + void incompleteFinalRecordIsRepairedWithoutSecondScanOrPrefixChange() throws Exception { + System.out.print("incompleteFinalRecordIsRepairedWithoutSecondScanOrPrefixChange "); + Path path = logPath("tail"); + RevocationTransitionFrameCodec.CompleteRecord first; + try (FilesystemRevocationLog log = FilesystemRevocationLog.create(path, STORE_ID, authority())) { + first = log.append(FIRST, held(1L, 1L)); + } + byte[] validPrefix = java.util.Arrays.copyOf(Files.readAllBytes(path), Math.toIntExact(first.recordEnd())); + try (FileChannel channel = FileChannel.open(path, + StandardOpenOption.READ, StandardOpenOption.WRITE)) { + channel.position(channel.size()); + RevocationTransitionFrameCodec codec = new RevocationTransitionFrameCodec(); + codec.write(channel, linkedData(2L, FIRST, clear(2L, 2L), 1L, + first.commitment(), first.commitment())); + channel.truncate(channel.size() - 7L); + } + try (FilesystemRevocationLog reopened = FilesystemRevocationLog.open( + path, STORE_ID, authority())) { + assertEquals(1L, reopened.currentGlobalRevision()); + assertEquals(1L, reopened.scanInvocationCount()); + assertEquals(first.recordEnd(), Files.size(path)); + assertArrayEquals(validPrefix, Files.readAllBytes(path)); + reopened.append(FIRST, clear(2L, 3L)); + } + System.out.println("...ok"); + } + + @Test + void corruptCompleteRecordIsNeverTruncated() throws Exception { + System.out.print("corruptCompleteRecordIsNeverTruncated "); + Path path = logPath("corruption"); + try (FilesystemRevocationLog log = FilesystemRevocationLog.create(path, STORE_ID, authority())) { + log.append(FIRST, held(1L, 1L)); + } + byte[] corrupt = Files.readAllBytes(path); + corrupt[corrupt.length - 1] ^= 0x01; + Files.write(path, corrupt, StandardOpenOption.TRUNCATE_EXISTING); + long size = Files.size(path); + AtomicInteger truncations = new AtomicInteger(); + FilesystemRevocationLog.FaultInjector faults = point -> { + if (point == FilesystemRevocationLog.FaultPoint.TAIL_TRUNCATE) { + truncations.incrementAndGet(); + } + }; + assertThrows(FilesystemRevocationLog.CorruptLogException.class, + () -> FilesystemRevocationLog.open(path, STORE_ID, authority(), supportedProfile(), faults)); + assertEquals(0, truncations.get()); + assertEquals(size, Files.size(path)); + System.out.println("...ok"); + } + + @Test + void completeRecordAfterForceUncertaintyIsAuthoritativeOnReopen() throws Exception { + System.out.print("completeRecordAfterForceUncertaintyIsAuthoritativeOnReopen "); + Path path = logPath("uncertain"); + AtomicInteger forces = new AtomicInteger(); + FilesystemRevocationLog.FaultInjector faults = point -> { + if (point == FilesystemRevocationLog.FaultPoint.FILE_FORCE + && forces.incrementAndGet() == 2) { + throw new IOException("injected force uncertainty"); + } + }; + FilesystemRevocationLog log = FilesystemRevocationLog.create( + path, STORE_ID, authority(), supportedProfile(), faults); + assertThrows(FilesystemRevocationLog.OutcomeUnknownException.class, + () -> log.append(FIRST, held(1L, 1L))); + assertTrue(log.recoveryRequired()); + assertThrows(IOException.class, () -> log.append(SECOND, held(1L, 2L))); + log.close(); + try (FilesystemRevocationLog reopened = FilesystemRevocationLog.open( + path, STORE_ID, authority())) { + assertEquals(1L, reopened.scan().globalRevision()); + assertEquals(1, reopened.activeCredentialCount()); + } + System.out.println("...ok"); + } + + @Test + void appendFailureFencesTheWriterAndRetainsThePriorPrefix() throws Exception { + System.out.print("appendFailureFencesTheWriterAndRetainsThePriorPrefix "); + Path path = logPath("append-failure"); + FilesystemRevocationLog.FaultInjector faults = point -> { + if (point == FilesystemRevocationLog.FaultPoint.APPEND) { + throw new IOException("injected append failure"); + } + }; + FilesystemRevocationLog log = FilesystemRevocationLog.create( + path, STORE_ID, authority(), supportedProfile(), faults); + assertThrows(FilesystemRevocationLog.OutcomeUnknownException.class, + () -> log.append(FIRST, held(1L, 1L))); + assertTrue(log.recoveryRequired()); + log.close(); + try (FilesystemRevocationLog reopened = FilesystemRevocationLog.open( + path, STORE_ID, authority())) { + assertEquals(0L, reopened.scan().globalRevision()); + } + System.out.println("...ok"); + } + + @Test + void authorityPreambleAndEntryTypeFailuresFailClosed() throws Exception { + System.out.print("authorityPreambleAndEntryTypeFailuresFailClosed "); + Path path = logPath("authority"); + try (FilesystemRevocationLog log = FilesystemRevocationLog.create(path, STORE_ID, authority())) { + assertThrows(IOException.class, + () -> log.append(new PkiId("credential:foreign"), held(1L, 1L))); + } + assertThrows(FilesystemRevocationLog.CorruptLogException.class, + () -> FilesystemRevocationLog.open(path, FOREIGN_STORE_ID, authority())); + Path directoryEntry = temporaryDirectory.resolve("directory-entry"); + Files.createDirectories(directoryEntry); + assertThrows(IOException.class, + () -> FilesystemRevocationLog.open(directoryEntry, STORE_ID, authority())); + Path symlink = temporaryDirectory.resolve("symlink.log"); + Files.createSymbolicLink(symlink, path); + assertThrows(IOException.class, + () -> FilesystemRevocationLog.open(symlink, STORE_ID, authority())); + System.out.println("...ok"); + } + + @Test + void firstRecordChainIsBoundToTheAuthenticatedStorePreamble() throws Exception { + System.out.print("firstRecordChainIsBoundToTheAuthenticatedStorePreamble "); + Path sourcePath = logPath("store-bound-source"); + try (FilesystemRevocationLog source = FilesystemRevocationLog.create( + sourcePath, STORE_ID, authority())) { + source.append(FIRST, held(1L, 1L)); + } + byte[] sourceBytes = Files.readAllBytes(sourcePath); + Path targetPath = logPath("store-bound-target"); + RevocationTransitionFrameCodec codec = new RevocationTransitionFrameCodec(); + try (FileChannel target = rawChannel(targetPath)) { + codec.writePreamble(target, FOREIGN_STORE_ID); + java.nio.ByteBuffer record = java.nio.ByteBuffer.wrap( + sourceBytes, RevocationTransitionFrameCodec.PREAMBLE_BYTES, + sourceBytes.length - RevocationTransitionFrameCodec.PREAMBLE_BYTES); + while (record.hasRemaining()) { + target.write(record); + } + } + long physicalEnd = Files.size(targetPath); + assertThrows(FilesystemRevocationLog.CorruptLogException.class, + () -> FilesystemRevocationLog.open(targetPath, FOREIGN_STORE_ID, authority())); + assertEquals(physicalEnd, Files.size(targetPath)); + System.out.println("...ok"); + } + + @Test + void advisoryParentForceFailureEmitsOneRedactedWarningAndCreationSucceeds() throws Exception { + System.out.print("advisoryParentForceFailureEmitsOneRedactedWarningAndCreationSucceeds "); + Path path = logPath("warning"); + Logger logger = Logger.getLogger(FilesystemRevocationLog.class.getName()); + AtomicInteger warnings = new AtomicInteger(); + Handler handler = new Handler() { + @Override + public void publish(LogRecord record) { + if (record.getLevel().intValue() >= Level.WARNING.intValue()) { + assertEquals( + "POSIX revocation-log creation durability is limited; continuing in best-effort mode", + record.getMessage()); + assertEquals(null, record.getThrown()); + warnings.incrementAndGet(); + } + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }; + logger.addHandler(handler); + try { + FilesystemRevocationLog.CapabilityProfile limited = + new FilesystemRevocationLog.CapabilityProfile() { + @Override + public boolean posixAvailable(Path parent) { + return true; + } + + @Override + public boolean localFileSystem(Path parent) { + return true; + } + + @Override + public void forceParent(Path parent) throws IOException { + throw new IOException("sensitive path detail"); + } + }; + try (FilesystemRevocationLog log = FilesystemRevocationLog.create( + path, STORE_ID, authority(), limited, FilesystemRevocationLog.FaultInjector.NONE)) { + assertEquals(STORE_ID, log.storeId()); + } + assertEquals(1, warnings.get()); + } finally { + logger.removeHandler(handler); + } + System.out.println("...ok"); + } + + @Test + void limitedCapabilitiesStillAttemptParentForceAndLoggingFailureIsAdvisory() throws Exception { + System.out.print("limitedCapabilitiesStillAttemptParentForceAndLoggingFailureIsAdvisory "); + Path path = logPath("limited-parent-force"); + AtomicInteger parentForces = new AtomicInteger(); + FilesystemRevocationLog.CapabilityProfile limited = + new FilesystemRevocationLog.CapabilityProfile() { + @Override + public boolean posixAvailable(Path parent) { + return false; + } + + @Override + public boolean localFileSystem(Path parent) { + return false; + } + + @Override + public void forceParent(Path parent) { + parentForces.incrementAndGet(); + } + }; + Logger logger = Logger.getLogger(FilesystemRevocationLog.class.getName()); + Handler failing = new Handler() { + @Override + public void publish(LogRecord record) { + throw new IllegalStateException("injected handler failure"); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }; + logger.addHandler(failing); + try (FilesystemRevocationLog log = FilesystemRevocationLog.create( + path, STORE_ID, authority(), limited, FilesystemRevocationLog.FaultInjector.NONE)) { + assertEquals(STORE_ID, log.storeId()); + } finally { + logger.removeHandler(failing); + } + assertEquals(1, parentForces.get()); + System.out.println("...ok"); + } + + @Test + void streamingSinkIsProvisionalAndNoHistoryCollectionIsRetained() throws Exception { + System.out.print("streamingSinkIsProvisionalAndNoHistoryCollectionIsRetained "); + Path path = logPath("sink"); + try (FilesystemRevocationLog log = FilesystemRevocationLog.create(path, STORE_ID, authority())) { + log.append(FIRST, held(1L, 1L)); + log.append(SECOND, held(1L, 2L)); + AtomicInteger accepted = new AtomicInteger(); + AtomicInteger completed = new AtomicInteger(); + log.scan(new FilesystemRevocationLog.RecoverySink() { + @Override + public void accept(RevocationTransitionFrameCodec.CompleteRecord record) { + accepted.incrementAndGet(); + } + + @Override + public void complete() { + completed.incrementAndGet(); + } + + @Override + public void abort() { + throw new AssertionError("valid scan must not abort"); + } + }); + assertEquals(2, accepted.get()); + assertEquals(1, completed.get()); + assertEquals(2, log.activeCredentialCount()); + } + String source = Files.readString(Path.of( + "src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationLog.java")); + assertFalse(source.contains("List")); + assertFalse(source.contains("readAllBytes()")); + assertFalse(source.contains("MAX_TRANSITIONS")); + System.out.println("...ok"); + } + + @Test + void recoverySinkFailuresRemainCallbackFailuresAndAlwaysAbort() throws Exception { + System.out.print("recoverySinkFailuresRemainCallbackFailuresAndAlwaysAbort "); + Path path = logPath("sink-failures"); + try (FilesystemRevocationLog log = FilesystemRevocationLog.create(path, STORE_ID, authority())) { + log.append(FIRST, held(1L, 1L)); + IOException acceptFailure = new IOException("injected sink accept failure"); + AtomicInteger acceptAborts = new AtomicInteger(); + IOException observedAccept = assertThrows(IOException.class, + () -> log.scan(new FilesystemRevocationLog.RecoverySink() { + @Override + public void accept(RevocationTransitionFrameCodec.CompleteRecord record) + throws IOException { + throw acceptFailure; + } + + @Override + public void complete() { + throw new AssertionError("failed replay must not complete"); + } + + @Override + public void abort() { + acceptAborts.incrementAndGet(); + } + })); + assertSame(acceptFailure, observedAccept); + assertEquals(1, acceptAborts.get()); + + IOException completeFailure = new IOException("injected sink completion failure"); + AtomicInteger completeAborts = new AtomicInteger(); + IOException observedComplete = assertThrows(IOException.class, + () -> log.scan(new FilesystemRevocationLog.RecoverySink() { + @Override + public void accept(RevocationTransitionFrameCodec.CompleteRecord record) { + } + + @Override + public void complete() throws IOException { + throw completeFailure; + } + + @Override + public void abort() { + completeAborts.incrementAndGet(); + } + })); + assertSame(completeFailure, observedComplete); + assertEquals(1, completeAborts.get()); + } + System.out.println("...ok"); + } + + private Path logPath(String name) throws IOException { + Path root = temporaryDirectory.resolve(name); + Path path = new FsPaths(root).revocationTransitionLog(); + Files.createDirectories(path.getParent()); + return path; + } + + private Path rawLog( + String name, List records) throws IOException { + Path path = logPath(name); + RevocationTransitionFrameCodec codec = new RevocationTransitionFrameCodec(); + try (FileChannel channel = rawChannel(path)) { + codec.writePreamble(channel, STORE_ID); + for (RevocationTransitionFrameCodec.TransitionData record : records) { + codec.write(channel, record); + } + } + return path; + } + + private static FileChannel rawChannel(Path path) throws IOException { + return FileChannel.open(path, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.READ, StandardOpenOption.WRITE); + } + + private static FilesystemRevocationLog.CredentialAuthority authority() { + return credentialId -> { + if (!AUTHORIZED.contains(credentialId)) { + throw new IOException("Credential does not belong to this store"); + } + }; + } + + private static FilesystemRevocationLog.CapabilityProfile supportedProfile() { + return new FilesystemRevocationLog.CapabilityProfile() { + @Override + public boolean posixAvailable(Path parent) { + return true; + } + + @Override + public boolean localFileSystem(Path parent) { + return true; + } + + @Override + public void forceParent(Path parent) { + } + }; + } + + private static RevocationTransitionFrameCodec.TransitionData firstData( + long globalRevision, PkiId credentialId, RevocationTransition transition) { + return new RevocationTransitionFrameCodec.TransitionData( + globalRevision, RevocationTransitionFrameCodec.initialCommitment(STORE_ID), credentialId, + OptionalLong.empty(), Optional.empty(), transition); + } + + private static RevocationTransitionFrameCodec.TransitionData linkedData( + long globalRevision, + PkiId credentialId, + RevocationTransition transition, + long previousCredentialGlobalRevision, + RevocationTransitionFrameCodec.Commitment previousGlobal, + RevocationTransitionFrameCodec.Commitment previousCredential) { + return new RevocationTransitionFrameCodec.TransitionData( + globalRevision, previousGlobal, credentialId, + OptionalLong.of(previousCredentialGlobalRevision), + Optional.of(previousCredential), transition); + } + + 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 RevocationTransition permanent(long revision, long second) { + return new RevocationTransition(revision, RevocationState.PERMANENTLY_REVOKED, + Instant.ofEpochSecond(second), Optional.of(RevocationReason.KEY_COMPROMISE), + new SimpleAttributeSet()); + } +} diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/RevocationTransitionFrameCodecTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/RevocationTransitionFrameCodecTest.java new file mode 100644 index 0000000..b4cb56f --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/fs/RevocationTransitionFrameCodecTest.java @@ -0,0 +1,441 @@ +/******************************************************************************* + * 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.assertFalse; +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.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.HexFormat; +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 RevocationTransitionFrameCodecTest { + + private static final MetadataStoreId STORE_ID = + new MetadataStoreId("00112233445566778899aabbccddeeff"); + private static final PkiId CREDENTIAL = new PkiId("credential:canonical:1"); + private static final byte[] DOMAIN = "ZeroEcho revocation transition record v1" + .getBytes(java.nio.charset.StandardCharsets.US_ASCII); + + @TempDir + Path temporaryDirectory; + + @Test + void canonicalRoundTripPreservesEveryTransitionField() throws Exception { + System.out.print("canonicalRoundTripPreservesEveryTransitionField "); + SimpleAttributeSet attributes = new SimpleAttributeSet(List.of( + new SimpleAttributeSet.Entry(new AttributeId("z.example"), List.of( + new AttributeValue.StringValue("value"), + new AttributeValue.BooleanValue(true), + new AttributeValue.IntegerValue(Long.MAX_VALUE), + new AttributeValue.InstantValue(Instant.ofEpochSecond(55L, 42)), + new AttributeValue.BytesValue(new byte[] { 0x01, 0x02 }))), + new SimpleAttributeSet.Entry(new AttributeId("a.example"), List.of()))); + RevocationTransition transition = new RevocationTransition( + 1L, RevocationState.PERMANENTLY_REVOKED, + Instant.ofEpochSecond(123_456L, 789), + Optional.of(RevocationReason.KEY_COMPROMISE), attributes); + RevocationTransitionFrameCodec.TransitionData data = firstData(1L, transition); + Path path = temporaryDirectory.resolve("round-trip.log"); + RevocationTransitionFrameCodec codec = new RevocationTransitionFrameCodec(); + try (FileChannel channel = newChannel(path)) { + codec.writePreamble(channel, STORE_ID); + RevocationTransitionFrameCodec.CompleteRecord written = codec.write(channel, data); + RevocationTransitionFrameCodec.ReadResult decoded = + codec.read(channel, RevocationTransitionFrameCodec.PREAMBLE_BYTES); + assertEquals(RevocationTransitionFrameCodec.Classification.COMPLETE_RECORD, + decoded.classification()); + RevocationTransitionFrameCodec.CompleteRecord record = decoded.record().orElseThrow(); + assertEquals(written.commitment(), record.commitment()); + assertEquals(data.globalRevision(), record.data().globalRevision()); + assertEquals(data.credentialId(), record.data().credentialId()); + assertEquals(transition.revision(), record.data().transition().revision()); + assertEquals(transition.state(), record.data().transition().state()); + assertEquals(transition.time(), record.data().transition().time()); + assertEquals(transition.permanentReason(), record.data().transition().permanentReason()); + assertEquals(List.of(), record.data().transition().attributes() + .getAll(new AttributeId("a.example"))); + assertArrayEquals(new byte[] { 0x01, 0x02 }, + ((AttributeValue.BytesValue) record.data().transition().attributes() + .getAll(new AttributeId("z.example")).get(4)).value()); + assertEquals(RevocationTransitionFrameCodec.Classification.END_OF_INPUT, + codec.read(channel, record.recordEnd()).classification()); + } + System.out.println("...ok"); + } + + @Test + void preambleAndSuccessorLinksRoundTripCanonically() throws Exception { + System.out.print("preambleAndSuccessorLinksRoundTripCanonically "); + Path path = temporaryDirectory.resolve("links.log"); + RevocationTransitionFrameCodec codec = new RevocationTransitionFrameCodec(); + try (FileChannel channel = newChannel(path)) { + codec.writePreamble(channel, STORE_ID); + assertEquals(STORE_ID, codec.readPreamble(channel)); + channel.position(RevocationTransitionFrameCodec.PREAMBLE_BYTES); + RevocationTransitionFrameCodec.CompleteRecord first = codec.write( + channel, firstData(1L, held(1L, 10L))); + RevocationTransitionFrameCodec.TransitionData second = + new RevocationTransitionFrameCodec.TransitionData( + 2L, first.commitment(), CREDENTIAL, + OptionalLong.of(1L), Optional.of(first.commitment()), clear(2L, 11L)); + RevocationTransitionFrameCodec.CompleteRecord written = codec.write(channel, second); + RevocationTransitionFrameCodec.CompleteRecord decoded = + codec.read(channel, first.recordEnd()).record().orElseThrow(); + assertEquals(written, decoded); + assertEquals(OptionalLong.of(1L), decoded.data().previousCredentialGlobalRevision()); + assertEquals(Optional.of(first.commitment()), decoded.data().previousCredentialCommitment()); + } + System.out.println("...ok"); + } + + @Test + void everyPartialFixedHeaderIsAnIncompleteTail() throws Exception { + System.out.print("everyPartialFixedHeaderIsAnIncompleteTail "); + byte[] complete = encodedLog(firstData(1L, held(1L, 10L))); + RevocationTransitionFrameCodec codec = new RevocationTransitionFrameCodec(); + for (int bytes = 1; bytes < RevocationTransitionFrameCodec.HEADER_BYTES; bytes++) { + Path path = temporaryDirectory.resolve("header-" + bytes + ".log"); + Files.write(path, java.util.Arrays.copyOf(complete, + RevocationTransitionFrameCodec.PREAMBLE_BYTES + bytes)); + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { + assertEquals(RevocationTransitionFrameCodec.Classification.INCOMPLETE_TAIL, + codec.read(channel, RevocationTransitionFrameCodec.PREAMBLE_BYTES).classification()); + } + } + System.out.println("...ok"); + } + + @Test + void partialPayloadAndCommitmentAreIncompleteButAuthenticatedLengthCorruptionIsCorrupt() throws Exception { + System.out.print("partialPayloadAndCommitmentAreIncompleteButAuthenticatedLengthCorruptionIsCorrupt "); + byte[] complete = encodedLog(firstData(1L, held(1L, 10L))); + int recordStart = RevocationTransitionFrameCodec.PREAMBLE_BYTES; + long payloadLength = ByteBuffer.wrap(complete, recordStart + 48, Long.BYTES) + .order(ByteOrder.BIG_ENDIAN).getLong(); + int payloadStart = recordStart + RevocationTransitionFrameCodec.HEADER_BYTES; + int footerStart = Math.toIntExact(payloadStart + payloadLength); + int[] cuts = { payloadStart, payloadStart + 1, footerStart, footerStart + 1, complete.length - 1 }; + RevocationTransitionFrameCodec codec = new RevocationTransitionFrameCodec(); + for (int cut : cuts) { + Path path = temporaryDirectory.resolve("tail-" + cut + ".log"); + Files.write(path, java.util.Arrays.copyOf(complete, cut)); + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { + assertEquals(RevocationTransitionFrameCodec.Classification.INCOMPLETE_TAIL, + codec.read(channel, recordStart).classification()); + } + } + complete[recordStart + 55] ^= 0x01; + Path corrupt = temporaryDirectory.resolve("length-corrupt.log"); + Files.write(corrupt, complete); + try (FileChannel channel = FileChannel.open(corrupt, StandardOpenOption.READ)) { + assertEquals(RevocationTransitionFrameCodec.Classification.CORRUPT_RECORD, + codec.read(channel, recordStart).classification()); + } + System.out.println("...ok"); + } + + @Test + void malformedSemanticFieldsAndTrailingPayloadAreCorrupt() throws Exception { + System.out.print("malformedSemanticFieldsAndTrailingPayloadAreCorrupt "); + byte[] original = encodedLog(firstData(1L, held(1L, 10L))); + int payloadStart = RevocationTransitionFrameCodec.PREAMBLE_BYTES + + RevocationTransitionFrameCodec.HEADER_BYTES; + byte[][] corruptions = new byte[4][]; + corruptions[0] = original.clone(); + corruptions[0][payloadStart] = 0x02; + corruptions[1] = original.clone(); + corruptions[1][payloadStart + 2] = 0x01; + corruptions[2] = original.clone(); + int credentialStart = payloadStart + 8; + corruptions[2][credentialStart] = (byte) 0xC0; + corruptions[3] = appendSemanticTrailingByte(original); + RevocationTransitionFrameCodec codec = new RevocationTransitionFrameCodec(); + for (int index = 0; index < corruptions.length; index++) { + refreshDigests(corruptions[index]); + Path path = temporaryDirectory.resolve("semantic-" + index + ".log"); + Files.write(path, corruptions[index]); + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { + assertEquals(RevocationTransitionFrameCodec.Classification.CORRUPT_RECORD, + codec.read(channel, RevocationTransitionFrameCodec.PREAMBLE_BYTES).classification()); + } + } + System.out.println("...ok"); + } + + @Test + void commitmentMismatchAndUnknownHeaderFieldsAreCorrupt() throws Exception { + System.out.print("commitmentMismatchAndUnknownHeaderFieldsAreCorrupt "); + byte[] commitmentMismatch = encodedLog(firstData(1L, held(1L, 10L))); + commitmentMismatch[commitmentMismatch.length - 1] ^= 0x01; + byte[] unknownVersion = encodedLog(firstData(1L, held(1L, 10L))); + int header = RevocationTransitionFrameCodec.PREAMBLE_BYTES; + unknownVersion[header + 5] = 0x02; + refreshHeaderDigest(unknownVersion); + byte[] nonzeroFlags = encodedLog(firstData(1L, held(1L, 10L))); + nonzeroFlags[header + 7] = 0x01; + refreshHeaderDigest(nonzeroFlags); + RevocationTransitionFrameCodec codec = new RevocationTransitionFrameCodec(); + List values = List.of(commitmentMismatch, unknownVersion, nonzeroFlags); + for (int index = 0; index < values.size(); index++) { + Path path = temporaryDirectory.resolve("header-corrupt-" + index + ".log"); + Files.write(path, values.get(index)); + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { + assertEquals(RevocationTransitionFrameCodec.Classification.CORRUPT_RECORD, + codec.read(channel, header).classification()); + } + } + System.out.println("...ok"); + } + + @Test + void invalidLengthsRevisionsAndIdentityAreRejectedBeforeWriting() throws Exception { + System.out.print("invalidLengthsRevisionsAndIdentityAreRejectedBeforeWriting "); + RevocationTransitionFrameCodec codec = new RevocationTransitionFrameCodec(); + Path path = temporaryDirectory.resolve("rejected.log"); + try (FileChannel channel = newChannel(path)) { + codec.writePreamble(channel, STORE_ID); + assertThrows(IllegalArgumentException.class, () -> codec.write(channel, + new RevocationTransitionFrameCodec.TransitionData( + 0L, RevocationTransitionFrameCodec.initialCommitment(STORE_ID), CREDENTIAL, + OptionalLong.empty(), Optional.empty(), held(1L, 1L)))); + String excessive = "x".repeat(RevocationTransitionFrameCodec.MAX_COMPONENT_BYTES + 1); + assertThrows(IllegalArgumentException.class, () -> codec.write(channel, + firstData(1L, new RevocationTransition( + 1L, RevocationState.HELD, Instant.EPOCH, Optional.empty(), + new SimpleAttributeSet(List.of(new SimpleAttributeSet.Entry( + new AttributeId(excessive), List.of()))))))); + assertEquals(RevocationTransitionFrameCodec.PREAMBLE_BYTES, channel.size()); + } + assertThrows(IllegalArgumentException.class, () -> new RevocationTransitionFrameCodec.Commitment("00")); + System.out.println("...ok"); + } + + @Test + void invalidStateReasonCombinationsAreRejectedOnEncodeAndDecode() throws Exception { + System.out.print("invalidStateReasonCombinationsAreRejectedOnEncodeAndDecode "); + RevocationTransitionFrameCodec codec = new RevocationTransitionFrameCodec(); + Path encodePath = temporaryDirectory.resolve("invalid-state-reason-encode.log"); + List invalid = List.of( + transition(RevocationState.HELD, Optional.of(RevocationReason.KEY_COMPROMISE)), + transition(RevocationState.CLEAR, Optional.of(RevocationReason.UNSPECIFIED)), + transition(RevocationState.PERMANENTLY_REVOKED, Optional.empty()), + transition(RevocationState.PERMANENTLY_REVOKED, + Optional.of(RevocationReason.CERTIFICATE_HOLD)), + transition(RevocationState.PERMANENTLY_REVOKED, + Optional.of(RevocationReason.REMOVE_FROM_CRL))); + try (FileChannel channel = newChannel(encodePath)) { + codec.writePreamble(channel, STORE_ID); + for (RevocationTransition transition : invalid) { + assertThrows(IllegalArgumentException.class, + () -> codec.write(channel, firstData(1L, transition))); + } + assertEquals(RevocationTransitionFrameCodec.PREAMBLE_BYTES, channel.size()); + } + + byte[] malformed = encodedLog(firstData(1L, held(1L, 10L))); + int reasonOffset = reasonOffset(malformed); + malformed[reasonOffset] = 2; + refreshDigests(malformed); + Path decodePath = temporaryDirectory.resolve("invalid-state-reason-decode.log"); + Files.write(decodePath, malformed); + try (FileChannel channel = FileChannel.open(decodePath, StandardOpenOption.READ)) { + assertEquals(RevocationTransitionFrameCodec.Classification.CORRUPT_RECORD, + codec.read(channel, RevocationTransitionFrameCodec.PREAMBLE_BYTES).classification()); + } + System.out.println("...ok"); + } + + @Test + void malformedUtf16IsRejectedBeforeAnyRecordHeaderByteIsWritten() throws Exception { + System.out.print("malformedUtf16IsRejectedBeforeAnyRecordHeaderByteIsWritten "); + RevocationTransitionFrameCodec codec = new RevocationTransitionFrameCodec(); + Path path = temporaryDirectory.resolve("malformed-utf16.log"); + List malformed = List.of( + new RevocationTransitionFrameCodec.TransitionData( + 1L, RevocationTransitionFrameCodec.initialCommitment(STORE_ID), + new PkiId("credential:\uD800"), OptionalLong.empty(), Optional.empty(), + held(1L, 1L)), + firstData(1L, transitionWithAttributes(new SimpleAttributeSet(List.of( + new SimpleAttributeSet.Entry(new AttributeId("attribute.\uD800"), List.of()))))), + firstData(1L, transitionWithAttributes(new SimpleAttributeSet(List.of( + new SimpleAttributeSet.Entry(new AttributeId("attribute.string"), List.of( + new AttributeValue.StringValue("value\uD800")))))))); + try (FileChannel channel = newChannel(path)) { + codec.writePreamble(channel, STORE_ID); + for (RevocationTransitionFrameCodec.TransitionData data : malformed) { + assertThrows(IllegalArgumentException.class, () -> codec.write(channel, data)); + assertEquals(RevocationTransitionFrameCodec.PREAMBLE_BYTES, channel.size()); + } + } + System.out.println("...ok"); + } + + @Test + void repeatedDecodeIsDeterministicAndRetainsOnlyOneFiniteRecord() throws Exception { + System.out.print("repeatedDecodeIsDeterministicAndRetainsOnlyOneFiniteRecord "); + Path path = temporaryDirectory.resolve("deterministic.log"); + Files.write(path, encodedLog(firstData(1L, held(1L, 10L)))); + RevocationTransitionFrameCodec codec = new RevocationTransitionFrameCodec(); + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { + RevocationTransitionFrameCodec.ReadResult first = + codec.read(channel, RevocationTransitionFrameCodec.PREAMBLE_BYTES); + RevocationTransitionFrameCodec.ReadResult second = + codec.read(channel, RevocationTransitionFrameCodec.PREAMBLE_BYTES); + assertEquals(first, second); + assertTrue(first.record().isPresent()); + assertFalse(RevocationTransitionFrameCodec.CompleteRecord.class + .getRecordComponents()[0].getType().isArray()); + } + System.out.println("...ok"); + } + + private byte[] encodedLog(RevocationTransitionFrameCodec.TransitionData data) throws IOException { + Path path = Files.createTempFile(temporaryDirectory, "encoded-", ".log"); + RevocationTransitionFrameCodec codec = new RevocationTransitionFrameCodec(); + try (FileChannel channel = newChannel(path)) { + codec.writePreamble(channel, STORE_ID); + codec.write(channel, data); + } + return Files.readAllBytes(path); + } + + private static RevocationTransitionFrameCodec.TransitionData firstData( + long globalRevision, RevocationTransition transition) { + return new RevocationTransitionFrameCodec.TransitionData( + globalRevision, RevocationTransitionFrameCodec.initialCommitment(STORE_ID), CREDENTIAL, + OptionalLong.empty(), Optional.empty(), transition); + } + + 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 RevocationTransition transition( + RevocationState state, Optional reason) { + return new RevocationTransition( + 1L, state, Instant.EPOCH, reason, new SimpleAttributeSet()); + } + + private static RevocationTransition transitionWithAttributes(SimpleAttributeSet attributes) { + return new RevocationTransition( + 1L, RevocationState.HELD, Instant.EPOCH, Optional.empty(), attributes); + } + + private static FileChannel newChannel(Path path) throws IOException { + return FileChannel.open(path, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.READ, StandardOpenOption.WRITE); + } + + private static byte[] appendSemanticTrailingByte(byte[] original) { + int header = RevocationTransitionFrameCodec.PREAMBLE_BYTES; + long oldLength = ByteBuffer.wrap(original, header + 48, Long.BYTES) + .order(ByteOrder.BIG_ENDIAN).getLong(); + int footer = Math.toIntExact(header + RevocationTransitionFrameCodec.HEADER_BYTES + oldLength); + byte[] expanded = new byte[original.length + 1]; + System.arraycopy(original, 0, expanded, 0, footer); + expanded[footer] = 0x00; + System.arraycopy(original, footer, expanded, footer + 1, + RevocationTransitionFrameCodec.COMMITMENT_BYTES); + ByteBuffer.wrap(expanded, header + 48, Long.BYTES) + .order(ByteOrder.BIG_ENDIAN).putLong(oldLength + 1L); + return expanded; + } + + private static int reasonOffset(byte[] encoded) { + int payload = RevocationTransitionFrameCodec.PREAMBLE_BYTES + + RevocationTransitionFrameCodec.HEADER_BYTES; + int credentialLength = ByteBuffer.wrap(encoded, payload + 4, Integer.BYTES) + .order(ByteOrder.BIG_ENDIAN).getInt(); + int state = payload + 4 + Integer.BYTES + credentialLength + + Long.BYTES + 1 + Long.BYTES + Integer.BYTES; + return state + 1; + } + + private static void refreshDigests(byte[] encoded) throws NoSuchAlgorithmException { + refreshHeaderDigest(encoded); + int header = RevocationTransitionFrameCodec.PREAMBLE_BYTES; + long payloadLength = ByteBuffer.wrap(encoded, header + 48, Long.BYTES) + .order(ByteOrder.BIG_ENDIAN).getLong(); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + digest.update(DOMAIN); + digest.update(encoded, header, RevocationTransitionFrameCodec.HEADER_BYTES); + digest.update(encoded, header + RevocationTransitionFrameCodec.HEADER_BYTES, + Math.toIntExact(payloadLength)); + byte[] commitment = digest.digest(); + System.arraycopy(commitment, 0, encoded, + header + RevocationTransitionFrameCodec.HEADER_BYTES + Math.toIntExact(payloadLength), + commitment.length); + } + + private static void refreshHeaderDigest(byte[] encoded) throws NoSuchAlgorithmException { + int header = RevocationTransitionFrameCodec.PREAMBLE_BYTES; + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + digest.update(encoded, header, 56); + byte[] headerDigest = digest.digest(); + System.arraycopy(headerDigest, 0, encoded, header + 56, headerDigest.length); + } +}