feat(pki): add append-only revocation transition log
Add a strict authenticated global revocation-transition log with bounded-memory append and recovery scanning. Preserve current revocation semantics while establishing the scalable persistence substrate for later authority cutover, derived indexing, checkpointing, and streamed CRL snapshots.
This commit is contained in:
@@ -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<String> LOCAL_FILE_SYSTEMS =
|
||||
Set.of("apfs", "btrfs", "ext2", "ext3", "ext4", "tmpfs", "ufs", "xfs", "zfs");
|
||||
private static final Set<java.nio.file.attribute.PosixFilePermission> 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<PkiId, LatestState> 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<PkiId, LatestState> 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<PkiId, LatestState> 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<OpenOption> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<PkiId> 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<List<RevocationTransitionFrameCodec.TransitionData>> 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<RevocationTransition>"));
|
||||
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<RevocationTransitionFrameCodec.TransitionData> 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());
|
||||
}
|
||||
}
|
||||
@@ -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<byte[]> 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<RevocationTransition> 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<RevocationTransitionFrameCodec.TransitionData> 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<RevocationReason> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user