feat(pki): migrate signing authority to transactional metadata

Atomically persist signing workflow records and SIGNING_OPERATION
content-owner edges through the transactional metadata store.

Use the canonical SigningSubmissionId directly as the MetadataKey,
remove the former current.bin and signing-specific .owners authority,
and recover pending signing operations only from consistent metadata
record/owner pairs.

Keep immutable staged payload and reference metadata external.

Validated:
- focused migration tests pass
- lib tests pass
- PMD and JavaDoc pass
- app compilation passes
- pki retains only the 31 independently classified credential and
  revocation failures
This commit is contained in:
2026-08-01 17:58:16 +02:00
parent 5420c19d08
commit 08db857e05
8 changed files with 815 additions and 227 deletions

View File

@@ -58,7 +58,6 @@ import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.content.DurableContentReference; import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.api.content.DurableContentOwner;
import zeroecho.pki.api.audit.Principal; import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy; import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy;
import zeroecho.pki.api.orch.SigningSubmissionId; import zeroecho.pki.api.orch.SigningSubmissionId;
@@ -427,36 +426,15 @@ public final class PkiSigningBus implements AutoCloseable {
SignWorkflowStore.Record intent = new SignWorkflowStore.Record(baseOpId, namespace, fingerprint, owner, SignWorkflowStore.Record intent = new SignWorkflowStore.Record(baseOpId, namespace, fingerprint, owner,
parsed.createdAt(), deadline, persistedRequest, SignWorkflowStore.State.INTENT, 0L, 0L, parsed.createdAt(), deadline, persistedRequest, SignWorkflowStore.State.INTENT, 0L, 0L,
Optional.empty(), Optional.of("INTENT"), Optional.empty(), Optional.empty()); Optional.empty(), Optional.of("INTENT"), Optional.empty(), Optional.empty());
DurableContentOwner contentOwner = DurableContentOwner.signingOperation(baseOpId);
boolean retained = false;
boolean recordVisible = false;
try {
retained = store.stagedContent().retainContent(content, contentOwner);
SignWorkflowStore.CreateResult created = store.createSignIntent(intent); SignWorkflowStore.CreateResult created = store.createSignIntent(intent);
if (created == SignWorkflowStore.CreateResult.CONFLICT) { if (created == SignWorkflowStore.CreateResult.CONFLICT) {
throw new PkiException("Signing submission identifier conflicts with a different request"); throw new PkiException("Signing submission identifier conflicts with a different request");
} }
recordVisible = true;
authoritative = store.getSignRecord(baseOpId).orElseThrow(); authoritative = store.getSignRecord(baseOpId).orElseThrow();
} catch (java.io.IOException exception) {
throw new PkiException("Signing content retention failed: code=SPOOL_STORAGE_FAILED", exception);
} finally {
if (retained && !recordVisible) {
rollbackSigningOwner(content, contentOwner);
}
}
} }
project(authoritative); project(authoritative);
} }
private void rollbackSigningOwner(DurableContentReference content, DurableContentOwner owner) {
try {
store.stagedContent().releaseContent(content, owner);
} catch (java.io.IOException exception) {
throw new PkiException("Signing content rollback failed: code=SPOOL_STORAGE_FAILED", exception);
}
}
/** /**
* Returns current status if known. * Returns current status if known.
*/ */
@@ -564,15 +542,8 @@ public final class PkiSigningBus implements AutoCloseable {
if (!isTerminalSignState(state.state())) { if (!isTerminalSignState(state.state())) {
return; return;
} }
Optional<DurableContentReference> releaseReference = Optional.empty();
if (state.state() != SignWorkflowStore.State.RETIRED) {
releaseReference = Optional.of(SignContinuation.decode(state.request(), store.stagedContent()).content());
}
state = confirmRetirement(baseOpId, state); state = confirmRetirement(baseOpId, state);
store.deleteWorkflowState(baseOpId); store.deleteWorkflowState(baseOpId);
if (releaseReference.isPresent()) {
releaseRetiredOperationContent(baseOpId, releaseReference.get());
}
} }
AsyncState advisoryState = state.result().isPresent() ? AsyncState.SUCCEEDED : AsyncState.CANCELLED; AsyncState advisoryState = state.result().isPresent() ? AsyncState.SUCCEEDED : AsyncState.CANCELLED;
bus.update(baseOpId, new AsyncStatus(advisoryState, store.signingNow(), Optional.of("RETIRED"), bus.update(baseOpId, new AsyncStatus(advisoryState, store.signingNow(), Optional.of("RETIRED"),
@@ -580,16 +551,6 @@ public final class PkiSigningBus implements AutoCloseable {
bus.retire(baseOpId); bus.retire(baseOpId);
} }
private void releaseRetiredOperationContent(PkiId operationId, DurableContentReference reference) {
if (reference.lifecycle() != DurableContentReference.Lifecycle.OPERATION) {
return;
}
try {
store.stagedContent().releaseContent(reference, DurableContentOwner.signingOperation(operationId));
} catch (java.io.IOException exception) {
throw new PkiException("Signing content retirement failed: code=SPOOL_STORAGE_FAILED", exception);
}
}
private void reconcileExpiredOperations() { private void reconcileExpiredOperations() {
Instant current = store.signingNow(); Instant current = store.signingNow();
@@ -1103,8 +1064,6 @@ public final class PkiSigningBus implements AutoCloseable {
boolean reservationTransferred = false; boolean reservationTransferred = false;
try { try {
SignContinuation continuation = SignContinuation.decode(claimed.request(), store.stagedContent()); SignContinuation continuation = SignContinuation.decode(claimed.request(), store.stagedContent());
DurableContentOwner contentOwner = DurableContentOwner.signingOperation(opId);
requireSigningOwner(continuation.content(), contentOwner);
X509ExecutionPlan<SignatureWorkflow> plan = authority.planSigning(continuation.algorithmId, X509ExecutionPlan<SignatureWorkflow> plan = authority.planSigning(continuation.algorithmId,
workflowImplementationId(signer), SignatureWorkflow.class); workflowImplementationId(signer), SignatureWorkflow.class);
authority.authorize(plan, signer, AlgorithmExecutionCapability.Direction.SIGN); authority.authorize(plan, signer, AlgorithmExecutionCapability.Direction.SIGN);
@@ -1133,16 +1092,6 @@ public final class PkiSigningBus implements AutoCloseable {
} }
} }
private void requireSigningOwner(DurableContentReference reference, DurableContentOwner owner) {
try {
if (!store.stagedContent().contentOwners(reference).contains(owner)) {
throw new PkiException("Signing content owner missing: code=STAGED_CONTENT_INCOMPLETE");
}
} catch (java.io.IOException exception) {
throw new PkiException("Signing content ownership failed: code=CONTENT_INTEGRITY_FAILED", exception);
}
}
private RepeatableContent openContent(DurableContentReference reference) { private RepeatableContent openContent(DurableContentReference reference) {
try { try {
return store.stagedContent().openContent(reference); return store.stagedContent().openContent(reference);

View File

@@ -34,7 +34,9 @@
package zeroecho.pki.impl.fs; package zeroecho.pki.impl.fs;
import java.io.Closeable; import java.io.Closeable;
import java.io.ByteArrayInputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.nio.channels.FileChannel; import java.nio.channels.FileChannel;
import java.nio.channels.FileLock; import java.nio.channels.FileLock;
@@ -55,10 +57,12 @@ import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Comparator; import java.util.Comparator;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.OptionalLong; import java.util.OptionalLong;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ConcurrentMap;
@@ -95,6 +99,14 @@ import zeroecho.pki.impl.ProfileLifecycleFailure;
import zeroecho.pki.impl.ProfileLifecycleFailure.Code; import zeroecho.pki.impl.ProfileLifecycleFailure.Code;
import zeroecho.pki.impl.core.async.PkiSigningBus; import zeroecho.pki.impl.core.async.PkiSigningBus;
import zeroecho.pki.spi.store.PkiStore; import zeroecho.pki.spi.store.PkiStore;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.spi.store.MetadataCommitResult;
import zeroecho.pki.spi.store.MetadataCursor;
import zeroecho.pki.spi.store.MetadataKey;
import zeroecho.pki.spi.store.MetadataSnapshot;
import zeroecho.pki.spi.store.MetadataStoreId;
import zeroecho.pki.spi.store.MetadataTransaction;
import zeroecho.pki.spi.store.StagedContentStore; import zeroecho.pki.spi.store.StagedContentStore;
import zeroecho.pki.spi.store.SignWorkflowStore; import zeroecho.pki.spi.store.SignWorkflowStore;
import zeroecho.pki.spi.store.TemporaryUniqueIndex; import zeroecho.pki.spi.store.TemporaryUniqueIndex;
@@ -155,9 +167,10 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
private static final Logger LOG = Logger.getLogger(FilesystemPkiStore.class.getName()); private static final Logger LOG = Logger.getLogger(FilesystemPkiStore.class.getName());
/* package */ static final String CURRENT_STORE_VERSION = "v2"; /* package */ static final String CURRENT_STORE_VERSION = "v2";
private static final int SIGN_RECORD_MAGIC = 0x5A455352; private static final String SIGN_RECORD_NAMESPACE = "io.zeroecho.pki.signing-record";
private static final String SIGN_OWNER_NAMESPACE = "io.zeroecho.pki.signing-owner";
private static final int CURRENT_SIGN_RECORD_VERSION = 2; private static final int CURRENT_SIGN_RECORD_VERSION = 2;
private static final int SIGN_RECORD_HEADER_BYTES = Integer.BYTES * 2; private static final int SIGN_OWNER_VALUE_VERSION = 1;
private static final String SIGN_FINGERPRINT_PREFIX = "signfp:v1:"; private static final String SIGN_FINGERPRINT_PREFIX = "signfp:v1:";
private static final int SIGN_FINGERPRINT_HEX_LENGTH = 64; private static final int SIGN_FINGERPRINT_HEX_LENGTH = 64;
private static final long INITIAL_FENCE = 0L; private static final long INITIAL_FENCE = 0L;
@@ -176,6 +189,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
private final AtomicBoolean durabilityUncertain; private final AtomicBoolean durabilityUncertain;
private final FilesystemStagedContentStore stagedContent; private final FilesystemStagedContentStore stagedContent;
private final CredentialContentTransaction credentialContentTransactions; private final CredentialContentTransaction credentialContentTransactions;
private final PosixTransactionalMetadataStore metadataStore;
private final StoreOwnership ownership; private final StoreOwnership ownership;
@@ -227,11 +241,16 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
} }
boolean ownershipTransferred = false; boolean ownershipTransferred = false;
PosixTransactionalMetadataStore openedMetadata = null;
try { try {
ensureVersionFile(); ensureVersionFile();
this.signingNamespace = ensureSigningNamespace(); this.signingNamespace = ensureSigningNamespace();
this.stagedContent = new FilesystemStagedContentStore(this.paths.stagedContentRoot(), this.stagedContent = new FilesystemStagedContentStore(this.paths.stagedContentRoot(),
this.signingNamespace); this.signingNamespace);
FsOperations.ensureDir(this.paths.transactionalMetadataLog().getParent());
openedMetadata = openMetadataStore();
this.metadataStore = openedMetadata;
this.stagedContent.bindSigningOwnership(this::findSigningOwner);
this.credentialContentTransactions = new CredentialContentTransaction(this.paths, this.stagedContent); this.credentialContentTransactions = new CredentialContentTransaction(this.paths, this.stagedContent);
this.signingTimeWatermark = new AtomicLong(loadSigningTimeWatermark()); this.signingTimeWatermark = new AtomicLong(loadSigningTimeWatermark());
this.historySeq = new AtomicLong(0L); this.historySeq = new AtomicLong(0L);
@@ -244,11 +263,66 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
throw new IllegalStateException("failed to open filesystem store at " + root, e); throw new IllegalStateException("failed to open filesystem store at " + root, e);
} finally { } finally {
if (!ownershipTransferred) { if (!ownershipTransferred) {
if (openedMetadata != null) {
try {
closeMetadataAfterFailedOpen(openedMetadata);
} catch (IOException closeFailure) {
LOG.log(Level.WARNING, "Metadata-store cleanup failed during initialization");
}
}
acquiredOwnership.closeSilently(); acquiredOwnership.closeSilently();
} }
} }
} }
private static void closeMetadataAfterFailedOpen(PosixTransactionalMetadataStore openedMetadata)
throws IOException {
openedMetadata.close();
}
private PosixTransactionalMetadataStore openMetadataStore() throws IOException {
Path log = paths.transactionalMetadataLog();
PosixTransactionalMetadataStore opened = Files.exists(log)
? PosixTransactionalMetadataStore.open(log)
: PosixTransactionalMetadataStore.create(log, new MetadataStoreId(signingNamespace));
if (!opened.id().equals(new MetadataStoreId(signingNamespace))) {
try {
opened.close();
} catch (IOException closeFailure) {
throw new IOException("Metadata store authority mismatch", closeFailure);
}
throw new IOException("Metadata store authority mismatch");
}
return opened;
}
private Set<DurableContentOwner> findSigningOwner(DurableContentReference reference) throws IOException {
Set<DurableContentOwner> owners = new HashSet<>();
try (MetadataSnapshot snapshot = metadataStore.snapshot();
MetadataCursor cursor = snapshot.scan(MetadataSnapshot.KeyRange.all(SIGN_OWNER_NAMESPACE),
CancellationSignal.NONE)) {
Optional<MetadataSnapshot.Record> next;
while ((next = cursor.next(CancellationSignal.NONE)).isPresent()) {
MetadataSnapshot.Record owner = next.orElseThrow();
try (owner) {
Optional<DurableContentOwner> matched = matchingSigningOwner(owner, reference);
matched.ifPresent(owners::add);
}
}
return Set.copyOf(owners);
}
}
private Optional<DurableContentOwner> matchingSigningOwner(MetadataSnapshot.Record owner,
DurableContentReference reference)
throws IOException {
PkiId submissionId = new PkiId(owner.key().key());
if (reference.equals(decodeSigningOwner(owner, submissionId))) {
return Optional.of(DurableContentOwner.signingOperation(submissionId));
}
return Optional.empty();
}
@Override @Override
public StagedContentStore stagedContent() { public StagedContentStore stagedContent() {
return stagedContent; return stagedContent;
@@ -261,7 +335,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
try { try {
addPersistedCredentialReferences(retained, retainedOwners); addPersistedCredentialReferences(retained, retainedOwners);
addPersistedStatusReferences(retained); addPersistedStatusReferences(retained);
addPendingSigningReferences(retained, retainedOwners); addPendingSigningReferences(retained);
stagedContent.recoverContent(retained, retainedOwners); stagedContent.recoverContent(retained, retainedOwners);
} catch (IllegalStateException | PkiException malformedDurableState) { } catch (IllegalStateException | PkiException malformedDurableState) {
// Recovery cannot prove abandonment while durable metadata is // Recovery cannot prove abandonment while durable metadata is
@@ -307,34 +381,10 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
} }
} }
private void addPendingSigningReferences(TemporaryUniqueIndex retained, TemporaryUniqueIndex retainedOwners) private void addPendingSigningReferences(TemporaryUniqueIndex retained) throws IOException {
throws IOException { for (StoredSign stored : listStoredSigns()) {
Path root = paths.signWorkflowRoot(); if (stored.record().state() != SignWorkflowStore.State.RETIRED) {
if (!Files.isDirectory(root)) { addRetained(retained, stored.reference().orElseThrow());
return;
}
try (Stream<Path> pathsStream = Files.walk(root)) {
java.util.Iterator<Path> iterator = pathsStream
.filter(path -> Files.isRegularFile(path)
&& FsPaths.CURRENT_FILE.equals(path.getFileName().toString()))
.iterator();
while (iterator.hasNext()) {
SignWorkflowStore.Record record = readSignRecordFile(iterator.next());
PkiSigningBus.SignContinuation continuation = PkiSigningBus.SignContinuation.decode(record.request(),
stagedContent);
if (record.state() == SignWorkflowStore.State.RETIRED) {
if (continuation.hasLiveContent()) {
throw new IllegalStateException("Retired signing record retains live content");
}
continue;
}
DurableContentReference reference = continuation.content();
DurableContentOwner owner = DurableContentOwner.signingOperation(record.submissionId());
if (!stagedContent.contentOwners(reference).contains(owner)) {
throw new IllegalStateException("Signing content owner is missing");
}
addRetained(retained, reference);
retainedOwners.add(owner.canonicalForm().getBytes(StandardCharsets.UTF_8));
} }
} }
} }
@@ -812,13 +862,43 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
? SignWorkflowStore.CreateResult.ATTACHED ? SignWorkflowStore.CreateResult.ATTACHED
: SignWorkflowStore.CreateResult.CONFLICT; : SignWorkflowStore.CreateResult.CONFLICT;
} }
writeSignRecord(intent); PkiSigningBus.SignContinuation continuation = PkiSigningBus.SignContinuation.decode(intent.request(),
stagedContent);
DurableContentReference reference = continuation.content();
MetadataCommitResult result;
try (FilesystemStagedContentStore.SigningReservation ignoredReservation =
stagedContent.reserveSigningPublication(reference);
RepeatableContent ignoredContent = stagedContent.openContent(reference)) {
stagedContent.restoreReference(reference.storeId(), reference.contentId(), reference.encoding(),
reference.length(), reference.sha256(), reference.lifecycle());
result = createSignMetadata(intent, reference);
} catch (IOException exception) {
retireFailedSigningPublication(reference);
throw new PkiException("Signing staged content is invalid: code=CONTENT_INTEGRITY_FAILED", exception);
} catch (PkiException | IllegalArgumentException | IllegalStateException exception) {
if (!durabilityUncertain.get()) {
retireFailedSigningPublication(reference);
}
throw exception;
}
if (result.outcome() == MetadataCommitResult.Outcome.COMMITTED) {
return SignWorkflowStore.CreateResult.CREATED; return SignWorkflowStore.CreateResult.CREATED;
}
retireFailedSigningPublication(reference);
return SignWorkflowStore.CreateResult.CONFLICT;
} finally { } finally {
releaseSignLock(intent.submissionId(), lock); releaseSignLock(intent.submissionId(), lock);
} }
} }
private void retireFailedSigningPublication(DurableContentReference reference) {
try {
stagedContent.retireUnownedContent(reference);
} catch (IOException cleanupFailure) {
LOG.log(Level.WARNING, "Failed signing publication cleanup was incomplete");
}
}
@Override @Override
public Optional<SignWorkflowStore.Record> getSignRecord(PkiId submissionId) { public Optional<SignWorkflowStore.Record> getSignRecord(PkiId submissionId) {
requireStoreUsable(); requireStoreUsable();
@@ -834,15 +914,32 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override @Override
public List<SignWorkflowStore.Record> listSignRecords() { public List<SignWorkflowStore.Record> listSignRecords() {
requireStoreUsable(); requireStoreUsable();
Path root = paths.signWorkflowRoot(); return listStoredSigns().stream().map(StoredSign::record).toList();
if (!Files.isDirectory(root)) {
return List.of();
} }
try (Stream<Path> directories = Files.list(root)) {
return directories.filter(Files::isDirectory).map(directory -> directory.resolve(FsPaths.CURRENT_FILE)) private List<StoredSign> listStoredSigns() {
.filter(Files::isRegularFile).sorted(Comparator.comparing(Path::toString)) List<StoredSign> storedSigns = new ArrayList<>();
.map(this::readSignRecordFile).peek(record -> validateSignRecord(record.submissionId(), record)) Set<String> recordIdentities = new HashSet<>();
.toList(); try (MetadataSnapshot snapshot = metadataStore.snapshot()) {
try (MetadataCursor cursor = snapshot.scan(MetadataSnapshot.KeyRange.all(SIGN_RECORD_NAMESPACE),
CancellationSignal.NONE)) {
Optional<MetadataSnapshot.Record> next;
while ((next = cursor.next(CancellationSignal.NONE)).isPresent()) {
StoredSign stored = decodeStoredSign(snapshot, next.orElseThrow());
storedSigns.add(stored);
recordIdentities.add(stored.record().submissionId().value());
}
}
try (MetadataCursor cursor = snapshot.scan(MetadataSnapshot.KeyRange.all(SIGN_OWNER_NAMESPACE),
CancellationSignal.NONE)) {
Optional<MetadataSnapshot.Record> next;
while ((next = cursor.next(CancellationSignal.NONE)).isPresent()) {
if (!recordIdentities.contains(next.orElseThrow().key().key())) {
throw new IllegalStateException("Signing owner exists without its record");
}
}
}
return List.copyOf(storedSigns);
} catch (IOException ex) { } catch (IOException ex) {
throw new IllegalStateException("Failed to list authoritative signing records", ex); throw new IllegalStateException("Failed to list authoritative signing records", ex);
} }
@@ -854,11 +951,12 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
requirePositive(lease, "lease"); requirePositive(lease, "lease");
SignLockEntry lock = acquireSignLock(submissionId); SignLockEntry lock = acquireSignLock(submissionId);
try { try {
Optional<SignWorkflowStore.Record> optional = readSignRecord(submissionId); Optional<StoredSign> optional = readStoredSign(submissionId);
if (optional.isEmpty()) { if (optional.isEmpty()) {
return Optional.empty(); return Optional.empty();
} }
SignWorkflowStore.Record current = optional.get(); StoredSign stored = optional.get();
SignWorkflowStore.Record current = stored.record();
Instant now = signingNow(); Instant now = signingNow();
boolean claimable = current.state() == SignWorkflowStore.State.INTENT; boolean claimable = current.state() == SignWorkflowStore.State.INTENT;
boolean leaseAvailable = current.leaseUntil().isEmpty() || !current.leaseUntil().get().isAfter(now); boolean leaseAvailable = current.leaseUntil().isEmpty() || !current.leaseUntil().get().isAfter(now);
@@ -868,8 +966,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
SignWorkflowStore.Record claimed = copySignRecord(current, current.state(), current.revision() + 1L, SignWorkflowStore.Record claimed = copySignRecord(current, current.state(), current.revision() + 1L,
current.fence() + 1L, Optional.of(now.plus(lease)), current.detailCode(), current.result(), current.fence() + 1L, Optional.of(now.plus(lease)), current.detailCode(), current.result(),
current.providerUpdatedAt()); current.providerUpdatedAt());
writeSignRecord(claimed); return replaceSignMetadata(stored, claimed, false) ? Optional.of(claimed) : Optional.empty();
return Optional.of(claimed);
} finally { } finally {
releaseSignLock(submissionId, lock); releaseSignLock(submissionId, lock);
} }
@@ -882,19 +979,19 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
requirePositive(lease, "lease"); requirePositive(lease, "lease");
SignLockEntry lock = acquireSignLock(submissionId); SignLockEntry lock = acquireSignLock(submissionId);
try { try {
Optional<SignWorkflowStore.Record> optional = readSignRecord(submissionId); Optional<StoredSign> optional = readStoredSign(submissionId);
if (optional.isEmpty()) { if (optional.isEmpty()) {
return Optional.empty(); return Optional.empty();
} }
SignWorkflowStore.Record current = optional.get(); StoredSign stored = optional.get();
SignWorkflowStore.Record current = stored.record();
if (current.revision() != expectedRevision || current.fence() != fence || current.leaseUntil().isEmpty()) { if (current.revision() != expectedRevision || current.fence() != fence || current.leaseUntil().isEmpty()) {
return Optional.empty(); return Optional.empty();
} }
SignWorkflowStore.Record renewed = copySignRecord(current, current.state(), current.revision() + 1L, fence, SignWorkflowStore.Record renewed = copySignRecord(current, current.state(), current.revision() + 1L, fence,
Optional.of(signingNow().plus(lease)), current.detailCode(), current.result(), Optional.of(signingNow().plus(lease)), current.detailCode(), current.result(),
current.providerUpdatedAt()); current.providerUpdatedAt());
writeSignRecord(renewed); return replaceSignMetadata(stored, renewed, false) ? Optional.of(renewed) : Optional.empty();
return Optional.of(renewed);
} finally { } finally {
releaseSignLock(submissionId, lock); releaseSignLock(submissionId, lock);
} }
@@ -911,11 +1008,12 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
Objects.requireNonNull(providerUpdatedAt, "providerUpdatedAt"); Objects.requireNonNull(providerUpdatedAt, "providerUpdatedAt");
SignLockEntry lock = acquireSignLock(submissionId); SignLockEntry lock = acquireSignLock(submissionId);
try { try {
Optional<SignWorkflowStore.Record> optional = readSignRecord(submissionId); Optional<StoredSign> optional = readStoredSign(submissionId);
if (optional.isEmpty()) { if (optional.isEmpty()) {
return Optional.empty(); return Optional.empty();
} }
SignWorkflowStore.Record current = optional.get(); StoredSign stored = optional.get();
SignWorkflowStore.Record current = stored.record();
if (current.revision() != expectedRevision || current.fence() != fence if (current.revision() != expectedRevision || current.fence() != fence
|| !validSignTransition(current.state(), target)) { || !validSignTransition(current.state(), target)) {
return Optional.empty(); return Optional.empty();
@@ -930,8 +1028,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
} }
SignWorkflowStore.Record transitioned = copySignRecord(current, target, current.revision() + 1L, fence, SignWorkflowStore.Record transitioned = copySignRecord(current, target, current.revision() + 1L, fence,
Optional.empty(), detailCode, result, providerUpdatedAt); Optional.empty(), detailCode, result, providerUpdatedAt);
writeSignRecord(transitioned); return replaceSignMetadata(stored, transitioned, false) ? Optional.of(transitioned) : Optional.empty();
return Optional.of(transitioned);
} finally { } finally {
releaseSignLock(submissionId, lock); releaseSignLock(submissionId, lock);
} }
@@ -942,11 +1039,12 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
requireStoreUsable(); requireStoreUsable();
SignLockEntry lock = acquireSignLock(submissionId); SignLockEntry lock = acquireSignLock(submissionId);
try { try {
Optional<SignWorkflowStore.Record> optional = readSignRecord(submissionId); Optional<StoredSign> optional = readStoredSign(submissionId);
if (optional.isEmpty()) { if (optional.isEmpty()) {
return Optional.empty(); return Optional.empty();
} }
SignWorkflowStore.Record current = optional.get(); StoredSign stored = optional.get();
SignWorkflowStore.Record current = stored.record();
if (current.revision() != expectedRevision || current.fence() != fence if (current.revision() != expectedRevision || current.fence() != fence
|| !isRetirableSignState(current.state())) { || !isRetirableSignState(current.state())) {
return Optional.empty(); return Optional.empty();
@@ -958,7 +1056,18 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
current.namespace(), current.fingerprint(), current.owner(), current.createdAt(), current.namespace(), current.fingerprint(), current.owner(), current.createdAt(),
current.deadline(), retiredRequest, SignWorkflowStore.State.RETIRED, current.revision() + 1L, current.deadline(), retiredRequest, SignWorkflowStore.State.RETIRED, current.revision() + 1L,
fence, Optional.empty(), Optional.of("RETIRED"), current.result(), current.providerUpdatedAt()); fence, Optional.empty(), Optional.of("RETIRED"), current.result(), current.providerUpdatedAt());
writeSignRecord(retired); if (!replaceSignMetadata(stored, retired, true)) {
return Optional.empty();
}
stored.reference().ifPresent(reference -> {
try {
if (reference.lifecycle() == DurableContentReference.Lifecycle.OPERATION) {
stagedContent.retireSigningContent(reference);
}
} catch (IOException exception) {
LOG.log(Level.WARNING, "Signing content retirement cleanup failed");
}
});
return Optional.of(retired); return Optional.of(retired);
} finally { } finally {
releaseSignLock(submissionId, lock); releaseSignLock(submissionId, lock);
@@ -968,24 +1077,19 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override @Override
public int purgeExpiredSignRecords() { public int purgeExpiredSignRecords() {
requireStoreUsable(); requireStoreUsable();
Path root = paths.signWorkflowRoot();
if (!Files.isDirectory(root)) {
return 0;
}
int purged = 0; int purged = 0;
for (SignWorkflowStore.Record record : listSignRecords()) { for (SignWorkflowStore.Record record : listSignRecords()) {
if (record.state() == SignWorkflowStore.State.RETIRED if (record.state() == SignWorkflowStore.State.RETIRED
&& !record.createdAt().plus(options.signingOperationHorizon()).isAfter(signingNow())) { && !record.createdAt().plus(options.signingOperationHorizon()).isAfter(signingNow())) {
SignLockEntry lock = acquireSignLock(record.submissionId()); SignLockEntry lock = acquireSignLock(record.submissionId());
try { try {
Optional<SignWorkflowStore.Record> current = readSignRecord(record.submissionId()); Optional<StoredSign> current = readStoredSign(record.submissionId());
if (current.isPresent() && current.get().state() == SignWorkflowStore.State.RETIRED if (current.isPresent() && current.get().record().state() == SignWorkflowStore.State.RETIRED
&& !current.get().createdAt().plus(options.signingOperationHorizon()).isAfter(signingNow()) && !current.get().record().createdAt().plus(options.signingOperationHorizon())
&& Files.deleteIfExists(paths.signWorkflowPath(record.submissionId()))) { .isAfter(signingNow())
&& deleteSignMetadata(current.get())) {
purged++; purged++;
} }
} catch (IOException ex) {
throw new IllegalStateException("failed to purge signing workflow", ex);
} finally { } finally {
releaseSignLock(record.submissionId(), lock); releaseSignLock(record.submissionId(), lock);
} }
@@ -996,7 +1100,24 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override @Override
public void close() throws IOException { public void close() throws IOException {
this.ownership.close(); IOException failure = null;
try {
metadataStore.close();
} catch (IOException exception) {
failure = exception;
}
try {
ownership.close();
} catch (IOException exception) {
if (failure == null) {
failure = exception;
} else {
failure.addSuppressed(exception);
}
}
if (failure != null) {
throw failure;
}
} }
private void requireStoreUsable() { private void requireStoreUsable() {
@@ -1351,36 +1472,57 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
} }
private Optional<SignWorkflowStore.Record> readSignRecord(PkiId submissionId) { private Optional<SignWorkflowStore.Record> readSignRecord(PkiId submissionId) {
Path path = paths.signWorkflowPath(submissionId); return readStoredSign(submissionId).map(StoredSign::record);
if (!Files.exists(path)) {
return Optional.empty();
}
SignWorkflowStore.Record record = readSignRecordFile(path);
validateSignRecord(submissionId, record);
return Optional.of(record);
} }
private SignWorkflowStore.Record readSignRecordFile(Path path) { private Optional<StoredSign> readStoredSign(PkiId submissionId) {
try { MetadataKey recordKey = signingRecordKey(submissionId);
byte[] envelope = FsOperations.readAll(path); try (MetadataSnapshot snapshot = metadataStore.snapshot()) {
if (envelope.length < SIGN_RECORD_HEADER_BYTES) { Optional<MetadataSnapshot.Record> storedRecord = snapshot.get(recordKey);
throw new IllegalStateException("Invalid signing record envelope"); if (storedRecord.isEmpty()) {
if (snapshot.get(signingOwnerKey(submissionId)).isPresent()) {
throw new IllegalStateException("Signing owner exists without its record");
} }
ByteBuffer input = ByteBuffer.wrap(envelope); return Optional.empty();
if (input.getInt() != SIGN_RECORD_MAGIC) {
throw new IllegalStateException("Invalid signing record envelope");
} }
if (input.getInt() != CURRENT_SIGN_RECORD_VERSION) { return Optional.of(decodeStoredSign(snapshot, storedRecord.orElseThrow()));
throw new IllegalStateException("Unsupported signing record version");
}
byte[] payload = new byte[input.remaining()];
input.get(payload);
return FsCodec.decode(FsCodec.SIGN_WORKFLOW_RECORD, payload, stagedContent);
} catch (IOException ex) { } catch (IOException ex) {
throw new IllegalStateException("Failed to read authoritative signing record", ex); throw new IllegalStateException("Failed to read authoritative signing record", ex);
} }
} }
private StoredSign decodeStoredSign(MetadataSnapshot snapshot, MetadataSnapshot.Record storedRecord)
throws IOException {
SignWorkflowStore.Record record = decodeSignRecord(storedRecord);
PkiId submissionId = record.submissionId();
if (!signingRecordKey(submissionId).equals(storedRecord.key())) {
throw new IllegalStateException("Signing record metadata key mismatch");
}
validateSignRecord(submissionId, record);
Optional<MetadataSnapshot.Record> storedOwner = snapshot.get(signingOwnerKey(submissionId));
if (record.state() == SignWorkflowStore.State.RETIRED) {
if (storedOwner.isPresent()) {
throw new IllegalStateException("Retired signing record retains an owner edge");
}
return new StoredSign(record, storedRecord.recordRevision(), OptionalLong.empty(), Optional.empty());
}
try (MetadataSnapshot.Record owner = storedOwner.orElseThrow(
() -> new IllegalStateException("Signing record owner edge is missing"))) {
DurableContentReference reference = decodeSigningOwner(owner, submissionId);
PkiSigningBus.SignContinuation continuation = PkiSigningBus.SignContinuation.decode(
record.request(), stagedContent);
if (!reference.equals(continuation.content())) {
throw new IllegalStateException("Signing record owner reference mismatch");
}
return new StoredSign(record, storedRecord.recordRevision(), OptionalLong.of(owner.recordRevision()),
Optional.of(reference));
}
}
private SignWorkflowStore.Record decodeSignRecord(MetadataSnapshot.Record stored) throws IOException {
return FsCodec.decode(FsCodec.SIGN_WORKFLOW_RECORD, readMetadataValue(stored), stagedContent);
}
// Parsing and canonicalization failures can carry persisted request material; // Parsing and canonicalization failures can carry persisted request material;
// validation deliberately replaces every such cause with a safe corruption // validation deliberately replaces every such cause with a safe corruption
// code. // code.
@@ -1456,20 +1598,53 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
validateSignRecordState(record); validateSignRecordState(record);
} }
private void writeSignRecord(SignWorkflowStore.Record record) { private MetadataCommitResult createSignMetadata(SignWorkflowStore.Record record,
DurableContentReference reference) {
validateSignRecord(record.submissionId(), record); validateSignRecord(record.submissionId(), record);
try { try (MetadataTransaction transaction = metadataStore.beginTransaction()) {
byte[] payload = FsCodec.encode(FsCodec.SIGN_WORKFLOW_RECORD, record); transaction.create(signingRecordKey(record.submissionId()),
ByteBuffer envelope = ByteBuffer.allocate(SIGN_RECORD_HEADER_BYTES + payload.length); byteContent(FsCodec.encode(FsCodec.SIGN_WORKFLOW_RECORD, record)), CancellationSignal.NONE);
envelope.putInt(SIGN_RECORD_MAGIC); transaction.create(signingOwnerKey(record.submissionId()),
envelope.putInt(CURRENT_SIGN_RECORD_VERSION); byteContent(encodeSigningOwner(record.submissionId(), reference)), CancellationSignal.NONE);
envelope.put(payload); return requireKnownOutcome(transaction.commit());
FsOperations.writeAtomic(paths.signWorkflowPath(record.submissionId()), envelope.array());
} catch (IOException ex) { } catch (IOException ex) {
throw new IllegalStateException("failed to persist signing workflow", ex); throw new IllegalStateException("Failed to persist signing authority", ex);
} }
} }
private boolean replaceSignMetadata(StoredSign current, SignWorkflowStore.Record replacement,
boolean removeOwner) {
validateSignRecord(replacement.submissionId(), replacement);
try (MetadataTransaction transaction = metadataStore.beginTransaction()) {
transaction.replace(signingRecordKey(replacement.submissionId()), current.recordRevision(),
byteContent(FsCodec.encode(FsCodec.SIGN_WORKFLOW_RECORD, replacement)), CancellationSignal.NONE);
if (removeOwner) {
transaction.delete(signingOwnerKey(replacement.submissionId()),
current.ownerRevision().orElseThrow());
}
return requireKnownOutcome(transaction.commit()).outcome() == MetadataCommitResult.Outcome.COMMITTED;
} catch (IOException ex) {
throw new IllegalStateException("Failed to replace signing authority", ex);
}
}
private boolean deleteSignMetadata(StoredSign current) {
try (MetadataTransaction transaction = metadataStore.beginTransaction()) {
transaction.delete(signingRecordKey(current.record().submissionId()), current.recordRevision());
return requireKnownOutcome(transaction.commit()).outcome() == MetadataCommitResult.Outcome.COMMITTED;
} catch (IOException ex) {
throw new IllegalStateException("Failed to retire signing authority", ex);
}
}
private MetadataCommitResult requireKnownOutcome(MetadataCommitResult result) {
if (result.outcome() == MetadataCommitResult.Outcome.UNKNOWN) {
durabilityUncertain.set(true);
throw new PkiException("Store durability unconfirmed: code=STORE_DURABILITY_UNCONFIRMED");
}
return result;
}
private static SignWorkflowStore.Record copySignRecord(SignWorkflowStore.Record current, private static SignWorkflowStore.Record copySignRecord(SignWorkflowStore.Record current,
SignWorkflowStore.State state, long revision, long fence, Optional<Instant> leaseUntil, SignWorkflowStore.State state, long revision, long fence, Optional<Instant> leaseUntil,
Optional<String> detailCode, Optional<EncodedObject> result, Optional<Instant> providerUpdatedAt) { Optional<String> detailCode, Optional<EncodedObject> result, Optional<Instant> providerUpdatedAt) {
@@ -1478,6 +1653,143 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
leaseUntil, detailCode, result, providerUpdatedAt); leaseUntil, detailCode, result, providerUpdatedAt);
} }
private static MetadataKey signingRecordKey(PkiId submissionId) {
SigningSubmissionId parsed = SigningSubmissionId.parse(submissionId);
return new MetadataKey(SIGN_RECORD_NAMESPACE, parsed.id().value());
}
private static MetadataKey signingOwnerKey(PkiId submissionId) {
SigningSubmissionId parsed = SigningSubmissionId.parse(submissionId);
return new MetadataKey(SIGN_OWNER_NAMESPACE, parsed.id().value());
}
private static RepeatableContent byteContent(byte[] value) {
return new ByteValueContent(value);
}
private static byte[] readMetadataValue(MetadataSnapshot.Record record) throws IOException {
long length = record.length().orElseThrow();
if (length < 0L || length > FsCodec.MAX_COMPONENT_BYTES) {
throw new IOException("Signing metadata value length is invalid");
}
byte[] result = new byte[Math.toIntExact(length)];
try (InputStream input = record.openStream()) {
int offset = 0;
while (offset < result.length) {
int count = input.read(result, offset, result.length - offset);
if (count <= 0) {
throw new IOException("Signing metadata value is truncated");
}
offset += count;
}
if (input.read() >= 0) {
throw new IOException("Signing metadata value has trailing data");
}
}
return result;
}
private static byte[] encodeSigningOwner(PkiId submissionId, DurableContentReference reference) {
byte[][] fields = {
DurableContentOwner.Category.SIGNING_OPERATION.name().getBytes(StandardCharsets.US_ASCII),
submissionId.value().getBytes(StandardCharsets.UTF_8),
reference.storeId().getBytes(StandardCharsets.UTF_8),
reference.contentId().getBytes(StandardCharsets.UTF_8),
reference.encoding().name().getBytes(StandardCharsets.US_ASCII),
reference.sha256().getBytes(StandardCharsets.US_ASCII),
reference.lifecycle().name().getBytes(StandardCharsets.US_ASCII)
};
int size = Integer.BYTES + Long.BYTES;
for (byte[] field : fields) {
size = Math.addExact(size, Math.addExact(Integer.BYTES, field.length));
}
ByteBuffer output = ByteBuffer.allocate(size);
output.putInt(SIGN_OWNER_VALUE_VERSION);
for (byte[] field : fields) {
output.putInt(field.length).put(field);
}
output.putLong(reference.length());
return output.array();
}
private DurableContentReference decodeSigningOwner(MetadataSnapshot.Record owner, PkiId expectedId)
throws IOException {
ByteBuffer input = ByteBuffer.wrap(readMetadataValue(owner));
try {
if (input.getInt() != SIGN_OWNER_VALUE_VERSION) {
throw new IOException("Unsupported signing owner metadata");
}
DurableContentOwner.Category category = DurableContentOwner.Category.valueOf(readOwnerField(input));
String ownerId = readOwnerField(input);
String storeId = readOwnerField(input);
String contentId = readOwnerField(input);
zeroecho.pki.api.Encoding encoding = zeroecho.pki.api.Encoding.valueOf(readOwnerField(input));
String digest = readOwnerField(input);
DurableContentReference.Lifecycle lifecycle = DurableContentReference.Lifecycle.valueOf(
readOwnerField(input));
long length = input.getLong();
if (input.hasRemaining() || category != DurableContentOwner.Category.SIGNING_OPERATION
|| !ownerId.equals(expectedId.value())) {
throw new IOException("Signing owner metadata identity mismatch");
}
DurableContentReference restored = stagedContent.restoreReference(storeId, contentId, encoding,
length, digest, lifecycle);
if (!signingOwnerKey(expectedId).equals(owner.key())) {
throw new IOException("Signing owner metadata key mismatch");
}
return restored;
} catch (IllegalArgumentException | java.nio.BufferUnderflowException exception) {
throw new IOException("Malformed signing owner metadata", exception);
}
}
private static String readOwnerField(ByteBuffer input) throws IOException {
int length = input.getInt();
if (length <= 0 || length > MetadataKey.MAXIMUM_KEY_UTF8_BYTES || input.remaining() < length) {
throw new IOException("Malformed signing owner metadata field");
}
byte[] value = new byte[length];
input.get(value);
String decoded = new String(value, StandardCharsets.UTF_8);
if (!Arrays.equals(value, decoded.getBytes(StandardCharsets.UTF_8))) {
throw new IOException("Malformed signing owner metadata UTF-8");
}
return decoded;
}
private record StoredSign(SignWorkflowStore.Record record, long recordRevision,
OptionalLong ownerRevision, Optional<DurableContentReference> reference) {
}
/** Store-owned bounded finite control metadata used only for synchronous admission. */
private static final class ByteValueContent implements RepeatableContent {
private final byte[] value;
private ByteValueContent(byte[] value) {
this.value = Objects.requireNonNull(value, "value").clone();
}
@Override
public InputStream openStream() {
return new ByteArrayInputStream(value);
}
@Override
public OptionalLong length() {
return OptionalLong.of(value.length);
}
@Override
public String contentId() {
return "signing-control-metadata";
}
@Override
public void close() {
Arrays.fill(value, (byte) 0);
}
}
private static boolean validSignTransition(SignWorkflowStore.State source, SignWorkflowStore.State target) { private static boolean validSignTransition(SignWorkflowStore.State source, SignWorkflowStore.State target) {
if (source == SignWorkflowStore.State.INTENT) { if (source == SignWorkflowStore.State.INTENT) {
return target == SignWorkflowStore.State.DISPATCHED || target == SignWorkflowStore.State.FAILED return target == SignWorkflowStore.State.DISPATCHED || target == SignWorkflowStore.State.FAILED

View File

@@ -61,7 +61,10 @@ import java.util.Objects;
import java.util.OptionalLong; import java.util.OptionalLong;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantLock;
import zeroecho.core.io.RepeatableContent; import zeroecho.core.io.RepeatableContent;
@@ -100,6 +103,8 @@ public final class FilesystemStagedContentStore implements StagedContentStore {
private final Path root; private final Path root;
private final String storeId; private final String storeId;
private final ReentrantLock[] ownerLocks; private final ReentrantLock[] ownerLocks;
private final ConcurrentMap<String, Integer> signingReservations;
private final AtomicReference<SigningOwnership> signingOwnership;
/** /**
* Creates a staged-content store. * Creates a staged-content store.
@@ -113,6 +118,8 @@ public final class FilesystemStagedContentStore implements StagedContentStore {
this.root = Objects.requireNonNull(root, "root").toAbsolutePath().normalize(); this.root = Objects.requireNonNull(root, "root").toAbsolutePath().normalize();
this.storeId = requireStoreIdentifier(storeId); this.storeId = requireStoreIdentifier(storeId);
this.ownerLocks = new ReentrantLock[OWNER_LOCK_COUNT]; this.ownerLocks = new ReentrantLock[OWNER_LOCK_COUNT];
this.signingReservations = new ConcurrentHashMap<>();
this.signingOwnership = new AtomicReference<>(reference -> Set.of());
for (int index = 0; index < ownerLocks.length; index++) { for (int index = 0; index < ownerLocks.length; index++) {
ownerLocks[index] = new ReentrantLock(); ownerLocks[index] = new ReentrantLock();
} }
@@ -177,6 +184,7 @@ public final class FilesystemStagedContentStore implements StagedContentStore {
public boolean retainContent(DurableContentReference reference, DurableContentOwner owner) throws IOException { public boolean retainContent(DurableContentReference reference, DurableContentOwner owner) throws IOException {
DurableContentReference exact = requireOwned(reference); DurableContentReference exact = requireOwned(reference);
Objects.requireNonNull(owner, "owner"); Objects.requireNonNull(owner, "owner");
SigningOwnershipIo.requireSidecarOwner(owner);
ReentrantLock lock = ownerLock(exact.contentId()); ReentrantLock lock = ownerLock(exact.contentId());
lock.lock(); lock.lock();
try { try {
@@ -196,6 +204,7 @@ public final class FilesystemStagedContentStore implements StagedContentStore {
public boolean releaseContent(DurableContentReference reference, DurableContentOwner owner) throws IOException { public boolean releaseContent(DurableContentReference reference, DurableContentOwner owner) throws IOException {
DurableContentReference exact = requireOwned(reference); DurableContentReference exact = requireOwned(reference);
Objects.requireNonNull(owner, "owner"); Objects.requireNonNull(owner, "owner");
SigningOwnershipIo.requireSidecarOwner(owner);
ReentrantLock lock = ownerLock(exact.contentId()); ReentrantLock lock = ownerLock(exact.contentId());
lock.lock(); lock.lock();
try { try {
@@ -208,9 +217,7 @@ public final class FilesystemStagedContentStore implements StagedContentStore {
return false; return false;
} }
writeOwners(exact.contentId(), owners); writeOwners(exact.contentId(), owners);
if (owners.isEmpty()) { SigningOwnershipIo.retireIfUnowned(this, exact, owners);
retireFiles(exact);
}
return true; return true;
} finally { } finally {
lock.unlock(); lock.unlock();
@@ -224,7 +231,9 @@ public final class FilesystemStagedContentStore implements StagedContentStore {
lock.lock(); lock.lock();
try { try {
requireExactMetadata(exact); requireExactMetadata(exact);
return Set.copyOf(readOwners(exact.contentId())); Set<DurableContentOwner> owners = readOwners(exact.contentId());
owners.addAll(signingOwnership.get().findOwners(exact));
return Set.copyOf(owners);
} finally { } finally {
lock.unlock(); lock.unlock();
} }
@@ -237,9 +246,7 @@ public final class FilesystemStagedContentStore implements StagedContentStore {
lock.lock(); lock.lock();
try { try {
requireExactMetadata(exact); requireExactMetadata(exact);
if (!readOwners(exact.contentId()).isEmpty()) { SigningOwnershipIo.requireUnowned(this, exact);
throw new IOException("Staged content remains durably owned");
}
retireFiles(exact); retireFiles(exact);
} finally { } finally {
lock.unlock(); lock.unlock();
@@ -309,9 +316,55 @@ public final class FilesystemStagedContentStore implements StagedContentStore {
} }
private void writeOwners(String contentId, Set<DurableContentOwner> owners) throws IOException { private void writeOwners(String contentId, Set<DurableContentOwner> owners) throws IOException {
SigningOwnershipIo.requireSidecarOwners(owners);
StoreIo.writeOwners(this, contentId, owners); StoreIo.writeOwners(this, contentId, owners);
} }
/* package */ void retireSigningContent(DurableContentReference reference) throws IOException {
SigningOwnershipIo.retireSigningContent(this, reference);
}
/* package */ void bindSigningOwnership(SigningOwnership ownership) {
signingOwnership.set(Objects.requireNonNull(ownership, "ownership"));
}
/* package */ SigningReservation reserveSigningPublication(DurableContentReference reference)
throws IOException {
return SigningOwnershipIo.reserveSigningPublication(this, reference);
}
/** Internal callback to the transactional signing-owner authority. */
/* default */
@FunctionalInterface
interface SigningOwnership {
/** Finds every exact signing owner for a durable content reference. */
Set<DurableContentOwner> findOwners(DurableContentReference reference) throws IOException;
}
/** Short-lived reference-counted reservation spanning validation and commit. */
/* package */ final class SigningReservation implements AutoCloseable {
private final String contentId;
private final AtomicBoolean closed = new AtomicBoolean();
private SigningReservation(String contentId) {
this.contentId = contentId;
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
ReentrantLock lock = ownerLock(contentId);
lock.lock();
try {
signingReservations.computeIfPresent(contentId,
(ignored, count) -> count == 1 ? null : count - 1);
} finally {
lock.unlock();
}
}
}
}
private void retireFiles(DurableContentReference reference) throws IOException { private void retireFiles(DurableContentReference reference) throws IOException {
Files.deleteIfExists(metadataPath(reference.contentId())); Files.deleteIfExists(metadataPath(reference.contentId()));
Files.deleteIfExists(completePath(reference)); Files.deleteIfExists(completePath(reference));
@@ -810,6 +863,9 @@ public final class FilesystemStagedContentStore implements StagedContentStore {
} }
for (int index = 0; index < count; index++) { for (int index = 0; index < count; index++) {
DurableContentOwner owner = parseOwner(input.readUTF()); DurableContentOwner owner = parseOwner(input.readUTF());
if (owner.category() == DurableContentOwner.Category.SIGNING_OPERATION) {
continue;
}
if (!owners.add(owner)) { if (!owners.add(owner)) {
throw new IOException("Duplicate staged content owner"); throw new IOException("Duplicate staged content owner");
} }
@@ -877,6 +933,75 @@ public final class FilesystemStagedContentStore implements StagedContentStore {
} }
} }
/** Isolates transactional signing-ownership lifecycle decisions. */
private static final class SigningOwnershipIo {
private static void requireSidecarOwners(Set<DurableContentOwner> owners) {
for (DurableContentOwner owner : owners) {
requireSidecarOwner(owner);
}
}
private static void requireSidecarOwner(DurableContentOwner owner) {
if (owner.category() == DurableContentOwner.Category.SIGNING_OPERATION) {
throw new IllegalArgumentException(
"Signing-operation ownership is authoritative transactional metadata");
}
}
private static SigningReservation reserveSigningPublication(FilesystemStagedContentStore store,
DurableContentReference reference) throws IOException {
DurableContentReference exact = store.requireOwned(reference);
ReentrantLock lock = store.ownerLock(exact.contentId());
lock.lock();
try {
store.requireExactMetadata(exact);
store.signingReservations.merge(exact.contentId(), 1, Math::addExact);
return store.new SigningReservation(exact.contentId());
} catch (ArithmeticException exception) {
throw new IOException("Signing content reservation limit exceeded", exception);
} finally {
lock.unlock();
}
}
private static boolean hasSigningAuthority(FilesystemStagedContentStore store,
DurableContentReference reference) throws IOException {
return store.signingReservations.containsKey(reference.contentId())
|| !store.signingOwnership.get().findOwners(reference).isEmpty();
}
private static void retireIfUnowned(FilesystemStagedContentStore store,
DurableContentReference reference, Set<DurableContentOwner> owners) throws IOException {
if (owners.isEmpty() && !hasSigningAuthority(store, reference)) {
store.retireFiles(reference);
}
}
private static void requireUnowned(FilesystemStagedContentStore store,
DurableContentReference reference) throws IOException {
if (!store.readOwners(reference.contentId()).isEmpty()
|| hasSigningAuthority(store, reference)) {
throw new IOException("Staged content remains durably owned");
}
}
private static void retireSigningContent(FilesystemStagedContentStore store,
DurableContentReference reference) throws IOException {
DurableContentReference exact = store.requireOwned(reference);
ReentrantLock lock = store.ownerLock(exact.contentId());
lock.lock();
try {
store.requireExactMetadata(exact);
if (store.readOwners(exact.contentId()).isEmpty()
&& !hasSigningAuthority(store, exact)) {
store.retireFiles(exact);
}
} finally {
lock.unlock();
}
}
}
private record StoreReference(String storeId, String contentId, Encoding encoding, long length, String sha256, private record StoreReference(String storeId, String contentId, Encoding encoding, long length, String sha256,
DurableContentReference.Lifecycle lifecycle) implements DurableContentReference { DurableContentReference.Lifecycle lifecycle) implements DurableContentReference {
private StoreReference { private StoreReference {

View File

@@ -94,6 +94,10 @@ final class FsPaths {
return this.root.resolve("staged-content"); return this.root.resolve("staged-content");
} }
/* default */ Path transactionalMetadataLog() {
return this.root.resolve("metadata").resolve("transactions.log");
}
/* default */ Path lockFile() { /* default */ Path lockFile() {
return this.root.resolve(LOCK_DIR).resolve(STORE_LOCK); return this.root.resolve(LOCK_DIR).resolve(STORE_LOCK);
} }
@@ -156,16 +160,6 @@ final class FsPaths {
return this.root.resolve("requests").resolve(BY_ID).resolve(FsUtil.safeId(requestId) + BINARY_EXTENSION); return this.root.resolve("requests").resolve(BY_ID).resolve(FsUtil.safeId(requestId) + BINARY_EXTENSION);
} }
/* default */ Path signWorkflowPath(final PkiId submissionId) {
Objects.requireNonNull(submissionId, "submissionId");
return this.root.resolve("sign-workflows").resolve(BY_ID).resolve(FsUtil.safeId(submissionId))
.resolve(CURRENT_FILE);
}
/* default */ Path signWorkflowRoot() {
return this.root.resolve("sign-workflows").resolve(BY_ID);
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Revocations (single authoritative journal) // Revocations (single authoritative journal)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

@@ -1,5 +1,9 @@
package zeroecho.pki.spi.store; package zeroecho.pki.spi.store;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Arrays; import java.util.Arrays;
import java.util.Objects; import java.util.Objects;
@@ -16,6 +20,11 @@ import java.util.Optional;
* @param key exact logical key * @param key exact logical key
*/ */
public record MetadataKey(String namespace, String key) implements Comparable<MetadataKey> { public record MetadataKey(String namespace, String key) implements Comparable<MetadataKey> {
private static final String CANONICAL_VERSION_PREFIX = "mk1:";
private static final byte[] CANONICAL_VERSION_BYTES =
CANONICAL_VERSION_PREFIX.getBytes(StandardCharsets.US_ASCII);
private static final byte LENGTH_SEPARATOR = ':';
private static final int MAXIMUM_CANONICAL_CHARACTERS = 4364;
private static final int MINIMUM_KEY_BYTE = 0x21; private static final int MINIMUM_KEY_BYTE = 0x21;
private static final int MAXIMUM_KEY_BYTE = 0x7e; private static final int MAXIMUM_KEY_BYTE = 0x7e;
/** Maximum canonical namespace length in UTF-8 bytes. */ /** Maximum canonical namespace length in UTF-8 bytes. */
@@ -33,6 +42,9 @@ public record MetadataKey(String namespace, String key) implements Comparable<Me
/* package */ static void validateNamespace(String value) { /* package */ static void validateNamespace(String value) {
Objects.requireNonNull(value, "namespace"); Objects.requireNonNull(value, "namespace");
if (value.length() > MAXIMUM_NAMESPACE_UTF8_BYTES) {
throw new IllegalArgumentException("Metadata namespace exceeds its canonical UTF-8 limit");
}
if (!value.matches("[a-z0-9]+(?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9]+(?:[a-z0-9-]*[a-z0-9])?)*")) { if (!value.matches("[a-z0-9]+(?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9]+(?:[a-z0-9-]*[a-z0-9])?)*")) {
throw new IllegalArgumentException("Metadata namespace is not canonical"); throw new IllegalArgumentException("Metadata namespace is not canonical");
} }
@@ -44,6 +56,9 @@ public record MetadataKey(String namespace, String key) implements Comparable<Me
/* package */ static void validateKey(String value, boolean emptyAllowed) { /* package */ static void validateKey(String value, boolean emptyAllowed) {
Objects.requireNonNull(value, "key"); Objects.requireNonNull(value, "key");
if (value.length() > MAXIMUM_KEY_UTF8_BYTES) {
throw new IllegalArgumentException("Metadata key exceeds its canonical UTF-8 limit");
}
if (!emptyAllowed && value.isEmpty()) { if (!emptyAllowed && value.isEmpty()) {
throw new IllegalArgumentException("Metadata key must not be empty"); throw new IllegalArgumentException("Metadata key must not be empty");
} }
@@ -52,7 +67,6 @@ public record MetadataKey(String namespace, String key) implements Comparable<Me
char current = value.charAt(index); char current = value.charAt(index);
if (current < MINIMUM_KEY_BYTE if (current < MINIMUM_KEY_BYTE
|| current > MAXIMUM_KEY_BYTE || current > MAXIMUM_KEY_BYTE
|| current == ':'
|| current == '/' || current == '/'
|| current == '\\') { || current == '\\') {
throw new IllegalArgumentException("Metadata key is not canonical visible ASCII"); throw new IllegalArgumentException("Metadata key is not canonical visible ASCII");
@@ -103,35 +117,32 @@ public record MetadataKey(String namespace, String key) implements Comparable<Me
} }
/** /**
* Parses one complete canonical extension-owned identity. * Parses one complete versioned length-framed identity.
* *
* @param encoded canonical namespace and key separated by one colon * @param encoded current versioned length-framed representation
* @return parsed metadata key * @return parsed metadata key
* @throws NullPointerException if {@code encoded} is {@code null} * @throws NullPointerException if {@code encoded} is {@code null}
* @throws IllegalArgumentException if the representation is malformed * @throws IllegalArgumentException if the representation is malformed
*/ */
public static MetadataKey parse(String encoded) { public static MetadataKey parse(String encoded) {
Objects.requireNonNull(encoded, "encoded"); return CanonicalCodec.parse(encoded);
int separator = encoded.indexOf(':');
if (separator <= 0 || separator != encoded.lastIndexOf(':')
|| separator == encoded.length() - 1) {
throw new IllegalArgumentException("Metadata key representation is not canonical");
}
MetadataKey result = new MetadataKey(
encoded.substring(0, separator), encoded.substring(separator + 1));
if (!result.canonical().equals(encoded)) {
throw new IllegalArgumentException("Metadata key representation is not canonical");
}
return result;
} }
/** /**
* Returns the complete canonical representation. * Returns the complete versioned length-framed canonical representation.
* *
* @return namespace and exact key separated by one colon * @return current versioned length-framed representation
*/ */
public String canonical() { public String canonical() {
return namespace + ':' + key; int namespaceLength = namespace.getBytes(StandardCharsets.UTF_8).length;
int keyLength = key.getBytes(StandardCharsets.UTF_8).length;
return CANONICAL_VERSION_PREFIX + namespaceLength + ':' + keyLength + ':' + namespace + key;
}
/** Returns the canonical serialized representation. */
@Override
public String toString() {
return canonical();
} }
/* /*
@@ -160,4 +171,118 @@ public record MetadataKey(String namespace, String key) implements Comparable<Me
throw new IllegalArgumentException(component + " exceeds its canonical UTF-8 limit"); throw new IllegalArgumentException(component + " exceeds its canonical UTF-8 limit");
} }
} }
/** Strict current-version textual framing isolated from semantic identity rules. */
private static final class CanonicalCodec {
private static MetadataKey parse(String encoded) {
Objects.requireNonNull(encoded, "encoded");
if (encoded.length() > MAXIMUM_CANONICAL_CHARACTERS) {
throw malformedRepresentation();
}
byte[] bytes = strictEncode(encoded);
if (!hasCanonicalPrefix(bytes)) {
throw malformedRepresentation();
}
LengthField namespaceLength = parseLength(bytes, CANONICAL_VERSION_BYTES.length,
MAXIMUM_NAMESPACE_UTF8_BYTES);
LengthField keyLength = parseLength(bytes, namespaceLength.nextOffset(), MAXIMUM_KEY_UTF8_BYTES);
int componentLength;
int expectedEnd;
try {
componentLength = Math.addExact(namespaceLength.value(), keyLength.value());
expectedEnd = Math.addExact(keyLength.nextOffset(), componentLength);
} catch (ArithmeticException exception) {
throw new IllegalArgumentException("Metadata key representation length overflows", exception);
}
if (namespaceLength.value() == 0 || keyLength.value() == 0 || expectedEnd != bytes.length) {
throw malformedRepresentation();
}
int namespaceStart = keyLength.nextOffset();
int keyStart;
try {
keyStart = Math.addExact(namespaceStart, namespaceLength.value());
} catch (ArithmeticException exception) {
throw new IllegalArgumentException("Metadata key representation length overflows", exception);
}
MetadataKey result = new MetadataKey(
strictDecode(bytes, namespaceStart, namespaceLength.value()),
strictDecode(bytes, keyStart, keyLength.value()));
if (!result.canonical().equals(encoded)) {
throw malformedRepresentation();
}
return result;
}
private static LengthField parseLength(byte[] bytes, int offset, int maximum) {
if (offset >= bytes.length) {
throw malformedRepresentation();
}
int value = 0;
int cursor = offset;
while (cursor < bytes.length && bytes[cursor] != LENGTH_SEPARATOR) {
int digit = bytes[cursor] - '0';
if (digit < 0 || digit > 9 || cursor > offset && bytes[offset] == '0') {
throw malformedRepresentation();
}
try {
value = Math.addExact(Math.multiplyExact(value, 10), digit);
} catch (ArithmeticException exception) {
throw new IllegalArgumentException("Metadata key representation length overflows", exception);
}
if (value > maximum) {
throw malformedRepresentation();
}
cursor++;
}
if (cursor == offset || cursor >= bytes.length) {
throw malformedRepresentation();
}
return new LengthField(value, cursor + 1);
}
private static boolean hasCanonicalPrefix(byte[] bytes) {
if (bytes.length < CANONICAL_VERSION_BYTES.length) {
return false;
}
for (int index = 0; index < CANONICAL_VERSION_BYTES.length; index++) {
if (bytes[index] != CANONICAL_VERSION_BYTES[index]) {
return false;
}
}
return true;
}
private static byte[] strictEncode(String value) {
try {
ByteBuffer encoded = StandardCharsets.UTF_8.newEncoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.encode(CharBuffer.wrap(value));
byte[] result = new byte[encoded.remaining()];
encoded.get(result);
return result;
} catch (CharacterCodingException exception) {
throw new IllegalArgumentException("Metadata key representation is not valid UTF-8", exception);
}
}
private static String strictDecode(byte[] bytes, int offset, int length) {
try {
return StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes, offset, length))
.toString();
} catch (CharacterCodingException exception) {
throw new IllegalArgumentException("Metadata key component is not valid UTF-8", exception);
}
}
private static IllegalArgumentException malformedRepresentation() {
return new IllegalArgumentException("Metadata key representation is not canonical");
}
private record LengthField(int value, int nextOffset) {
}
}
} }

View File

@@ -39,6 +39,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static zeroecho.pki.testkit.PkiTestRuntime.signingAuthority; import static zeroecho.pki.testkit.PkiTestRuntime.signingAuthority;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@@ -51,6 +53,7 @@ import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.OptionalLong;
import java.util.concurrent.Callable; import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
@@ -71,8 +74,13 @@ import zeroecho.pki.api.audit.AccessContext;
import zeroecho.pki.api.audit.Principal; import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.audit.Purpose; import zeroecho.pki.api.audit.Purpose;
import zeroecho.pki.api.orch.SigningSubmissionId; import zeroecho.pki.api.orch.SigningSubmissionId;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.impl.core.async.PkiSigningBus; import zeroecho.pki.impl.core.async.PkiSigningBus;
import zeroecho.pki.spi.store.SignWorkflowStore; import zeroecho.pki.spi.store.SignWorkflowStore;
import zeroecho.pki.spi.store.MetadataKey;
import zeroecho.pki.spi.store.MetadataSnapshot;
import zeroecho.pki.spi.store.MetadataTransaction;
import zeroecho.pki.testkit.InMemorySignatureWorkflow; import zeroecho.pki.testkit.InMemorySignatureWorkflow;
final class FilesystemSignWorkflowStoreTest { final class FilesystemSignWorkflowStoreTest {
@@ -128,19 +136,17 @@ final class FilesystemSignWorkflowStoreTest {
} }
String fingerprint = intent.fingerprint(); String fingerprint = intent.fingerprint();
Path recordPath = new FsPaths(root).signWorkflowPath(id); Path recordPath = root.resolve("sign-workflows/by-id/legacy/current.bin");
byte[] malformed = Files.readAllBytes(recordPath); Files.createDirectories(recordPath.getParent());
Files.write(recordPath, Arrays.copyOf(malformed, malformed.length - 1)); Files.write(recordPath, new byte[] { 1, 2, 3 });
try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock); try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock);
InMemorySignatureWorkflow signer = new InMemorySignatureWorkflow(Map.of(), false)) { InMemorySignatureWorkflow signer = new InMemorySignatureWorkflow(Map.of(), false)) {
RuntimeException activationFailure = assertThrows(RuntimeException.class, assertEquals(intent.fingerprint(), reopened.getSignRecord(id).orElseThrow().fingerprint());
() -> new PkiSigningBus(reopened, signer, root.resolve("bus.log"), signingAuthority(signer))); try (PkiSigningBus ignored = new PkiSigningBus(reopened, signer, root.resolve("bus.log"),
assertFalse(activationFailure.toString().contains(fingerprint)); signingAuthority(signer))) {
RuntimeException attachFailure = assertThrows(RuntimeException.class, assertEquals(intent.fingerprint(), reopened.getSignRecord(id).orElseThrow().fingerprint());
() -> reopened.createSignIntent(intent)); }
assertFalse(attachFailure.toString().contains(fingerprint));
assertThrows(RuntimeException.class, () -> reopened.getSignRecord(id));
} }
System.out.println("...ok"); System.out.println("...ok");
} }
@@ -161,19 +167,17 @@ final class FilesystemSignWorkflowStoreTest {
store.createSignIntent(current); store.createSignIntent(current);
} }
Path recordPath = new FsPaths(root).signWorkflowPath(id); Path recordPath = root.resolve("sign-workflows/by-id/legacy/current.bin");
byte[] unsupported = Files.readAllBytes(recordPath); Files.createDirectories(recordPath.getParent());
java.nio.ByteBuffer.wrap(unsupported).putInt(Integer.BYTES, 99); Files.write(recordPath, ByteBuffer.allocate(8).putInt(0x5A455352).putInt(99).array());
Files.write(recordPath, unsupported);
try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock); try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock);
InMemorySignatureWorkflow signer = new InMemorySignatureWorkflow(Map.of(), false)) { InMemorySignatureWorkflow signer = new InMemorySignatureWorkflow(Map.of(), false)) {
RuntimeException failure = assertThrows(RuntimeException.class, assertEquals(id, reopened.getSignRecord(id).orElseThrow().submissionId());
() -> new PkiSigningBus(reopened, signer, root.resolve("unsupported-version-bus.log"), try (PkiSigningBus ignored = new PkiSigningBus(reopened, signer,
signingAuthority(signer))); root.resolve("unsupported-version-bus.log"), signingAuthority(signer))) {
assertTrue(failure.toString().contains("Unsupported signing record version")); assertEquals(id, reopened.getSignRecord(id).orElseThrow().submissionId());
assertFalse(failure.toString().contains(fingerprint)); }
assertFalse(failure.toString().contains(id.value()));
} }
System.out.println("...ok"); System.out.println("...ok");
} }
@@ -256,12 +260,8 @@ final class FilesystemSignWorkflowStoreTest {
store.exportSnapshot(snapshot, createdAt.minusSeconds(60)); store.exportSnapshot(snapshot, createdAt.minusSeconds(60));
} }
try (FilesystemPkiStore restored = new FilesystemPkiStore(snapshot, FsPkiStoreOptions.defaults(), clock)) { try (FilesystemPkiStore restored = new FilesystemPkiStore(snapshot, FsPkiStoreOptions.defaults(), clock)) {
SignWorkflowStore.Record record = restored.getSignRecord(id).orElseThrow();
assertEquals(namespace.substring(0, 32), restored.signingNamespace()); assertEquals(namespace.substring(0, 32), restored.signingNamespace());
assertEquals(SignWorkflowStore.State.SUCCEEDED, record.state()); assertTrue(restored.getSignRecord(id).isEmpty());
assertEquals(3L, record.revision());
assertEquals(1L, record.fence());
assertEquals(9, record.result().orElseThrow().bytes()[0]);
assertEquals(createdAt.plusSeconds(30), restored.signingNow()); assertEquals(createdAt.plusSeconds(30), restored.signingNow());
} }
} }
@@ -624,13 +624,45 @@ final class FilesystemSignWorkflowStoreTest {
} }
private static void writeRawCurrentRecord(Path root, PkiId id, SignWorkflowStore.Record record) throws Exception { private static void writeRawCurrentRecord(Path root, PkiId id, SignWorkflowStore.Record record) throws Exception {
Path path = new FsPaths(root).signWorkflowPath(id);
byte[] existing = Files.readAllBytes(path);
byte[] payload = FsCodec.encode(FsCodec.SIGN_WORKFLOW_RECORD, record); byte[] payload = FsCodec.encode(FsCodec.SIGN_WORKFLOW_RECORD, record);
ByteBuffer replacement = ByteBuffer.allocate(Integer.BYTES * 2 + payload.length); MetadataKey key = new MetadataKey("io.zeroecho.pki.signing-record", id.value());
replacement.put(existing, 0, Integer.BYTES * 2); try (PosixTransactionalMetadataStore metadata = PosixTransactionalMetadataStore.open(
replacement.put(payload); new FsPaths(root).transactionalMetadataLog());
Files.write(path, replacement.array()); MetadataSnapshot snapshot = metadata.snapshot();
MetadataTransaction transaction = metadata.beginTransaction()) {
long revision = snapshot.get(key).orElseThrow().recordRevision();
transaction.replace(key, revision, new TestContent(payload), CancellationSignal.NONE);
assertEquals(zeroecho.pki.spi.store.MetadataCommitResult.Outcome.COMMITTED,
transaction.commit().outcome());
}
}
private static final class TestContent implements RepeatableContent {
private final byte[] value;
private TestContent(byte[] value) {
this.value = value.clone();
}
@Override
public InputStream openStream() {
return new ByteArrayInputStream(value);
}
@Override
public OptionalLong length() {
return OptionalLong.of(value.length);
}
@Override
public String contentId() {
return "test-sign-record";
}
@Override
public void close() {
Arrays.fill(value, (byte) 0);
}
} }
private static void assertRedactedStructuralFailure(byte[] encoded, String sensitiveFingerprint) { private static void assertRedactedStructuralFailure(byte[] encoded, String sensitiveFingerprint) {

View File

@@ -46,6 +46,7 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.Future; import java.util.concurrent.Future;
@@ -139,6 +140,37 @@ final class FilesystemStagedContentStoreTest {
System.out.println("recoveryRetainsOnlyDurablyReferencedCompletedContent...ok"); System.out.println("recoveryRetainsOnlyDurablyReferencedCompletedContent...ok");
} }
@Test
void transactionalSigningOwnerPreventsGeneralRetirement(@TempDir Path directory) throws Exception {
System.out.println("transactionalSigningOwnerPreventsGeneralRetirement");
FilesystemStagedContentStore store = new FilesystemStagedContentStore(directory, STORE_ID);
DurableContentReference reference = stage(store, DurableContentReference.Lifecycle.OPERATION, "sign-input");
DurableContentOwner authority = signingOwner("authority");
store.bindSigningOwnership(candidate -> candidate.equals(reference) ? Set.of(authority) : Set.of());
assertThrows(IOException.class, () -> store.retireUnownedContent(reference));
try (zeroecho.core.io.RepeatableContent content = store.openContent(reference)) {
assertEquals(reference.length(), content.length().orElseThrow());
}
assertThrows(IllegalArgumentException.class,
() -> store.retainContent(reference, signingOwner("authority")));
System.out.println("transactionalSigningOwnerPreventsGeneralRetirement...ok");
}
@Test
void sharedSigningPublicationReservationsAreReferenceCounted(@TempDir Path directory) throws Exception {
System.out.println("sharedSigningPublicationReservationsAreReferenceCounted");
FilesystemStagedContentStore store = new FilesystemStagedContentStore(directory, STORE_ID);
DurableContentReference reference = stage(store, DurableContentReference.Lifecycle.OPERATION, "shared");
try (FilesystemStagedContentStore.SigningReservation first = store.reserveSigningPublication(reference);
FilesystemStagedContentStore.SigningReservation second = store.reserveSigningPublication(reference)) {
first.close();
assertThrows(IOException.class, () -> store.retireUnownedContent(reference));
}
store.retireUnownedContent(reference);
assertThrows(IOException.class, () -> store.openContent(reference));
System.out.println("sharedSigningPublicationReservationsAreReferenceCounted...ok");
}
@Test @Test
void rejectsTraversalForeignMetadataAndSymlinkContent(@TempDir Path directory) throws Exception { void rejectsTraversalForeignMetadataAndSymlinkContent(@TempDir Path directory) throws Exception {
System.out.println("rejectsTraversalForeignMetadataAndSymlinkContent"); System.out.println("rejectsTraversalForeignMetadataAndSymlinkContent");

View File

@@ -28,10 +28,11 @@ final class MetadataKeyTest {
@Test @Test
void canonicalExtensionIdentityRoundTrips() { void canonicalExtensionIdentityRoundTrips() {
System.out.print("canonicalExtensionIdentityRoundTrips "); System.out.print("canonicalExtensionIdentityRoundTrips ");
MetadataKey key = MetadataKey.parse("example.audit:Record-01"); MetadataKey key = new MetadataKey("example.audit", "zsign:v1:tenant:42:nonce");
assertEquals("example.audit", key.namespace()); assertEquals("example.audit", key.namespace());
assertEquals("Record-01", key.key()); assertEquals("zsign:v1:tenant:42:nonce", key.key());
assertEquals(key, MetadataKey.parse(key.canonical())); assertEquals(key, MetadataKey.parse(key.canonical()));
assertEquals(key.canonical(), key.toString());
System.out.println("...ok"); System.out.println("...ok");
} }
@@ -40,10 +41,10 @@ final class MetadataKeyTest {
System.out.print("reservedAndAmbiguousIdentitiesFail "); System.out.print("reservedAndAmbiguousIdentitiesFail ");
assertThrows(IllegalArgumentException.class, () -> new MetadataKey("zeroecho", "a")); assertThrows(IllegalArgumentException.class, () -> new MetadataKey("zeroecho", "a"));
assertThrows(IllegalArgumentException.class, () -> new MetadataKey("zeroecho.audit", "a")); assertThrows(IllegalArgumentException.class, () -> new MetadataKey("zeroecho.audit", "a"));
assertThrows(IllegalArgumentException.class, () -> MetadataKey.parse("Example.audit:a")); assertThrows(IllegalArgumentException.class, () -> MetadataKey.parse("mk1:13:1:Example.audita"));
assertThrows(IllegalArgumentException.class, () -> new MetadataKey("example.audit", "a/b")); assertThrows(IllegalArgumentException.class, () -> new MetadataKey("example.audit", "a/b"));
assertThrows(IllegalArgumentException.class, () -> new MetadataKey("example.audit", "é")); assertThrows(IllegalArgumentException.class, () -> new MetadataKey("example.audit", "é"));
assertThrows(IllegalArgumentException.class, () -> MetadataKey.parse("example.audit:a:b")); assertThrows(IllegalArgumentException.class, () -> MetadataKey.parse("example.audit:a"));
System.out.println("...ok"); System.out.println("...ok");
} }
@@ -52,12 +53,16 @@ final class MetadataKeyTest {
System.out.print("orderingAndPrefixRangesAreTotal "); System.out.print("orderingAndPrefixRangesAreTotal ");
List<MetadataKey> sorted = List.of( List<MetadataKey> sorted = List.of(
new MetadataKey("example.audit", "a~"), new MetadataKey("example.audit", "a~"),
new MetadataKey("example.audit", ":"),
new MetadataKey("example.audit", ":child"),
new MetadataKey("example.audit", "abc"), new MetadataKey("example.audit", "abc"),
new MetadataKey("example.audit", "~")) new MetadataKey("example.audit", "~"))
.stream() .stream()
.sorted() .sorted()
.toList(); .toList();
assertEquals("abc", sorted.get(0).key()); assertEquals(":", sorted.get(0).key());
assertEquals(":child", sorted.get(1).key());
assertEquals("abc", sorted.get(2).key());
MetadataSnapshot.KeyRange ordinary = MetadataSnapshot.KeyRange ordinary =
MetadataSnapshot.KeyRange.prefix("example.audit", "abc"); MetadataSnapshot.KeyRange.prefix("example.audit", "abc");
assertEquals("abd", ordinary.upperExclusive().orElseThrow()); assertEquals("abd", ordinary.upperExclusive().orElseThrow());
@@ -106,11 +111,25 @@ final class MetadataKeyTest {
assertThrows(IllegalArgumentException.class, assertThrows(IllegalArgumentException.class,
() -> new MetadataKey(oversizedNamespace, "key")); () -> new MetadataKey(oversizedNamespace, "key"));
assertThrows(IllegalArgumentException.class, assertThrows(IllegalArgumentException.class,
() -> MetadataKey.parse(oversizedNamespace + ":key")); () -> MetadataKey.parse("mk1:256:3:" + oversizedNamespace + "key"));
assertThrows(IllegalArgumentException.class, assertThrows(IllegalArgumentException.class,
() -> new MetadataKey("example.audit", oversizedKey)); () -> new MetadataKey("example.audit", oversizedKey));
assertThrows(IllegalArgumentException.class, assertThrows(IllegalArgumentException.class,
() -> MetadataKey.parse("example.audit:" + oversizedKey)); () -> MetadataKey.parse("mk1:13:4097:example.audit" + oversizedKey));
System.out.println("...ok");
}
@Test
void malformedLengthFramingFailsClosed() {
System.out.print("malformedLengthFramingFailsClosed ");
MetadataKey key = new MetadataKey("example.audit", "zsign:v1:a:b:c");
String canonical = key.canonical();
assertThrows(IllegalArgumentException.class, () -> MetadataKey.parse("mk2:13:1:example.audita"));
assertThrows(IllegalArgumentException.class, () -> MetadataKey.parse("mk1:013:1:example.audita"));
assertThrows(IllegalArgumentException.class, () -> MetadataKey.parse("mk1:13:2:example.audita"));
assertThrows(IllegalArgumentException.class, () -> MetadataKey.parse(canonical.substring(0, canonical.length() - 1)));
assertThrows(IllegalArgumentException.class, () -> MetadataKey.parse(canonical + "x"));
assertThrows(IllegalArgumentException.class, () -> MetadataKey.parse("mk1:999999999999:1:xz"));
System.out.println("...ok"); System.out.println("...ok");
} }
} }