value = attrs.get(id);
- if (value.isEmpty()) {
- return Optional.empty();
- }
- if (!(value.get() instanceof AttributeValue.StringValue)) {
- throw new PkiException(typeMessage);
- }
- return Optional.of(((AttributeValue.StringValue) value.get()).value());
- }
-
/**
* Parses the issuer certificate DER into an X.509 certificate holder.
*
@@ -531,7 +484,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
try {
return new X509CertificateHolder(issuerCertDer);
} catch (Exception ex) {
- throw new PkiException("Invalid issuer certificate DER", ex);
+ throw new PkiException("Invalid issuer certificate: code=ISSUER_CERTIFICATE_INVALID");
}
}
}
@@ -568,9 +521,32 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
private static String sha256Hex(byte[] in) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
- return HexFormat.of().formatHex(md.digest(in));
+ byte[] digest = md.digest(in);
+ try {
+ return HexFormat.of().formatHex(digest);
+ } finally {
+ java.util.Arrays.fill(digest, (byte) 0);
+ }
} catch (Exception ex) {
- throw new IllegalStateException("SHA-256 not available", ex);
+ throw new IllegalStateException("SHA-256 unavailable: code=DIGEST_UNAVAILABLE");
+ }
+ }
+
+ private static SubjectPublicKeyInfo parseSubjectPublicKeyInfo(EncodedObject encoded) {
+ byte[] bytes = encoded.bytes();
+ try {
+ return SubjectPublicKeyInfo.getInstance(bytes);
+ } finally {
+ java.util.Arrays.fill(bytes, (byte) 0);
+ }
+ }
+
+ private static String fingerprintEncoded(EncodedObject encoded) {
+ byte[] bytes = encoded.bytes();
+ try {
+ return sha256Hex(bytes);
+ } finally {
+ java.util.Arrays.fill(bytes, (byte) 0);
}
}
}
diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509ProofOfPossessionVerifier.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509ProofOfPossessionVerifier.java
index 624439c..efa839c 100644
--- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509ProofOfPossessionVerifier.java
+++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509ProofOfPossessionVerifier.java
@@ -38,6 +38,7 @@ import java.util.Optional;
import org.bouncycastle.operator.ContentVerifierProvider;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.pkcs.PKCS10CertificationRequest;
import zeroecho.pki.api.attr.AttributeValue;
@@ -93,6 +94,9 @@ import zeroecho.pki.spi.framework.ProofOfPossessionVerifier;
* authorization checks, or profile compliance checks.
* The presence of a valid PKCS#10 self-signature does not by itself imply
* that the requester is entitled to receive the requested certificate.
+ * This verifier accepts signature algorithms supported by its Bouncy Castle
+ * provider, including RSASSA-PSS. Deployments requiring a narrower or stronger
+ * algorithm policy must enforce it in the issuance policy layer.
* The CSR payload carried under {@link BcX509Attributes#CSR_DER} may be
* operationally sensitive and must not be logged unsafely.
*
@@ -104,6 +108,8 @@ import zeroecho.pki.spi.framework.ProofOfPossessionVerifier;
*/
public final class BcX509ProofOfPossessionVerifier implements ProofOfPossessionVerifier {
+ private static final BouncyCastleProvider BC_PROVIDER = new BouncyCastleProvider();
+
/**
* Verifies proof of possession for a parsed PKCS#10 certification request.
*
@@ -177,7 +183,8 @@ public final class BcX509ProofOfPossessionVerifier implements ProofOfPossessionV
} catch (Exception ex) {
return new ProofOfPossessionResult(ProofOfPossessionStatus.FAILED, Optional.of("Invalid CSR"));
}
- ContentVerifierProvider cvp = new JcaContentVerifierProviderBuilder().build(csr.getSubjectPublicKeyInfo());
+ ContentVerifierProvider cvp = new JcaContentVerifierProviderBuilder().setProvider(BC_PROVIDER)
+ .build(csr.getSubjectPublicKeyInfo());
boolean ok = csr.isSignatureValid(cvp);
if (ok) {
return new ProofOfPossessionResult(ProofOfPossessionStatus.VERIFIED, Optional.empty());
diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSigner.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSigner.java
index 89da479..c2f22b7 100644
--- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSigner.java
+++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSigner.java
@@ -37,6 +37,7 @@ import java.io.ByteArrayOutputStream;
import java.io.OutputStream;
import java.time.Duration;
import java.time.Instant;
+import java.util.Arrays;
import java.util.Optional;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
@@ -86,9 +87,9 @@ import zeroecho.pki.util.async.AsyncState;
* The signer polls the bus by repeatedly calling
* {@link PkiSigningBus#sweep(Instant)} and {@link PkiSigningBus#status(PkiId)}
* until the workflow succeeds, fails, or times out.
- * On successful completion or explicit failure, persisted workflow state is
- * deleted through {@link PkiSigningBus#deleteWorkflowState(PkiId)} before the
- * method returns or throws.
+ * Every submitted operation is retired through
+ * {@link PkiSigningBus#retireSignOperation(PkiId, String)} before the method
+ * returns or throws.
*
*
* Security considerations
@@ -108,6 +109,8 @@ import zeroecho.pki.util.async.AsyncState;
* intended for one certificate or CRL signing flow.
*
*/
+// PMD cannot infer that retaining provider causes would violate the redaction contract.
+@SuppressWarnings("PMD.PreserveStackTrace")
public final class PkiBusContentSigner implements ContentSigner {
private final PkiSigningBus bus;
@@ -115,7 +118,7 @@ public final class PkiBusContentSigner implements ContentSigner {
private final String algorithmId;
private final Duration ttl;
- private final ByteArrayOutputStream baos;
+ private final WipeableByteArrayOutputStream baos;
/**
* Creates a signer that routes signature generation through the PKI signing
@@ -149,7 +152,7 @@ public final class PkiBusContentSigner implements ContentSigner {
this.keyRef = keyRef;
this.algorithmId = algorithmId;
this.ttl = ttl;
- this.baos = new ByteArrayOutputStream();
+ this.baos = new WipeableByteArrayOutputStream();
}
/**
@@ -203,8 +206,10 @@ public final class PkiBusContentSigner implements ContentSigner {
*
* On successful completion, the signature bytes contained in the workflow
* result are returned. If the workflow reports success but no result is
- * available, the method fails explicitly. On terminal success or failure, the
- * persisted workflow state is deleted before the method returns or throws.
+ * available, the method fails explicitly. Every operation for which submission
+ * is attempted is retired before the method returns or throws. Cleanup failures
+ * are suppressed on the primary signing failure and otherwise become the
+ * returned failure.
*
*
*
@@ -219,38 +224,105 @@ public final class PkiBusContentSigner implements ContentSigner {
* complete within the configured TTL
*/
@Override
+ @SuppressWarnings("PMD.AvoidCatchingGenericException")
public byte[] getSignature() {
byte[] tbs = baos.toByteArray();
- EncodedObject payload = new EncodedObject(Encoding.BINARY, tbs);
+ byte[] consumedResult = null;
+ PkiId opId = null;
+ boolean retirementRequired = false;
+ Throwable primaryFailure = null;
+ try {
+ EncodedObject payload = new EncodedObject(Encoding.BINARY, tbs);
+ Principal owner = new Principal("SYSTEM", "pki");
+ opId = bus.newSubmissionId();
- Principal owner = new Principal("SYSTEM", "pki");
- PkiId clientOpId = new PkiId("sign:" + Integer.toUnsignedString(System.identityHashCode(this)) + ":"
- + Long.toUnsignedString(System.nanoTime()));
- PkiId opId = bus.canonicalizeOperationId(clientOpId, owner);
+ AccessContext ac = new AccessContext(owner, new Purpose("X509_SIGN"), Optional.empty(),
+ Optional.empty());
+ SignContinuation cont = new SignContinuation(ac, algorithmId, payload, keyRef, Encoding.BINARY,
+ Optional.empty());
- AccessContext ac = new AccessContext(owner, new Purpose("X509_SIGN"), Optional.empty(), Optional.empty());
- SignContinuation cont = new SignContinuation(ac, algorithmId, payload, keyRef, Encoding.BINARY,
- Optional.empty());
-
- bus.submitSign(opId, owner, keyRef, algorithmId, payload, ttl, Optional.of(cont.encode()));
+ retirementRequired = true;
+ bus.submitSign(opId, owner, keyRef, algorithmId, payload, ttl, Optional.of(cont.encode()));
+ consumedResult = awaitSignature(opId);
+ return consumedResult.clone();
+ } catch (RuntimeException failure) {
+ PkiException sanitized = new PkiException("Signing workflow failed: code=SIGNING_WORKFLOW_FAILED");
+ primaryFailure = sanitized;
+ throw sanitized;
+ } catch (Error failure) {
+ primaryFailure = failure;
+ throw failure;
+ } finally {
+ try {
+ if (retirementRequired) {
+ retirePreservingFailure(opId, primaryFailure);
+ }
+ } finally {
+ Arrays.fill(tbs, (byte) 0);
+ baos.wipe();
+ if (consumedResult != null) {
+ Arrays.fill(consumedResult, (byte) 0);
+ }
+ }
+ }
+ }
+ private byte[] awaitSignature(PkiId opId) {
Instant deadline = Instant.now().plus(ttl);
while (Instant.now().isBefore(deadline)) {
bus.sweep(Instant.now());
- Optional st = bus.status(opId);
- if (st.isPresent() && st.get().state() == AsyncState.SUCCEEDED) {
- Optional res = bus.consumeResult(opId);
- bus.deleteWorkflowState(opId);
- if (res.isEmpty()) {
+ Optional status = bus.status(opId);
+ if (status.isPresent() && status.get().state() == AsyncState.SUCCEEDED) {
+ Optional result = bus.consumeResult(opId);
+ if (result.isEmpty()) {
throw new PkiException("Missing signature result");
}
- return res.get().bytes();
+ return result.get().bytes();
}
- if (st.isPresent() && st.get().state() == AsyncState.FAILED) {
- bus.deleteWorkflowState(opId);
- throw new PkiException("Signing failed");
+ if (status.isPresent() && isTerminalFailure(status.get().state())) {
+ throw new PkiException("Signing failed: " + status.get().state());
}
+ boundedWait(deadline);
}
throw new PkiException("Signing did not complete before TTL");
}
+
+ private static boolean isTerminalFailure(AsyncState state) {
+ return state == AsyncState.FAILED || state == AsyncState.CANCELLED || state == AsyncState.EXPIRED;
+ }
+
+ @SuppressWarnings("PMD.DoNotUseThreads")
+ private static void boundedWait(Instant deadline) {
+ long remainingMillis = Duration.between(Instant.now(), deadline).toMillis();
+ long waitMillis = Math.min(10L, Math.max(1L, remainingMillis));
+ try {
+ Thread.sleep(waitMillis);
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ throw new PkiException("Interrupted while awaiting signing", ex);
+ }
+ }
+
+ @SuppressWarnings("PMD.AvoidCatchingGenericException")
+ private void retirePreservingFailure(PkiId opId, Throwable primaryFailure) {
+ try {
+ bus.retireSignOperation(opId, primaryFailure == null ? "completed" : "failed-or-expired");
+ } catch (RuntimeException cleanupFailure) {
+ if (primaryFailure == null) {
+ throw new PkiException("Signing cleanup failed: code=SIGNING_CLEANUP_FAILED");
+ }
+ }
+ }
+
+ /**
+ * Byte-array output stream whose retained backing storage can be overwritten
+ * after one signing attempt.
+ */
+ private static final class WipeableByteArrayOutputStream extends ByteArrayOutputStream {
+
+ private void wipe() {
+ Arrays.fill(buf, (byte) 0);
+ reset();
+ }
+ }
}
diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java
index 877bb19..2798bde 100644
--- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java
@@ -35,25 +35,42 @@ package zeroecho.pki.impl.fs;
import java.io.Closeable;
import java.io.IOException;
+import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
+import java.nio.channels.FileLock;
+import java.nio.channels.OverlappingFileLockException;
+import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.SecureRandom;
+import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Comparator;
+import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.locks.ReentrantLock;
import java.util.logging.Level;
import java.util.logging.Logger;
+import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.credential.Credential;
+import zeroecho.pki.api.orch.SigningSubmissionId;
import zeroecho.pki.api.orch.WorkflowStateRecord;
import zeroecho.pki.api.policy.PolicyTrace;
import zeroecho.pki.api.profile.CertificateProfile;
@@ -61,7 +78,9 @@ import zeroecho.pki.api.publication.PublicationRecord;
import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.api.revocation.RevokedRecord;
import zeroecho.pki.api.status.StatusObject;
+import zeroecho.pki.impl.core.async.PkiSigningBus;
import zeroecho.pki.spi.store.PkiStore;
+import zeroecho.pki.spi.store.SignWorkflowStore;
/**
* Filesystem-based reference implementation of {@link PkiStore}.
@@ -111,17 +130,31 @@ import zeroecho.pki.spi.store.PkiStore;
* are limited to object type, safe IDs, and file operation outcomes.
*
*/
+@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity", "PMD.TooManyMethods" })
public final class FilesystemPkiStore implements PkiStore, Closeable {
private static final Logger LOG = Logger.getLogger(FilesystemPkiStore.class.getName());
private static final String VERSION_V1 = "v1";
+ private static final int SIGN_RECORD_MAGIC = 0x5A455352;
+ private static final int SIGN_RECORD_VERSION = 1;
+ private static final int SIGN_RECORD_HEADER_BYTES = Integer.BYTES * 2;
+ private static final String SIGN_FINGERPRINT_PREFIX = "signfp:v1:";
+ private static final int SIGN_FINGERPRINT_HEX_LENGTH = 64;
+ private static final long INITIAL_FENCE = 0L;
+ private static final String STORE_OWNERSHIP_UNAVAILABLE =
+ "Filesystem store ownership unavailable: code=STORE_ALREADY_OPEN";
private final FsPkiStoreOptions options;
private final FsPaths paths;
private final AtomicLong historySeq;
+ private final Clock clock;
+ private final String signingNamespace;
+ private final AtomicLong signingTimeWatermark;
+ private final ReentrantLock signingTimeLock;
+ private final ConcurrentMap signLocks;
- private final FileChannel lockChannel;
+ private final StoreOwnership ownership;
/**
* Opens or creates a filesystem PKI store rooted at {@code root}.
@@ -132,25 +165,55 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
* @throws IllegalStateException if the store cannot be opened or locked
*/
public FilesystemPkiStore(final Path root, final FsPkiStoreOptions options) {
+ this(root, options, Clock.systemUTC());
+ }
+
+ /**
+ * Opens or creates a filesystem store with an explicit authoritative clock.
+ *
+ * @param root store root directory
+ * @param options store options
+ * @param clock authoritative signing workflow clock
+ * @throws IllegalArgumentException if an argument is {@code null}
+ * @throws IllegalStateException if the store cannot be opened or is already
+ * owned by another store instance or process
+ */
+ // StoreOwnership transfers to this store on success and remains open until close();
+ // try-with-resources here would release process ownership at constructor return.
+ @SuppressWarnings("PMD.CloseResource")
+ public FilesystemPkiStore(final Path root, final FsPkiStoreOptions options, final Clock clock) {
this.options = Objects.requireNonNull(options, "options");
Objects.requireNonNull(root, "root");
+ this.clock = Objects.requireNonNull(clock, "clock");
+ this.signLocks = new ConcurrentHashMap<>();
+ this.signingTimeLock = new ReentrantLock();
+ this.paths = new FsPaths(root);
+ StoreOwnership acquiredOwnership;
try {
FsOperations.ensureDir(root);
- this.paths = new FsPaths(root);
FsOperations.ensureDir(this.paths.lockFile().getParent());
+ acquiredOwnership = StoreOwnership.acquire(this.paths.lockFile());
+ } catch (IOException e) {
+ throw new IllegalStateException("failed to open filesystem store at " + root, e);
+ }
- this.lockChannel = FileChannel.open(this.paths.lockFile(), StandardOpenOption.CREATE,
- StandardOpenOption.WRITE);
- lockChannel.lock(); // exclusive lock
-
+ boolean ownershipTransferred = false;
+ try {
ensureVersionFile();
+ this.signingNamespace = ensureSigningNamespace();
+ this.signingTimeWatermark = new AtomicLong(loadSigningTimeWatermark());
this.historySeq = new AtomicLong(0L);
LOG.log(Level.INFO, "running in {0}", root);
-
+ this.ownership = acquiredOwnership;
+ ownershipTransferred = true;
} catch (IOException e) {
throw new IllegalStateException("failed to open filesystem store at " + root, e);
+ } finally {
+ if (!ownershipTransferred) {
+ acquiredOwnership.closeSilently();
+ }
}
}
@@ -161,7 +224,11 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
*
* This method is an implementation-only feature. It does not modify the current
* store; it clones a new store layout and reconstructs {@code current.bin} for
- * history-tracked entities.
+ * history-tracked entities. The signing namespace, monotonic signing-time
+ * watermark, and authoritative signing workflow records are safety metadata:
+ * their current export-time values are copied regardless of {@code at}, because
+ * historical reconstruction could permit identifier reuse or lose a terminal
+ * result.
*
*
* @param targetRoot new store root to create/populate
@@ -369,11 +436,709 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
}
@Override
- public void close() throws IOException {
- synchronized (this.lockChannel) { // NOPMD
- if (this.lockChannel.isOpen()) {
- this.lockChannel.close();
+ public Instant signingNow() {
+ signingTimeLock.lock();
+ try {
+ long monotonic = Math.max(signingTimeWatermark.get(), clock.instant().toEpochMilli());
+ persistSigningTimeWatermark(monotonic);
+ signingTimeWatermark.set(monotonic);
+ return Instant.ofEpochMilli(monotonic);
+ } finally {
+ signingTimeLock.unlock();
+ }
+ }
+
+ @Override
+ public String signingNamespace() {
+ return signingNamespace;
+ }
+
+ @Override
+ public Duration signingHorizon() {
+ return options.signingOperationHorizon();
+ }
+
+ @Override
+ public Duration signingPermittedSkew() {
+ return options.signingIdPermittedSkew();
+ }
+
+ @Override
+ public SignWorkflowStore.CreateResult createSignIntent(SignWorkflowStore.Record intent) {
+ Objects.requireNonNull(intent, "intent");
+ SignLockEntry lock = acquireSignLock(intent.submissionId());
+ try {
+ SigningSubmissionId parsed = SigningSubmissionId.parse(intent.submissionId());
+ if (!intent.namespace().startsWith(signingNamespace + ".")) {
+ throw new IllegalArgumentException("Signing intent belongs to a different store namespace");
}
+ parsed.validate(intent.namespace(), signingNow(), options.signingOperationHorizon(),
+ options.signingIdPermittedSkew());
+ if (!parsed.createdAt().equals(intent.createdAt()) || intent.deadline().isBefore(intent.createdAt())
+ || intent.deadline().isAfter(parsed.createdAt().plus(options.signingOperationHorizon()))) {
+ throw new IllegalArgumentException("Signing intent timestamps do not match its stable identifier");
+ }
+ if (intent.state() != SignWorkflowStore.State.INTENT || intent.revision() != 0L || intent.fence() != 0L
+ || intent.leaseUntil().isPresent() || intent.result().isPresent()) {
+ throw new IllegalArgumentException("New signing intent has invalid initial state");
+ }
+ Optional existing = readSignRecord(intent.submissionId());
+ if (existing.isPresent()) {
+ return existing.get().fingerprint().equals(intent.fingerprint())
+ ? SignWorkflowStore.CreateResult.ATTACHED
+ : SignWorkflowStore.CreateResult.CONFLICT;
+ }
+ writeSignRecord(intent);
+ return SignWorkflowStore.CreateResult.CREATED;
+ } finally {
+ releaseSignLock(intent.submissionId(), lock);
+ }
+ }
+
+ @Override
+ public Optional getSignRecord(PkiId submissionId) {
+ Objects.requireNonNull(submissionId, "submissionId");
+ SignLockEntry lock = acquireSignLock(submissionId);
+ try {
+ return readSignRecord(submissionId);
+ } finally {
+ releaseSignLock(submissionId, lock);
+ }
+ }
+
+ @Override
+ public List listSignRecords() {
+ Path root = paths.signWorkflowRoot();
+ if (!Files.isDirectory(root)) {
+ return List.of();
+ }
+ try (java.util.stream.Stream directories = Files.list(root)) {
+ return directories.filter(Files::isDirectory)
+ .map(directory -> directory.resolve(FsPaths.CURRENT_FILE))
+ .filter(Files::isRegularFile)
+ .sorted(Comparator.comparing(Path::toString))
+ .map(this::readSignRecordFile)
+ .peek(record -> validateSignRecord(record.submissionId(), record))
+ .toList();
+ } catch (IOException ex) {
+ throw new IllegalStateException("Failed to list authoritative signing records", ex);
+ }
+ }
+
+ @Override
+ public Optional tryClaimSign(PkiId submissionId, long expectedRevision,
+ Duration lease) {
+ requirePositive(lease, "lease");
+ SignLockEntry lock = acquireSignLock(submissionId);
+ try {
+ Optional optional = readSignRecord(submissionId);
+ if (optional.isEmpty()) {
+ return Optional.empty();
+ }
+ SignWorkflowStore.Record current = optional.get();
+ Instant now = signingNow();
+ boolean claimable = current.state() == SignWorkflowStore.State.INTENT;
+ boolean leaseAvailable = current.leaseUntil().isEmpty() || !current.leaseUntil().get().isAfter(now);
+ if (current.revision() != expectedRevision || !claimable || !leaseAvailable) {
+ return Optional.empty();
+ }
+ SignWorkflowStore.Record claimed = copySignRecord(current, current.state(), current.revision() + 1L,
+ current.fence() + 1L, Optional.of(now.plus(lease)), current.detailCode(), current.result(),
+ current.providerUpdatedAt());
+ writeSignRecord(claimed);
+ return Optional.of(claimed);
+ } finally {
+ releaseSignLock(submissionId, lock);
+ }
+ }
+
+ @Override
+ public Optional renewSignClaim(PkiId submissionId, long expectedRevision, long fence,
+ Duration lease) {
+ requirePositive(lease, "lease");
+ SignLockEntry lock = acquireSignLock(submissionId);
+ try {
+ Optional optional = readSignRecord(submissionId);
+ if (optional.isEmpty()) {
+ return Optional.empty();
+ }
+ SignWorkflowStore.Record current = optional.get();
+ if (current.revision() != expectedRevision || current.fence() != fence || current.leaseUntil().isEmpty()) {
+ return Optional.empty();
+ }
+ SignWorkflowStore.Record renewed = copySignRecord(current, current.state(), current.revision() + 1L,
+ fence, Optional.of(signingNow().plus(lease)), current.detailCode(), current.result(),
+ current.providerUpdatedAt());
+ writeSignRecord(renewed);
+ return Optional.of(renewed);
+ } finally {
+ releaseSignLock(submissionId, lock);
+ }
+ }
+
+ @Override
+ public Optional transitionSign(PkiId submissionId, long expectedRevision, long fence,
+ SignWorkflowStore.State target, Optional detailCode, Optional result,
+ Optional providerUpdatedAt) {
+ Objects.requireNonNull(target, "target");
+ Objects.requireNonNull(detailCode, "detailCode");
+ Objects.requireNonNull(result, "result");
+ Objects.requireNonNull(providerUpdatedAt, "providerUpdatedAt");
+ SignLockEntry lock = acquireSignLock(submissionId);
+ try {
+ Optional optional = readSignRecord(submissionId);
+ if (optional.isEmpty()) {
+ return Optional.empty();
+ }
+ SignWorkflowStore.Record current = optional.get();
+ if (current.revision() != expectedRevision || current.fence() != fence
+ || !validSignTransition(current.state(), target)) {
+ return Optional.empty();
+ }
+ if (target == SignWorkflowStore.State.SUCCEEDED && result.isEmpty()
+ || target != SignWorkflowStore.State.SUCCEEDED && result.isPresent()) {
+ throw new IllegalArgumentException("Signing result does not match target state");
+ }
+ if (target == SignWorkflowStore.State.SUCCEEDED
+ && (providerUpdatedAt.isEmpty()
+ || !providerUpdatedAt.get().isBefore(current.deadline()))) {
+ throw new IllegalArgumentException("Successful signing completion timestamp is not trustworthy");
+ }
+ SignWorkflowStore.Record transitioned = copySignRecord(current, target, current.revision() + 1L, fence,
+ Optional.empty(), detailCode, result, providerUpdatedAt);
+ writeSignRecord(transitioned);
+ return Optional.of(transitioned);
+ } finally {
+ releaseSignLock(submissionId, lock);
+ }
+ }
+
+ @Override
+ public Optional retireSign(PkiId submissionId, long expectedRevision, long fence) {
+ SignLockEntry lock = acquireSignLock(submissionId);
+ try {
+ Optional optional = readSignRecord(submissionId);
+ if (optional.isEmpty()) {
+ return Optional.empty();
+ }
+ SignWorkflowStore.Record current = optional.get();
+ if (current.revision() != expectedRevision || current.fence() != fence
+ || !isRetirableSignState(current.state())) {
+ return Optional.empty();
+ }
+ SignWorkflowStore.Record retired = copySignRecord(current, SignWorkflowStore.State.RETIRED,
+ current.revision() + 1L, fence, Optional.empty(), Optional.of("RETIRED"), current.result(),
+ current.providerUpdatedAt());
+ writeSignRecord(retired);
+ return Optional.of(retired);
+ } finally {
+ releaseSignLock(submissionId, lock);
+ }
+ }
+
+ @Override
+ public int purgeExpiredSignRecords() {
+ Path root = paths.signWorkflowRoot();
+ if (!Files.isDirectory(root)) {
+ return 0;
+ }
+ int purged = 0;
+ for (SignWorkflowStore.Record record : listSignRecords()) {
+ if (record.state() == SignWorkflowStore.State.RETIRED
+ && !record.createdAt().plus(options.signingOperationHorizon()).isAfter(signingNow())) {
+ SignLockEntry lock = acquireSignLock(record.submissionId());
+ try {
+ Optional current = readSignRecord(record.submissionId());
+ if (current.isPresent() && current.get().state() == SignWorkflowStore.State.RETIRED
+ && !current.get().createdAt().plus(options.signingOperationHorizon())
+ .isAfter(signingNow())
+ && Files.deleteIfExists(paths.signWorkflowPath(record.submissionId()))) {
+ purged++;
+ }
+ } catch (IOException ex) {
+ throw new IllegalStateException("failed to purge signing workflow", ex);
+ } finally {
+ releaseSignLock(record.submissionId(), lock);
+ }
+ }
+ }
+ return purged;
+ }
+
+ @Override
+ public void close() throws IOException {
+ this.ownership.close();
+ }
+
+ private SignLockEntry acquireSignLock(PkiId submissionId) {
+ SignLockEntry entry = signLocks.compute(submissionId, (ignored, current) -> {
+ SignLockEntry selected = current == null ? new SignLockEntry() : current;
+ selected.references.incrementAndGet();
+ return selected;
+ });
+ entry.lock.lock();
+ return entry;
+ }
+
+ private void releaseSignLock(PkiId submissionId, SignLockEntry entry) {
+ entry.lock.unlock();
+ signLocks.computeIfPresent(submissionId, (ignored, current) -> {
+ if (current != entry) { // NOPMD - identity protects a replacement lock entry
+ return current;
+ }
+ return current.references.decrementAndGet() == 0 ? null : current;
+ });
+ }
+
+ private Optional readSignRecord(PkiId submissionId) {
+ Path path = paths.signWorkflowPath(submissionId);
+ 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) {
+ try {
+ byte[] envelope = FsOperations.readAll(path);
+ if (envelope.length < SIGN_RECORD_HEADER_BYTES) {
+ throw new IllegalStateException("Invalid signing record envelope");
+ }
+ ByteBuffer input = ByteBuffer.wrap(envelope);
+ if (input.getInt() != SIGN_RECORD_MAGIC) {
+ throw new IllegalStateException("Invalid signing record envelope");
+ }
+ if (input.getInt() != SIGN_RECORD_VERSION) {
+ throw new IllegalStateException("Unsupported signing record version");
+ }
+ byte[] payload = new byte[input.remaining()];
+ input.get(payload);
+ return FsCodec.decode(payload, SignWorkflowStore.Record.class);
+ } catch (IOException ex) {
+ throw new IllegalStateException("Failed to read authoritative signing record", ex);
+ }
+ }
+
+ // Parsing and canonicalization failures can carry persisted request material;
+ // validation deliberately replaces every such cause with a safe corruption code.
+ @SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
+ private void validateSignRecord(PkiId requestedId, SignWorkflowStore.Record record) {
+ Objects.requireNonNull(requestedId, "requestedId");
+ Objects.requireNonNull(record, "record");
+
+ SigningSubmissionId parsed;
+ PkiSigningBus.SignContinuation continuation;
+ Instant horizonEnd;
+ Instant futureLimit;
+ try {
+ parsed = SigningSubmissionId.parse(record.submissionId());
+ continuation = PkiSigningBus.SignContinuation.decode(record.request());
+ horizonEnd = record.createdAt().plus(options.signingOperationHorizon());
+ futureLimit = signingNow().plus(options.signingIdPermittedSkew());
+ } catch (RuntimeException ex) {
+ throw invalidSignRecord(record, "IDENTITY_OR_REQUEST_INVALID");
+ }
+
+ requireValidSignRecord(requestedId.equals(record.submissionId()), record, "SUBMISSION_ID_MISMATCH");
+ requireValidSignRecord(record.namespace().startsWith(signingNamespace + "."), record,
+ "STORE_NAMESPACE_MISMATCH");
+ requireValidSignRecord(parsed.namespace().equals(record.namespace()), record, "ID_NAMESPACE_MISMATCH");
+ requireValidSignRecord(parsed.createdAt().equals(record.createdAt()), record, "ID_TIMESTAMP_MISMATCH");
+ requireValidSignRecord(!record.createdAt().isAfter(futureLimit), record, "CREATION_TIME_FUTURE");
+ requireValidSignRecord(record.deadline().isAfter(record.createdAt())
+ && !record.deadline().isAfter(horizonEnd), record, "DEADLINE_INVALID");
+ requireValidSignRecord(continuation.isBoundTo(record.submissionId(), record.owner()), record,
+ "CONTINUATION_IDENTITY_MISMATCH");
+
+ byte[] storedRequest = record.request().bytes();
+ byte[] canonicalRequest = null;
+ String expectedFingerprint;
+ try {
+ canonicalRequest = continuation.encode().bytes();
+ expectedFingerprint = continuation.semanticFingerprint(record.namespace(), record.deadline());
+ } catch (RuntimeException ex) {
+ Arrays.fill(storedRequest, (byte) 0);
+ if (canonicalRequest != null) {
+ Arrays.fill(canonicalRequest, (byte) 0);
+ }
+ throw invalidSignRecord(record, "FINGERPRINT_RECOMPUTE_FAILED");
+ }
+ try {
+ requireValidSignRecord(MessageDigest.isEqual(storedRequest, canonicalRequest), record,
+ "CONTINUATION_NONCANONICAL");
+ requireValidSignRecord(hasCanonicalFingerprintShape(record.fingerprint()), record,
+ "FINGERPRINT_FORMAT_INVALID");
+ requireValidSignRecord(constantTimeAsciiEquals(record.fingerprint(), expectedFingerprint), record,
+ "FINGERPRINT_MISMATCH");
+ } finally {
+ Arrays.fill(storedRequest, (byte) 0);
+ Arrays.fill(canonicalRequest, (byte) 0);
+ }
+
+ requireValidSignRecord(record.revision() >= record.fence(), record, "REVISION_FENCE_ORDER_INVALID");
+ requireValidSignRecord(record.detailCode().isEmpty() || !record.detailCode().get().isBlank(), record,
+ "DETAIL_CODE_INVALID");
+ if (record.leaseUntil().isPresent()) {
+ Instant leaseUntil = record.leaseUntil().get();
+ requireValidSignRecord(leaseUntil.isAfter(record.createdAt()) && !leaseUntil.isAfter(horizonEnd), record,
+ "LEASE_TIME_INVALID");
+ }
+ if (record.result().isPresent()) {
+ requireValidSignRecord(record.result().get().bytes().length <= FsCodec.MAX_COMPONENT_BYTES, record,
+ "RESULT_SIZE_INVALID");
+ }
+
+ validateSignRecordState(record);
+ }
+
+ private void writeSignRecord(SignWorkflowStore.Record record) {
+ validateSignRecord(record.submissionId(), record);
+ try {
+ byte[] payload = FsCodec.encode(record);
+ ByteBuffer envelope = ByteBuffer.allocate(SIGN_RECORD_HEADER_BYTES + payload.length);
+ envelope.putInt(SIGN_RECORD_MAGIC);
+ envelope.putInt(SIGN_RECORD_VERSION);
+ envelope.put(payload);
+ FsOperations.writeAtomic(paths.signWorkflowPath(record.submissionId()), envelope.array());
+ } catch (IOException ex) {
+ throw new IllegalStateException("failed to persist signing workflow", ex);
+ }
+ }
+
+ private static SignWorkflowStore.Record copySignRecord(SignWorkflowStore.Record current,
+ SignWorkflowStore.State state, long revision, long fence, Optional leaseUntil,
+ Optional detailCode, Optional result, Optional providerUpdatedAt) {
+ return new SignWorkflowStore.Record(current.submissionId(), current.namespace(), current.fingerprint(),
+ current.owner(), current.createdAt(), current.deadline(), current.request(), state, revision, fence,
+ leaseUntil, detailCode, result, providerUpdatedAt);
+ }
+
+ private static boolean validSignTransition(SignWorkflowStore.State source, SignWorkflowStore.State target) {
+ if (source == SignWorkflowStore.State.INTENT) {
+ return target == SignWorkflowStore.State.DISPATCHED || target == SignWorkflowStore.State.FAILED
+ || target == SignWorkflowStore.State.CANCELLING || target == SignWorkflowStore.State.EXPIRED;
+ }
+ if (source == SignWorkflowStore.State.DISPATCHED) {
+ return target == SignWorkflowStore.State.SUCCEEDED || target == SignWorkflowStore.State.FAILED
+ || target == SignWorkflowStore.State.CANCELLING || target == SignWorkflowStore.State.EXPIRED;
+ }
+ if (source == SignWorkflowStore.State.CANCELLING) {
+ return target == SignWorkflowStore.State.CANCELLING
+ || target == SignWorkflowStore.State.SUCCEEDED || target == SignWorkflowStore.State.FAILED
+ || target == SignWorkflowStore.State.CANCELLED || target == SignWorkflowStore.State.EXPIRED;
+ }
+ return false;
+ }
+
+ private static boolean isRetirableSignState(SignWorkflowStore.State state) {
+ return state == SignWorkflowStore.State.SUCCEEDED || state == SignWorkflowStore.State.FAILED
+ || state == SignWorkflowStore.State.CANCELLED || state == SignWorkflowStore.State.EXPIRED;
+ }
+
+ private static void validateSignRecordState(SignWorkflowStore.Record record) {
+ switch (record.state()) {
+ case INTENT -> {
+ requireValidSignRecord(record.result().isEmpty() && record.providerUpdatedAt().isEmpty(), record,
+ "INTENT_TERMINAL_DATA_PRESENT");
+ requireDetailCode(record, "INTENT");
+ if (record.fence() == INITIAL_FENCE) {
+ requireValidSignRecord(record.revision() == 0L && record.leaseUntil().isEmpty(), record,
+ "INTENT_INITIAL_CLAIM_INVALID");
+ } else {
+ requireValidSignRecord(record.revision() >= record.fence()
+ && record.leaseUntil().isPresent(), record, "INTENT_CLAIM_INVALID");
+ }
+ }
+ case DISPATCHED -> {
+ requireValidSignRecord(record.fence() > 0L && record.revision() > record.fence(), record,
+ "DISPATCH_REVISION_INVALID");
+ requireValidSignRecord(record.leaseUntil().isEmpty() && record.result().isEmpty()
+ && record.providerUpdatedAt().isEmpty(), record, "DISPATCH_TERMINAL_DATA_PRESENT");
+ requireDetailCode(record, "DISPATCHED");
+ }
+ case CANCELLING -> {
+ requireValidSignRecord(record.revision() > record.fence(), record, "CANCELLING_REVISION_INVALID");
+ requireValidSignRecord(record.leaseUntil().isEmpty() && record.result().isEmpty()
+ && record.providerUpdatedAt().isEmpty(), record, "CANCELLING_TERMINAL_DATA_PRESENT");
+ requireValidSignRecord(record.detailCode().filter(code -> "CANCEL_REQUESTED".equals(code)
+ || "CANCEL_SUBMITTED".equals(code)).isPresent(), record, "DETAIL_CODE_INVALID");
+ }
+ case SUCCEEDED -> {
+ requireValidSignRecord(record.fence() > 0L && record.revision() - record.fence() >= 2L, record,
+ "SUCCESS_REVISION_INVALID");
+ requireValidSignRecord(record.leaseUntil().isEmpty() && record.result().isPresent()
+ && record.providerUpdatedAt().isPresent(), record, "SUCCESS_EVIDENCE_MISSING");
+ requireValidSignRecord(record.providerUpdatedAt().get().isBefore(record.deadline()), record,
+ "PROVIDER_TIME_INVALID");
+ requireValidSignRecord(!hasContradictorySuccessCode(record.detailCode()), record,
+ "SUCCESS_DETAIL_CONTRADICTORY");
+ }
+ case FAILED -> {
+ requireValidSignRecord(record.fence() > 0L && record.revision() > record.fence(), record,
+ "FAILURE_REVISION_INVALID");
+ requireValidSignRecord(record.leaseUntil().isEmpty() && record.result().isEmpty(), record,
+ "FAILURE_RESULT_OR_LEASE_PRESENT");
+ requireValidSignRecord(!hasLifecycleCode(record.detailCode(), "SIGNED"), record,
+ "FAILURE_DETAIL_CONTRADICTORY");
+ }
+ case CANCELLED -> {
+ requireValidSignRecord(record.revision() - record.fence() >= 2L, record,
+ "CANCELLED_REVISION_INVALID");
+ requireValidSignRecord(record.leaseUntil().isEmpty() && record.result().isEmpty(), record,
+ "CANCELLED_RESULT_OR_LEASE_PRESENT");
+ requireValidSignRecord(!hasLifecycleCode(record.detailCode(), "SIGNED"), record,
+ "CANCELLED_DETAIL_CONTRADICTORY");
+ }
+ case EXPIRED -> {
+ requireValidSignRecord(record.revision() > record.fence(), record, "EXPIRED_REVISION_INVALID");
+ requireValidSignRecord(record.leaseUntil().isEmpty() && record.result().isEmpty(), record,
+ "EXPIRED_RESULT_OR_LEASE_PRESENT");
+ requireValidSignRecord(!hasLifecycleCode(record.detailCode(), "SIGNED"), record,
+ "EXPIRED_DETAIL_CONTRADICTORY");
+ }
+ case RETIRED -> {
+ requireValidSignRecord(record.leaseUntil().isEmpty(), record, "RETIRED_LEASE_PRESENT");
+ requireDetailCode(record, "RETIRED");
+ if (record.result().isPresent()) {
+ requireValidSignRecord(record.fence() > 0L && record.revision() - record.fence() >= 3L
+ && record.providerUpdatedAt().isPresent()
+ && record.providerUpdatedAt().get().isBefore(record.deadline()), record,
+ "PROVIDER_TIME_INVALID");
+ } else {
+ requireValidSignRecord(record.revision() - record.fence() >= 2L, record,
+ "RETIRED_TERMINAL_REVISION_INVALID");
+ }
+ }
+ }
+ }
+
+ private static boolean hasCanonicalFingerprintShape(String fingerprint) {
+ if (fingerprint.length() != SIGN_FINGERPRINT_PREFIX.length() + SIGN_FINGERPRINT_HEX_LENGTH
+ || !fingerprint.startsWith(SIGN_FINGERPRINT_PREFIX)) {
+ return false;
+ }
+ for (int index = SIGN_FINGERPRINT_PREFIX.length(); index < fingerprint.length(); index++) {
+ char value = fingerprint.charAt(index);
+ if (!(value >= '0' && value <= '9' || value >= 'a' && value <= 'f')) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static boolean constantTimeAsciiEquals(String left, String right) {
+ byte[] leftBytes = left.getBytes(StandardCharsets.US_ASCII);
+ byte[] rightBytes = right.getBytes(StandardCharsets.US_ASCII);
+ try {
+ return MessageDigest.isEqual(leftBytes, rightBytes);
+ } finally {
+ Arrays.fill(leftBytes, (byte) 0);
+ Arrays.fill(rightBytes, (byte) 0);
+ }
+ }
+
+ private static boolean hasContradictorySuccessCode(Optional detailCode) {
+ return hasLifecycleCode(detailCode, "INTENT")
+ || hasLifecycleCode(detailCode, "DISPATCHED")
+ || hasLifecycleCode(detailCode, "CANCEL_REQUESTED")
+ || hasLifecycleCode(detailCode, "CANCELLED")
+ || hasLifecycleCode(detailCode, "EXPIRED")
+ || hasLifecycleCode(detailCode, "RETIRED")
+ || hasLifecycleCode(detailCode, "REQUEST_INTEGRITY_FAILURE")
+ || hasLifecycleCode(detailCode, "PROVIDER_ID_MISMATCH")
+ || hasLifecycleCode(detailCode, "PROVIDER_SUBMISSION_FAILED")
+ || hasLifecycleCode(detailCode, "PROVIDER_RESULT_MISSING")
+ || hasLifecycleCode(detailCode, "LATE_PROVIDER_SUCCESS");
+ }
+
+ private static boolean hasLifecycleCode(Optional detailCode, String code) {
+ return detailCode.filter(code::equals).isPresent();
+ }
+
+ private static void requireDetailCode(SignWorkflowStore.Record record, String required) {
+ requireValidSignRecord(record.detailCode().filter(required::equals).isPresent(), record,
+ "STATE_DETAIL_CODE_INVALID");
+ }
+
+ private static void requireValidSignRecord(boolean condition, SignWorkflowStore.Record record, String code) {
+ if (!condition) {
+ throw invalidSignRecord(record, code);
+ }
+ }
+
+ private static IllegalStateException invalidSignRecord(SignWorkflowStore.Record record, String code) {
+ return new IllegalStateException("Signing record corruption: type=sign-workflow version="
+ + SIGN_RECORD_VERSION + " state=" + record.state() + " code=" + code);
+ }
+
+ private static void requirePositive(Duration value, String name) {
+ Objects.requireNonNull(value, name);
+ if (value.isZero() || value.isNegative()) {
+ throw new IllegalArgumentException(name + " must be positive");
+ }
+ }
+
+ /**
+ * Reference-counted lock entry removed when no mutation uses it.
+ */
+ private static final class SignLockEntry {
+ private final ReentrantLock lock = new ReentrantLock();
+ private final AtomicInteger references = new AtomicInteger();
+ }
+
+ /**
+ * Owns the operating-system resources that exclude a second store process.
+ *
+ *
+ * Ownership begins when {@link #acquire(Path)} returns and ends when
+ * {@link #close()} releases the lock and closes its channel. The resources
+ * intentionally remain open for the complete lifetime of their enclosing
+ * {@link FilesystemPkiStore}.
+ *
+ */
+ private static final class StoreOwnership implements AutoCloseable {
+ private final FileChannel channel;
+ private final FileLock lock;
+ private final ReentrantLock closeLock = new ReentrantLock();
+ private boolean closed;
+
+ private StoreOwnership(FileChannel channel, FileLock lock) {
+ this.channel = channel;
+ this.lock = lock;
+ }
+
+ // Raw lock exceptions are deliberately replaced by the stable redacted contention code.
+ @SuppressWarnings("PMD.PreserveStackTrace")
+ private static StoreOwnership acquire(Path lockFile) throws IOException {
+ FileChannel channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
+ FileLock lock = null;
+ try {
+ try {
+ lock = channel.tryLock();
+ } catch (OverlappingFileLockException contention) {
+ throw new IllegalStateException(STORE_OWNERSHIP_UNAVAILABLE);
+ }
+ if (lock == null) {
+ throw new IllegalStateException(STORE_OWNERSHIP_UNAVAILABLE);
+ }
+ return new StoreOwnership(channel, lock);
+ } finally {
+ if (lock == null) {
+ closePartial(channel);
+ }
+ }
+ }
+
+ private static void closePartial(FileChannel channel) {
+ try {
+ channel.close();
+ } catch (IOException ignored) {
+ // Contention remains authoritative and contains no operating-system detail.
+ }
+ }
+
+ private void closeSilently() {
+ try {
+ close();
+ } catch (IOException ignored) {
+ // Constructor failure remains authoritative and safely redacted.
+ }
+ }
+
+ @Override
+ public void close() throws IOException {
+ closeLock.lock();
+ try {
+ if (closed) {
+ return;
+ }
+ boolean releaseFailed = false;
+ boolean closeFailed = false;
+ try {
+ if (lock.isValid()) {
+ lock.release();
+ }
+ } catch (IOException failure) {
+ releaseFailed = true;
+ }
+ try {
+ channel.close();
+ } catch (IOException failure) {
+ closeFailed = true;
+ }
+ closed = true;
+ if (releaseFailed || closeFailed) {
+ throw new IOException("Filesystem store ownership release failed: "
+ + "code=STORE_OWNERSHIP_RELEASE_FAILED");
+ }
+ } finally {
+ closeLock.unlock();
+ }
+ }
+ }
+
+ private String ensureSigningNamespace() throws IOException {
+ Path file = paths.signingNamespaceFile();
+ if (Files.exists(file)) {
+ String value = Files.readString(file, StandardCharsets.US_ASCII).trim();
+ if (!value.matches("[a-f0-9]{32}")) {
+ throw new IllegalStateException("Invalid persisted signing namespace");
+ }
+ return value;
+ }
+ byte[] random = new byte[16];
+ new SecureRandom().nextBytes(random);
+ String value = HexFormat.of().formatHex(random);
+ FsOperations.writeAtomic(file, value.getBytes(StandardCharsets.US_ASCII));
+ return value;
+ }
+
+ private long loadSigningTimeWatermark() throws IOException {
+ Path file = paths.signingTimeWatermarkFile();
+ long observed = clock.instant().toEpochMilli();
+ if (!Files.exists(file)) {
+ writeDurableSigningTimeWatermark(file, observed);
+ return observed;
+ }
+ try {
+ return Math.max(observed, Long.parseLong(Files.readString(file, StandardCharsets.US_ASCII).trim()));
+ } catch (NumberFormatException ex) {
+ throw new IllegalStateException("Invalid persisted signing time watermark", ex);
+ }
+ }
+
+ private void persistSigningTimeWatermark(long epochMilli) {
+ try {
+ writeDurableSigningTimeWatermark(paths.signingTimeWatermarkFile(), epochMilli);
+ } catch (IOException ex) {
+ throw new IllegalStateException("Failed to persist signing time watermark", ex);
+ }
+ }
+
+ private static void writeDurableSigningTimeWatermark(Path target, long epochMilli) throws IOException {
+ Path parent = target.getParent();
+ FsOperations.ensureDir(parent);
+ Path temporary = Files.createTempFile(parent, ".signing-time-watermark-", ".tmp");
+ try {
+ ByteBuffer bytes = ByteBuffer.wrap(Long.toString(epochMilli).getBytes(StandardCharsets.US_ASCII));
+ try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) {
+ while (bytes.hasRemaining()) {
+ channel.write(bytes);
+ }
+ channel.force(true);
+ }
+ try {
+ Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
+ } catch (AtomicMoveNotSupportedException ex) {
+ Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
+ }
+ try (FileChannel directory = FileChannel.open(parent, StandardOpenOption.READ)) {
+ directory.force(true);
+ }
+ } finally {
+ Files.deleteIfExists(temporary);
}
}
diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java
index 5b69055..c7d918b 100644
--- a/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java
@@ -46,8 +46,10 @@ import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
+import java.util.Optional;
import zeroecho.core.io.Util;
+import zeroecho.pki.spi.store.SignWorkflowStore;
/**
* Compact binary codec for filesystem persistence.
@@ -82,7 +84,7 @@ import zeroecho.core.io.Util;
@SuppressWarnings("PMD.CyclomaticComplexity")
final class FsCodec {
- private static final int MAX_STRING_BYTES = 256 * 1024;
+ /* package */ static final int MAX_COMPONENT_BYTES = 256 * 1024;
private static final Map, Class>> PRIMITIVE_TO_WRAPPER = Map.of(boolean.class, Boolean.class, byte.class,
Byte.class, short.class, Short.class, int.class, Integer.class, long.class, Long.class, char.class,
@@ -135,6 +137,9 @@ final class FsCodec {
try {
ByteArrayInputStream bis = new ByteArrayInputStream(data);
Object decoded = readAny(bis, expectedType);
+ if (bis.available() != 0) {
+ throw new IllegalStateException("trailing data after " + expectedType.getName());
+ }
return expectedType.cast(decoded);
} catch (IOException e) {
throw new IllegalStateException("decoding failed: " + expectedType.getName(), e);
@@ -157,7 +162,7 @@ final class FsCodec {
case Duration duration -> writeDurationCompact(out, duration);
case java.util.List> list -> writeListCompact(out, list);
case java.util.Set> set -> writeSetCompact(out, set);
- case java.util.Optional> optional -> writeOptionalCompact(out, optional);
+ case Optional> optional -> writeOptionalCompact(out, optional);
default -> writeComplexCompact(out, value);
}
}
@@ -242,7 +247,7 @@ final class FsCodec {
});
}
- private static void writeOptionalCompact(final OutputStream out, final java.util.Optional> optional)
+ private static void writeOptionalCompact(final OutputStream out, final Optional> optional)
throws IOException {
out.write(TAG_OPTIONAL);
if (optional.isPresent()) {
@@ -273,7 +278,10 @@ final class FsCodec {
out.write(TAG_ENUM);
Util.writeUTF8(out, type.getName());
Enum> enumValue = (Enum>) value;
- Util.writePack7I(out, enumValue.ordinal());
+ int persistentCode = value instanceof SignWorkflowStore.State state
+ ? state.persistentCode()
+ : enumValue.ordinal();
+ Util.writePack7I(out, persistentCode);
}
private static void writeRecordCompact(final OutputStream out, final Class> type, final Object value)
@@ -375,7 +383,7 @@ final class FsCodec {
}
private static String readStringCompact(final InputStream in) throws IOException {
- return Util.readUTF8(in, MAX_STRING_BYTES);
+ return Util.readUTF8(in, MAX_COMPONENT_BYTES);
}
private static int readIntegerCompact(final InputStream in) throws IOException {
@@ -395,7 +403,7 @@ final class FsCodec {
}
private static byte[] readBytesCompact(final InputStream in) throws IOException {
- return Util.read(in, MAX_STRING_BYTES);
+ return Util.read(in, MAX_COMPONENT_BYTES);
}
private static Instant readInstantCompact(final InputStream in) throws IOException {
@@ -411,13 +419,20 @@ final class FsCodec {
}
private static Object readEnumCompact(final InputStream in) throws IOException {
- String enumTypeName = Util.readUTF8(in, MAX_STRING_BYTES);
+ String enumTypeName = Util.readUTF8(in, MAX_COMPONENT_BYTES);
Class> enumType = loadClass(enumTypeName);
if (!enumType.isEnum()) {
throw new IllegalStateException("encoded enum type is not an enum: " + enumTypeName);
}
int ordinal = Util.readPack7I(in);
+ if (SignWorkflowStore.State.class.equals(enumType)) {
+ try {
+ return SignWorkflowStore.State.fromPersistentCode(ordinal);
+ } catch (IllegalArgumentException ex) {
+ throw new IllegalStateException("invalid signing workflow state code " + ordinal, ex);
+ }
+ }
Object[] constants = enumType.getEnumConstants();
if (constants == null || ordinal < 0 || ordinal >= constants.length) {
throw new IllegalStateException("invalid enum ordinal " + ordinal + " for " + enumTypeName);
@@ -426,7 +441,7 @@ final class FsCodec {
}
private static Object readRecordCompact(final InputStream in) throws IOException {
- String recordTypeName = Util.readUTF8(in, MAX_STRING_BYTES);
+ String recordTypeName = Util.readUTF8(in, MAX_COMPONENT_BYTES);
Class> recordType = loadClass(recordTypeName);
if (!recordType.isRecord()) {
throw new IllegalStateException("encoded record type is not a record: " + recordTypeName);
@@ -455,8 +470,8 @@ final class FsCodec {
}
private static Object readFallbackStringCompact(final InputStream in) throws IOException {
- String typeName = Util.readUTF8(in, MAX_STRING_BYTES);
- String value = Util.readUTF8(in, MAX_STRING_BYTES);
+ String typeName = Util.readUTF8(in, MAX_COMPONENT_BYTES);
+ String value = Util.readUTF8(in, MAX_COMPONENT_BYTES);
Class> type = loadClass(typeName);
Method stringFactory = findStringFactory(type);
@@ -502,10 +517,10 @@ final class FsCodec {
throw new IOException("unexpected EOF");
}
if (present == 0) {
- return java.util.Optional.empty();
+ return Optional.empty();
}
Object element = readAny(in, Object.class);
- return java.util.Optional.of(element);
+ return Optional.of(element);
}
private static boolean isTypeCompatible(final Class> expectedType, final Class> actualType) {
@@ -559,4 +574,4 @@ final class FsCodec {
return null;
}
-}
\ No newline at end of file
+}
diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java
index 7bf65c3..a62dff1 100644
--- a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java
@@ -79,6 +79,14 @@ final class FsPaths {
return this.root.resolve(VERSION_FILE);
}
+ /* default */ Path signingNamespaceFile() {
+ return this.root.resolve("SIGNING_NAMESPACE");
+ }
+
+ /* default */ Path signingTimeWatermarkFile() {
+ return this.root.resolve("SIGNING_TIME_WATERMARK");
+ }
+
/* default */ Path lockFile() {
return this.root.resolve(LOCK_DIR).resolve(STORE_LOCK);
}
@@ -135,6 +143,16 @@ final class FsPaths {
return this.root.resolve("requests").resolve(BY_ID).resolve(FsUtil.safeId(requestId) + ".bin");
}
+ /* 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 (mutable with history)
// -------------------------------------------------------------------------
diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsPkiStoreOptions.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsPkiStoreOptions.java
index 8e35098..40c5f6f 100644
--- a/pki/src/main/java/zeroecho/pki/impl/fs/FsPkiStoreOptions.java
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsPkiStoreOptions.java
@@ -60,9 +60,13 @@ import java.util.Optional;
* @param workflowHistoryPolicy history policy for workflow continuation state
* @param strictSnapshotExport whether snapshot export is strict
* (fail-closed)
+ * @param signingOperationHorizon maximum stable signing identifier and record
+ * retention horizon
+ * @param signingIdPermittedSkew permitted future signing identifier clock skew
*/
public record FsPkiStoreOptions(FsHistoryPolicy caHistoryPolicy, FsHistoryPolicy profileHistoryPolicy,
- FsHistoryPolicy revocationHistoryPolicy, FsHistoryPolicy workflowHistoryPolicy, boolean strictSnapshotExport) {
+ FsHistoryPolicy revocationHistoryPolicy, FsHistoryPolicy workflowHistoryPolicy, boolean strictSnapshotExport,
+ Duration signingOperationHorizon, Duration signingIdPermittedSkew) {
/**
* Canonical constructor with validation.
@@ -74,6 +78,14 @@ public record FsPkiStoreOptions(FsHistoryPolicy caHistoryPolicy, FsHistoryPolicy
Objects.requireNonNull(profileHistoryPolicy, "profileHistoryPolicy");
Objects.requireNonNull(revocationHistoryPolicy, "revocationHistoryPolicy");
Objects.requireNonNull(workflowHistoryPolicy, "workflowHistoryPolicy");
+ Objects.requireNonNull(signingOperationHorizon, "signingOperationHorizon");
+ Objects.requireNonNull(signingIdPermittedSkew, "signingIdPermittedSkew");
+ if (signingOperationHorizon.isZero() || signingOperationHorizon.isNegative()) {
+ throw new IllegalArgumentException("signingOperationHorizon must be positive");
+ }
+ if (signingIdPermittedSkew.isNegative()) {
+ throw new IllegalArgumentException("signingIdPermittedSkew must not be negative");
+ }
}
/**
@@ -89,6 +101,7 @@ public record FsPkiStoreOptions(FsHistoryPolicy caHistoryPolicy, FsHistoryPolicy
*/
public static FsPkiStoreOptions defaults() {
FsHistoryPolicy ninetyDays = FsHistoryPolicy.onWrite(Optional.of(Duration.ofDays(90)));
- return new FsPkiStoreOptions(ninetyDays, ninetyDays, ninetyDays, ninetyDays, true);
+ return new FsPkiStoreOptions(ninetyDays, ninetyDays, ninetyDays, ninetyDays, true, Duration.ofDays(90),
+ Duration.ZERO);
}
}
diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java
index cdd6379..009bdf1 100644
--- a/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java
@@ -60,6 +60,11 @@ import java.util.logging.Logger;
* to {@code current.bin} only when strict mode is disabled.
* Write-once objects are copied as-is (they are immutable). This exporter
* does not attempt to prune them by time unless an upstream index exists.
+ * Signing namespace, signing-time watermark, and authoritative
+ * {@code sign-workflows} records are current safety metadata copied at export
+ * time. They are intentionally not reconstructed at {@code at}: rolling them
+ * back could make a stable identifier reusable or discard a completed signing
+ * result.
*
*/
final class FsSnapshotExporter {
@@ -82,6 +87,8 @@ final class FsSnapshotExporter {
FsPaths dst = new FsPaths(targetRoot);
Files.writeString(dst.versionFile(), "v1");
+ copyFile(sourceRoot.resolve("SIGNING_NAMESPACE"), targetRoot.resolve("SIGNING_NAMESPACE"));
+ copyFile(sourceRoot.resolve("SIGNING_TIME_WATERMARK"), targetRoot.resolve("SIGNING_TIME_WATERMARK"));
// copy write-once trees as-is (best-effort, deterministic order)
copyTreeIfExists(sourceRoot.resolve("credentials"), targetRoot.resolve("credentials"));
@@ -89,6 +96,7 @@ final class FsSnapshotExporter {
copyTreeIfExists(sourceRoot.resolve("status"), targetRoot.resolve("status"));
copyTreeIfExists(sourceRoot.resolve("policy"), targetRoot.resolve("policy"));
copyTreeIfExists(sourceRoot.resolve("publications"), targetRoot.resolve("publications"));
+ copyTreeIfExists(sourceRoot.resolve("sign-workflows"), targetRoot.resolve("sign-workflows"));
// reconstruct mutable entities from history (CAS, PROFILES, REVOCATIONS)
reconstructMutableTree(sourceRoot.resolve("cas"), targetRoot.resolve("cas"), at,
@@ -192,4 +200,11 @@ final class FsSnapshotExporter {
}
});
}
+
+ private static void copyFile(final Path source, final Path target) throws IOException {
+ if (!Files.isRegularFile(source)) {
+ throw new IllegalStateException("Required snapshot metadata is missing: " + source.getFileName());
+ }
+ FsOperations.writeAtomic(target, Files.readAllBytes(source));
+ }
}
diff --git a/pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflow.java b/pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflow.java
index d3f578c..c708170 100644
--- a/pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflow.java
+++ b/pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflow.java
@@ -34,10 +34,18 @@
package zeroecho.pki.spi.crypto;
import java.io.Closeable;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.security.DigestOutputStream;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
import java.time.Instant;
+import java.util.HexFormat;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
+import java.util.function.Consumer;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding;
@@ -83,6 +91,8 @@ import zeroecho.pki.api.audit.AccessContext;
* audit logs and API responses.
*
*/
+// PMD cannot infer that retaining digest/encoding causes would violate the redaction contract.
+@SuppressWarnings("PMD.PreserveStackTrace")
public interface SignatureWorkflow extends Closeable {
/**
@@ -92,6 +102,30 @@ public interface SignatureWorkflow extends Closeable {
*/
String id();
+ /**
+ * Validates the authoritative signing domain selected by orchestration.
+ *
+ *
+ * Providers must fail fast when their configured horizon or skew differs. The
+ * default contract accepts the standard {@code P90D}/{@code PT0S} domain and a
+ * namespace whose provider component is {@link #id()}.
+ *
+ *
+ * @param namespace combined stable store and provider namespace
+ * @param horizon authoritative operation horizon
+ * @param permittedSkew authoritative future timestamp skew
+ */
+ default void validateSigningDomain(String namespace, java.time.Duration horizon,
+ java.time.Duration permittedSkew) {
+ Objects.requireNonNull(namespace, "namespace");
+ Objects.requireNonNull(horizon, "horizon");
+ Objects.requireNonNull(permittedSkew, "permittedSkew");
+ if (!namespace.endsWith("." + id()) || !java.time.Duration.ofDays(90).equals(horizon)
+ || !java.time.Duration.ZERO.equals(permittedSkew)) {
+ throw new IllegalArgumentException("Signature workflow signing domain mismatch");
+ }
+ }
+
/**
* Submits a signing request.
*
@@ -100,13 +134,37 @@ public interface SignatureWorkflow extends Closeable {
* (encoded) on success.
*
*
+ *
+ * The provider must durably bind {@link SignRequest#submissionId()} to the
+ * constant-time verified {@link SignRequest#semanticFingerprint()} before
+ * invoking cryptography. The first accepted request may execute once. Any
+ * concurrent or later request with the same non-expired identifier and
+ * fingerprint attaches to the existing non-terminal or terminal outcome and
+ * must not invoke cryptography again. A different fingerprint is a conflict.
+ * The returned identifier must equal the submitted identifier.
+ *
+ *
+ *
+ * {@link SignRequest#fencingToken()} is monotonic. A stale token must not mutate
+ * state; a completion may commit only while its fingerprint, token, and running
+ * state remain current. Providers must check an optional request deadline
+ * before starting cryptography or approval execution and again before committing
+ * success. A completion at the deadline is late; it must become
+ * {@link State#EXPIRED} without exposing a result. Terminal states are immutable. At and after the
+ * configured horizon, submission is rejected and {@link #status(PkiId)} reports
+ * {@link State#EXPIRED}, including after payload/result purge and restart.
+ * Provider callbacks must run after operation state locks are released.
+ *
+ *
* Failure model (normative)
*
* - For validation and policy failures: do not throw; return operation id and
* expose failure via {@link #status(PkiId)} with {@code FAILED} and stable
* {@code detailCode}.
- * - May throw {@link IllegalArgumentException} only for programmer errors
- * (e.g., {@code request == null}).
+ * - Throws {@link IllegalStateException} for identifier/fingerprint conflicts
+ * or stale fencing tokens.
+ * - May throw {@link IllegalArgumentException} for malformed, foreign, or
+ * expired identifiers and programmer errors.
*
*
* @param request request (never {@code null})
@@ -152,11 +210,18 @@ public interface SignatureWorkflow extends Closeable {
/**
* Best-effort cancellation.
*
+ *
+ * A {@code true} return value means only that the provider accepted the
+ * cancellation request. It does not prove that the operation is terminal.
+ * Callers must re-read {@link #status(PkiId)} and may retire state only after an
+ * immutable terminal status is observed.
+ *
+ *
* @param operationId operation id (never {@code null})
* @param reason non-sensitive reason (never blank)
* @return true if cancellation was accepted; false if already terminal/unknown
*/
- boolean cancel(PkiId operationId, String reason);
+ boolean cancel(PkiId operationId, long fencingToken, String reason);
/**
* Registers a notification sink for status changes.
@@ -192,6 +257,10 @@ public interface SignatureWorkflow extends Closeable {
* {@link #submitSign(SignRequest)}.
*
*
+ * @param submissionId stable caller-assigned submission identifier
+ * @param namespace provider/store namespace
+ * @param semanticFingerprint versioned request fingerprint excluding fence
+ * @param fencingToken current positive fencing token
* @param accessContext audit/governance context (never
* {@code null})
* @param keyRef opaque reference to the private key (never
@@ -202,10 +271,16 @@ public interface SignatureWorkflow extends Closeable {
* @param preferredSignatureEncoding preferred signature encoding (optional)
* @param deadline optional absolute deadline
*/
- record SignRequest(AccessContext accessContext, KeyRef keyRef, String algorithmId, EncodedObject payload,
+ record SignRequest(PkiId submissionId, String namespace, String semanticFingerprint, long fencingToken,
+ AccessContext accessContext, KeyRef keyRef, String algorithmId, EncodedObject payload,
Optional preferredSignatureEncoding, Optional deadline) {
+ private static final long MIN_FENCING_TOKEN = 1L;
+
public SignRequest {
+ Objects.requireNonNull(submissionId, "submissionId");
+ Objects.requireNonNull(namespace, "namespace");
+ Objects.requireNonNull(semanticFingerprint, "semanticFingerprint");
Objects.requireNonNull(accessContext, "accessContext");
Objects.requireNonNull(keyRef, "keyRef");
Objects.requireNonNull(algorithmId, "algorithmId");
@@ -215,6 +290,103 @@ public interface SignatureWorkflow extends Closeable {
if (algorithmId.isBlank()) {
throw new IllegalArgumentException("algorithmId must not be blank");
}
+ if (fencingToken < MIN_FENCING_TOKEN) {
+ throw new IllegalArgumentException("fencingToken must be positive");
+ }
+ String expected = fingerprint(namespace, accessContext, keyRef, algorithmId, payload,
+ preferredSignatureEncoding, deadline);
+ if (!constantTimeFingerprintEquals(expected, semanticFingerprint)) {
+ throw new IllegalArgumentException("semanticFingerprint does not match signing request");
+ }
+ }
+
+ /**
+ * Creates a request with its canonical versioned fingerprint.
+ */
+ public static SignRequest create(PkiId submissionId, String namespace, long fencingToken,
+ AccessContext accessContext, KeyRef keyRef, String algorithmId, EncodedObject payload,
+ Optional preferredSignatureEncoding, Optional deadline) {
+ String fingerprint = fingerprint(namespace, accessContext, keyRef, algorithmId, payload,
+ preferredSignatureEncoding, deadline);
+ return new SignRequest(submissionId, namespace, fingerprint, fencingToken, accessContext, keyRef,
+ algorithmId, payload, preferredSignatureEncoding, deadline);
+ }
+
+ /**
+ * Computes the canonical {@code signfp:v1} semantic fingerprint in
+ * O(payload) time and O(payload) temporary memory from the defensive payload
+ * copy, with constant-size digest state.
+ */
+ public static String fingerprint(String namespace, AccessContext accessContext, KeyRef keyRef,
+ String algorithmId, EncodedObject payload, Optional preferredSignatureEncoding,
+ Optional deadline) {
+ Objects.requireNonNull(namespace, "namespace");
+ Objects.requireNonNull(accessContext, "accessContext");
+ Objects.requireNonNull(keyRef, "keyRef");
+ Objects.requireNonNull(algorithmId, "algorithmId");
+ Objects.requireNonNull(payload, "payload");
+ Objects.requireNonNull(preferredSignatureEncoding, "preferredSignatureEncoding");
+ Objects.requireNonNull(deadline, "deadline");
+ try {
+ return fingerprintWithDigest(namespace, accessContext, keyRef, algorithmId, payload,
+ preferredSignatureEncoding, deadline, MessageDigest.getInstance("SHA-256"), ignored -> {
+ });
+ } catch (NoSuchAlgorithmException ex) {
+ throw new IllegalStateException("Unable to compute signing request fingerprint");
+ }
+ }
+
+ /* default */ static String fingerprintWithDigest(String namespace, AccessContext accessContext, KeyRef keyRef,
+ String algorithmId, EncodedObject payload, Optional preferredSignatureEncoding,
+ Optional deadline, MessageDigest digest, Consumer cleanupObserver) {
+ Objects.requireNonNull(digest, "digest");
+ Objects.requireNonNull(cleanupObserver, "cleanupObserver");
+ byte[] payloadBytes = payload.bytes();
+ byte[] digestBytes = null;
+ try {
+ try (DataOutputStream output = new DataOutputStream(
+ new DigestOutputStream(OutputStream.nullOutputStream(), digest))) {
+ output.writeUTF("sign-request-v1");
+ output.writeUTF(namespace);
+ output.writeUTF(accessContext.principal().type());
+ output.writeUTF(accessContext.principal().name());
+ output.writeUTF(accessContext.purpose().value());
+ output.writeUTF(accessContext.objectId().map(PkiId::value).orElse(""));
+ output.writeUTF(accessContext.formatId().map(zeroecho.pki.api.FormatId::value).orElse(""));
+ output.writeUTF(keyRef.value());
+ output.writeUTF(algorithmId);
+ output.writeUTF(payload.encoding().name());
+ output.writeInt(payloadBytes.length);
+ output.write(payloadBytes);
+ output.writeUTF(preferredSignatureEncoding.map(Enum::name).orElse(""));
+ output.writeUTF(deadline.map(Instant::toString).orElse(""));
+ }
+ digestBytes = digest.digest();
+ return "signfp:v1:" + HexFormat.of().formatHex(digestBytes);
+ } catch (IOException ex) {
+ throw new IllegalStateException("Unable to compute signing request fingerprint");
+ } finally {
+ clearOwned(payloadBytes, cleanupObserver);
+ clearOwned(digestBytes, cleanupObserver);
+ }
+ }
+
+ private static boolean constantTimeFingerprintEquals(String left, String right) {
+ byte[] leftBytes = left.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
+ byte[] rightBytes = right.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
+ try {
+ return MessageDigest.isEqual(leftBytes, rightBytes);
+ } finally {
+ java.util.Arrays.fill(leftBytes, (byte) 0);
+ java.util.Arrays.fill(rightBytes, (byte) 0);
+ }
+ }
+
+ private static void clearOwned(byte[] owned, Consumer cleanupObserver) {
+ if (owned != null) {
+ java.util.Arrays.fill(owned, (byte) 0);
+ cleanupObserver.accept(owned);
+ }
}
}
@@ -287,6 +459,13 @@ public interface SignatureWorkflow extends Closeable {
* {@code OperationStatus} must no longer change.
*
*
+ *
+ * For terminal states, {@link #updatedAt()} is the durable completion timestamp,
+ * not the time at which a caller happened to poll. Providers must preserve that
+ * value across restart. A successful status at or after its request deadline is
+ * invalid and must instead be exposed as {@link State#EXPIRED} without a result.
+ *
+ *
* @param state current lifecycle state of the operation (never
* {@code null})
* @param updatedAt timestamp of the last state transition (never
@@ -306,6 +485,10 @@ public interface SignatureWorkflow extends Closeable {
Objects.requireNonNull(updatedAt, "updatedAt");
Objects.requireNonNull(detailCode, "detailCode");
Objects.requireNonNull(result, "result");
+ if (state == State.SUCCEEDED && result.isEmpty()
+ || state != State.SUCCEEDED && result.isPresent()) {
+ throw new IllegalArgumentException("Operation result does not match status state");
+ }
}
/**
@@ -400,6 +583,9 @@ public interface SignatureWorkflow extends Closeable {
* The callback must be treated as a best-effort notification mechanism and must
* not be relied upon as the sole source of truth; callers should always be able
* to query the authoritative state via {@link SignatureWorkflow#status(PkiId)}.
+ * Delivery may be coalesced or dropped under load. Providers must not require
+ * callback processing to finish an operation, and sink implementations should
+ * return promptly without waiting for operation-level coordination.
*
*/
@FunctionalInterface
diff --git a/pki/src/main/java/zeroecho/pki/spi/framework/CredentialFramework.java b/pki/src/main/java/zeroecho/pki/spi/framework/CredentialFramework.java
index 4163c89..72e7dcf 100644
--- a/pki/src/main/java/zeroecho/pki/spi/framework/CredentialFramework.java
+++ b/pki/src/main/java/zeroecho/pki/spi/framework/CredentialFramework.java
@@ -40,8 +40,9 @@ import zeroecho.pki.api.FormatId;
*
*
* A framework implementation provides request parsing, proof-of-possession
- * verification (if applicable), credential issuance backend, and status object
- * generation for a particular {@link FormatId}.
+ * verification and status object generation for a particular {@link FormatId}.
+ * Credential minting is intentionally not exposed by this framework facade; core
+ * issuance services own that privileged implementation boundary.
*
*/
public interface CredentialFramework {
@@ -67,13 +68,6 @@ public interface CredentialFramework {
*/
ProofOfPossessionVerifier proofOfPossessionVerifier();
- /**
- * Returns the issuer backend for this framework.
- *
- * @return issuer backend
- */
- CredentialIssuerBackend issuerBackend();
-
/**
* Returns the status object generator for this framework.
*
diff --git a/pki/src/main/java/zeroecho/pki/spi/framework/CredentialIssuerBackend.java b/pki/src/main/java/zeroecho/pki/spi/framework/CredentialIssuerBackend.java
index 60a213e..3999e80 100644
--- a/pki/src/main/java/zeroecho/pki/spi/framework/CredentialIssuerBackend.java
+++ b/pki/src/main/java/zeroecho/pki/spi/framework/CredentialIssuerBackend.java
@@ -33,10 +33,10 @@
******************************************************************************/
package zeroecho.pki.spi.framework;
-import zeroecho.pki.api.ca.IntermediateCertIssueCommand;
import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialBundle;
-import zeroecho.pki.api.issuance.IssueEndEntityCommand;
+import zeroecho.pki.impl.core.ManagedCaIssuance;
+import zeroecho.pki.impl.core.VerifiedIssuanceCandidate;
/**
* SPI contract for framework-specific credential issuance backends.
@@ -59,17 +59,19 @@ import zeroecho.pki.api.issuance.IssueEndEntityCommand;
*
* Architectural role
*
- * - {@link CredentialFramework} selects the concrete backend appropriate for
- * a credential format.
- * - PKI core services prepare validated issuance commands and call this
+ *
- PKI runtime wiring injects the concrete backend into authoritative core
+ * services without publishing it through {@link CredentialFramework}.
+ * - PKI core services prepare opaque proof-gated issuance authorities and call this
* backend to obtain framework-specific credentials.
+ * - The backend is a privileged post-gate component whose method signatures
+ * cannot accept raw issuance commands.
* - This backend performs format-specific credential assembly, not CA policy
* orchestration, lifecycle control, or long-term persistence.
*
*
* Implementation expectations
*
- * - Implementations should validate that the supplied command contains the
+ *
- Implementations should validate that the supplied authority contains the
* framework-specific material required for issuance.
* - Implementations should fail explicitly when mandatory issuer material,
* subject material, or framework-specific overrides are missing or
@@ -103,21 +105,27 @@ public interface CredentialIssuerBackend {
*
*
* This operation produces a credential for a non-CA subject, typically from a
- * validated certification request or equivalent subject input carried by the
- * {@link IssueEndEntityCommand}. The returned {@link CredentialBundle} may
+ * cryptographically verified certification request carried by the opaque
+ * {@link VerifiedIssuanceCandidate}. The returned {@link CredentialBundle} may
* contain the issued leaf credential together with any additional runtime
* bundle material defined by the concrete framework, such as chain elements or
* accompanying metadata.
*
*
*
- * The command is expected to carry all framework-specific issuance inputs
+ * The parameter type has no public constructor or factory. Only the core
+ * issuance gate can create it after mandatory proof-of-possession verification.
+ *
+ *
+ *
+ * The candidate carries all framework-specific issuance inputs
* required by the concrete implementation, including any issuer wiring
* attributes, profile identifiers, validity overrides, and subject request
* material. The exact interpretation of those fields is framework-specific.
*
*
- * @param command end-entity issuance command; must not be {@code null}
+ * @param candidate gate-produced verified issuance candidate; must not be
+ * {@code null}
* @return issued credential bundle, never {@code null}
* @throws IllegalArgumentException if {@code command} is {@code null} or
* structurally invalid for the concrete
@@ -126,7 +134,7 @@ public interface CredentialIssuerBackend {
* or other framework-specific issuance
* processing fails
*/
- CredentialBundle issueEndEntity(IssueEndEntityCommand command);
+ CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate);
/**
* Issues a CA credential for an existing CA subject entity.
@@ -139,12 +147,13 @@ public interface CredentialIssuerBackend {
*
*
*
- * The supplied {@link IntermediateCertIssueCommand} is expected to contain the
- * issuer CA reference, subject CA reference, profile selection, and any
- * framework-specific attributes needed to construct the CA credential.
+ * The supplied {@link ManagedCaIssuance} can be constructed only after the core
+ * CA proof gate has completed a managed-key possession challenge and bound the
+ * exact public key, subject, operation, and authoritative attributes.
*
*
- * @param command CA credential issuance command; must not be {@code null}
+ * @param issuance gate-produced managed CA issuance authority; must not be
+ * {@code null}
* @return issued CA credential, never {@code null}
* @throws IllegalArgumentException if {@code command} is {@code null} or
* structurally invalid for the concrete
@@ -153,5 +162,5 @@ public interface CredentialIssuerBackend {
* or other framework-specific issuance
* processing fails
*/
- Credential issueIntermediateCertificate(IntermediateCertIssueCommand command);
+ Credential issueIntermediateCertificate(ManagedCaIssuance issuance);
}
diff --git a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java
index da8f70f..09a1c37 100644
--- a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java
+++ b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java
@@ -70,7 +70,7 @@ import zeroecho.pki.api.status.StatusObject;
* {@link IllegalStateException} when an operation cannot be completed safely.
*
*/
-public interface PkiStore {
+public interface PkiStore extends SignWorkflowStore {
/**
* Persists or updates a Certificate Authority (CA) record.
diff --git a/pki/src/main/java/zeroecho/pki/spi/store/SignWorkflowStore.java b/pki/src/main/java/zeroecho/pki/spi/store/SignWorkflowStore.java
new file mode 100644
index 0000000..8431342
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/spi/store/SignWorkflowStore.java
@@ -0,0 +1,293 @@
+/*******************************************************************************
+ * 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.spi.store;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import zeroecho.pki.api.EncodedObject;
+import zeroecho.pki.api.PkiId;
+import zeroecho.pki.api.audit.Principal;
+
+/**
+ * Authoritative durable state machine for signing submissions.
+ *
+ *
+ * Implementations must provide atomic per-operation compare-and-set behavior.
+ * External signer, callback, and audit code must never run while an implementation
+ * holds its coordination lock. Different submission identifiers must not share a
+ * permanent global mutation lock.
+ *
+ *
+ *
+ * Retained records are the source of truth through {@link #signingHorizon()}.
+ * Purging a record does not make its stable identifier reusable because creation
+ * validates the identifier timestamp against the same horizon.
+ *
+ */
+public interface SignWorkflowStore {
+
+ /**
+ * Signing orchestration states.
+ *
+ *
+ * {@link #CANCELLING} is authoritative and non-terminal: it records that
+ * cancellation was requested before the provider is called. A provider result
+ * observed while cancelling must still be reconciled before retirement.
+ * Persistent codes are stable and independent of declaration order.
+ *
+ */
+ enum State {
+ INTENT(10),
+ DISPATCHED(20),
+ SUCCEEDED(40),
+ FAILED(50),
+ CANCELLED(60),
+ RETIRED(70),
+ EXPIRED(80),
+ CANCELLING(30);
+
+ private final int persistentCode;
+
+ State(int persistentCode) {
+ this.persistentCode = persistentCode;
+ }
+
+ /**
+ * Returns the stable filesystem persistence code.
+ *
+ * @return stable positive code
+ */
+ public int persistentCode() {
+ return persistentCode;
+ }
+
+ /**
+ * Resolves a current stable persistence code.
+ *
+ * @param code persisted code
+ * @return matching state
+ * @throws IllegalArgumentException if the code is unknown
+ */
+ public static State fromPersistentCode(int code) {
+ return switch (code) {
+ case 10 -> INTENT;
+ case 20 -> DISPATCHED;
+ case 30 -> CANCELLING;
+ case 40 -> SUCCEEDED;
+ case 50 -> FAILED;
+ case 60 -> CANCELLED;
+ case 70 -> RETIRED;
+ case 80 -> EXPIRED;
+ default -> throw new IllegalArgumentException("Unknown signing workflow state code: " + code);
+ };
+ }
+ }
+
+ /**
+ * Complete durable signing state.
+ *
+ * @param submissionId stable submission identifier
+ * @param namespace provider/store namespace
+ * @param fingerprint versioned semantic request fingerprint
+ * @param owner request owner
+ * @param createdAt store-validated creation time
+ * @param deadline request deadline
+ * @param request versioned request continuation
+ * @param state lifecycle state
+ * @param revision monotonic record revision
+ * @param fence monotonic claim fencing token
+ * @param leaseUntil optional active claim lease
+ * @param detailCode optional non-sensitive detail code
+ * @param result optional terminal signature result
+ * @param providerUpdatedAt optional durable provider terminal-transition time;
+ * the provider clock may be independent of the store
+ * clock, but successful completion must be strictly
+ * before {@code deadline}
+ */
+ record Record(PkiId submissionId, String namespace, String fingerprint, Principal owner, Instant createdAt,
+ Instant deadline, EncodedObject request, State state, long revision, long fence,
+ Optional leaseUntil, Optional detailCode, Optional result,
+ Optional providerUpdatedAt) {
+ public Record {
+ Objects.requireNonNull(submissionId, "submissionId");
+ Objects.requireNonNull(namespace, "namespace");
+ Objects.requireNonNull(fingerprint, "fingerprint");
+ Objects.requireNonNull(owner, "owner");
+ Objects.requireNonNull(createdAt, "createdAt");
+ Objects.requireNonNull(deadline, "deadline");
+ Objects.requireNonNull(request, "request");
+ Objects.requireNonNull(state, "state");
+ Objects.requireNonNull(leaseUntil, "leaseUntil");
+ Objects.requireNonNull(detailCode, "detailCode");
+ Objects.requireNonNull(result, "result");
+ Objects.requireNonNull(providerUpdatedAt, "providerUpdatedAt");
+ if (namespace.isBlank() || fingerprint.isBlank()) {
+ throw new IllegalArgumentException("namespace and fingerprint must not be blank");
+ }
+ if (deadline.isBefore(createdAt)) {
+ throw new IllegalArgumentException("deadline must not be before createdAt");
+ }
+ if (revision < 0L || fence < 0L) {
+ throw new IllegalArgumentException("revision and fence must not be negative");
+ }
+ }
+ }
+
+ /** Result of idempotent intent creation. */
+ enum CreateResult {
+ CREATED, ATTACHED, CONFLICT
+ }
+
+ /**
+ * Returns store-authoritative time.
+ *
+ * @return current store time
+ */
+ Instant signingNow();
+
+ /**
+ * Returns the stable trusted namespace owned by this store.
+ *
+ * @return namespace persisted for the lifetime of the store
+ */
+ String signingNamespace();
+
+ /**
+ * Returns configured retention/identity horizon.
+ *
+ * @return positive horizon
+ */
+ Duration signingHorizon();
+
+ /**
+ * Returns the accepted future clock skew for signing identifiers.
+ *
+ * @return non-negative permitted skew
+ */
+ Duration signingPermittedSkew();
+
+ /**
+ * Creates an INTENT or attaches to an identical existing record.
+ *
+ * @param intent revision-zero INTENT
+ * @return creation result
+ */
+ CreateResult createSignIntent(Record intent);
+
+ /**
+ * Returns authoritative state for an identifier.
+ *
+ * @param submissionId stable submission identifier
+ * @return retained state, or empty when unknown or purged
+ */
+ Optional getSignRecord(PkiId submissionId);
+
+ /**
+ * Returns a stable snapshot of retained signing records.
+ *
+ * @return retained records
+ */
+ List listSignRecords();
+
+ /**
+ * Atomically claims an intent and increments its revision and fencing token.
+ *
+ * @param submissionId stable submission identifier
+ * @param expectedRevision caller-observed revision
+ * @param lease positive claim duration
+ * @return claimed record, or empty when compare-and-set loses
+ */
+ Optional tryClaimSign(PkiId submissionId, long expectedRevision, Duration lease);
+
+ /**
+ * Atomically renews an owned lease.
+ *
+ * @param submissionId stable submission identifier
+ * @param expectedRevision caller-observed revision
+ * @param fence current fencing token
+ * @param lease positive renewal duration
+ * @return renewed record, or empty when compare-and-set loses
+ */
+ Optional renewSignClaim(PkiId submissionId, long expectedRevision, long fence, Duration lease);
+
+ /**
+ * Atomically transitions state when revision and fence are current.
+ *
+ *
+ * Cancellation follows {@code INTENT|DISPATCHED -> CANCELLING ->
+ * CANCELLED|SUCCEEDED|FAILED|EXPIRED}. The intermediate state must be durable
+ * before a provider cancellation call. A {@code CANCELLING -> CANCELLING}
+ * transition records that the external cancellation submission returned,
+ * without treating provider acceptance as terminal.
+ *
+ *
+ * @param submissionId stable submission identifier
+ * @param expectedRevision caller-observed revision
+ * @param fence current fencing token
+ * @param target allowed target state
+ * @param detailCode optional non-sensitive detail
+ * @param result signature result, required only for success
+ * @param providerUpdatedAt durable provider terminal-transition time from the
+ * provider clock domain; required for success and
+ * required to be strictly before the request deadline
+ * @return transitioned record, or empty when compare-and-set loses
+ */
+ Optional transitionSign(PkiId submissionId, long expectedRevision, long fence, State target,
+ Optional detailCode, Optional result, Optional providerUpdatedAt);
+
+ /**
+ * Atomically retires a terminal current record while retaining its identity
+ * and any successful result. Non-terminal records, including
+ * {@link State#CANCELLING}, are not retirable.
+ *
+ * @param submissionId stable submission identifier
+ * @param expectedRevision caller-observed revision
+ * @param fence current fencing token
+ * @return retired record, or empty when compare-and-set loses
+ */
+ Optional retireSign(PkiId submissionId, long expectedRevision, long fence);
+
+ /**
+ * Purges retired payload-bearing records after the configured horizon.
+ * Non-retired records, including active and cancellation-pending provider
+ * operations, must be retained for reconciliation.
+ *
+ * @return number of purged records
+ */
+ int purgeExpiredSignRecords();
+}
diff --git a/pki/src/main/java/zeroecho/pki/util/async/impl/DurableAsyncBus.java b/pki/src/main/java/zeroecho/pki/util/async/impl/DurableAsyncBus.java
index 25e354b..143a8e8 100644
--- a/pki/src/main/java/zeroecho/pki/util/async/impl/DurableAsyncBus.java
+++ b/pki/src/main/java/zeroecho/pki/util/async/impl/DurableAsyncBus.java
@@ -90,8 +90,8 @@ import zeroecho.pki.util.async.codec.ResultCodec;
* - {@link #update(Object, AsyncStatus, Optional)} persists a status
* transition, optionally persists a result, dispatches an event, and applies
* terminal-state cleanup rules.
- * - {@link #consumeResult(Object)} returns a successful result once and then
- * deletes the operation from the in-memory state.
+ * - {@link #consumeResult(Object)} returns a successful result once and
+ * durably tombstones the operation.
*
*
* Durability semantics
@@ -100,6 +100,8 @@ import zeroecho.pki.util.async.codec.ResultCodec;
* Status transitions are persisted as internal {@code T1} records.
* Results are persisted as internal {@code R1} records only when
* {@link ResultCodec#persistsResults()} returns {@code true}.
+ * Consumption and explicit retirement are persisted as internal {@code D1}
+ * tombstones.
* The log format is internal to this implementation and versioned by the
* leading record token.
* Corrupted lines encountered during replay are ignored with a warning, and
@@ -146,6 +148,8 @@ public final class DurableAsyncBus // NOPMD
private static final String REC_SNAPSHOT = "S1";
private static final String REC_STATUS = "T1";
private static final String REC_RESULT = "R1";
+ private static final String REC_DELETE = "D1";
+ private static final String ARG_OP_ID = "opId";
private final IdCodec opIdCodec;
private final IdCodec ownerCodec;
@@ -285,7 +289,7 @@ public final class DurableAsyncBus // NOPMD
public AsyncOperationSnapshot submit(OpId opId, String type, Owner owner,
EndpointId endpointId, Instant createdAt, Duration ttl) {
- Objects.requireNonNull(opId, "opId");
+ Objects.requireNonNull(opId, ARG_OP_ID);
Objects.requireNonNull(type, "type");
Objects.requireNonNull(owner, "owner");
Objects.requireNonNull(endpointId, "endpointId");
@@ -354,7 +358,7 @@ public final class DurableAsyncBus // NOPMD
*/
@Override
public void update(OpId opId, AsyncStatus status, Optional result) {
- Objects.requireNonNull(opId, "opId");
+ Objects.requireNonNull(opId, ARG_OP_ID);
Objects.requireNonNull(status, "status");
Objects.requireNonNull(result, "result");
@@ -381,10 +385,11 @@ public final class DurableAsyncBus // NOPMD
dispatchEvent(opId, snap, status, result);
if (status.isTerminal()) {
- // keep terminal status for diagnostics and deterministic polling; only
- // successful operations retain their result for later consumption
- if (status.state() != AsyncState.SUCCEEDED) { // NOPMD
- dropActiveOperation(opId);
+ // All terminal operations leave the active set. Only successful
+ // operations retain their result for later consumption.
+ active.remove(opId);
+ if (status.state() != AsyncState.SUCCEEDED) {
+ results.remove(opId);
}
}
}
@@ -399,7 +404,7 @@ public final class DurableAsyncBus // NOPMD
*/
@Override
public Optional status(OpId opId) {
- Objects.requireNonNull(opId, "opId");
+ Objects.requireNonNull(opId, ARG_OP_ID);
return Optional.ofNullable(lastStatus.get(opId));
}
@@ -419,7 +424,7 @@ public final class DurableAsyncBus // NOPMD
*/
@Override
public Optional> snapshot(OpId opId) {
- Objects.requireNonNull(opId, "opId");
+ Objects.requireNonNull(opId, ARG_OP_ID);
return Optional.ofNullable(active.get(opId));
}
@@ -427,9 +432,9 @@ public final class DurableAsyncBus // NOPMD
* Returns and removes a previously stored successful result.
*
*
- * Result consumption is destructive. After a successful consume, the in-memory
- * operation state is deleted completely through
- * {@link #deleteOperation(Object)} to keep storage bounded.
+ * Result consumption is destructive. Before returning the result, this method
+ * appends a deletion tombstone and removes the complete in-memory operation
+ * state. Consequently, replay cannot make a consumed result visible again.
*
*
* @param opId operation identifier; must not be {@code null}
@@ -438,14 +443,14 @@ public final class DurableAsyncBus // NOPMD
*/
@Override
public Optional consumeResult(OpId opId) {
- Objects.requireNonNull(opId, "opId");
+ Objects.requireNonNull(opId, ARG_OP_ID);
- Result r = results.remove(opId);
+ Result r = results.get(opId);
if (r == null) {
return Optional.empty();
}
- // on successful consumption, forget operation entirely (bounded storage)
+ store.appendLine(encodeDeleteLine(opId));
deleteOperation(opId);
if (LOG.isLoggable(Level.INFO)) {
@@ -455,6 +460,25 @@ public final class DurableAsyncBus // NOPMD
return Optional.of(r);
}
+ /**
+ * Durably retires all bus state for an operation.
+ *
+ *
+ * Retirement appends a tombstone before deleting in-memory state. Replay
+ * applies records in order, so snapshots, statuses, and results preceding the
+ * tombstone cannot resurrect after restart. A later explicit submission using
+ * the same identifier remains visible because its records follow the tombstone.
+ *
+ *
+ * @param opId operation identifier; must not be {@code null}
+ * @throws NullPointerException if {@code opId} is {@code null}
+ */
+ public void retire(OpId opId) {
+ Objects.requireNonNull(opId, ARG_OP_ID);
+ store.appendLine(encodeDeleteLine(opId));
+ deleteOperation(opId);
+ }
+
/**
* Performs one maintenance and polling sweep.
*
@@ -502,7 +526,7 @@ public final class DurableAsyncBus // NOPMD
} catch (RuntimeException ex) { // NOPMD
// endpoint misbehaved; do not fail sweep
if (LOG.isLoggable(Level.WARNING)) {
- LOG.log(Level.WARNING, "Async endpoint status() failed; opId=" + safeOpId(opId), ex);
+ logSafeFailure("ENDPOINT_STATUS_FAILED", opId, ex);
}
continue;
}
@@ -523,7 +547,7 @@ public final class DurableAsyncBus // NOPMD
res = endpoint.result(opId);
} catch (RuntimeException ex) { // NOPMD
if (LOG.isLoggable(Level.WARNING)) {
- LOG.log(Level.WARNING, "Async endpoint result() failed; opId=" + safeOpId(opId), ex);
+ logSafeFailure("ENDPOINT_RESULT_FAILED", opId, ex);
}
}
}
@@ -616,7 +640,7 @@ public final class DurableAsyncBus // NOPMD
h.onEvent(event);
} catch (RuntimeException ex) { // NOPMD
if (LOG.isLoggable(Level.WARNING)) {
- LOG.log(Level.WARNING, "Async handler failed; opId=" + safeOpId(opId), ex);
+ logSafeFailure("HANDLER_FAILED", opId, ex);
}
}
}
@@ -652,10 +676,13 @@ public final class DurableAsyncBus // NOPMD
} else if (line.startsWith(REC_RESULT + "|")) {
applyResultLine(line);
applied++;
+ } else if (line.startsWith(REC_DELETE + "|")) {
+ applyDeleteLine(line);
+ applied++;
}
} catch (RuntimeException ex) { // NOPMD
// corrupted line: ignore safely without logging contents
- LOG.log(Level.WARNING, "Corrupted async store line encountered; skipping.", ex);
+ logRecordRejected(ex);
}
}
@@ -705,6 +732,12 @@ public final class DurableAsyncBus // NOPMD
AsyncStatus st = new AsyncStatus(state, updatedAt, dc, details);
lastStatus.put(opId, st);
+ if (st.isTerminal()) {
+ active.remove(opId);
+ if (state != AsyncState.SUCCEEDED) {
+ results.remove(opId);
+ }
+ }
}
/**
@@ -722,7 +755,20 @@ public final class DurableAsyncBus // NOPMD
// R1|opId|resultToken
OpId opId = opIdCodec.decode(parts[1]);
Result r = resultCodec.decode(parts[2]);
- results.put(opId, r);
+ AsyncStatus status = lastStatus.get(opId);
+ if (active.containsKey(opId) || status != null && status.state() == AsyncState.SUCCEEDED) {
+ results.put(opId, r);
+ }
+ }
+
+ /**
+ * Applies one persisted deletion tombstone.
+ *
+ * @param line deletion record line in internal {@code D1} format
+ */
+ private void applyDeleteLine(String line) {
+ String[] parts = split(line, 2);
+ deleteOperation(opIdCodec.decode(parts[1]));
}
/**
@@ -762,6 +808,10 @@ public final class DurableAsyncBus // NOPMD
return REC_RESULT + "|" + opIdCodec.encode(opId) + "|" + resultCodec.encode(r);
}
+ private String encodeDeleteLine(OpId opId) {
+ return REC_DELETE + "|" + opIdCodec.encode(opId);
+ }
+
/**
* Encodes a details map into the internal status-details token form.
*
@@ -881,6 +931,20 @@ public final class DurableAsyncBus // NOPMD
}
}
+ private void logSafeFailure(String code, OpId opId, Throwable failure) {
+ if (LOG.isLoggable(Level.WARNING)) {
+ LOG.log(Level.WARNING, "Async operation failed: code={0}, opId={1}, exception={2}",
+ new Object[] { code, safeOpId(opId), failure.getClass().getName() });
+ }
+ }
+
+ private static void logRecordRejected(RuntimeException failure) {
+ if (LOG.isLoggable(Level.WARNING)) {
+ LOG.log(Level.WARNING, "Async store record rejected: code={0}, exception={1}",
+ new Object[] { "ASYNC_RECORD_INVALID", failure.getClass().getName() });
+ }
+ }
+
/**
* Encodes an endpoint identifier for safe logging and truncates long values.
*
diff --git a/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java b/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java
new file mode 100644
index 0000000..f5a8bf1
--- /dev/null
+++ b/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java
@@ -0,0 +1,1114 @@
+/*******************************************************************************
+ * 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.e2e;
+
+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.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.MessageDigest;
+import java.security.PublicKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.bouncycastle.asn1.ASN1ObjectIdentifier;
+import org.bouncycastle.asn1.pkcs.CertificationRequestInfo;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
+import org.bouncycastle.asn1.x509.BasicConstraints;
+import org.bouncycastle.asn1.x509.Extension;
+import org.bouncycastle.asn1.x509.KeyUsage;
+import org.bouncycastle.cert.X509CertificateHolder;
+import org.bouncycastle.cert.X509v3CertificateBuilder;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.operator.ContentSigner;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+import org.bouncycastle.pkcs.PKCS10CertificationRequest;
+import org.bouncycastle.pkcs.PKCS10CertificationRequestBuilder;
+import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import zeroecho.pki.api.CaService;
+import zeroecho.pki.api.EncodedObject;
+import zeroecho.pki.api.Encoding;
+import zeroecho.pki.api.FormatId;
+import zeroecho.pki.api.KeyRef;
+import zeroecho.pki.api.PkiException;
+import zeroecho.pki.api.PkiId;
+import zeroecho.pki.api.SubjectRef;
+import zeroecho.pki.api.Validity;
+import zeroecho.pki.api.attr.AttributeSet;
+import zeroecho.pki.api.attr.AttributeValue;
+import zeroecho.pki.api.audit.AuditEvent;
+import zeroecho.pki.api.ca.CaCreateCommand;
+import zeroecho.pki.api.ca.CaImportCommand;
+import zeroecho.pki.api.ca.CaKeyRotationCommand;
+import zeroecho.pki.api.ca.CaRecord;
+import zeroecho.pki.api.ca.CaRolloverCommand;
+import zeroecho.pki.api.ca.IntermediateCertIssueCommand;
+import zeroecho.pki.api.ca.IntermediateCreateCommand;
+import zeroecho.pki.api.credential.Credential;
+import zeroecho.pki.api.credential.CredentialBundle;
+import zeroecho.pki.api.credential.CredentialStatus;
+import zeroecho.pki.api.issuance.IssueEndEntityCommand;
+import zeroecho.pki.api.issuance.ReissueCommand;
+import zeroecho.pki.api.issuance.RenewCommand;
+import zeroecho.pki.api.issuance.ReplaceCommand;
+import zeroecho.pki.api.request.CertificationRequest;
+import zeroecho.pki.api.request.ParsedCertificationRequest;
+import zeroecho.pki.api.request.ProofOfPossessionResult;
+import zeroecho.pki.api.request.ProofOfPossessionStatus;
+import zeroecho.pki.impl.core.DefaultIssuanceService;
+import zeroecho.pki.impl.core.ManagedCaIssuance;
+import zeroecho.pki.impl.core.VerifiedIssuanceCandidate;
+import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
+import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes;
+import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialIssuerBackend;
+import zeroecho.pki.impl.framework.x509.bc.BcX509ProofOfPossessionVerifier;
+import zeroecho.pki.spi.framework.CredentialFramework;
+import zeroecho.pki.spi.framework.CredentialIssuerBackend;
+import zeroecho.pki.testkit.PkiTestRuntime;
+
+/**
+ * End-to-end regression tests for PKI proof-of-possession issuance gates.
+ */
+final class PkiProofGateE2eTest {
+
+ @Test
+ void issuerBackendApiRequiresOpaqueGateProducedInputs() throws Exception {
+ System.out.println("issuerBackendApiRequiresOpaqueGateProducedInputs");
+
+ assertTrue(Modifier.isFinal(VerifiedIssuanceCandidate.class.getModifiers()));
+ assertTrue(Modifier.isFinal(ManagedCaIssuance.class.getModifiers()));
+ assertTrue(java.util.Arrays.stream(VerifiedIssuanceCandidate.class.getDeclaredConstructors())
+ .noneMatch(constructor -> Modifier.isPublic(constructor.getModifiers())));
+ assertTrue(java.util.Arrays.stream(ManagedCaIssuance.class.getDeclaredConstructors())
+ .noneMatch(constructor -> Modifier.isPublic(constructor.getModifiers())));
+ Class> managedKeyProof = Class.forName("zeroecho.pki.impl.core.CaProofGate$ManagedKeyProof");
+ assertTrue(java.util.Arrays.stream(managedKeyProof.getDeclaredConstructors())
+ .noneMatch(constructor -> Modifier.isPublic(constructor.getModifiers())));
+ assertTrue(java.util.Arrays.stream(CredentialFramework.class.getMethods())
+ .noneMatch(method -> method.getName().equals("issuerBackend")));
+
+ Method endEntity = java.util.Arrays.stream(CredentialIssuerBackend.class.getMethods())
+ .filter(method -> method.getName().equals("issueEndEntity"))
+ .findFirst().orElseThrow();
+ Method intermediate = java.util.Arrays.stream(CredentialIssuerBackend.class.getMethods())
+ .filter(method -> method.getName().equals("issueIntermediateCertificate"))
+ .findFirst().orElseThrow();
+ assertArrayEquals(new Class>[] { VerifiedIssuanceCandidate.class }, endEntity.getParameterTypes());
+ assertArrayEquals(new Class>[] { ManagedCaIssuance.class }, intermediate.getParameterTypes());
+ assertTrue(java.util.Arrays.stream(BcX509CredentialIssuerBackend.class.getMethods())
+ .filter(method -> method.getName().startsWith("issue"))
+ .noneMatch(method -> java.util.Arrays.asList(method.getParameterTypes())
+ .contains(IssueEndEntityCommand.class)
+ || java.util.Arrays.asList(method.getParameterTypes())
+ .contains(IntermediateCertIssueCommand.class)));
+
+ System.out.println("...backend methods=" + endEntity.getParameterTypes()[0].getSimpleName() + ","
+ + intermediate.getParameterTypes()[0].getSimpleName());
+ System.out.println("issuerBackendApiRequiresOpaqueGateProducedInputs...ok");
+ }
+
+ @Test
+ void proofGatesAloneCanReachIssuerBackend(@TempDir Path tempDir) throws Exception {
+ System.out.println("proofGatesAloneCanReachIssuerBackend");
+
+ KeyPair rootKey = generateRsa();
+ KeyPair subjectKey = generateRsa();
+ KeyPair wrongKey = generateRsa();
+ KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:root");
+ KeyRef subjectKeyRef = new KeyRef("kref:v1:keyring:test:subject");
+ Map keys = Map.of(rootKeyRef, rootKey, subjectKeyRef, subjectKey);
+ try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), keys)) {
+ CountingIssuerBackend counting = new CountingIssuerBackend(runtime.issuerBackend());
+ DefaultIssuanceService issuance = new DefaultIssuanceService(runtime.store(), runtime.framework(),
+ counting, runtime.auditSink());
+ ParsedCertificationRequest valid = parse(runtime, makeCsr(subjectKey, subjectKey, "CN=Leaf"));
+
+ assertThrows(PkiException.class,
+ () -> issuance.issueEndEntity(new IssueEndEntityCommand(new PkiId("ca:absent"),
+ parse(runtime, makeCsr(subjectKey, wrongKey, "CN=Leaf")), "default", Optional.empty(),
+ new SimpleAttributeSet())));
+ assertEquals(0, counting.endEntityCalls.get());
+
+ PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(),
+ new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet()));
+ CredentialBundle issued = issuance.issueEndEntity(new IssueEndEntityCommand(rootCaId, valid, "default",
+ Optional.empty(), new SimpleAttributeSet()));
+ assertEquals(1, counting.endEntityCalls.get());
+ assertArrayEquals(subjectKey.getPublic().getEncoded(),
+ new X509CertificateHolder(issued.credential().encoded().bytes()).getSubjectPublicKeyInfo()
+ .getEncoded());
+
+ CaService caService = runtime.caService(counting);
+ caService.createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId,
+ new SubjectRef("CN=Intermediate"), "default", Optional.of(subjectKeyRef),
+ new SimpleAttributeSet()));
+ assertEquals(1, counting.intermediateCalls.get());
+
+ runtime.replaceResolvedKey(subjectKeyRef, wrongKey.getPublic());
+ assertThrows(PkiException.class,
+ () -> caService.createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(),
+ rootCaId, new SubjectRef("CN=Rejected"), "default", Optional.of(subjectKeyRef),
+ new SimpleAttributeSet())));
+ assertEquals(1, counting.intermediateCalls.get());
+
+ System.out.println("...backend calls=" + counting.endEntityCalls.get() + "/"
+ + counting.intermediateCalls.get());
+ }
+
+ System.out.println("proofGatesAloneCanReachIssuerBackend...ok");
+ }
+
+ @Test
+ void unsupportedIssuanceVariantsFailWithoutSideEffects(@TempDir Path tempDir) throws Exception {
+ System.out.println("unsupportedIssuanceVariantsFailWithoutSideEffects");
+
+ try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), Map.of())) {
+ CountingIssuerBackend counting = new CountingIssuerBackend(runtime.issuerBackend());
+ DefaultIssuanceService issuance = new DefaultIssuanceService(runtime.store(), runtime.framework(),
+ counting, runtime.auditSink());
+ CaService caService = runtime.caService(counting);
+ ParsedCertificationRequest request = new ParsedCertificationRequest(new PkiId("csr:unsupported"),
+ runtime.framework().formatId(), new SubjectRef("CN=Unsupported"),
+ new EncodedObject(Encoding.DER, new byte[] { 1 }), Optional.empty(), Optional.empty(),
+ new SimpleAttributeSet());
+ PkiId objectId = new PkiId("object:unsupported");
+ long filesBefore;
+ try (java.util.stream.Stream files = Files.walk(tempDir)) {
+ filesBefore = files.filter(Files::isRegularFile).count();
+ }
+
+ assertThrows(PkiException.class,
+ () -> issuance.renew(new RenewCommand(objectId, Optional.empty(), new SimpleAttributeSet())));
+ assertThrows(PkiException.class,
+ () -> issuance.replace(new ReplaceCommand(objectId, request, "default",
+ new SimpleAttributeSet())));
+ assertThrows(PkiException.class,
+ () -> issuance.reissue(new ReissueCommand(objectId, new SimpleAttributeSet())));
+ assertThrows(PkiException.class,
+ () -> caService.rolloverCaCertificate(new CaRolloverCommand(objectId, Optional.empty(),
+ Optional.empty(), new SimpleAttributeSet())));
+ assertThrows(PkiException.class,
+ () -> caService.rotateCaKey(new CaKeyRotationCommand(objectId, Optional.empty(),
+ Optional.empty(), new SimpleAttributeSet())));
+
+ long filesAfter;
+ try (java.util.stream.Stream files = Files.walk(tempDir)) {
+ filesAfter = files.filter(Files::isRegularFile).count();
+ }
+ assertEquals(filesBefore, filesAfter);
+ assertEquals(0, counting.endEntityCalls.get());
+ assertEquals(0, counting.intermediateCalls.get());
+ assertEquals(0, runtime.submittedSignCount());
+ assertTrue(runtime.store().listCas().isEmpty());
+ assertTrue(runtime.store().listWorkflowStates().isEmpty());
+ assertTrue(runtime.auditSink().snapshot().isEmpty());
+
+ System.out.println("...unsupported operations=5");
+ }
+
+ System.out.println("unsupportedIssuanceVariantsFailWithoutSideEffects...ok");
+ }
+
+ @Test
+ void endEntityGateRejectsMalformedTamperedAndSubstitutedRequests(@TempDir Path tempDir) throws Exception {
+ System.out.println("endEntityGateRejectsMalformedTamperedAndSubstitutedRequests");
+
+ KeyPair subjectKey = generateRsa();
+ KeyPair otherKey = generateRsa();
+ KeyRef subjectKeyRef = new KeyRef("kref:v1:keyring:test:subject");
+ try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"),
+ Map.of(subjectKeyRef, subjectKey))) {
+ PKCS10CertificationRequest validCsr = makeCsr(subjectKey, subjectKey, "CN=Subject");
+ ParsedCertificationRequest valid = parse(runtime, validCsr);
+ ParsedCertificationRequest pss = parse(runtime,
+ makeCsr(subjectKey, subjectKey, "CN=PssSubject", "SHA256withRSAandMGF1"));
+ ProofOfPossessionResult pssProof = new BcX509ProofOfPossessionVerifier().verify(pss,
+ new zeroecho.pki.api.issuance.VerificationPolicy(true, Optional.empty()));
+ assertEquals(ProofOfPossessionStatus.VERIFIED, pssProof.status());
+ org.bouncycastle.asn1.pkcs.CertificationRequest original = validCsr.toASN1Structure();
+ CertificationRequestInfo requestInfo = original.getCertificationRequestInfo();
+ org.bouncycastle.asn1.pkcs.CertificationRequest unknownAlgorithm =
+ new org.bouncycastle.asn1.pkcs.CertificationRequest(requestInfo,
+ new AlgorithmIdentifier(new ASN1ObjectIdentifier("1.2.3.4.5.6.7")),
+ original.getSignature());
+ ParsedCertificationRequest unsupported = parse(runtime,
+ new PKCS10CertificationRequest(unknownAlgorithm));
+ ProofOfPossessionResult unsupportedProof = new BcX509ProofOfPossessionVerifier().verify(unsupported,
+ new zeroecho.pki.api.issuance.VerificationPolicy(true, Optional.empty()));
+ assertEquals(ProofOfPossessionStatus.FAILED, unsupportedProof.status());
+
+ assertRejected(runtime, withAttributes(valid, new SimpleAttributeSet()), "CSR_MISSING");
+ assertRejected(runtime, withCsr(valid, new byte[] { 0x01 }), "CSR_MALFORMED");
+
+ byte[] tampered = csrDer(valid).clone();
+ tampered[tampered.length - 1] ^= 0x01;
+ ParsedCertificationRequest tamperedParsed = parse(runtime, new PKCS10CertificationRequest(tampered));
+ assertRejected(runtime, tamperedParsed, "PROOF_FAILED");
+
+ ParsedCertificationRequest wrongSigner = parse(runtime,
+ makeCsr(subjectKey, otherKey, "CN=Subject"));
+ assertRejected(runtime, wrongSigner, "PROOF_FAILED");
+
+ assertRejected(runtime,
+ new ParsedCertificationRequest(new PkiId("csr:substituted"), valid.formatId(), valid.subjectRef(),
+ valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(),
+ valid.attributes()),
+ "REQUEST_ID_MISMATCH");
+ assertRejected(runtime,
+ new ParsedCertificationRequest(valid.requestId(), valid.formatId(), new SubjectRef("CN=Other"),
+ valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(),
+ valid.attributes()),
+ "SUBJECT_MISMATCH");
+ assertRejected(runtime,
+ new ParsedCertificationRequest(valid.requestId(), valid.formatId(), valid.subjectRef(),
+ new EncodedObject(Encoding.DER, otherKey.getPublic().getEncoded()),
+ valid.requestedValidity(), valid.requestedProfileId(), valid.attributes()),
+ "SPKI_MISMATCH");
+ assertRejected(runtime,
+ new ParsedCertificationRequest(valid.requestId(), new FormatId("unsupported"), valid.subjectRef(),
+ valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(),
+ valid.attributes()),
+ "FORMAT_UNSUPPORTED");
+ byte[] maximum = new byte[1024 * 1024];
+ System.arraycopy(csrDer(valid), 0, maximum, 0, csrDer(valid).length);
+ assertRejected(runtime, withCsr(valid, maximum), "CSR_MALFORMED");
+ assertRejected(runtime, withCsr(valid, new byte[1024 * 1024 + 1]), "CSR_TOO_LARGE");
+
+ assertTrue(runtime.store().listCas().isEmpty());
+ AuditEvent last = runtime.auditSink().snapshot().get(runtime.auditSink().snapshot().size() - 1);
+ assertEquals("ISSUE_END_ENTITY_REJECTED", last.action());
+ assertEquals("SYSTEM", last.principal().type());
+ assertEquals("pki", last.principal().name());
+ assertEquals(Set.of("code"), last.details().keySet());
+ }
+
+ System.out.println("endEntityGateRejectsMalformedTamperedAndSubstitutedRequests...ok");
+ }
+
+ @Test
+ void endEntityGateForcesRequiredPolicyAndRejectsEveryNonVerifiedStatus(@TempDir Path tempDir) throws Exception {
+ System.out.println("endEntityGateForcesRequiredPolicyAndRejectsEveryNonVerifiedStatus");
+
+ KeyPair subjectKey = generateRsa();
+ KeyRef subjectKeyRef = new KeyRef("kref:v1:keyring:test:subject");
+ for (ProofOfPossessionStatus status : new ProofOfPossessionStatus[] {
+ ProofOfPossessionStatus.NOT_PRESENT,
+ ProofOfPossessionStatus.NOT_SUPPORTED,
+ ProofOfPossessionStatus.FAILED }) {
+ AtomicBoolean required = new AtomicBoolean();
+ Path caseDir = tempDir.resolve(status.name());
+ try (PkiTestRuntime runtime = PkiTestRuntime.create(caseDir, caseDir.resolve("bus.log"),
+ Map.of(subjectKeyRef, subjectKey), Map.of(subjectKeyRef, subjectKey.getPublic()),
+ (request, policy) -> {
+ required.set(policy.requireProofOfPossession());
+ return new ProofOfPossessionResult(status, Optional.empty());
+ })) {
+ ParsedCertificationRequest parsed = parse(runtime, makeCsr(subjectKey, subjectKey, "CN=Subject"));
+ assertThrows(PkiException.class, () -> issue(runtime, parsed));
+ assertTrue(required.get());
+ assertEquals("PROOF_" + status.name(),
+ runtime.auditSink().snapshot().get(0).details().get("code"));
+ assertTrue(runtime.store().listCas().isEmpty());
+ }
+ }
+
+ System.out.println("endEntityGateForcesRequiredPolicyAndRejectsEveryNonVerifiedStatus...ok");
+ }
+
+ @Test
+ void verifiedSnapshotSurvivesCallerArrayMutationAndIssuerOverrides(@TempDir Path tempDir) throws Exception {
+ System.out.println("verifiedSnapshotSurvivesCallerArrayMutationAndIssuerOverrides");
+
+ KeyPair rootKey = generateRsa();
+ KeyPair subjectKey = generateRsa();
+ KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:root");
+ KeyRef subjectKeyRef = new KeyRef("kref:v1:keyring:test:subject");
+ AtomicReference mutation = new AtomicReference<>(() -> {
+ });
+ BcX509ProofOfPossessionVerifier delegate = new BcX509ProofOfPossessionVerifier();
+
+ Map signingKeys = Map.of(rootKeyRef, rootKey, subjectKeyRef, subjectKey);
+ Map resolvedKeys = Map.of(rootKeyRef, rootKey.getPublic(), subjectKeyRef,
+ subjectKey.getPublic());
+ try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), signingKeys,
+ resolvedKeys, (request, policy) -> {
+ ProofOfPossessionResult result = delegate.verify(request, policy);
+ mutation.get().run();
+ return result;
+ })) {
+ PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(),
+ new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet()));
+ ParsedCertificationRequest parsed = parse(runtime, makeCsr(subjectKey, subjectKey, "CN=Subject"));
+ byte[] callerCsr = csrDer(parsed);
+ byte[] callerSpki = parsed.publicKeyInfo().bytes();
+ mutation.set(() -> {
+ callerCsr[0] ^= 0x7f;
+ callerSpki[0] ^= 0x7f;
+ });
+
+ AttributeSet hostileOverrides = SimpleAttributeSet.builder()
+ .put(BcX509Attributes.ISSUER_CERT_DER, new AttributeValue.BytesValue(new byte[] { 0x01 }))
+ .put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue("attacker-key"))
+ .build();
+ CredentialBundle bundle = runtime.issuanceService()
+ .issueEndEntity(new IssueEndEntityCommand(rootCaId, parsed, "default", Optional.empty(),
+ hostileOverrides));
+
+ X509CertificateHolder issued = new X509CertificateHolder(bundle.credential().encoded().bytes());
+ assertEquals("CN=Root", issued.getIssuer().toString());
+ assertEquals("CN=Subject", issued.getSubject().toString());
+ assertArrayEquals(subjectKey.getPublic().getEncoded(), issued.getSubjectPublicKeyInfo().getEncoded());
+ }
+
+ System.out.println("verifiedSnapshotSurvivesCallerArrayMutationAndIssuerOverrides...ok");
+ }
+
+ @Test
+ void caProofGatesVerifyRootAndManagedIntermediateKeys(@TempDir Path tempDir) throws Exception {
+ System.out.println("caProofGatesVerifyRootAndManagedIntermediateKeys");
+
+ KeyPair expectedRoot = generateRsa();
+ KeyPair wrongRootSigner = generateRsa();
+ KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:root");
+ Path wrongDir = tempDir.resolve("wrong-root");
+ try (PkiTestRuntime runtime = PkiTestRuntime.create(wrongDir, wrongDir.resolve("bus.log"),
+ Map.of(rootKeyRef, wrongRootSigner), Map.of(rootKeyRef, expectedRoot.getPublic()),
+ new BcX509ProofOfPossessionVerifier())) {
+ assertThrows(PkiException.class,
+ () -> runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(),
+ new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef),
+ new SimpleAttributeSet())));
+ assertTrue(runtime.store().listCas().isEmpty());
+ assertTrue(runtime.store().listWorkflowStates().isEmpty());
+ assertEquals(1, runtime.submittedSignCount());
+ assertEquals("MANAGED_KEY_PROOF_FAILED",
+ runtime.auditSink().snapshot().get(0).details().get("code"));
+ }
+ Path failedDir = tempDir.resolve("failed-workflow");
+ try (PkiTestRuntime runtime = PkiTestRuntime.create(failedDir, failedDir.resolve("bus.log"), Map.of(),
+ Map.of(rootKeyRef, expectedRoot.getPublic()), new BcX509ProofOfPossessionVerifier())) {
+ assertThrows(PkiException.class,
+ () -> runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(),
+ new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef),
+ new SimpleAttributeSet())));
+ assertTrue(runtime.store().listWorkflowStates().isEmpty());
+ assertEquals(1, runtime.submittedSignCount());
+ }
+
+ KeyPair rootKey = generateRsa();
+ KeyPair intermediateKey = generateRsa();
+ KeyRef intermediateKeyRef = new KeyRef("kref:v1:keyring:test:intermediate");
+ Path validDir = tempDir.resolve("valid-intermediate");
+ Map keys = new HashMap<>();
+ keys.put(rootKeyRef, rootKey);
+ keys.put(intermediateKeyRef, intermediateKey);
+ try (PkiTestRuntime runtime = PkiTestRuntime.create(validDir, validDir.resolve("bus.log"), keys)) {
+ PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(),
+ new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet()));
+ AttributeSet hostile = hostileIntermediateAttributes(expectedRoot.getPublic());
+ PkiId intermediateCaId = runtime.caService()
+ .createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId,
+ new SubjectRef("CN=Intermediate"), "default", Optional.of(intermediateKeyRef), hostile));
+
+ Credential first = runtime.caService().getCa(intermediateCaId).caCredentials().get(0);
+ X509CertificateHolder firstHolder = new X509CertificateHolder(first.encoded().bytes());
+ assertEquals("CN=Root", firstHolder.getIssuer().toString());
+ assertEquals("CN=Intermediate", firstHolder.getSubject().toString());
+ assertArrayEquals(intermediateKey.getPublic().getEncoded(),
+ firstHolder.getSubjectPublicKeyInfo().getEncoded());
+
+ Credential additional = runtime.caService()
+ .issueIntermediateCertificate(new IntermediateCertIssueCommand(runtime.framework().formatId(),
+ rootCaId, intermediateCaId, "default", Optional.empty(), hostile));
+ X509CertificateHolder additionalHolder = new X509CertificateHolder(additional.encoded().bytes());
+ assertEquals("CN=Intermediate", additionalHolder.getSubject().toString());
+ assertArrayEquals(intermediateKey.getPublic().getEncoded(),
+ additionalHolder.getSubjectPublicKeyInfo().getEncoded());
+ }
+
+ System.out.println("caProofGatesVerifyRootAndManagedIntermediateKeys...ok");
+ }
+
+ @Test
+ void caIssuerImportAndWorkflowFailuresLeaveNoDurableSideEffects(@TempDir Path tempDir) throws Exception {
+ System.out.println("caIssuerImportAndWorkflowFailuresLeaveNoDurableSideEffects");
+
+ KeyPair rootKey = generateRsa();
+ KeyPair replacementRootKey = generateRsa();
+ KeyPair intermediateKey = generateRsa();
+ KeyPair subjectKey = generateRsa();
+ KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:root");
+ KeyRef intermediateKeyRef = new KeyRef("kref:v1:keyring:test:intermediate");
+
+ Path issuerDir = tempDir.resolve("issuer-mismatch");
+ try (PkiTestRuntime runtime = PkiTestRuntime.create(issuerDir, issuerDir.resolve("bus.log"),
+ Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey))) {
+ PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(),
+ new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet()));
+ assertEquals(2, runtime.submittedSignCount());
+ runtime.replaceManagedKey(rootKeyRef, replacementRootKey);
+ assertThrows(PkiException.class,
+ () -> runtime.caService()
+ .createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(),
+ rootCaId, new SubjectRef("CN=Intermediate"), "default",
+ Optional.of(intermediateKeyRef), new SimpleAttributeSet())));
+ assertEquals(4, runtime.submittedSignCount());
+ assertEquals(1, runtime.store().listCas().size());
+ assertTrue(runtime.store().listWorkflowStates().isEmpty());
+ assertEquals("ISSUER_MANAGED_KEY_MISMATCH",
+ runtime.auditSink().snapshot().get(runtime.auditSink().snapshot().size() - 1).details()
+ .get("code"));
+ }
+
+ Path additionalDir = tempDir.resolve("additional-issuer-mismatch");
+ try (PkiTestRuntime runtime = PkiTestRuntime.create(additionalDir, additionalDir.resolve("bus.log"),
+ Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey))) {
+ PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(),
+ new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet()));
+ PkiId intermediateCaId = runtime.caService()
+ .createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId,
+ new SubjectRef("CN=Intermediate"), "default", Optional.of(intermediateKeyRef),
+ new SimpleAttributeSet()));
+ assertEquals(5, runtime.submittedSignCount());
+ runtime.replaceManagedKey(rootKeyRef, replacementRootKey);
+ assertThrows(PkiException.class,
+ () -> runtime.caService().issueIntermediateCertificate(
+ new IntermediateCertIssueCommand(runtime.framework().formatId(), rootCaId,
+ intermediateCaId, "default", Optional.empty(), new SimpleAttributeSet())));
+ assertEquals(7, runtime.submittedSignCount());
+ assertEquals(1, runtime.caService().getCa(intermediateCaId).caCredentials().size());
+ assertTrue(runtime.store().listWorkflowStates().isEmpty());
+ }
+
+ byte[] rootCertificate;
+ byte[] intermediateCertificate;
+ byte[] leafCertificate;
+ Path sourceDir = tempDir.resolve("import-source");
+ try (PkiTestRuntime source = PkiTestRuntime.create(sourceDir, sourceDir.resolve("bus.log"),
+ Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey))) {
+ PkiId rootCaId = source.caService().createRoot(new CaCreateCommand(source.framework().formatId(),
+ new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet()));
+ rootCertificate = source.caService().getCa(rootCaId).caCredentials().get(0).encoded().bytes().clone();
+ PkiId intermediateCaId = source.caService()
+ .createIntermediate(new IntermediateCreateCommand(source.framework().formatId(), rootCaId,
+ new SubjectRef("CN=Intermediate"), "default", Optional.of(intermediateKeyRef),
+ new SimpleAttributeSet()));
+ intermediateCertificate = source.caService().getCa(intermediateCaId).caCredentials().get(0).encoded()
+ .bytes().clone();
+ ParsedCertificationRequest leaf = parse(source, makeCsr(subjectKey, subjectKey, "CN=Leaf"));
+ leafCertificate = source.issuanceService()
+ .issueEndEntity(new IssueEndEntityCommand(rootCaId, leaf, "default", Optional.empty(),
+ new SimpleAttributeSet()))
+ .credential().encoded().bytes().clone();
+ }
+ Path importDir = tempDir.resolve("import-mismatch");
+ try (PkiTestRuntime target = PkiTestRuntime.create(importDir, importDir.resolve("bus.log"),
+ Map.of(rootKeyRef, replacementRootKey))) {
+ CaImportCommand command = new CaImportCommand(target.framework().formatId(), new SubjectRef("CN=Root"),
+ "default", rootKeyRef, new EncodedObject(Encoding.DER, rootCertificate),
+ new SimpleAttributeSet());
+ assertThrows(PkiException.class, () -> target.caService().importRoot(command));
+ assertTrue(target.store().listCas().isEmpty());
+ assertTrue(target.store().listWorkflowStates().isEmpty());
+ assertEquals("ROOT_MANAGED_KEY_MISMATCH",
+ target.auditSink().snapshot().get(target.auditSink().snapshot().size() - 1).details().get("code"));
+ }
+
+ Path mutationDir = tempDir.resolve("import-mutation");
+ byte[] callerOwnedCertificate = rootCertificate.clone();
+ byte[] expectedImportedCertificate = rootCertificate.clone();
+ try (PkiTestRuntime target = PkiTestRuntime.create(mutationDir, mutationDir.resolve("bus.log"),
+ Map.of(rootKeyRef, rootKey))) {
+ target.onPublicKeyResolve(() -> callerOwnedCertificate[callerOwnedCertificate.length - 1] ^= 0x01);
+ PkiId importedCaId = target.caService().importRoot(new CaImportCommand(target.framework().formatId(),
+ new SubjectRef("CN=Root"), "default", rootKeyRef,
+ new EncodedObject(Encoding.DER, callerOwnedCertificate), new SimpleAttributeSet()));
+ assertArrayEquals(expectedImportedCertificate,
+ target.caService().getCa(importedCaId).caCredentials().get(0).encoded().bytes());
+ }
+
+ assertInvalidRootImport(tempDir.resolve("import-leaf"), rootKeyRef, rootKey, leafCertificate, "CN=Leaf");
+ assertInvalidRootImport(tempDir.resolve("import-non-self"), intermediateKeyRef, intermediateKey,
+ intermediateCertificate, "CN=Intermediate");
+ assertInvalidRootImport(tempDir.resolve("import-subject"), rootKeyRef, rootKey, rootCertificate, "CN=Wrong");
+ byte[] invalidSelfSignature = rootCertificate.clone();
+ invalidSelfSignature[invalidSelfSignature.length - 1] ^= 0x01;
+ assertInvalidRootImport(tempDir.resolve("import-signature"), rootKeyRef, rootKey, invalidSelfSignature,
+ "CN=Root");
+
+ Path signingFailureDir = tempDir.resolve("signing-failure");
+ try (PkiTestRuntime signingFailure = PkiTestRuntime.create(signingFailureDir,
+ signingFailureDir.resolve("bus.log"), Map.of())) {
+ assertThrows(PkiException.class,
+ () -> signingFailure.caService().createRoot(new CaCreateCommand(
+ signingFailure.framework().formatId(),
+ new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef),
+ new SimpleAttributeSet())));
+ assertTrue(signingFailure.store().listCas().isEmpty());
+ assertTrue(signingFailure.store().listWorkflowStates().isEmpty());
+ assertTrue(signingFailure.store().listSignRecords().isEmpty());
+ assertTrue(signingFailure.store().listPublicationRecords().isEmpty());
+ assertFalse(signingFailure.hasRunningSignatureOperations());
+ assertEquals(0, signingFailure.submittedSignCount());
+ assertEquals("MANAGED_KEY_UNAVAILABLE",
+ signingFailure.auditSink().snapshot()
+ .get(signingFailure.auditSink().snapshot().size() - 1).details().get("code"));
+ }
+
+ System.out.println("caIssuerImportAndWorkflowFailuresLeaveNoDurableSideEffects...ok");
+ }
+
+ private static void assertInvalidRootImport(Path rootDir, KeyRef keyRef, KeyPair keyPair, byte[] certificate,
+ String subject) throws Exception {
+ try (PkiTestRuntime runtime = PkiTestRuntime.create(rootDir, rootDir.resolve("bus.log"),
+ Map.of(keyRef, keyPair))) {
+ CaImportCommand command = new CaImportCommand(runtime.framework().formatId(), new SubjectRef(subject),
+ "default", keyRef, new EncodedObject(Encoding.DER, certificate), new SimpleAttributeSet());
+ assertThrows(PkiException.class, () -> runtime.caService().importRoot(command));
+ assertTrue(runtime.store().listCas().isEmpty());
+ assertEquals(0, runtime.submittedSignCount());
+ assertEquals("ROOT_CREDENTIAL_INVALID",
+ runtime.auditSink().snapshot().get(runtime.auditSink().snapshot().size() - 1).details().get("code"));
+ }
+ }
+
+ @Test
+ void endEntityPostconditionsAndAuditFailureRejectBeforePersistence(@TempDir Path tempDir) throws Exception {
+ System.out.println("endEntityPostconditionsAndAuditFailureRejectBeforePersistence");
+
+ KeyPair rootKey = generateRsa();
+ KeyPair subjectKey = generateRsa();
+ KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:root");
+ Path runtimeDir = tempDir.resolve("runtime");
+ try (PkiTestRuntime runtime = PkiTestRuntime.create(runtimeDir, runtimeDir.resolve("bus.log"),
+ Map.of(rootKeyRef, rootKey))) {
+ PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(),
+ new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet()));
+ ParsedCertificationRequest subject = parse(runtime, makeCsr(subjectKey, subjectKey, "CN=Subject"));
+ ParsedCertificationRequest substitute = parse(runtime,
+ makeCsr(subjectKey, subjectKey, "CN=Substitute"));
+ AtomicReference substitutedBundle = new AtomicReference<>();
+ CredentialIssuerBackend delegateBackend = runtime.issuerBackend();
+ CredentialIssuerBackend throwingBackend = new CredentialIssuerBackend() {
+ @Override
+ public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) {
+ throw new IllegalStateException("DO_NOT_LOG_SIGNATURE_SENTINEL");
+ }
+
+ @Override
+ public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) {
+ throw new IllegalStateException("DO_NOT_LOG_SIGNATURE_SENTINEL");
+ }
+ };
+ DefaultIssuanceService throwingBackendService = new DefaultIssuanceService(runtime.store(),
+ runtime.framework(), throwingBackend, runtime.auditSink());
+ PkiException backendRejection = assertThrows(PkiException.class,
+ () -> throwingBackendService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject,
+ "default", Optional.empty(), new SimpleAttributeSet())));
+ assertThrowableRedacted(backendRejection, "DO_NOT_LOG_SIGNATURE_SENTINEL");
+
+ CredentialIssuerBackend maliciousBackend = new CredentialIssuerBackend() {
+ @Override
+ public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) {
+ CredentialBundle bundle = delegateBackend.issueEndEntity(candidate);
+ Credential raw = bundle.credential();
+ Credential forgedMetadata = new Credential(raw.credentialId(), raw.formatId(), raw.issuerRef(),
+ substitute.subjectRef(), raw.validity(), raw.serialOrUniqueId(), raw.publicKeyId(),
+ raw.profileId(), raw.status(), raw.encoded(), raw.attributes());
+ bundle = new CredentialBundle(forgedMetadata, bundle.supportingObjects());
+ substitutedBundle.set(bundle);
+ return bundle;
+ }
+
+ @Override
+ public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) {
+ return delegateBackend.issueIntermediateCertificate(issuance);
+ }
+ };
+ DefaultIssuanceService service = new DefaultIssuanceService(runtime.store(), runtime.framework(),
+ maliciousBackend, runtime.auditSink());
+ assertThrows(PkiException.class,
+ () -> service.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, "default",
+ Optional.empty(), new SimpleAttributeSet())));
+ Credential substitutedCredential = substitutedBundle.get().credential();
+ assertTrue(runtime.store().getCredential(substitutedCredential.credentialId()).isEmpty());
+ assertEquals("BACKEND_CREDENTIAL_MISMATCH",
+ runtime.auditSink().snapshot().get(runtime.auditSink().snapshot().size() - 1).details().get("code"));
+
+ CredentialIssuerBackend invalidSignatureBackend = new CredentialIssuerBackend() {
+ @Override
+ public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) {
+ CredentialBundle rawBundle = delegateBackend.issueEndEntity(candidate);
+ Credential raw = rawBundle.credential();
+ byte[] invalid = raw.encoded().bytes().clone();
+ invalid[invalid.length - 1] ^= 0x01;
+ Credential invalidCredential = new Credential(raw.credentialId(), raw.formatId(),
+ raw.issuerRef(), raw.subjectRef(), raw.validity(), raw.serialOrUniqueId(),
+ raw.publicKeyId(), raw.profileId(), raw.status(),
+ new EncodedObject(Encoding.DER, invalid), raw.attributes());
+ return new CredentialBundle(invalidCredential, rawBundle.supportingObjects());
+ }
+
+ @Override
+ public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) {
+ return delegateBackend.issueIntermediateCertificate(issuance);
+ }
+ };
+ DefaultIssuanceService invalidSignatureService = new DefaultIssuanceService(runtime.store(),
+ runtime.framework(), invalidSignatureBackend, runtime.auditSink());
+ assertThrows(PkiException.class,
+ () -> invalidSignatureService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject,
+ "default", Optional.empty(), new SimpleAttributeSet())));
+
+ AtomicReference rawBundle = new AtomicReference<>();
+ CredentialIssuerBackend mutableBackend = new CredentialIssuerBackend() {
+ @Override
+ public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) {
+ CredentialBundle raw = delegateBackend.issueEndEntity(candidate);
+ rawBundle.set(raw);
+ return raw;
+ }
+
+ @Override
+ public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) {
+ return delegateBackend.issueIntermediateCertificate(issuance);
+ }
+ };
+ DefaultIssuanceService snapshotService = new DefaultIssuanceService(runtime.store(),
+ runtime.framework(), mutableBackend, runtime.auditSink());
+ CredentialBundle returned = snapshotService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject,
+ "default", Optional.empty(), new SimpleAttributeSet()));
+ byte[] expectedLeaf = returned.credential().encoded().bytes().clone();
+ rawBundle.get().credential().encoded().bytes()[0] ^= 0x01;
+ rawBundle.get().supportingObjects().get(0).bytes()[0] ^= 0x01;
+ assertArrayEquals(expectedLeaf, returned.credential().encoded().bytes());
+ assertArrayEquals(expectedLeaf,
+ runtime.store().getCredential(returned.credential().credentialId()).orElseThrow().encoded()
+ .bytes());
+
+ CaRecord root = runtime.caService().getCa(rootCaId);
+ Credential original = root.caCredentials().get(0);
+ Credential revoked = new Credential(original.credentialId(), original.formatId(), original.issuerRef(),
+ original.subjectRef(), original.validity(), original.serialOrUniqueId(), original.publicKeyId(),
+ original.profileId(), CredentialStatus.REVOKED, original.encoded(), original.attributes());
+ runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(),
+ root.subjectRef(), List.of(revoked)));
+ int before = runtime.submittedSignCount();
+ assertThrows(PkiException.class,
+ () -> runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(rootCaId, subject,
+ "default", Optional.empty(), new SimpleAttributeSet())));
+ assertEquals(before, runtime.submittedSignCount());
+
+ Validity expiredValidity = new Validity(Instant.now().minus(Duration.ofDays(2)),
+ Instant.now().minus(Duration.ofDays(1)));
+ Credential expired = new Credential(original.credentialId(), original.formatId(), original.issuerRef(),
+ original.subjectRef(), expiredValidity, original.serialOrUniqueId(), original.publicKeyId(),
+ original.profileId(), CredentialStatus.ISSUED, original.encoded(), original.attributes());
+ runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(),
+ root.subjectRef(), List.of(expired)));
+ assertThrows(PkiException.class,
+ () -> runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(rootCaId, subject,
+ "default", Optional.empty(), new SimpleAttributeSet())));
+ assertEquals(before, runtime.submittedSignCount());
+
+ ParsedCertificationRequest missing = withAttributes(subject, new SimpleAttributeSet());
+ AttributeSet hostileAttributes = new AttributeSet() {
+ @Override
+ public Set ids() {
+ return Set.of();
+ }
+
+ @Override
+ public Optional get(zeroecho.pki.api.attr.AttributeId id) {
+ throw new IllegalStateException("DO_NOT_LOG_CSR_SENTINEL");
+ }
+
+ @Override
+ public List getAll(zeroecho.pki.api.attr.AttributeId id) {
+ return List.of();
+ }
+ };
+ PkiException parserRejection = assertThrows(PkiException.class,
+ () -> runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(rootCaId,
+ withAttributes(subject, hostileAttributes), "default", Optional.empty(),
+ new SimpleAttributeSet())));
+ assertThrowableRedacted(parserRejection, "DO_NOT_LOG_CSR_SENTINEL");
+
+ DefaultIssuanceService failingAudit = new DefaultIssuanceService(runtime.store(), runtime.framework(),
+ runtime.issuerBackend(), event -> {
+ throw new IllegalStateException("DO_NOT_LOG_PAYLOAD_SENTINEL");
+ });
+ PkiException rejection = assertThrows(PkiException.class,
+ () -> failingAudit.issueEndEntity(new IssueEndEntityCommand(rootCaId, missing, "default",
+ Optional.empty(), new SimpleAttributeSet())));
+ assertTrue(rejection.getMessage().contains("CSR_MISSING"));
+ assertThrowableRedacted(rejection, "DO_NOT_LOG_PAYLOAD_SENTINEL");
+ }
+
+ System.out.println("endEntityPostconditionsAndAuditFailureRejectBeforePersistence...ok");
+ }
+
+ @Test
+ void intermediateBackendOutputIsValidatedAndSnapshotted(@TempDir Path tempDir) throws Exception {
+ KeyPair rootKey = generateRsa();
+ KeyPair intermediateKey = generateRsa();
+ KeyPair wrongKey = generateRsa();
+ KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:root");
+ KeyRef intermediateKeyRef = new KeyRef("kref:v1:keyring:test:intermediate");
+ try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"),
+ Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey))) {
+ PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(),
+ new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet()));
+ CredentialIssuerBackend delegate = runtime.issuerBackend();
+ CredentialIssuerBackend wrongKeyBackend = new CredentialIssuerBackend() {
+ @Override
+ public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) {
+ return delegate.issueEndEntity(candidate);
+ }
+
+ @Override
+ public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) {
+ Credential raw = delegate.issueIntermediateCertificate(issuance);
+ return rebuildIntermediateIdentity(raw, rootKey, Optional.of(wrongKey.getPublic()),
+ Optional.empty());
+ }
+ };
+ CaService wrongKeyService = runtime.caService(wrongKeyBackend);
+ assertThrows(PkiException.class,
+ () -> wrongKeyService.createIntermediate(new IntermediateCreateCommand(runtime.framework()
+ .formatId(), rootCaId, new SubjectRef("CN=Intermediate"), "default",
+ Optional.of(intermediateKeyRef), new SimpleAttributeSet())));
+ assertEquals(1, runtime.store().listCas().size());
+
+ PkiId intermediateCaId = runtime.caService()
+ .createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId,
+ new SubjectRef("CN=Intermediate"), "default", Optional.of(intermediateKeyRef),
+ new SimpleAttributeSet()));
+ CredentialIssuerBackend wrongSubjectBackend = new CredentialIssuerBackend() {
+ @Override
+ public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) {
+ return delegate.issueEndEntity(candidate);
+ }
+
+ @Override
+ public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) {
+ Credential raw = delegate.issueIntermediateCertificate(issuance);
+ return rebuildIntermediateIdentity(raw, rootKey, Optional.empty(),
+ Optional.of("CN=WrongIntermediate"));
+ }
+ };
+ CaService wrongSubjectService = runtime.caService(wrongSubjectBackend);
+ assertThrows(PkiException.class,
+ () -> wrongSubjectService.issueIntermediateCertificate(new IntermediateCertIssueCommand(
+ runtime.framework().formatId(), rootCaId, intermediateCaId, "default", Optional.empty(),
+ new SimpleAttributeSet())));
+ assertEquals(1, runtime.caService().getCa(intermediateCaId).caCredentials().size());
+
+ CredentialIssuerBackend invalidSignatureBackend = new CredentialIssuerBackend() {
+ @Override
+ public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) {
+ return delegate.issueEndEntity(candidate);
+ }
+
+ @Override
+ public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) {
+ Credential raw = delegate.issueIntermediateCertificate(issuance);
+ byte[] invalid = raw.encoded().bytes().clone();
+ invalid[invalid.length - 1] ^= 0x01;
+ return new Credential(raw.credentialId(), raw.formatId(), raw.issuerRef(),
+ raw.subjectRef(), raw.validity(), raw.serialOrUniqueId(), raw.publicKeyId(),
+ raw.profileId(), raw.status(), new EncodedObject(Encoding.DER, invalid),
+ raw.attributes());
+ }
+ };
+ CaService invalidSignatureService = runtime.caService(invalidSignatureBackend);
+ assertThrows(PkiException.class,
+ () -> invalidSignatureService.issueIntermediateCertificate(new IntermediateCertIssueCommand(
+ runtime.framework().formatId(), rootCaId, intermediateCaId, "default", Optional.empty(),
+ new SimpleAttributeSet())));
+ assertEquals(1, runtime.caService().getCa(intermediateCaId).caCredentials().size());
+
+ for (IntermediateExtensionVariant variant : IntermediateExtensionVariant.values()) {
+ CaService maliciousExtensionService = runtime.caService(
+ extensionVariantBackend(delegate, rootKey, variant));
+ assertThrows(PkiException.class,
+ () -> maliciousExtensionService.issueIntermediateCertificate(
+ new IntermediateCertIssueCommand(runtime.framework().formatId(), rootCaId,
+ intermediateCaId, "default", Optional.empty(), new SimpleAttributeSet())),
+ variant.name());
+ assertEquals(1, runtime.caService().getCa(intermediateCaId).caCredentials().size(), variant.name());
+ }
+
+ AtomicReference rawCredential = new AtomicReference<>();
+ CredentialIssuerBackend mutableBackend = new CredentialIssuerBackend() {
+ @Override
+ public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) {
+ return delegate.issueEndEntity(candidate);
+ }
+
+ @Override
+ public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) {
+ Credential raw = delegate.issueIntermediateCertificate(issuance);
+ rawCredential.set(raw);
+ return raw;
+ }
+ };
+ CaService snapshotService = runtime.caService(mutableBackend);
+ Credential returned = snapshotService.issueIntermediateCertificate(new IntermediateCertIssueCommand(
+ runtime.framework().formatId(), rootCaId, intermediateCaId, "default", Optional.empty(),
+ new SimpleAttributeSet()));
+ byte[] expected = returned.encoded().bytes().clone();
+ rawCredential.get().encoded().bytes()[0] ^= 0x01;
+ assertArrayEquals(expected, returned.encoded().bytes());
+ assertArrayEquals(expected, runtime.store().getCredential(returned.credentialId()).orElseThrow()
+ .encoded().bytes());
+ }
+ }
+
+ private enum IntermediateExtensionVariant {
+ ABSENT_BASIC_CONSTRAINTS,
+ NONCRITICAL_BASIC_CONSTRAINTS,
+ UNLIMITED_BASIC_CONSTRAINTS,
+ ABSENT_KEY_USAGE,
+ NONCRITICAL_KEY_USAGE,
+ INCOMPATIBLE_KEY_USAGE
+ }
+
+ private static final class CountingIssuerBackend implements CredentialIssuerBackend {
+
+ private final CredentialIssuerBackend delegate;
+ private final AtomicInteger endEntityCalls;
+ private final AtomicInteger intermediateCalls;
+
+ private CountingIssuerBackend(CredentialIssuerBackend delegate) {
+ this.delegate = delegate;
+ this.endEntityCalls = new AtomicInteger();
+ this.intermediateCalls = new AtomicInteger();
+ }
+
+ @Override
+ public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) {
+ endEntityCalls.incrementAndGet();
+ return delegate.issueEndEntity(candidate);
+ }
+
+ @Override
+ public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) {
+ intermediateCalls.incrementAndGet();
+ return delegate.issueIntermediateCertificate(issuance);
+ }
+ }
+
+ private static CredentialIssuerBackend extensionVariantBackend(
+ CredentialIssuerBackend delegate, KeyPair issuerKey,
+ IntermediateExtensionVariant variant) {
+ return new CredentialIssuerBackend() {
+ @Override
+ public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) {
+ return delegate.issueEndEntity(candidate);
+ }
+
+ @Override
+ public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) {
+ Credential credential = delegate.issueIntermediateCertificate(issuance);
+ return rebuildIntermediateExtensions(credential, issuerKey, variant);
+ }
+ };
+ }
+
+ private static Credential rebuildIntermediateIdentity(Credential credential, KeyPair issuerKey,
+ Optional subjectPublicKey, Optional subjectName) {
+ try {
+ X509CertificateHolder original = new X509CertificateHolder(credential.encoded().bytes());
+ X500Name subject = subjectName.map(X500Name::new).orElse(original.getSubject());
+ org.bouncycastle.asn1.x509.SubjectPublicKeyInfo publicKeyInfo = subjectPublicKey
+ .map(key -> org.bouncycastle.asn1.x509.SubjectPublicKeyInfo.getInstance(key.getEncoded()))
+ .orElse(original.getSubjectPublicKeyInfo());
+ X509v3CertificateBuilder builder = new X509v3CertificateBuilder(original.getIssuer(),
+ original.getSerialNumber(), original.getNotBefore(), original.getNotAfter(), subject,
+ publicKeyInfo);
+ builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(0));
+ builder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign));
+ ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(issuerKey.getPrivate());
+ byte[] encoded = builder.build(signer).getEncoded();
+ return new Credential(new PkiId("x509:" + sha256Hex(encoded)), credential.formatId(),
+ credential.issuerRef(), new SubjectRef(subject.toString()), credential.validity(),
+ credential.serialOrUniqueId(), new PkiId("spki:" + sha256Hex(publicKeyInfo.getEncoded())),
+ credential.profileId(), credential.status(), new EncodedObject(Encoding.DER, encoded),
+ credential.attributes());
+ } catch (Exception ex) {
+ throw new PkiException("Failed to create adversarial intermediate identity", ex);
+ }
+ }
+
+ private static Credential rebuildIntermediateExtensions(Credential credential, KeyPair issuerKey,
+ IntermediateExtensionVariant variant) {
+ try {
+ X509CertificateHolder original = new X509CertificateHolder(credential.encoded().bytes());
+ X509v3CertificateBuilder builder = new X509v3CertificateBuilder(original.getIssuer(),
+ original.getSerialNumber(), original.getNotBefore(), original.getNotAfter(), original.getSubject(),
+ original.getSubjectPublicKeyInfo());
+ if (variant != IntermediateExtensionVariant.ABSENT_BASIC_CONSTRAINTS) {
+ boolean critical = variant != IntermediateExtensionVariant.NONCRITICAL_BASIC_CONSTRAINTS;
+ BasicConstraints constraints = variant == IntermediateExtensionVariant.UNLIMITED_BASIC_CONSTRAINTS
+ ? new BasicConstraints(true)
+ : new BasicConstraints(0);
+ builder.addExtension(Extension.basicConstraints, critical, constraints);
+ }
+ if (variant != IntermediateExtensionVariant.ABSENT_KEY_USAGE) {
+ boolean critical = variant != IntermediateExtensionVariant.NONCRITICAL_KEY_USAGE;
+ int usages = KeyUsage.keyCertSign | KeyUsage.cRLSign;
+ if (variant == IntermediateExtensionVariant.INCOMPATIBLE_KEY_USAGE) {
+ usages |= KeyUsage.digitalSignature;
+ }
+ builder.addExtension(Extension.keyUsage, critical, new KeyUsage(usages));
+ }
+ ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(issuerKey.getPrivate());
+ byte[] encoded = builder.build(signer).getEncoded();
+ return new Credential(new PkiId("x509:" + sha256Hex(encoded)), credential.formatId(),
+ credential.issuerRef(), credential.subjectRef(), credential.validity(),
+ credential.serialOrUniqueId(), credential.publicKeyId(), credential.profileId(),
+ credential.status(), new EncodedObject(Encoding.DER, encoded), credential.attributes());
+ } catch (Exception ex) {
+ throw new PkiException("Failed to create adversarial intermediate certificate", ex);
+ }
+ }
+
+ private static ParsedCertificationRequest parse(PkiTestRuntime runtime, PKCS10CertificationRequest csr)
+ throws Exception {
+ return runtime.certificationRequestService()
+ .parse(new CertificationRequest(runtime.framework().formatId(),
+ new EncodedObject(Encoding.DER, csr.getEncoded())));
+ }
+
+ private static PKCS10CertificationRequest makeCsr(KeyPair publicKeyPair, KeyPair signingKeyPair, String dn)
+ throws Exception {
+ return makeCsr(publicKeyPair, signingKeyPair, dn, "SHA256withRSA");
+ }
+
+ private static PKCS10CertificationRequest makeCsr(KeyPair publicKeyPair, KeyPair signingKeyPair, String dn,
+ String algorithm) throws Exception {
+ PKCS10CertificationRequestBuilder builder = new JcaPKCS10CertificationRequestBuilder(new X500Name(dn),
+ publicKeyPair.getPublic());
+ JcaContentSignerBuilder signerBuilder = new JcaContentSignerBuilder(algorithm);
+ if ("SHA256withRSAandMGF1".equals(algorithm)) {
+ signerBuilder.setProvider(new BouncyCastleProvider());
+ }
+ ContentSigner signer = signerBuilder.build(signingKeyPair.getPrivate());
+ return builder.build(signer);
+ }
+
+ private static KeyPair generateRsa() throws Exception {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(2048);
+ return generator.generateKeyPair();
+ }
+
+ private static String sha256Hex(byte[] value) {
+ try {
+ return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value));
+ } catch (Exception ex) {
+ throw new IllegalStateException("SHA-256 unavailable", ex);
+ }
+ }
+
+ private static byte[] csrDer(ParsedCertificationRequest request) {
+ return ((AttributeValue.BytesValue) request.attributes().get(BcX509Attributes.CSR_DER).orElseThrow()).value();
+ }
+
+ private static ParsedCertificationRequest withCsr(ParsedCertificationRequest source, byte[] csrDer) {
+ AttributeSet attributes = SimpleAttributeSet.builder()
+ .put(BcX509Attributes.CSR_DER, new AttributeValue.BytesValue(csrDer)).build();
+ return withAttributes(source, attributes);
+ }
+
+ private static ParsedCertificationRequest withAttributes(ParsedCertificationRequest source,
+ AttributeSet attributes) {
+ return new ParsedCertificationRequest(source.requestId(), source.formatId(), source.subjectRef(),
+ source.publicKeyInfo(), source.requestedValidity(), source.requestedProfileId(), attributes);
+ }
+
+ private static void assertRejected(PkiTestRuntime runtime, ParsedCertificationRequest request, String code) {
+ int before = runtime.auditSink().snapshot().size();
+ assertThrows(PkiException.class, () -> issue(runtime, request));
+ assertEquals(before + 1, runtime.auditSink().snapshot().size());
+ assertEquals(code, runtime.auditSink().snapshot().get(before).details().get("code"));
+ }
+
+ private static void issue(PkiTestRuntime runtime, ParsedCertificationRequest request) {
+ runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(new PkiId("ca:absent"), request, "default",
+ Optional.empty(), new SimpleAttributeSet()));
+ }
+
+ private static AttributeSet hostileIntermediateAttributes(PublicKey wrongPublicKey) {
+ return SimpleAttributeSet.builder()
+ .put(BcX509Attributes.ISSUER_CERT_DER, new AttributeValue.BytesValue(new byte[] { 0x01 }))
+ .put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue("attacker-key"))
+ .put(BcX509Attributes.SUBJECT_SPKI_DER,
+ new AttributeValue.BytesValue(wrongPublicKey.getEncoded()))
+ .put(BcX509Attributes.SUBJECT_DN, new AttributeValue.StringValue("CN=Attacker")).build();
+ }
+
+ private static void assertThrowableRedacted(Throwable failure, String sentinel) {
+ Throwable current = failure;
+ while (current != null) {
+ assertFalse(String.valueOf(current.getMessage()).contains(sentinel));
+ for (Throwable suppressed : current.getSuppressed()) {
+ assertThrowableRedacted(suppressed, sentinel);
+ }
+ current = current.getCause();
+ }
+ }
+}
diff --git a/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusFailureTest.java b/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusFailureTest.java
new file mode 100644
index 0000000..dcb8815
--- /dev/null
+++ b/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusFailureTest.java
@@ -0,0 +1,1136 @@
+/*******************************************************************************
+ * 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.core.async;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Proxy;
+import java.nio.file.Path;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.time.Duration;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import zeroecho.pki.api.EncodedObject;
+import zeroecho.pki.api.Encoding;
+import zeroecho.pki.api.KeyRef;
+import zeroecho.pki.api.PkiId;
+import zeroecho.pki.api.audit.AccessContext;
+import zeroecho.pki.api.audit.Principal;
+import zeroecho.pki.api.audit.Purpose;
+import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy;
+import zeroecho.pki.api.orch.WorkflowStateRecord;
+import zeroecho.pki.impl.fs.FilesystemPkiStore;
+import zeroecho.pki.impl.fs.FsPkiStoreOptions;
+import zeroecho.pki.api.PkiException;
+import zeroecho.pki.spi.crypto.SignatureWorkflow;
+import zeroecho.pki.spi.store.PkiStore;
+import zeroecho.pki.spi.store.SignWorkflowStore;
+import zeroecho.pki.testkit.InMemorySignatureWorkflow;
+import zeroecho.pki.util.async.AsyncState;
+
+final class PkiSigningBusFailureTest {
+
+ @Test
+ void acceptedCancellationWaitsForObservedTerminalProviderState(@TempDir Path tempDir) throws Exception {
+ AcceptedDelayedCancellationWorkflow signer = new AcceptedDelayedCancellationWorkflow();
+ try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"),
+ FsPkiStoreOptions.defaults());
+ PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"))) {
+ PkiId id = submit(bus);
+ assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state());
+
+ bus.retireSignOperation(id, "accepted cancellation");
+
+ assertEquals(SignWorkflowStore.State.CANCELLING, store.getSignRecord(id).orElseThrow().state());
+ assertTrue(store.getSignRecord(id).orElseThrow().result().isEmpty());
+ assertEquals(1, signer.cancellations.get());
+
+ signer.completeSuccessfully(id);
+ bus.retireSignOperation(id, "provider won cancellation race");
+
+ SignWorkflowStore.Record retired = store.getSignRecord(id).orElseThrow();
+ assertEquals(SignWorkflowStore.State.RETIRED, retired.state());
+ assertArrayEquals(new byte[] { 21 }, retired.result().orElseThrow().bytes());
+ assertEquals(1, signer.cancellations.get());
+ }
+ }
+
+ @Test
+ void deadlineAdvisoryStillRequiresProviderCancellationBeforeRetirement(@TempDir Path tempDir)
+ throws Exception {
+ Instant createdAt = Instant.parse("2026-06-07T08:09:10Z");
+ MutableClock clock = new MutableClock(createdAt);
+ ControlledWorkflow signer = new ControlledWorkflow(clock);
+ try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"),
+ FsPkiStoreOptions.defaults(), clock);
+ PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"))) {
+ PkiId id = submit(bus, Duration.ofSeconds(10));
+ assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state());
+ clock.set(createdAt.plusSeconds(10));
+
+ assertEquals(AsyncState.EXPIRED, bus.status(id).orElseThrow().state());
+ assertEquals(SignWorkflowStore.State.DISPATCHED, store.getSignRecord(id).orElseThrow().state());
+
+ bus.retireSignOperation(id, "deadline elapsed");
+
+ assertEquals(1, signer.cancellations.get());
+ assertEquals(SignWorkflowStore.State.RETIRED, store.getSignRecord(id).orElseThrow().state());
+ }
+ }
+
+ @Test
+ void providerCompletionTimestampControlsDeadlineAcrossRestart(@TempDir Path tempDir) throws Exception {
+ Instant createdAt = Instant.parse("2026-06-07T08:09:10Z");
+ Instant deadline = createdAt.plusSeconds(10);
+ MutableClock clock = new MutableClock(createdAt);
+ ControlledWorkflow signer = new ControlledWorkflow(clock);
+ Path storeRoot = tempDir.resolve("store");
+ Path busLog = tempDir.resolve("bus.log");
+ PkiId exact;
+ PkiId late;
+ try (FilesystemPkiStore store = new FilesystemPkiStore(storeRoot, FsPkiStoreOptions.defaults(), clock);
+ PkiSigningBus bus = new PkiSigningBus(store, signer, busLog)) {
+ PkiId onTime = submit(bus, Duration.ofSeconds(10));
+ assertEquals(AsyncState.RUNNING, bus.status(onTime).orElseThrow().state());
+ signer.succeedAt(onTime, deadline.minusNanos(1), (byte) 31);
+ assertEquals(AsyncState.SUCCEEDED, bus.status(onTime).orElseThrow().state());
+ assertEquals(31, bus.consumeResult(onTime).orElseThrow().bytes()[0]);
+
+ exact = submit(bus, Duration.ofSeconds(10));
+ assertEquals(AsyncState.RUNNING, bus.status(exact).orElseThrow().state());
+ signer.succeedAt(exact, deadline, (byte) 32);
+ assertEquals(AsyncState.EXPIRED, bus.status(exact).orElseThrow().state());
+ assertTrue(bus.consumeResult(exact).isEmpty());
+ assertEquals(deadline, store.getSignRecord(exact).orElseThrow().providerUpdatedAt().orElseThrow());
+
+ late = submit(bus, Duration.ofSeconds(10));
+ assertEquals(AsyncState.RUNNING, bus.status(late).orElseThrow().state());
+ signer.succeedAt(late, deadline.plusNanos(1), (byte) 33);
+ assertEquals(AsyncState.EXPIRED, bus.status(late).orElseThrow().state());
+ assertTrue(bus.consumeResult(late).isEmpty());
+ }
+
+ try (FilesystemPkiStore reopened = new FilesystemPkiStore(storeRoot, FsPkiStoreOptions.defaults(), clock);
+ PkiSigningBus replayed = new PkiSigningBus(reopened, signer, busLog)) {
+ assertEquals(AsyncState.EXPIRED, replayed.status(exact).orElseThrow().state());
+ assertTrue(replayed.consumeResult(exact).isEmpty());
+ assertEquals(AsyncState.EXPIRED, replayed.status(late).orElseThrow().state());
+ assertTrue(replayed.consumeResult(late).isEmpty());
+ replayed.retireSignOperation(late, "late completion cleanup");
+ assertEquals(SignWorkflowStore.State.RETIRED, reopened.getSignRecord(late).orElseThrow().state());
+ }
+ }
+
+ @Test
+ void rejectedCancellationReconcilesProviderSuccessBeforeRetirement(@TempDir Path tempDir) throws Exception {
+ CancelRejectedAfterCompletionWorkflow signer = new CancelRejectedAfterCompletionWorkflow();
+ Path storeRoot = tempDir.resolve("store");
+ Path busLog = tempDir.resolve("bus.log");
+ PkiId id;
+ try (FilesystemPkiStore store = new FilesystemPkiStore(storeRoot, FsPkiStoreOptions.defaults());
+ PkiSigningBus bus = new PkiSigningBus(store, signer, busLog)) {
+ id = submit(bus);
+ assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state());
+ bus.retireSignOperation(id, "provider completed");
+ SignWorkflowStore.Record retired = store.getSignRecord(id).orElseThrow();
+ assertEquals(SignWorkflowStore.State.RETIRED, retired.state());
+ assertArrayEquals(new byte[] { 11, 12 }, retired.result().orElseThrow().bytes());
+ assertEquals(1, signer.cancellations.get());
+ }
+ try (FilesystemPkiStore reopened = new FilesystemPkiStore(storeRoot, FsPkiStoreOptions.defaults());
+ PkiSigningBus replayed = new PkiSigningBus(reopened, signer, busLog)) {
+ assertEquals(AsyncState.SUCCEEDED, replayed.status(id).orElseThrow().state());
+ assertArrayEquals(new byte[] { 11, 12 }, replayed.consumeResult(id).orElseThrow().bytes());
+ }
+ }
+
+ @Test
+ void providerCallbackReturnsWhileSameOperationSubmissionIsBlocked(@TempDir Path tempDir) throws Exception {
+ BlockingWorkflow signer = new BlockingWorkflow();
+ try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"),
+ FsPkiStoreOptions.defaults());
+ PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"));
+ ExecutorService executor = Executors.newFixedThreadPool(2)) {
+ PkiId id = submit(bus);
+ Future> submission = executor.submit(() -> bus.status(id));
+ assertTrue(signer.entered.await(5, TimeUnit.SECONDS));
+ Future> callback = executor.submit(() -> signer.completeAndNotify(id));
+ callback.get(5, TimeUnit.SECONDS);
+ signer.release.countDown();
+ submission.get(5, TimeUnit.SECONDS);
+ assertEquals(AsyncState.SUCCEEDED, bus.status(id).orElseThrow().state());
+ assertArrayEquals(new byte[] { 13 }, bus.consumeResult(id).orElseThrow().bytes());
+ }
+ }
+
+ @Test
+ void cancellingOperationReplaysAfterRestart(@TempDir Path tempDir) throws Exception {
+ RetirementRaceWorkflow signer = new RetirementRaceWorkflow();
+ Path storeRoot = tempDir.resolve("store");
+ Path busLog = tempDir.resolve("bus.log");
+ PkiId id;
+ try (FilesystemPkiStore store = new FilesystemPkiStore(storeRoot, FsPkiStoreOptions.defaults());
+ PkiSigningBus bus = new PkiSigningBus(store, signer, busLog)) {
+ id = submit(bus);
+ assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state());
+ SignWorkflowStore.Record dispatched = store.getSignRecord(id).orElseThrow();
+ store.transitionSign(id, dispatched.revision(), dispatched.fence(),
+ SignWorkflowStore.State.CANCELLING, Optional.of("CANCEL_REQUESTED"), Optional.empty(),
+ Optional.empty())
+ .orElseThrow();
+ }
+ try (FilesystemPkiStore reopened = new FilesystemPkiStore(storeRoot, FsPkiStoreOptions.defaults());
+ PkiSigningBus replayed = new PkiSigningBus(reopened, signer, busLog)) {
+ assertEquals(AsyncState.RUNNING, replayed.status(id).orElseThrow().state());
+ replayed.retireSignOperation(id, "restart cancellation");
+ assertEquals(SignWorkflowStore.State.RETIRED, reopened.getSignRecord(id).orElseThrow().state());
+ assertEquals(1, signer.cancellations.get());
+ }
+ }
+
+ @Test
+ void retirementReconcilesCompletionAndAdvisoryCallbackWithoutResultLoss(@TempDir Path tempDir) throws Exception {
+ RetirementRaceWorkflow signer = new RetirementRaceWorkflow();
+ try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"),
+ FsPkiStoreOptions.defaults());
+ PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"));
+ ExecutorService executor = Executors.newFixedThreadPool(2)) {
+ PkiId id = submit(bus);
+ assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state());
+ signer.armStatusBlock();
+
+ Future> retirement = executor.submit(() -> bus.retireSignOperation(id, "completed concurrently"));
+ assertTrue(signer.statusEntered.await(5, TimeUnit.SECONDS));
+ Future> callback = executor.submit(signer::completeAndNotify);
+ retirement.get(5, TimeUnit.SECONDS);
+ callback.get(5, TimeUnit.SECONDS);
+
+ assertEquals(SignWorkflowStore.State.RETIRED, store.getSignRecord(id).orElseThrow().state());
+ assertEquals(7, store.getSignRecord(id).orElseThrow().result().orElseThrow().bytes()[0]);
+ assertEquals(AsyncState.SUCCEEDED, bus.status(id).orElseThrow().state());
+ assertEquals(7, bus.consumeResult(id).orElseThrow().bytes()[0]);
+ assertEquals(1, signer.submissions.get());
+ assertEquals(0, signer.cancellations.get());
+ }
+ }
+
+ @Test
+ void pollAndRetirementSerializeAndPreserveProviderCompletion(@TempDir Path tempDir) throws Exception {
+ RetirementRaceWorkflow signer = new RetirementRaceWorkflow();
+ try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"),
+ FsPkiStoreOptions.defaults());
+ PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"));
+ ExecutorService executor = Executors.newFixedThreadPool(2)) {
+ PkiId id = submit(bus);
+ assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state());
+ signer.armStatusBlock();
+
+ Future> poll = executor.submit(() -> bus.status(id));
+ assertTrue(signer.statusEntered.await(5, TimeUnit.SECONDS));
+ Future> retirement = executor.submit(() -> bus.retireSignOperation(id, "poll race"));
+ signer.completeWithoutNotification();
+ poll.get(5, TimeUnit.SECONDS);
+ retirement.get(5, TimeUnit.SECONDS);
+
+ assertEquals(7, bus.consumeResult(id).orElseThrow().bytes()[0]);
+ assertEquals(1, signer.submissions.get());
+ assertEquals(0, signer.cancellations.get());
+ }
+ }
+
+ @Test
+ void consumeAndRetirementShareCoordinatorWithoutResultLoss(@TempDir Path tempDir) throws Exception {
+ RetirementRaceWorkflow signer = new RetirementRaceWorkflow();
+ try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"),
+ FsPkiStoreOptions.defaults());
+ PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"));
+ ExecutorService executor = Executors.newFixedThreadPool(2)) {
+ PkiId id = submit(bus);
+ assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state());
+ signer.completeWithoutNotification();
+ CountDownLatch start = new CountDownLatch(1);
+ Future> consumed = executor.submit(() -> {
+ start.await();
+ return bus.consumeResult(id);
+ });
+ Future> retirement = executor.submit(() -> {
+ start.await();
+ bus.retireSignOperation(id, "consume race");
+ return null;
+ });
+ start.countDown();
+ retirement.get(5, TimeUnit.SECONDS);
+ Optional concurrentObservation = consumed.get(5, TimeUnit.SECONDS);
+ concurrentObservation.ifPresent(result -> assertEquals(7, result.bytes()[0]));
+ assertEquals(7, bus.consumeResult(id).orElseThrow().bytes()[0]);
+ assertEquals(1, signer.submissions.get());
+ }
+ }
+
+ @Test
+ void preDispatchExpiryMakesNoProviderCallAndPurgesOnlyAfterRetirement(@TempDir Path tempDir)
+ throws Exception {
+ Instant createdAt = Instant.parse("2026-05-06T07:08:09Z");
+ MutableClock clock = new MutableClock(createdAt);
+ ControlledWorkflow signer = new ControlledWorkflow(clock);
+ try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"),
+ FsPkiStoreOptions.defaults(), clock);
+ PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"))) {
+ PkiId id = submit(bus, Duration.ofSeconds(10));
+ assertEquals(1, store.listWorkflowStates().size());
+ clock.set(createdAt.plusSeconds(10));
+ bus.sweep(clock.instant());
+ assertEquals(0, signer.submissions.get());
+ assertEquals(SignWorkflowStore.State.RETIRED, store.getSignRecord(id).orElseThrow().state());
+ assertTrue(bus.consumeResult(id).isEmpty());
+ assertTrue(store.listWorkflowStates().isEmpty());
+
+ clock.set(createdAt.plus(Duration.ofDays(90)));
+ bus.sweep(clock.instant());
+ assertTrue(store.listSignRecords().isEmpty());
+ assertEquals(AsyncState.EXPIRED, bus.status(id).orElseThrow().state());
+ }
+ }
+
+ @Test
+ void postDispatchExpiryRetainsAuthorityUntilProviderTerminalReconciliation(@TempDir Path tempDir)
+ throws Exception {
+ Instant createdAt = Instant.parse("2026-05-06T07:08:09Z");
+ MutableClock clock = new MutableClock(createdAt);
+ AcceptedDelayedCancellationWorkflow signer = new AcceptedDelayedCancellationWorkflow(clock);
+ try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"),
+ FsPkiStoreOptions.defaults(), clock);
+ PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"))) {
+ PkiId id = submit(bus, Duration.ofSeconds(10));
+ assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state());
+ assertEquals(1, signer.submissions.get());
+
+ clock.set(createdAt.plusSeconds(10));
+ bus.sweep(clock.instant());
+ assertEquals(SignWorkflowStore.State.CANCELLING, store.getSignRecord(id).orElseThrow().state());
+ assertEquals("CANCEL_SUBMITTED",
+ store.getSignRecord(id).orElseThrow().detailCode().orElseThrow());
+ assertEquals(1, signer.cancellations.get());
+ assertEquals(1, store.listWorkflowStates().size());
+ assertTrue(bus.consumeResult(id).isEmpty());
+
+ bus.sweep(clock.instant());
+ assertEquals(1, signer.cancellations.get());
+ assertEquals(SignWorkflowStore.State.CANCELLING, store.getSignRecord(id).orElseThrow().state());
+
+ signer.completeCancelled(id, clock.instant());
+ bus.sweep(clock.instant());
+ assertEquals(SignWorkflowStore.State.RETIRED, store.getSignRecord(id).orElseThrow().state());
+ assertTrue(store.listWorkflowStates().isEmpty());
+ assertTrue(bus.consumeResult(id).isEmpty());
+
+ clock.set(createdAt.plus(Duration.ofDays(90)));
+ bus.sweep(clock.instant());
+ assertTrue(store.listSignRecords().isEmpty());
+ }
+ }
+
+ @Test
+ void blockedProviderCannotRedispatchAfterLeaseExpiry(@TempDir Path tempDir) throws Exception {
+ MutableClock clock = new MutableClock(Instant.parse("2026-05-06T07:08:09Z"));
+ BlockingWorkflow signer = new BlockingWorkflow();
+ try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"),
+ FsPkiStoreOptions.defaults(), clock);
+ PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"));
+ ExecutorService executor = Executors.newFixedThreadPool(2)) {
+ Principal owner = new Principal("TEST", "owner");
+ PkiId id = bus.newSubmissionId();
+ EncodedObject payload = new EncodedObject(Encoding.BINARY, new byte[] { 1 });
+ AccessContext access = new AccessContext(owner, new Purpose("TEST"), Optional.empty(), Optional.empty());
+ PkiSigningBus.SignContinuation continuation = new PkiSigningBus.SignContinuation(access,
+ "SHA256withRSA", payload, new KeyRef("test"), Encoding.BINARY, Optional.empty());
+ bus.submitSign(id, owner, new KeyRef("test"), "SHA256withRSA", payload, Duration.ofMinutes(5),
+ Optional.of(continuation.encode()));
+ Future> first = executor.submit(() -> bus.status(id));
+ assertTrue(signer.entered.await(5, TimeUnit.SECONDS));
+ clock.set(clock.instant().plusSeconds(60));
+ CountDownLatch secondStarted = new CountDownLatch(1);
+ Future> second = executor.submit(() -> {
+ secondStarted.countDown();
+ return bus.status(id);
+ });
+ assertTrue(secondStarted.await(5, TimeUnit.SECONDS));
+ assertEquals(1, signer.submissions.get());
+ second.get(5, TimeUnit.SECONDS);
+ signer.release.countDown();
+ first.get(5, TimeUnit.SECONDS);
+ assertEquals(1, signer.submissions.get());
+ }
+ }
+
+ @Test
+ void providerCallsPermitCrossThreadReentryOutsideOperationLock(@TempDir Path tempDir) throws Exception {
+ CrossThreadReentrantWorkflow signer = new CrossThreadReentrantWorkflow();
+ try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"),
+ FsPkiStoreOptions.defaults());
+ PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"))) {
+ signer.attach(bus);
+ PkiId id = submit(bus);
+ assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state());
+ bus.retireSignOperation(id, "reentrant cancellation");
+ assertEquals(SignWorkflowStore.State.RETIRED, store.getSignRecord(id).orElseThrow().state());
+ assertEquals(1, signer.submissions.get());
+ assertTrue(signer.statusReads.get() >= 2);
+ assertEquals(1, signer.cancellations.get());
+ } finally {
+ signer.close();
+ }
+ }
+
+ @Test
+ void blockedSubmissionDoesNotBlockAnotherOperation(@TempDir Path tempDir) throws Exception {
+ SelectiveBlockingWorkflow signer = new SelectiveBlockingWorkflow();
+ try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"),
+ FsPkiStoreOptions.defaults());
+ PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"));
+ ExecutorService executor = Executors.newFixedThreadPool(2)) {
+ PkiId blocked = submit(bus);
+ Future> first = executor.submit(() -> bus.status(blocked));
+ assertTrue(signer.blockedEntered.await(5, TimeUnit.SECONDS));
+
+ PkiId independent = submit(bus);
+ Future> second =
+ executor.submit(() -> bus.status(independent));
+ assertEquals(AsyncState.RUNNING, second.get(5, TimeUnit.SECONDS).orElseThrow().state());
+ assertEquals(2, signer.submissions.get());
+
+ signer.blockedRelease.countDown();
+ first.get(5, TimeUnit.SECONDS);
+ }
+ }
+
+ @Test
+ void providerStatusExceptionReleasesSingleFlightReservation(@TempDir Path tempDir) throws Exception {
+ ThrowOnceStatusWorkflow signer = new ThrowOnceStatusWorkflow();
+ try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"),
+ FsPkiStoreOptions.defaults());
+ PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"))) {
+ PkiId id = submit(bus);
+ PkiException sanitized = assertThrows(PkiException.class, () -> bus.status(id));
+ assertTrue(sanitized.getMessage().contains("PROVIDER_STATUS_FAILED"));
+ assertFalse(sanitized.getMessage().contains("injected status failure"));
+ assertNull(sanitized.getCause());
+ assertEquals(0, sanitized.getSuppressed().length);
+ assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state());
+ assertEquals(2, signer.statusReads.get());
+ }
+ }
+
+ @Test
+ void unsupportedContinuationVersionCannotActivate(@TempDir Path tempDir) throws Exception {
+ try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"),
+ FsPkiStoreOptions.defaults())) {
+ Principal owner = new Principal("SYSTEM", "pki");
+ Instant now = Instant.now();
+ EncodedObject unsupported = new EncodedObject(Encoding.BINARY, new byte[] { 99 });
+ store.putWorkflowState(new WorkflowStateRecord(new PkiId("unsupported-sign"),
+ PkiSigningBus.TYPE_SIGN, owner,
+ OrchestrationDurabilityPolicy.DURABLE_MIN_STATE, now, now, now.plusSeconds(60), Encoding.BINARY,
+ Optional.of(unsupported)));
+ InMemorySignatureWorkflow signer = new InMemorySignatureWorkflow(Map.of(), false);
+ try {
+ assertThrows(PkiException.class,
+ () -> new PkiSigningBus(store, signer, tempDir.resolve("unsupported-bus.log")));
+ } finally {
+ signer.close();
+ }
+ }
+ }
+
+ @Test
+ void advisoryProjectionFailureDoesNotOverrideAuthoritativeWorkflow(@TempDir Path tempDir) throws Exception {
+ FilesystemPkiStore delegate = new FilesystemPkiStore(tempDir.resolve("store"), FsPkiStoreOptions.defaults());
+ AtomicInteger writes = new AtomicInteger();
+ PkiStore failingStore = (PkiStore) Proxy.newProxyInstance(PkiStore.class.getClassLoader(),
+ new Class>[] { PkiStore.class }, (proxy, method, arguments) -> {
+ if ("putWorkflowState".equals(method.getName()) && writes.incrementAndGet() == 1) {
+ throw new IllegalStateException("injected continuation failure");
+ }
+ try {
+ return method.invoke(delegate, arguments);
+ } catch (InvocationTargetException ex) {
+ throw ex.getCause();
+ }
+ });
+
+ KeyPair keyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
+ KeyRef keyRef = new KeyRef("kref:v1:keyring:test:failure");
+ InMemorySignatureWorkflow signer = new InMemorySignatureWorkflow(Map.of(keyRef.value(), keyPair), false);
+ Path busLog = tempDir.resolve("bus.log");
+ Principal owner = new Principal("SYSTEM", "pki");
+ PkiId operationId;
+ try (PkiSigningBus bus = new PkiSigningBus(failingStore, signer, busLog)) {
+ operationId = bus.canonicalizeOperationId(new PkiId("sign:failure"), owner);
+ EncodedObject payload = new EncodedObject(Encoding.BINARY, new byte[] { 1, 2, 3 });
+ AccessContext access = new AccessContext(owner, new Purpose("TEST"), Optional.empty(), Optional.empty());
+ PkiSigningBus.SignContinuation continuation = new PkiSigningBus.SignContinuation(access,
+ "SHA256withRSA", payload, keyRef, Encoding.BINARY, Optional.empty());
+ bus.submitSign(operationId, owner, keyRef, "SHA256withRSA", payload, Duration.ofSeconds(5),
+ Optional.of(continuation.encode()));
+ bus.sweep(java.time.Instant.now());
+
+ assertEquals(AsyncState.RUNNING, bus.status(operationId).orElseThrow().state());
+ assertTrue(delegate.listWorkflowStates().isEmpty());
+ assertTrue(signer.hasRunningOperations());
+ bus.retireSignOperation(operationId, "test complete");
+ assertFalse(signer.hasRunningOperations());
+ }
+
+ try (PkiSigningBus replayed = new PkiSigningBus(delegate, signer, busLog)) {
+ assertEquals(AsyncState.CANCELLED, replayed.status(operationId).orElseThrow().state());
+ } finally {
+ signer.close();
+ delegate.close();
+ }
+ }
+
+ private static PkiId submit(PkiSigningBus bus) {
+ return submit(bus, Duration.ofMinutes(5));
+ }
+
+ private static PkiId submit(PkiSigningBus bus, Duration ttl) {
+ Principal owner = new Principal("TEST", "owner");
+ PkiId id = bus.newSubmissionId();
+ EncodedObject payload = new EncodedObject(Encoding.BINARY, new byte[] { 1 });
+ AccessContext access = new AccessContext(owner, new Purpose("TEST"), Optional.empty(), Optional.empty());
+ PkiSigningBus.SignContinuation continuation = new PkiSigningBus.SignContinuation(access,
+ "SHA256withRSA", payload, new KeyRef("test"), Encoding.BINARY, Optional.empty());
+ bus.submitSign(id, owner, new KeyRef("test"), "SHA256withRSA", payload, ttl,
+ Optional.of(continuation.encode()));
+ return id;
+ }
+
+ private static final class AcceptedDelayedCancellationWorkflow implements SignatureWorkflow {
+ private final Clock clock;
+ private final Map statuses = new java.util.concurrent.ConcurrentHashMap<>();
+ private final AtomicInteger submissions = new AtomicInteger();
+ private final AtomicInteger cancellations = new AtomicInteger();
+
+ private AcceptedDelayedCancellationWorkflow() {
+ this(Clock.systemUTC());
+ }
+
+ private AcceptedDelayedCancellationWorkflow(Clock clock) {
+ this.clock = clock;
+ }
+
+ @Override
+ public String id() {
+ return "accepted-delayed-cancellation";
+ }
+
+ @Override
+ public PkiId submitSign(SignRequest request) {
+ submissions.incrementAndGet();
+ statuses.put(request.submissionId(), new OperationStatus(State.RUNNING, clock.instant(),
+ Optional.of("RUNNING"), Optional.empty()));
+ return request.submissionId();
+ }
+
+ @Override
+ public PkiId submitVerify(VerifyRequest request) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public OperationStatus status(PkiId operationId) {
+ return statuses.get(operationId);
+ }
+
+ @Override
+ public boolean cancel(PkiId operationId, long fencingToken, String reason) {
+ cancellations.incrementAndGet();
+ return true;
+ }
+
+ @Override
+ public Registration register(NotificationSink sink) {
+ return () -> {
+ // Polling is authoritative.
+ };
+ }
+
+ @Override
+ public Set supportedAlgorithms() {
+ return Set.of("SHA256withRSA");
+ }
+
+ @Override
+ public void close() {
+ // No owned resources.
+ }
+
+ private void completeSuccessfully(PkiId operationId) {
+ OperationResult result = new OperationResult(
+ Optional.of(new EncodedObject(Encoding.BINARY, new byte[] { 21 })), Optional.empty());
+ statuses.put(operationId, new OperationStatus(State.SUCCEEDED, Instant.now(), Optional.of("SIGNED"),
+ Optional.of(result)));
+ }
+
+ private void completeCancelled(PkiId operationId, Instant completedAt) {
+ statuses.put(operationId, new OperationStatus(State.CANCELLED, completedAt,
+ Optional.of("CANCELLED"), Optional.empty()));
+ }
+ }
+
+ private static final class ControlledWorkflow implements SignatureWorkflow {
+ private final Clock clock;
+ private final Map statuses = new java.util.concurrent.ConcurrentHashMap<>();
+ private final AtomicInteger submissions = new AtomicInteger();
+ private final AtomicInteger cancellations = new AtomicInteger();
+
+ private ControlledWorkflow(Clock clock) {
+ this.clock = clock;
+ }
+
+ @Override
+ public String id() {
+ return "controlled";
+ }
+
+ @Override
+ public PkiId submitSign(SignRequest request) {
+ submissions.incrementAndGet();
+ statuses.put(request.submissionId(), new OperationStatus(State.RUNNING, clock.instant(),
+ Optional.of("RUNNING"), Optional.empty()));
+ return request.submissionId();
+ }
+
+ @Override
+ public PkiId submitVerify(VerifyRequest request) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public OperationStatus status(PkiId operationId) {
+ return statuses.get(operationId);
+ }
+
+ @Override
+ public boolean cancel(PkiId operationId, long fencingToken, String reason) {
+ cancellations.incrementAndGet();
+ statuses.put(operationId, new OperationStatus(State.CANCELLED, clock.instant(),
+ Optional.of("CANCELLED"), Optional.empty()));
+ return true;
+ }
+
+ @Override
+ public Registration register(NotificationSink sink) {
+ return () -> {
+ // Polling is authoritative.
+ };
+ }
+
+ @Override
+ public Set supportedAlgorithms() {
+ return Set.of("SHA256withRSA");
+ }
+
+ @Override
+ public void close() {
+ // No owned resources.
+ }
+
+ private void succeedAt(PkiId operationId, Instant completedAt, byte value) {
+ OperationResult result = new OperationResult(
+ Optional.of(new EncodedObject(Encoding.BINARY, new byte[] { value })), Optional.empty());
+ statuses.put(operationId, new OperationStatus(State.SUCCEEDED, completedAt, Optional.of("SIGNED"),
+ Optional.of(result)));
+ }
+ }
+
+ private static final class RetirementRaceWorkflow implements SignatureWorkflow {
+ private final AtomicReference status = new AtomicReference<>(
+ new OperationStatus(State.RUNNING, Instant.EPOCH, Optional.of("RUNNING"), Optional.empty()));
+ private final AtomicReference operationId = new AtomicReference<>();
+ private final AtomicReference sink = new AtomicReference<>();
+ private final AtomicBoolean blockStatus = new AtomicBoolean();
+ private final CountDownLatch statusEntered = new CountDownLatch(1);
+ private final CountDownLatch statusRelease = new CountDownLatch(1);
+ private final AtomicInteger submissions = new AtomicInteger();
+ private final AtomicInteger cancellations = new AtomicInteger();
+
+ @Override
+ public String id() {
+ return "retirement-race";
+ }
+
+ @Override
+ public PkiId submitSign(SignRequest request) {
+ submissions.incrementAndGet();
+ operationId.set(request.submissionId());
+ status.set(new OperationStatus(State.RUNNING, Instant.now(), Optional.of("RUNNING"), Optional.empty()));
+ return request.submissionId();
+ }
+
+ @Override
+ public PkiId submitVerify(VerifyRequest request) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public OperationStatus status(PkiId operationId) {
+ if (blockStatus.compareAndSet(true, false)) {
+ statusEntered.countDown();
+ try {
+ statusRelease.await();
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(ex);
+ }
+ }
+ return status.get();
+ }
+
+ @Override
+ public boolean cancel(PkiId operationId, long fencingToken, String reason) {
+ OperationStatus current = status.get();
+ if (current.isTerminal()) {
+ return false;
+ }
+ cancellations.incrementAndGet();
+ status.set(new OperationStatus(State.CANCELLED, Instant.now(), Optional.of("CANCELLED"),
+ Optional.empty()));
+ return true;
+ }
+
+ @Override
+ public Registration register(NotificationSink notificationSink) {
+ sink.set(notificationSink);
+ return () -> sink.compareAndSet(notificationSink, null);
+ }
+
+ @Override
+ public Set supportedAlgorithms() {
+ return Set.of("SHA256withRSA");
+ }
+
+ @Override
+ public void close() {
+ statusRelease.countDown();
+ }
+
+ private void armStatusBlock() {
+ blockStatus.set(true);
+ }
+
+ private void completeWithoutNotification() {
+ OperationResult result = new OperationResult(
+ Optional.of(new EncodedObject(Encoding.BINARY, new byte[] { 7 })), Optional.empty());
+ status.set(new OperationStatus(State.SUCCEEDED, Instant.now(), Optional.of("SIGNED"),
+ Optional.of(result)));
+ statusRelease.countDown();
+ }
+
+ private void completeAndNotify() {
+ completeWithoutNotification();
+ NotificationSink registered = sink.get();
+ if (registered != null) {
+ registered.onStatusChanged(operationId.get(),
+ new OperationStatus(State.FAILED, Instant.now(), Optional.of("ADVISORY"), Optional.empty()));
+ }
+ }
+ }
+
+ private static final class CancelRejectedAfterCompletionWorkflow implements SignatureWorkflow {
+ private final AtomicReference status = new AtomicReference<>(
+ new OperationStatus(State.RUNNING, Instant.EPOCH, Optional.of("RUNNING"), Optional.empty()));
+ private final AtomicInteger cancellations = new AtomicInteger();
+
+ @Override
+ public String id() {
+ return "cancel-rejected-after-completion";
+ }
+
+ @Override
+ public PkiId submitSign(SignRequest request) {
+ status.set(new OperationStatus(State.RUNNING, Instant.now(), Optional.of("RUNNING"), Optional.empty()));
+ return request.submissionId();
+ }
+
+ @Override
+ public PkiId submitVerify(VerifyRequest request) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public OperationStatus status(PkiId operationId) {
+ return status.get();
+ }
+
+ @Override
+ public boolean cancel(PkiId operationId, long fencingToken, String reason) {
+ cancellations.incrementAndGet();
+ OperationResult result = new OperationResult(
+ Optional.of(new EncodedObject(Encoding.BINARY, new byte[] { 11, 12 })), Optional.empty());
+ status.set(new OperationStatus(State.SUCCEEDED, Instant.now(), Optional.of("SIGNED"),
+ Optional.of(result)));
+ return false;
+ }
+
+ @Override
+ public Registration register(NotificationSink sink) {
+ return () -> {
+ // No callback is required for authoritative reconciliation.
+ };
+ }
+
+ @Override
+ public Set supportedAlgorithms() {
+ return Set.of("SHA256withRSA");
+ }
+
+ @Override
+ public void close() {
+ // No owned resources.
+ }
+ }
+
+ private static final class BlockingWorkflow implements SignatureWorkflow {
+ private final CountDownLatch entered = new CountDownLatch(1);
+ private final CountDownLatch release = new CountDownLatch(1);
+ private final AtomicInteger submissions = new AtomicInteger();
+ private final Map statuses = new java.util.concurrent.ConcurrentHashMap<>();
+ private final AtomicReference sink = new AtomicReference<>();
+
+ @Override
+ public String id() {
+ return "blocking-test";
+ }
+
+ @Override
+ public PkiId submitSign(SignRequest request) {
+ submissions.incrementAndGet();
+ statuses.put(request.submissionId(), new OperationStatus(State.RUNNING, Instant.now(),
+ Optional.of("RUNNING"), Optional.empty()));
+ entered.countDown();
+ try {
+ release.await();
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(ex);
+ }
+ return request.submissionId();
+ }
+
+ @Override
+ public PkiId submitVerify(VerifyRequest request) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public OperationStatus status(PkiId operationId) {
+ return statuses.get(operationId);
+ }
+
+ @Override
+ public boolean cancel(PkiId operationId, long fencingToken, String reason) {
+ return false;
+ }
+
+ @Override
+ public Registration register(NotificationSink sink) {
+ this.sink.set(sink);
+ return () -> this.sink.compareAndSet(sink, null);
+ }
+
+ @Override
+ public Set supportedAlgorithms() {
+ return Set.of("SHA256withRSA");
+ }
+
+ @Override
+ public void close() {
+ release.countDown();
+ }
+
+ private void completeAndNotify(PkiId operationId) {
+ OperationResult result = new OperationResult(
+ Optional.of(new EncodedObject(Encoding.BINARY, new byte[] { 13 })), Optional.empty());
+ OperationStatus succeeded = new OperationStatus(State.SUCCEEDED, Instant.now(), Optional.of("SIGNED"),
+ Optional.of(result));
+ statuses.put(operationId, succeeded);
+ NotificationSink registered = sink.get();
+ if (registered != null) {
+ registered.onStatusChanged(operationId, succeeded);
+ }
+ }
+ }
+
+ private static final class CrossThreadReentrantWorkflow implements SignatureWorkflow {
+ private final AtomicReference bus = new AtomicReference<>();
+ private final AtomicReference status = new AtomicReference<>();
+ private final ExecutorService reentrantExecutor = Executors.newSingleThreadExecutor();
+ private final AtomicInteger submissions = new AtomicInteger();
+ private final AtomicInteger statusReads = new AtomicInteger();
+ private final AtomicInteger cancellations = new AtomicInteger();
+
+ private void attach(PkiSigningBus attached) {
+ bus.set(attached);
+ }
+
+ @Override
+ public String id() {
+ return "cross-thread-reentrant";
+ }
+
+ @Override
+ public PkiId submitSign(SignRequest request) {
+ submissions.incrementAndGet();
+ status.set(new OperationStatus(State.RUNNING, Instant.now(), Optional.of("RUNNING"),
+ Optional.empty()));
+ reenterStatus(request.submissionId());
+ return request.submissionId();
+ }
+
+ @Override
+ public PkiId submitVerify(VerifyRequest request) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public OperationStatus status(PkiId requested) {
+ statusReads.incrementAndGet();
+ reenterStatus(requested);
+ return status.get();
+ }
+
+ @Override
+ public boolean cancel(PkiId requested, long fencingToken, String reason) {
+ cancellations.incrementAndGet();
+ reenterStatus(requested);
+ status.set(new OperationStatus(State.CANCELLED, Instant.now(), Optional.of("CANCELLED"),
+ Optional.empty()));
+ return true;
+ }
+
+ private void reenterStatus(PkiId requested) {
+ try {
+ reentrantExecutor.submit(() -> bus.get().status(requested)).get(5, TimeUnit.SECONDS);
+ } catch (Exception failure) {
+ throw new IllegalStateException("Cross-thread provider reentry failed", failure);
+ }
+ }
+
+ @Override
+ public Registration register(NotificationSink sink) {
+ return () -> {
+ // Polling is authoritative.
+ };
+ }
+
+ @Override
+ public Set supportedAlgorithms() {
+ return Set.of("SHA256withRSA");
+ }
+
+ @Override
+ public void close() {
+ reentrantExecutor.shutdownNow();
+ }
+ }
+
+ private static final class SelectiveBlockingWorkflow implements SignatureWorkflow {
+ private final AtomicReference blockedOperation = new AtomicReference<>();
+ private final Map statuses = new java.util.concurrent.ConcurrentHashMap<>();
+ private final CountDownLatch blockedEntered = new CountDownLatch(1);
+ private final CountDownLatch blockedRelease = new CountDownLatch(1);
+ private final AtomicInteger submissions = new AtomicInteger();
+
+ @Override
+ public String id() {
+ return "selective-blocking";
+ }
+
+ @Override
+ public PkiId submitSign(SignRequest request) {
+ submissions.incrementAndGet();
+ statuses.put(request.submissionId(), new OperationStatus(State.RUNNING, Instant.now(),
+ Optional.of("RUNNING"), Optional.empty()));
+ if (blockedOperation.compareAndSet(null, request.submissionId())
+ || blockedOperation.get().equals(request.submissionId())) {
+ blockedEntered.countDown();
+ try {
+ blockedRelease.await();
+ } catch (InterruptedException failure) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(failure);
+ }
+ }
+ return request.submissionId();
+ }
+
+ @Override
+ public PkiId submitVerify(VerifyRequest request) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public OperationStatus status(PkiId operationId) {
+ return statuses.get(operationId);
+ }
+
+ @Override
+ public boolean cancel(PkiId operationId, long fencingToken, String reason) {
+ statuses.put(operationId, new OperationStatus(State.CANCELLED, Instant.now(),
+ Optional.of("CANCELLED"), Optional.empty()));
+ return true;
+ }
+
+ @Override
+ public Registration register(NotificationSink sink) {
+ return () -> {
+ // Polling is authoritative.
+ };
+ }
+
+ @Override
+ public Set supportedAlgorithms() {
+ return Set.of("SHA256withRSA");
+ }
+
+ @Override
+ public void close() {
+ blockedRelease.countDown();
+ }
+ }
+
+ private static final class ThrowOnceStatusWorkflow implements SignatureWorkflow {
+ private final AtomicReference status = new AtomicReference<>();
+ private final AtomicInteger statusReads = new AtomicInteger();
+
+ @Override
+ public String id() {
+ return "throw-once-status";
+ }
+
+ @Override
+ public PkiId submitSign(SignRequest request) {
+ status.set(new OperationStatus(State.RUNNING, Instant.now(), Optional.of("RUNNING"), Optional.empty()));
+ return request.submissionId();
+ }
+
+ @Override
+ public PkiId submitVerify(VerifyRequest request) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public OperationStatus status(PkiId operationId) {
+ if (statusReads.incrementAndGet() == 1) {
+ throw new IllegalStateException("injected status failure");
+ }
+ return status.get();
+ }
+
+ @Override
+ public boolean cancel(PkiId operationId, long fencingToken, String reason) {
+ status.set(new OperationStatus(State.CANCELLED, Instant.now(), Optional.of("CANCELLED"),
+ Optional.empty()));
+ return true;
+ }
+
+ @Override
+ public Registration register(NotificationSink sink) {
+ return () -> {
+ // Polling is authoritative.
+ };
+ }
+
+ @Override
+ public Set supportedAlgorithms() {
+ return Set.of("SHA256withRSA");
+ }
+
+ @Override
+ public void close() {
+ // No owned resources.
+ }
+ }
+
+ private static final class MutableClock extends Clock {
+ private volatile Instant value;
+
+ private MutableClock(Instant value) {
+ this.value = value;
+ }
+
+ private void set(Instant next) {
+ value = next;
+ }
+
+ @Override
+ public ZoneId getZone() {
+ return java.time.ZoneOffset.UTC;
+ }
+
+ @Override
+ public Clock withZone(ZoneId zone) {
+ return this;
+ }
+
+ @Override
+ public Instant instant() {
+ return value;
+ }
+ }
+}
diff --git a/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusOperatorApprovalTest.java b/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusOperatorApprovalTest.java
index f2547f4..c8c400f 100644
--- a/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusOperatorApprovalTest.java
+++ b/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusOperatorApprovalTest.java
@@ -33,6 +33,7 @@
******************************************************************************/
package zeroecho.pki.impl.core.async;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
@@ -57,6 +58,7 @@ import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.audit.Purpose;
import zeroecho.pki.impl.fs.FilesystemPkiStore;
import zeroecho.pki.impl.fs.FsPkiStoreOptions;
+import zeroecho.pki.spi.crypto.SignatureWorkflow;
import zeroecho.pki.testkit.DurableOperatorApprovalSignatureWorkflow;
import zeroecho.pki.util.async.AsyncState;
import zeroecho.pki.util.async.AsyncStatus;
@@ -66,6 +68,29 @@ public final class PkiSigningBusOperatorApprovalTest {
@TempDir
Path tempDir;
+ @Test
+ public void operatorApprovalAtExpiredRequestDeadlineCannotStartSigning() throws Exception {
+ KeyPair pair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
+ KeyRef keyRef = new KeyRef("kref:test:expired-approval");
+ try (DurableOperatorApprovalSignatureWorkflow signer = new DurableOperatorApprovalSignatureWorkflow(
+ tempDir.resolve("expired-approval"), Duration.ofSeconds(10), Duration.ZERO,
+ Map.of(keyRef.value(), pair.getPrivate()))) {
+ AccessContext access = new AccessContext(new Principal("USER", "late"), new Purpose("ISSUANCE"),
+ Optional.empty(), Optional.empty());
+ SignatureWorkflow.SignRequest request = SignatureWorkflow.SignRequest.create(
+ new PkiId("late-approval"), "test", 1L, access, keyRef, "SHA256withRSA",
+ new EncodedObject(Encoding.BINARY, new byte[] { 1 }), Optional.of(Encoding.BINARY),
+ Optional.of(Instant.EPOCH));
+
+ PkiId operationId = signer.submitSign(request);
+ signer.approve(operationId);
+
+ SignatureWorkflow.OperationStatus status = signer.status(operationId);
+ assertEquals(SignatureWorkflow.State.EXPIRED, status.state());
+ assertTrue(status.result().isEmpty());
+ }
+ }
+
@Test
public void operatorApprove_completesSignature() throws Exception {
System.out.println("operatorApprove_completesSignature");
diff --git a/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusResilienceTest.java b/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusResilienceTest.java
index 26d702e..26799e8 100644
--- a/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusResilienceTest.java
+++ b/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusResilienceTest.java
@@ -131,14 +131,13 @@ public final class PkiSigningBusResilienceTest {
// requester "goes away" here (close)
}
- // "Requester restart": new instance attaches by knowing (owner, clientOpId) ->
- // same canonical opId.
+ // "Requester restart": the caller retains the stable submission identifier.
try (FilesystemPkiStore store2 = new FilesystemPkiStore(storeRoot, options);
DurableOperatorApprovalSignatureWorkflow signer2 = new DurableOperatorApprovalSignatureWorkflow(wfRoot,
Duration.ofSeconds(10), Duration.ofMillis(0), Map.of(keyRef.value(), kp.getPrivate()));
PkiSigningBus bus2 = new PkiSigningBus(store2, signer2, busFile)) {
- PkiId opId2 = bus2.canonicalizeOperationId(clientOpId, owner);
+ PkiId opId2 = opId;
System.out.println("...opIdReattach=" + opId2.value());
assertTrue(opId2.value().equals(opId.value()));
diff --git a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibKeyRefParsingTest.java b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibKeyRefParsingTest.java
index 14faaab..4fa8742 100644
--- a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibKeyRefParsingTest.java
+++ b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibKeyRefParsingTest.java
@@ -36,10 +36,14 @@ package zeroecho.pki.impl.crypto.zeroecholib;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.nio.file.Path;
+import java.security.SecureRandom;
+import java.time.Clock;
+import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding;
@@ -48,23 +52,30 @@ import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.audit.AccessContext;
import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.audit.Purpose;
+import zeroecho.pki.api.orch.SigningSubmissionId;
import zeroecho.pki.spi.crypto.SignatureWorkflow;
public final class ZeroEchoLibKeyRefParsingTest {
+ private static final String NAMESPACE = "0123456789abcdef0123456789abcdef.zeroecho-lib";
+
+ @TempDir
+ Path tempDir;
+
@Test
void signing_requires_prv_suffix_in_strict_mode_ok() {
System.out.println("signing_requires_prv_suffix_in_strict_mode_ok");
try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("zeroecho-lib", Path.of("nonexistent"),
- "zeroecho-lib:", true)) {
+ tempDir.resolve("operations-1"), Clock.systemUTC(), Duration.ofDays(90), "zeroecho-lib:", true)) {
AccessContext ctx = new AccessContext(new Principal("TEST", "unit"), new Purpose("UNIT_TEST"),
Optional.empty(), Optional.empty());
- SignatureWorkflow.SignRequest req = new SignatureWorkflow.SignRequest(ctx, new KeyRef("zeroecho-lib:abc"), // missing
- // .prv
- "ECDSA", new EncodedObject(Encoding.BINARY, new byte[] { 0x01 }), Optional.of(Encoding.BINARY),
+ PkiId submissionId = SigningSubmissionId.create(NAMESPACE, Instant.now(), new SecureRandom()).id();
+ SignatureWorkflow.SignRequest req = SignatureWorkflow.SignRequest.create(submissionId, NAMESPACE, 1L,
+ ctx, new KeyRef("zeroecho-lib:abc"), "ECDSA",
+ new EncodedObject(Encoding.BINARY, new byte[] { 0x01 }), Optional.of(Encoding.BINARY),
Optional.of(Instant.now()));
PkiId opId = wf.submitSign(req);
@@ -84,7 +95,7 @@ public final class ZeroEchoLibKeyRefParsingTest {
System.out.println("verify_with_publicKeyEncoded_invalid_spki_fails_with_crypto_failure_ok");
try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("zeroecho-lib", Path.of("nonexistent"),
- "zeroecho-lib:", true)) {
+ tempDir.resolve("operations-2"), Clock.systemUTC(), Duration.ofDays(90), "zeroecho-lib:", true)) {
AccessContext ctx = new AccessContext(new Principal("TEST", "unit"), new Purpose("UNIT_TEST"),
Optional.empty(), Optional.empty());
@@ -112,7 +123,7 @@ public final class ZeroEchoLibKeyRefParsingTest {
System.out.println("status_unknown_operation_is_deterministic_ok");
try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("zeroecho-lib", Path.of("nonexistent"),
- "zeroecho-lib:", true)) {
+ tempDir.resolve("operations-3"), Clock.systemUTC(), Duration.ofDays(90), "zeroecho-lib:", true)) {
PkiId unknown = new PkiId("00000000-0000-0000-0000-000000000000");
SignatureWorkflow.OperationStatus st = wf.status(unknown);
diff --git a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowPersistenceTest.java b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowPersistenceTest.java
new file mode 100644
index 0000000..21a4b92
--- /dev/null
+++ b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowPersistenceTest.java
@@ -0,0 +1,490 @@
+/*******************************************************************************
+ * 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.crypto.zeroecholib;
+
+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.nio.ByteBuffer;
+import java.nio.file.Path;
+import java.nio.file.Files;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.SecureRandom;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+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.EncodedObject;
+import zeroecho.pki.api.Encoding;
+import zeroecho.pki.api.KeyRef;
+import zeroecho.pki.api.PkiId;
+import zeroecho.pki.api.audit.AccessContext;
+import zeroecho.pki.api.audit.Principal;
+import zeroecho.pki.api.audit.Purpose;
+import zeroecho.pki.api.orch.SigningSubmissionId;
+import zeroecho.pki.spi.crypto.SignatureWorkflow;
+import zeroecho.core.alg.rsa.RsaPrivateKeySpec;
+import zeroecho.core.alg.rsa.RsaPublicKeySpec;
+import zeroecho.core.storage.KeyringStore;
+
+final class ZeroEchoLibSignatureWorkflowPersistenceTest {
+
+ private static final String NAMESPACE = "0123456789abcdef0123456789abcdef.zeroecho-lib";
+
+ @Test
+ void signingVerificationPersistenceAndCallbackCopiesAreCleared(@TempDir Path root) throws Exception {
+ System.out.println("signingVerificationPersistenceAndCallbackCopiesAreCleared");
+ Instant now = Instant.parse("2026-02-03T04:05:06.789Z");
+ Path keyring = root.resolve("keyring.txt");
+ KeyPair pair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
+ KeyringStore keyringStore = new KeyringStore(new zeroecho.sdk.ZeroEchoSession());
+ keyringStore.putPrivate("test.prv", "RSA", new RsaPrivateKeySpec(pair.getPrivate().getEncoded()));
+ keyringStore.putPublic("test.pub", "RSA", new RsaPublicKeySpec(pair.getPublic().getEncoded()));
+ keyringStore.save(keyring);
+
+ List cleared = new ArrayList<>();
+ List records = new ArrayList<>();
+ Logger logger = Logger.getLogger(ZeroEchoLibSignatureWorkflow.class.getName());
+ Level previousLevel = logger.getLevel();
+ Handler handler = new Handler() {
+ @Override
+ public void publish(LogRecord record) {
+ records.add(record);
+ }
+
+ @Override
+ public void flush() {
+ // No buffered output.
+ }
+
+ @Override
+ public void close() {
+ // No resources.
+ }
+ };
+ logger.setLevel(Level.FINE);
+ handler.setLevel(Level.FINE);
+ logger.addHandler(handler);
+ try (ZeroEchoLibSignatureWorkflow workflow = new ZeroEchoLibSignatureWorkflow("zeroecho-lib", keyring,
+ root.resolve("cleanup-operations"), Clock.fixed(now, ZoneOffset.UTC), Duration.ofDays(90),
+ "zeroecho-lib:", true, (category, bytes) -> cleared.add(new ObservedBuffer(category, bytes)));
+ SignatureWorkflow.Registration registration = workflow.register((operationId, status) -> {
+ throw new IllegalStateException("DO_NOT_LOG_SIGNATURE_SENTINEL");
+ })) {
+ byte[] message = new byte[] { 7, 8, 9, 10 };
+ PkiId signId = SigningSubmissionId.create(NAMESPACE, now, new SecureRandom()).id();
+ SignatureWorkflow.SignRequest signRequest = request(signId, 1L, message,
+ new KeyRef("zeroecho-lib:test.prv"), Optional.empty());
+ workflow.submitSign(signRequest);
+ EncodedObject signature = workflow.status(signId).result().orElseThrow().signature().orElseThrow();
+ assertTrue(signature.bytes().length > 0);
+
+ AccessContext access = signRequest.accessContext();
+ SignatureWorkflow.VerifyRequest verifyRequest = new SignatureWorkflow.VerifyRequest(access,
+ "SHA256withRSA", new EncodedObject(Encoding.BINARY, message), signature,
+ Optional.of(new KeyRef("zeroecho-lib:test.pub")), Optional.empty(), Optional.empty());
+ PkiId verifyId = workflow.submitVerify(verifyRequest);
+ assertEquals(Optional.of(true),
+ workflow.status(verifyId).result().orElseThrow().verified());
+
+ byte[] invalidBytes = signature.bytes();
+ invalidBytes[0] ^= 0x01;
+ SignatureWorkflow.VerifyRequest invalidRequest = new SignatureWorkflow.VerifyRequest(access,
+ "SHA256withRSA", new EncodedObject(Encoding.BINARY, message),
+ new EncodedObject(Encoding.BINARY, invalidBytes),
+ Optional.of(new KeyRef("zeroecho-lib:test.pub")), Optional.empty(), Optional.empty());
+ PkiId invalidId = workflow.submitVerify(invalidRequest);
+ assertEquals(Optional.of(false),
+ workflow.status(invalidId).result().orElseThrow().verified());
+
+ assertTrue(cleared.stream().anyMatch(value -> "sign-payload".equals(value.category())));
+ assertTrue(cleared.stream().anyMatch(value -> "sign-result-copy".equals(value.category())));
+ assertTrue(cleared.stream().anyMatch(value -> "verify-payload".equals(value.category())));
+ assertTrue(cleared.stream().anyMatch(value -> "verify-signature".equals(value.category())));
+ assertTrue(cleared.stream().anyMatch(value -> "persisted-operation-buffer".equals(value.category())));
+ assertTrue(cleared.stream().allMatch(value -> isCleared(value.bytes())));
+ assertTrue(records.stream().allMatch(record -> record.getThrown() == null));
+ assertTrue(records.stream().noneMatch(record -> String.valueOf(record.getMessage())
+ .contains("DO_NOT_LOG_SIGNATURE_SENTINEL")));
+ System.out.println("...clearedBuffers=" + cleared.size());
+ } finally {
+ logger.removeHandler(handler);
+ logger.setLevel(previousLevel);
+ }
+ System.out.println("signingVerificationPersistenceAndCallbackCopiesAreCleared...ok");
+ }
+
+ @Test
+ void unsupportedProviderRecordVersionFailsClosedWithRedactedDiagnostic(@TempDir Path root) throws Exception {
+ Instant now = Instant.parse("2026-02-03T04:05:06.789Z");
+ Clock clock = Clock.fixed(now, ZoneOffset.UTC);
+ Path operations = root.resolve("unsupported-version");
+ PkiId id = SigningSubmissionId.create(NAMESPACE, now, new SecureRandom()).id();
+ byte[] payload = new byte[] { 91, 92, 93 };
+ try (ZeroEchoLibSignatureWorkflow workflow = workflow(root, operations, clock)) {
+ workflow.submitSign(request(id, 1L, payload));
+ }
+
+ Path record = onlyOperationRecord(operations);
+ byte[] encoded = Files.readAllBytes(record);
+ ByteBuffer.wrap(encoded).putInt(99);
+ Files.write(record, encoded);
+
+ IllegalStateException failure = assertThrows(IllegalStateException.class,
+ () -> workflow(root, operations, clock));
+ assertFalse(failure.toString().contains(id.value()));
+ assertFalse(failure.toString().contains(java.util.Base64.getEncoder().encodeToString(payload)));
+ }
+
+ @Test
+ void signingDeadlineIsStrictAndSlowCryptoCannotCommitLate(@TempDir Path root) throws Exception {
+ Instant base = Instant.parse("2026-02-03T04:05:06Z");
+ Path keyring = root.resolve("keyring.txt");
+ KeyPair pair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
+ KeyringStore keyringStore = new KeyringStore(new zeroecho.sdk.ZeroEchoSession());
+ keyringStore.putPrivate("test.prv", "RSA", new RsaPrivateKeySpec(pair.getPrivate().getEncoded()));
+ keyringStore.putPublic("test.pub", "RSA", new RsaPublicKeySpec(pair.getPublic().getEncoded()));
+ keyringStore.save(keyring);
+
+ PkiId onTimeId = SigningSubmissionId.create(NAMESPACE, base, new SecureRandom()).id();
+ Clock onTimeClock = Clock.fixed(base, ZoneOffset.UTC);
+ try (ZeroEchoLibSignatureWorkflow workflow = workflow(root, root.resolve("on-time"), keyring, onTimeClock)) {
+ SignatureWorkflow.SignRequest onTime = request(onTimeId, 1L, new byte[] { 1 },
+ new KeyRef("zeroecho-lib:test.prv"), Optional.of(base.plusNanos(1)));
+ workflow.submitSign(onTime);
+ assertEquals(SignatureWorkflow.State.SUCCEEDED, workflow.status(onTimeId).state());
+ }
+
+ PkiId exactId = SigningSubmissionId.create(NAMESPACE, base, new SecureRandom()).id();
+ Clock exactClock = Clock.fixed(base, ZoneOffset.UTC);
+ try (ZeroEchoLibSignatureWorkflow workflow = workflow(root, root.resolve("exact"), keyring, exactClock)) {
+ SignatureWorkflow.SignRequest exact = request(exactId, 1L, new byte[] { 2 },
+ new KeyRef("zeroecho-lib:test.prv"), Optional.of(base));
+ workflow.submitSign(exact);
+ assertEquals(SignatureWorkflow.State.EXPIRED, workflow.status(exactId).state());
+ assertEquals(Optional.empty(), workflow.status(exactId).result());
+ }
+
+ PkiId crossingId = SigningSubmissionId.create(NAMESPACE, base, new SecureRandom()).id();
+ Instant deadline = base.plusSeconds(1);
+ StepClock crossingClock = new StepClock(base, deadline, 6);
+ Path crossingOperations = root.resolve("crossing");
+ try (ZeroEchoLibSignatureWorkflow workflow = workflow(root, crossingOperations, keyring, crossingClock)) {
+ SignatureWorkflow.SignRequest crossing = request(crossingId, 1L, new byte[] { 3 },
+ new KeyRef("zeroecho-lib:test.prv"), Optional.of(deadline));
+ workflow.submitSign(crossing);
+ assertEquals(SignatureWorkflow.State.EXPIRED, workflow.status(crossingId).state());
+ assertEquals(Optional.empty(), workflow.status(crossingId).result());
+ }
+ try (ZeroEchoLibSignatureWorkflow restarted = workflow(root, crossingOperations, keyring,
+ Clock.fixed(deadline, ZoneOffset.UTC))) {
+ assertEquals(SignatureWorkflow.State.EXPIRED, restarted.status(crossingId).state());
+ assertEquals(Optional.empty(), restarted.status(crossingId).result());
+ }
+ }
+
+ @Test
+ void encodedObjectAndSigningRequestDefensivelySnapshotBytes() {
+ byte[] source = new byte[] { 1, 2, 3 };
+ EncodedObject encoded = new EncodedObject(Encoding.BINARY, source);
+ source[0] = 9;
+ byte[] exposed = encoded.bytes();
+ exposed[1] = 9;
+ assertEquals(1, encoded.bytes()[0]);
+ assertEquals(2, encoded.bytes()[1]);
+
+ PkiId id = SigningSubmissionId.create(NAMESPACE, Instant.parse("2026-02-03T04:05:06Z"),
+ new SecureRandom()).id();
+ SignatureWorkflow.SignRequest request = request(id, 1L, encoded.bytes());
+ byte[] requestBytes = request.payload().bytes();
+ requestBytes[2] = 9;
+ assertEquals(3, request.payload().bytes()[2]);
+ assertEquals(request.semanticFingerprint(), SignatureWorkflow.SignRequest.fingerprint(NAMESPACE,
+ request.accessContext(), request.keyRef(), request.algorithmId(), request.payload(),
+ request.preferredSignatureEncoding(), request.deadline()));
+ }
+
+ @Test
+ void restartAttachConflictExpiryAndFence(@TempDir Path root) {
+ Instant now = Instant.parse("2026-02-03T04:05:06.789Z");
+ Clock clock = Clock.fixed(now, ZoneOffset.UTC);
+ Path operations = root.resolve("operations");
+ PkiId id = SigningSubmissionId.create(NAMESPACE, now, new SecureRandom()).id();
+ SignatureWorkflow.SignRequest request = request(id, 2L, new byte[] { 1 });
+
+ try (ZeroEchoLibSignatureWorkflow workflow = workflow(root, operations, clock)) {
+ assertEquals(id, workflow.submitSign(request));
+ assertEquals(SignatureWorkflow.State.FAILED, workflow.status(id).state());
+ }
+
+ try (ZeroEchoLibSignatureWorkflow restarted = workflow(root, operations, clock)) {
+ assertEquals(SignatureWorkflow.State.FAILED, restarted.status(id).state());
+ assertEquals(id, restarted.submitSign(request));
+ assertThrows(IllegalStateException.class, () -> restarted.submitSign(request(id, 1L, new byte[] { 1 })));
+ assertThrows(IllegalStateException.class, () -> restarted.submitSign(request(id, 3L, new byte[] { 2 })));
+ }
+ forcePersistedStateCode(operations, 50, 30);
+ try (ZeroEchoLibSignatureWorkflow recovered = workflow(root, operations, clock)) {
+ assertEquals(SignatureWorkflow.State.FAILED, recovered.status(id).state());
+ assertEquals("RECOVERY_INCOMPLETE", recovered.status(id).detailCode().orElseThrow());
+ }
+
+ Clock expired = Clock.fixed(now.plus(Duration.ofDays(90)), ZoneOffset.UTC);
+ try (ZeroEchoLibSignatureWorkflow restarted = workflow(root, operations, expired)) {
+ assertEquals(SignatureWorkflow.State.EXPIRED, restarted.status(id).state());
+ PkiId expiredId = SigningSubmissionId.create(NAMESPACE, now, new SecureRandom()).id();
+ assertThrows(IllegalArgumentException.class,
+ () -> restarted.submitSign(request(expiredId, 1L, new byte[] { 3 })));
+ }
+ }
+
+ @Test
+ void concurrentAttachCancelAndReentrantCallbackAreOperationLocal(@TempDir Path root) throws Exception {
+ Instant now = Instant.parse("2026-02-03T04:05:06.789Z");
+ Clock clock = Clock.fixed(now, ZoneOffset.UTC);
+ PkiId firstId = SigningSubmissionId.create(NAMESPACE, now, new SecureRandom()).id();
+ PkiId secondId = SigningSubmissionId.create(NAMESPACE, now, new SecureRandom()).id();
+ SignatureWorkflow.SignRequest first = request(firstId, 1L, new byte[] { 1 });
+ SignatureWorkflow.SignRequest second = request(secondId, 1L, new byte[] { 2 });
+ CountDownLatch callbackEntered = new CountDownLatch(1);
+ CountDownLatch callbackRelease = new CountDownLatch(1);
+ try (ZeroEchoLibSignatureWorkflow workflow = workflow(root, root.resolve("operations"), clock);
+ ExecutorService executor = Executors.newFixedThreadPool(3);
+ SignatureWorkflow.Registration registration = workflow.register((operationId, status) -> {
+ if (firstId.equals(operationId) && status.state() == SignatureWorkflow.State.RUNNING) {
+ assertEquals(SignatureWorkflow.State.RUNNING, workflow.status(operationId).state());
+ callbackEntered.countDown();
+ try {
+ callbackRelease.await();
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(ex);
+ }
+ }
+ })) {
+ Future firstFuture = executor.submit(() -> workflow.submitSign(first));
+ assertEquals(true, callbackEntered.await(5, TimeUnit.SECONDS));
+ assertEquals(firstId, executor.submit(() -> workflow.submitSign(first)).get(5, TimeUnit.SECONDS));
+ assertEquals(secondId, executor.submit(() -> workflow.submitSign(second)).get(5, TimeUnit.SECONDS));
+ assertEquals(true, workflow.cancel(firstId, 2L, "test cancellation"));
+ callbackRelease.countDown();
+ assertEquals(firstId, firstFuture.get(5, TimeUnit.SECONDS));
+ assertEquals(SignatureWorkflow.State.CANCELLED, workflow.status(firstId).state());
+ assertEquals(false, workflow.cancel(firstId, 1L, "stale"));
+ }
+ }
+
+ @Test
+ void concurrentWatermarkWritesNeverRegressAcrossRestart(@TempDir Path root) throws Exception {
+ Instant base = Instant.parse("2026-02-03T04:05:06.789Z");
+ Instant high = base.plusSeconds(60);
+ LatchClock clock = new LatchClock(base);
+ Path operations = root.resolve("operations");
+ try (ZeroEchoLibSignatureWorkflow workflow = workflow(root, operations, clock);
+ ExecutorService executor = Executors.newFixedThreadPool(2)) {
+ clock.arm(high);
+ Future> highRead = executor.submit(() -> workflow.status(new PkiId("unknown-high")));
+ assertEquals(true, clock.observed.await(5, TimeUnit.SECONDS));
+ clock.set(base.minusSeconds(60));
+ Future> rollbackRead = executor.submit(() -> workflow.status(new PkiId("unknown-low")));
+ clock.release.countDown();
+ highRead.get(5, TimeUnit.SECONDS);
+ rollbackRead.get(5, TimeUnit.SECONDS);
+ assertEquals(high.toEpochMilli(), Long.parseLong(Files.readString(
+ operations.resolve("TIME_WATERMARK")).trim()));
+ }
+ clock.set(base.minusSeconds(120));
+ try (ZeroEchoLibSignatureWorkflow restarted = workflow(root, operations, clock)) {
+ restarted.status(new PkiId("unknown-restart"));
+ assertEquals(high.toEpochMilli(), Long.parseLong(Files.readString(
+ operations.resolve("TIME_WATERMARK")).trim()));
+ }
+ }
+
+ private static ZeroEchoLibSignatureWorkflow workflow(Path root, Path operations, Clock clock) {
+ return workflow(root, operations, root.resolve("missing-keyring"), clock);
+ }
+
+ private static ZeroEchoLibSignatureWorkflow workflow(Path root, Path operations, Path keyring, Clock clock) {
+ return new ZeroEchoLibSignatureWorkflow("zeroecho-lib", keyring, operations, clock,
+ Duration.ofDays(90), "zeroecho-lib:", true);
+ }
+
+ private static SignatureWorkflow.SignRequest request(PkiId id, long fence, byte[] payload) {
+ return request(id, fence, payload, new KeyRef("zeroecho-lib:missing.prv"), Optional.empty());
+ }
+
+ private static SignatureWorkflow.SignRequest request(PkiId id, long fence, byte[] payload, KeyRef keyRef,
+ Optional deadline) {
+ AccessContext access = new AccessContext(new Principal("TEST", "owner"), new Purpose("TEST"),
+ Optional.empty(), Optional.empty());
+ return SignatureWorkflow.SignRequest.create(id, NAMESPACE, fence, access,
+ keyRef, "SHA256withRSA", new EncodedObject(Encoding.BINARY, payload),
+ Optional.of(Encoding.BINARY), deadline);
+ }
+
+ private static void forcePersistedStateCode(Path operations, int from, int to) {
+ try {
+ Path record;
+ try (java.util.stream.Stream paths = Files.list(operations.resolve("records"))) {
+ record = paths.filter(Files::isRegularFile).findFirst().orElseThrow();
+ }
+ byte[] bytes = Files.readAllBytes(record);
+ for (int index = bytes.length - Integer.BYTES; index >= 0; index--) {
+ if (bytes[index] == 0 && bytes[index + 1] == 0 && bytes[index + 2] == 0
+ && Byte.toUnsignedInt(bytes[index + 3]) == from) {
+ bytes[index + 3] = (byte) to;
+ Files.write(record, bytes);
+ return;
+ }
+ }
+ throw new IllegalStateException("Persisted state code not found");
+ } catch (java.io.IOException ex) {
+ throw new IllegalStateException(ex);
+ }
+ }
+
+ private static Path onlyOperationRecord(Path operations) throws java.io.IOException {
+ try (java.util.stream.Stream paths = Files.list(operations.resolve("records"))) {
+ return paths.filter(Files::isRegularFile).findFirst().orElseThrow();
+ }
+ }
+
+ private static boolean isCleared(byte[] bytes) {
+ for (byte value : bytes) {
+ if (value != 0) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private record ObservedBuffer(String category, byte[] bytes) {
+ }
+
+ private static final class LatchClock extends Clock {
+ private final AtomicBoolean block = new AtomicBoolean();
+ private final CountDownLatch observed = new CountDownLatch(1);
+ private final CountDownLatch release = new CountDownLatch(1);
+ private volatile Instant value;
+
+ private LatchClock(Instant value) {
+ this.value = value;
+ }
+
+ private void arm(Instant next) {
+ value = next;
+ block.set(true);
+ }
+
+ private void set(Instant next) {
+ value = next;
+ }
+
+ @Override
+ public ZoneId getZone() {
+ return ZoneOffset.UTC;
+ }
+
+ @Override
+ public Clock withZone(ZoneId zone) {
+ return this;
+ }
+
+ @Override
+ public Instant instant() {
+ Instant observedValue = value;
+ if (block.compareAndSet(true, false)) {
+ observed.countDown();
+ try {
+ release.await();
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(ex);
+ }
+ }
+ return observedValue;
+ }
+ }
+
+ private static final class StepClock extends Clock {
+ private final Instant initial;
+ private final Instant advanced;
+ private final int initialReads;
+ private final AtomicInteger reads = new AtomicInteger();
+
+ private StepClock(Instant initial, Instant advanced, int initialReads) {
+ this.initial = initial;
+ this.advanced = advanced;
+ this.initialReads = initialReads;
+ }
+
+ @Override
+ public ZoneId getZone() {
+ return ZoneOffset.UTC;
+ }
+
+ @Override
+ public Clock withZone(ZoneId zone) {
+ return this;
+ }
+
+ @Override
+ public Instant instant() {
+ return reads.incrementAndGet() <= initialReads ? initial : advanced;
+ }
+ }
+}
diff --git a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedEcdsaTest.java b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedEcdsaTest.java
index 06562e1..d84e5bc 100644
--- a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedEcdsaTest.java
+++ b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedEcdsaTest.java
@@ -40,6 +40,8 @@ import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.Signature;
import java.security.spec.ECGenParameterSpec;
+import java.time.Clock;
+import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
@@ -72,7 +74,8 @@ public final class ZeroEchoLibSignatureWorkflowVerifyEncodedEcdsaTest {
KeyringStore ks = new KeyringStore(new zeroecho.sdk.ZeroEchoSession());
ks.save(keyring);
- try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("wf", keyring, "zeroecho-lib:", true)) {
+ try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("wf", keyring,
+ tempDir.resolve("operations"), Clock.systemUTC(), Duration.ofDays(90), "zeroecho-lib:", true)) {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
kpg.initialize(new ECGenParameterSpec("secp256r1"));
KeyPair kp = kpg.generateKeyPair();
diff --git a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedTest.java b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedTest.java
index 5b90e2e..ddb9bff 100644
--- a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedTest.java
+++ b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedTest.java
@@ -39,6 +39,8 @@ import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.Signature;
+import java.time.Clock;
+import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
@@ -67,7 +69,8 @@ public final class ZeroEchoLibSignatureWorkflowVerifyEncodedTest {
KeyringStore ks = new KeyringStore(new zeroecho.sdk.ZeroEchoSession());
ks.save(keyring);
- try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("wf", keyring, "zeroecho-lib:", true)) {
+ try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("wf", keyring,
+ tempDir.resolve("operations"), Clock.systemUTC(), Duration.ofDays(90), "zeroecho-lib:", true)) {
KeyPair kp = KeyPairGenerator.getInstance("RSA").generateKeyPair();
byte[] payload = "pqc-ready".getBytes(java.nio.charset.StandardCharsets.UTF_8);
diff --git a/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSignerCleanupTest.java b/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSignerCleanupTest.java
new file mode 100644
index 0000000..0319724
--- /dev/null
+++ b/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSignerCleanupTest.java
@@ -0,0 +1,119 @@
+/*******************************************************************************
+ * 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.framework.x509.bc;
+
+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.nio.file.Files;
+import java.nio.file.Path;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import zeroecho.pki.api.KeyRef;
+import zeroecho.pki.api.PkiException;
+import zeroecho.pki.api.PkiId;
+import zeroecho.pki.impl.core.async.PkiSigningBus;
+import zeroecho.pki.impl.fs.FilesystemPkiStore;
+import zeroecho.pki.impl.fs.FsPkiStoreOptions;
+import zeroecho.pki.testkit.InMemorySignatureWorkflow;
+
+final class PkiBusContentSignerCleanupTest {
+
+ @Test
+ void endEntityFailureRetiresEverySigningArtifact(@TempDir Path tempDir) throws Exception {
+ assertCleanup(tempDir, false);
+ }
+
+ @Test
+ void intermediateFailureRetiresEverySigningArtifact(@TempDir Path tempDir) throws Exception {
+ assertCleanup(tempDir, true);
+ }
+
+ private static void assertCleanup(Path tempDir, boolean intermediate) throws Exception {
+ KeyPair subjectKey = generateRsa();
+ KeyRef issuerKeyRef = new KeyRef("kref:v1:keyring:test:issuer");
+ InMemorySignatureWorkflow signer = new InMemorySignatureWorkflow(Map.of(), true);
+ Path storeRoot = tempDir.resolve("store");
+ Path busLog = tempDir.resolve("bus.log");
+
+ try (FilesystemPkiStore store = new FilesystemPkiStore(storeRoot, FsPkiStoreOptions.defaults());
+ PkiSigningBus bus = new PkiSigningBus(store, signer, busLog)) {
+ PkiBusContentSigner contentSigner = new PkiBusContentSigner(bus, issuerKeyRef, "SHA256withRSA",
+ Duration.ofSeconds(5));
+ contentSigner.getOutputStream().write(intermediate
+ ? subjectKey.getPublic().getEncoded()
+ : new byte[] { 1, 2, 3 });
+ assertThrows(PkiException.class, contentSigner::getSignature);
+
+ assertEquals(1, signer.submittedSignCount());
+ assertTrue(store.listWorkflowStates().isEmpty());
+ assertFalse(signer.hasRunningOperations());
+ assertTrue(store.listCas().isEmpty());
+ assertFalse(Files.exists(storeRoot.resolve("credentials")));
+
+ PkiId operationId = submittedOperation(busLog);
+ try (PkiSigningBus replayed = new PkiSigningBus(store, signer, busLog)) {
+ assertEquals(zeroecho.pki.util.async.AsyncState.CANCELLED,
+ replayed.status(operationId).orElseThrow().state());
+ }
+ } finally {
+ signer.close();
+ }
+ }
+
+ private static PkiId submittedOperation(Path busLog) throws Exception {
+ List lines = Files.readAllLines(busLog);
+ for (String line : lines) {
+ if (line.startsWith("S1|")) {
+ return new PkiId(line.split("\\|", -1)[1]);
+ }
+ }
+ throw new AssertionError("No durable signing operation snapshot found");
+ }
+
+ private static KeyPair generateRsa() throws Exception {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(2048);
+ return generator.generateKeyPair();
+ }
+}
diff --git a/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifierTest.java b/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifierTest.java
index 927619d..5b870c6 100644
--- a/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifierTest.java
+++ b/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifierTest.java
@@ -87,7 +87,8 @@ public final class WorkflowProofOfPossessionVerifierTest {
ParsedCertificationRequest parsed = new BcX509CertificationRequestParser().parse(req);
ZeroEchoLibSignatureWorkflowProvider provider = new ZeroEchoLibSignatureWorkflowProvider();
- ProviderConfig cfg = new ProviderConfig(provider.id(), Map.of("keyringPath", keyringPath.toString()));
+ ProviderConfig cfg = new ProviderConfig(provider.id(), Map.of("keyringPath", keyringPath.toString(),
+ "operationRoot", tempDir.resolve("signing-operations").toString()));
SignatureWorkflow wf = provider.allocate(cfg);
WorkflowProofOfPossessionVerifier verifier = new WorkflowProofOfPossessionVerifier(wf);
diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreOwnershipTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreOwnershipTest.java
new file mode 100644
index 0000000..d212957
--- /dev/null
+++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreOwnershipTest.java
@@ -0,0 +1,312 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification,
+ * are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.pki.impl.fs;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.logging.LogManager;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Verifies exclusive process-lifetime ownership of a filesystem PKI store.
+ */
+final class FilesystemPkiStoreOwnershipTest {
+
+ private static final String OWNERSHIP_CODE = "STORE_ALREADY_OPEN";
+ private static final long PROCESS_TIMEOUT_SECONDS = 10L;
+
+ @Test
+ void sameJvmOwnerExcludesSecondStoreAndReleasesOnClose(@TempDir Path tempDir) throws Exception {
+ System.out.println("sameJvmOwnerExcludesSecondStoreAndReleasesOnClose");
+ Path root = tempDir.resolve("shared");
+ FsPkiStoreOptions options = FsPkiStoreOptions.defaults();
+
+ FilesystemPkiStore first = new FilesystemPkiStore(root, options);
+ try {
+ first.putProfile(FilesystemPkiStoreTest.TestObjects.minimalProfile("ownership-profile", true));
+ assertOwnershipRejected(root);
+ assertTrue(first.getProfile("ownership-profile").isPresent());
+ } finally {
+ first.close();
+ }
+ first.close();
+
+ try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, options)) {
+ assertTrue(reopened.getProfile("ownership-profile").isPresent());
+ }
+ System.out.println("sameJvmOwnerExcludesSecondStoreAndReleasesOnClose...ok");
+ }
+
+ @Test
+ void separateStoreRootsRemainIndependent(@TempDir Path tempDir) throws Exception {
+ System.out.println("separateStoreRootsRemainIndependent");
+ try (FilesystemPkiStore first = new FilesystemPkiStore(tempDir.resolve("first"),
+ FsPkiStoreOptions.defaults());
+ FilesystemPkiStore second = new FilesystemPkiStore(tempDir.resolve("second"),
+ FsPkiStoreOptions.defaults())) {
+ first.putProfile(FilesystemPkiStoreTest.TestObjects.minimalProfile("first-profile", true));
+ second.putProfile(FilesystemPkiStoreTest.TestObjects.minimalProfile("second-profile", true));
+ assertTrue(first.getProfile("first-profile").isPresent());
+ assertFalse(first.getProfile("second-profile").isPresent());
+ assertTrue(second.getProfile("second-profile").isPresent());
+ assertFalse(second.getProfile("first-profile").isPresent());
+ }
+ System.out.println("separateStoreRootsRemainIndependent...ok");
+ }
+
+ @Test
+ void constructorFailureAfterAcquisitionReleasesOwnership(@TempDir Path tempDir) throws Exception {
+ System.out.println("constructorFailureAfterAcquisitionReleasesOwnership");
+ Path root = tempDir.resolve("invalid-version");
+ Files.createDirectories(root);
+ Files.writeString(root.resolve(FsPaths.VERSION_FILE), "unsupported", StandardCharsets.US_ASCII);
+
+ assertThrows(IllegalStateException.class,
+ () -> new FilesystemPkiStore(root, FsPkiStoreOptions.defaults()));
+ Files.writeString(root.resolve(FsPaths.VERSION_FILE), "v1", StandardCharsets.US_ASCII);
+
+ try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) {
+ assertTrue(reopened.listCas().isEmpty());
+ }
+ System.out.println("constructorFailureAfterAcquisitionReleasesOwnership...ok");
+ }
+
+ @Test
+ void forkedJvmOwnerExcludesParentAndReleasesNormally(@TempDir Path tempDir) throws Exception {
+ System.out.println("forkedJvmOwnerExcludesParentAndReleasesNormally");
+ Path root = tempDir.resolve("forked-graceful");
+ try (ChildOwner child = ChildOwner.start(root)) {
+ child.expect("LOCK_ACQUIRED");
+ assertOwnershipRejected(root);
+ child.send("PING");
+ child.expect("STORE_USABLE");
+ child.send("CLOSE");
+ child.expect("STORE_CLOSED");
+ child.awaitExit(0);
+ }
+
+ try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) {
+ assertTrue(reopened.listCas().isEmpty());
+ }
+ System.out.println("forkedJvmOwnerExcludesParentAndReleasesNormally...ok");
+ }
+
+ @Test
+ void forkedJvmTerminationReleasesOperatingSystemLock(@TempDir Path tempDir) throws Exception {
+ System.out.println("forkedJvmTerminationReleasesOperatingSystemLock");
+ Path root = tempDir.resolve("forked-abrupt");
+ try (ChildOwner child = ChildOwner.start(root)) {
+ child.expect("LOCK_ACQUIRED");
+ assertOwnershipRejected(root);
+ child.destroyForcibly();
+ }
+
+ try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) {
+ assertTrue(reopened.listCas().isEmpty());
+ }
+ System.out.println("forkedJvmTerminationReleasesOperatingSystemLock...ok");
+ }
+
+ private static void assertOwnershipRejected(Path root) {
+ IllegalStateException failure = assertThrows(IllegalStateException.class,
+ () -> new FilesystemPkiStore(root, FsPkiStoreOptions.defaults()));
+ assertTrue(failure.getMessage().contains(OWNERSHIP_CODE));
+ assertNull(failure.getCause());
+ }
+
+ private static String childClasspath() throws Exception {
+ Set entries = new LinkedHashSet<>();
+ String configured = System.getProperty("java.class.path", "");
+ if (!configured.isBlank()) {
+ for (String entry : configured.split(java.io.File.pathSeparator)) {
+ entries.add(entry);
+ }
+ }
+ ClassLoader loader = FilesystemPkiStoreOwnershipTest.class.getClassLoader();
+ while (loader != null) {
+ if (loader instanceof URLClassLoader urls) {
+ for (URL url : urls.getURLs()) {
+ if ("file".equals(url.getProtocol())) {
+ entries.add(Path.of(url.toURI()).toString());
+ }
+ }
+ }
+ loader = loader.getParent();
+ }
+ entries.add(codeSource(FilesystemPkiStoreOwnershipTest.class));
+ entries.add(codeSource(FilesystemPkiStore.class));
+ return String.join(java.io.File.pathSeparator, entries);
+ }
+
+ private static String codeSource(Class> type) throws Exception {
+ return Path.of(type.getProtectionDomain().getCodeSource().getLocation().toURI()).toString();
+ }
+
+ private static final class ChildOwner implements AutoCloseable {
+ private final Process process;
+ private final BufferedReader output;
+ private final BufferedWriter input;
+ private final ExecutorService reader;
+
+ private ChildOwner(Process process) {
+ this.process = process;
+ this.output = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
+ this.input = new BufferedWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8));
+ this.reader = Executors.newSingleThreadExecutor();
+ }
+
+ private static ChildOwner start(Path root) throws Exception {
+ String executable = System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT)
+ .contains("win") ? "java.exe" : "java";
+ Path java = Path.of(System.getProperty("java.home"), "bin", executable);
+ Process process = new ProcessBuilder(java.toString(), "-cp", childClasspath(),
+ FilesystemPkiStoreLockProcess.class.getName(), root.toString()).redirectErrorStream(true).start();
+ return new ChildOwner(process);
+ }
+
+ private void send(String command) throws IOException {
+ input.write(command);
+ input.newLine();
+ input.flush();
+ }
+
+ private void expect(String expected) throws Exception {
+ Future line = reader.submit(output::readLine);
+ try {
+ assertEquals(expected, line.get(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+ } catch (ExecutionException ex) {
+ throw new IOException("Child protocol read failed", ex.getCause());
+ } catch (TimeoutException ex) {
+ line.cancel(true);
+ throw new IOException("Child protocol timed out");
+ }
+ }
+
+ private void awaitExit(int expectedCode) throws Exception {
+ assertTrue(process.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+ assertEquals(expectedCode, process.exitValue());
+ }
+
+ private void destroyForcibly() throws Exception {
+ process.destroyForcibly();
+ assertTrue(process.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+ }
+
+ @Override
+ public void close() throws Exception {
+ if (process.isAlive()) {
+ process.destroyForcibly();
+ process.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ }
+ input.close();
+ output.close();
+ reader.shutdownNow();
+ assertTrue(reader.awaitTermination(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+ }
+ }
+}
+
+/**
+ * Test-process protocol endpoint for filesystem-store ownership checks.
+ */
+final class FilesystemPkiStoreLockProcess {
+
+ private FilesystemPkiStoreLockProcess() {
+ }
+
+ /**
+ * Opens one store and serves the fixed ownership-test control protocol.
+ *
+ * @param args one filesystem store root
+ */
+ public static void main(String[] args) {
+ if (args.length != 1) {
+ System.out.println("SETUP_FAILED");
+ System.exit(2);
+ }
+ LogManager.getLogManager().reset();
+ try (FilesystemPkiStore store = new FilesystemPkiStore(Path.of(args[0]),
+ FsPkiStoreOptions.defaults());
+ BufferedReader control = new BufferedReader(new InputStreamReader(System.in,
+ StandardCharsets.UTF_8))) {
+ System.out.println("LOCK_ACQUIRED");
+ System.out.flush();
+ String command;
+ while ((command = control.readLine()) != null) {
+ if ("PING".equals(command)) {
+ store.listCas();
+ System.out.println("STORE_USABLE");
+ System.out.flush();
+ } else if ("CLOSE".equals(command)) {
+ store.close();
+ System.out.println("STORE_CLOSED");
+ System.out.flush();
+ return;
+ } else {
+ System.out.println("PROTOCOL_FAILED");
+ System.out.flush();
+ System.exit(3);
+ }
+ }
+ } catch (Exception failure) {
+ System.out.println("SETUP_FAILED:" + failure.getClass().getName());
+ System.out.flush();
+ System.exit(2);
+ }
+ }
+}
diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java
index 084b520..0da5d2c 100644
--- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java
+++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java
@@ -342,13 +342,15 @@ public final class FilesystemPkiStoreTest {
private static FsPkiStoreOptions nonStrictSnapshotOptions() {
FsPkiStoreOptions defaults = FsPkiStoreOptions.defaults();
return new FsPkiStoreOptions(defaults.caHistoryPolicy(), defaults.profileHistoryPolicy(),
- defaults.revocationHistoryPolicy(), defaults.workflowHistoryPolicy(), false);
+ defaults.revocationHistoryPolicy(), defaults.workflowHistoryPolicy(), false,
+ defaults.signingOperationHorizon(), defaults.signingIdPermittedSkew());
}
private static FsPkiStoreOptions strictSnapshotOptions() {
FsPkiStoreOptions defaults = FsPkiStoreOptions.defaults();
return new FsPkiStoreOptions(defaults.caHistoryPolicy(), defaults.profileHistoryPolicy(),
- defaults.revocationHistoryPolicy(), defaults.workflowHistoryPolicy(), true);
+ defaults.revocationHistoryPolicy(), defaults.workflowHistoryPolicy(), true,
+ defaults.signingOperationHorizon(), defaults.signingIdPermittedSkew());
}
private static void sleepMillis(long ms) throws InterruptedException {
diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemSignWorkflowStoreTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemSignWorkflowStoreTest.java
new file mode 100644
index 0000000..60cfe52
--- /dev/null
+++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemSignWorkflowStoreTest.java
@@ -0,0 +1,677 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification,
+ * are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.pki.impl.fs;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.nio.ByteBuffer;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.SecureRandom;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import zeroecho.core.io.Util;
+import zeroecho.pki.api.EncodedObject;
+import zeroecho.pki.api.Encoding;
+import zeroecho.pki.api.KeyRef;
+import zeroecho.pki.api.PkiId;
+import zeroecho.pki.api.audit.AccessContext;
+import zeroecho.pki.api.audit.Principal;
+import zeroecho.pki.api.audit.Purpose;
+import zeroecho.pki.api.orch.SigningSubmissionId;
+import zeroecho.pki.impl.core.async.PkiSigningBus;
+import zeroecho.pki.spi.store.SignWorkflowStore;
+import zeroecho.pki.testkit.InMemorySignatureWorkflow;
+
+final class FilesystemSignWorkflowStoreTest {
+
+ private static final Principal TEST_OWNER = new Principal("TEST", "owner");
+ private static final AccessContext TEST_ACCESS = new AccessContext(TEST_OWNER, new Purpose("SIGN"),
+ Optional.empty(), Optional.empty());
+ private static final KeyRef TEST_KEY = new KeyRef("test-key");
+ private static final String TEST_ALGORITHM = "SHA256withRSA";
+
+ @Test
+ void currentRecordCodecRequiresExactShapeAndRedactsStructuralFailures() throws Exception {
+ System.out.println("currentRecordCodecRequiresExactShapeAndRedactsStructuralFailures");
+ Instant createdAt = Instant.parse("2026-01-02T03:04:05.123Z");
+ PkiId id = SigningSubmissionId.create("0123456789abcdef0123456789abcdef.test-signer", createdAt,
+ new SecureRandom()).id();
+ SignWorkflowStore.Record current = intent(id, createdAt,
+ new EncodedObject(Encoding.BINARY, new byte[] { 7 }), TEST_ALGORITHM);
+ String fingerprint = current.fingerprint();
+ byte[] encoded = FsCodec.encode(current);
+
+ SignWorkflowStore.Record decoded = FsCodec.decode(encoded, SignWorkflowStore.Record.class);
+ assertEquals(current.submissionId(), decoded.submissionId());
+ assertEquals(current.providerUpdatedAt(), decoded.providerUpdatedAt());
+
+ int componentCountOffset = recordComponentCountOffset(encoded);
+ byte[] missingComponent = encoded.clone();
+ missingComponent[componentCountOffset] = (byte) (SignWorkflowStore.Record.class.getRecordComponents().length
+ - 1);
+ assertRedactedStructuralFailure(missingComponent, fingerprint);
+
+ byte[] extraComponent = encoded.clone();
+ extraComponent[componentCountOffset] = (byte) (SignWorkflowStore.Record.class.getRecordComponents().length
+ + 1);
+ assertRedactedStructuralFailure(extraComponent, fingerprint);
+
+ byte[] missingProviderUpdatedAt = Arrays.copyOf(encoded, encoded.length - 3);
+ assertRedactedStructuralFailure(missingProviderUpdatedAt, fingerprint);
+ System.out.println("...ok");
+ }
+
+ @Test
+ void malformedCurrentRecordCannotActivateAttachOrExposeResult(@TempDir Path root) throws Exception {
+ System.out.println("malformedCurrentRecordCannotActivateAttachOrExposeResult");
+ Instant createdAt = Instant.parse("2026-01-02T03:04:05.123Z");
+ MutableClock clock = new MutableClock(createdAt);
+ SignWorkflowStore.Record intent;
+ PkiId id;
+ try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock)) {
+ String namespace = store.signingNamespace() + ".test-signer";
+ id = SigningSubmissionId.create(namespace, createdAt, new SecureRandom()).id();
+ intent = intent(id, createdAt, new EncodedObject(Encoding.BINARY, new byte[] { 9 }), TEST_ALGORITHM);
+ store.createSignIntent(intent);
+ }
+ String fingerprint = intent.fingerprint();
+
+ Path recordPath = new FsPaths(root).signWorkflowPath(id);
+ byte[] malformed = Files.readAllBytes(recordPath);
+ int envelopeBytes = Integer.BYTES * 2;
+ int componentCountOffset = envelopeBytes
+ + recordComponentCountOffset(Arrays.copyOfRange(malformed, envelopeBytes, malformed.length));
+ malformed[componentCountOffset] = (byte) (SignWorkflowStore.Record.class.getRecordComponents().length - 1);
+ Files.write(recordPath, malformed);
+
+ try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock);
+ InMemorySignatureWorkflow signer = new InMemorySignatureWorkflow(Map.of(), false)) {
+ RuntimeException activationFailure = assertThrows(RuntimeException.class,
+ () -> new PkiSigningBus(reopened, signer, root.resolve("bus.log")));
+ assertFalse(activationFailure.toString().contains(fingerprint));
+ RuntimeException attachFailure = assertThrows(RuntimeException.class,
+ () -> reopened.createSignIntent(intent));
+ assertFalse(attachFailure.toString().contains(fingerprint));
+ assertThrows(RuntimeException.class, () -> reopened.getSignRecord(id));
+ }
+ System.out.println("...ok");
+ }
+
+ @Test
+ void unsupportedFilesystemSigningRecordVersionCannotActivate(@TempDir Path root) throws Exception {
+ System.out.println("unsupportedFilesystemSigningRecordVersionCannotActivate");
+ Instant createdAt = Instant.parse("2026-01-02T03:04:05.123Z");
+ MutableClock clock = new MutableClock(createdAt);
+ PkiId id;
+ String fingerprint;
+ try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock)) {
+ String namespace = store.signingNamespace() + ".test-signer";
+ id = SigningSubmissionId.create(namespace, createdAt, new SecureRandom()).id();
+ SignWorkflowStore.Record current = intent(id, createdAt,
+ new EncodedObject(Encoding.BINARY, new byte[] { 10 }), TEST_ALGORITHM);
+ fingerprint = current.fingerprint();
+ store.createSignIntent(current);
+ }
+
+ Path recordPath = new FsPaths(root).signWorkflowPath(id);
+ byte[] unsupported = Files.readAllBytes(recordPath);
+ java.nio.ByteBuffer.wrap(unsupported).putInt(Integer.BYTES, 99);
+ Files.write(recordPath, unsupported);
+
+ try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock);
+ InMemorySignatureWorkflow signer = new InMemorySignatureWorkflow(Map.of(), false)) {
+ RuntimeException failure = assertThrows(RuntimeException.class,
+ () -> new PkiSigningBus(reopened, signer, root.resolve("unsupported-version-bus.log")));
+ assertTrue(failure.toString().contains("Unsupported signing record version"));
+ assertFalse(failure.toString().contains(fingerprint));
+ assertFalse(failure.toString().contains(id.value()));
+ }
+ System.out.println("...ok");
+ }
+
+ @Test
+ void stableIdentityCasRestartConflictAndExpiry(@TempDir java.nio.file.Path root) throws Exception {
+ Instant createdAt = Instant.parse("2026-01-02T03:04:05.123Z");
+ MutableClock clock = new MutableClock(createdAt);
+ FsPkiStoreOptions defaults = FsPkiStoreOptions.defaults();
+ FsPkiStoreOptions options = new FsPkiStoreOptions(defaults.caHistoryPolicy(),
+ defaults.profileHistoryPolicy(), defaults.revocationHistoryPolicy(), defaults.workflowHistoryPolicy(),
+ true, Duration.ofDays(90), Duration.ZERO);
+ EncodedObject request = new EncodedObject(Encoding.BINARY, new byte[] { 1, 2, 3 });
+ PkiId id;
+ SignWorkflowStore.Record intent;
+ try (FilesystemPkiStore store = new FilesystemPkiStore(root, options, clock)) {
+ String namespace = store.signingNamespace() + ".test-signer";
+ id = SigningSubmissionId.create(namespace, createdAt, new SecureRandom()).id();
+ intent = intent(id, createdAt, request, TEST_ALGORITHM);
+ assertEquals(SignWorkflowStore.CreateResult.CREATED, store.createSignIntent(intent));
+ assertEquals(SignWorkflowStore.CreateResult.ATTACHED, store.createSignIntent(intent));
+ assertEquals(SignWorkflowStore.CreateResult.CONFLICT,
+ store.createSignIntent(intent(id, createdAt, request, "SHA512withRSA")));
+
+ SignWorkflowStore.Record claimed = store.tryClaimSign(id, 0L, Duration.ofSeconds(30)).orElseThrow();
+ assertEquals(1L, claimed.revision());
+ assertEquals(1L, claimed.fence());
+ assertTrue(store.tryClaimSign(id, 0L, Duration.ofSeconds(30)).isEmpty());
+ assertTrue(store.transitionSign(id, claimed.revision(), claimed.fence(),
+ SignWorkflowStore.State.DISPATCHED, Optional.of("DISPATCHED"), Optional.empty(),
+ Optional.empty()).isPresent());
+ }
+
+ try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, options, clock)) {
+ assertEquals(SignWorkflowStore.State.DISPATCHED, reopened.getSignRecord(id).orElseThrow().state());
+ clock.set(createdAt.plus(Duration.ofDays(90)));
+ assertEquals(0, reopened.purgeExpiredSignRecords());
+ SignWorkflowStore.Record active = reopened.getSignRecord(id).orElseThrow();
+ SignWorkflowStore.Record failed = reopened.transitionSign(id, active.revision(), active.fence(),
+ SignWorkflowStore.State.FAILED, Optional.of("PROVIDER_FAILED"), Optional.empty(),
+ Optional.of(clock.instant())).orElseThrow();
+ reopened.retireSign(id, failed.revision(), failed.fence()).orElseThrow();
+ assertEquals(1, reopened.purgeExpiredSignRecords());
+ assertFalse(reopened.getSignRecord(id).isPresent());
+ assertThrows(IllegalArgumentException.class, () -> reopened.createSignIntent(intent));
+ }
+ try (FilesystemPkiStore foreign = new FilesystemPkiStore(root.resolveSibling("foreign"), options, clock)) {
+ assertThrows(IllegalArgumentException.class, () -> foreign.createSignIntent(intent));
+ }
+ }
+
+ @Test
+ void snapshotRetainsNamespaceResultRevisionFenceAndRollbackWatermark(@TempDir java.nio.file.Path root)
+ throws Exception {
+ Instant createdAt = Instant.parse("2026-04-05T06:07:08.900Z");
+ MutableClock clock = new MutableClock(createdAt);
+ Path snapshot = root.resolveSibling(root.getFileName() + "-snapshot");
+ String namespace;
+ PkiId id;
+ try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock)) {
+ namespace = store.signingNamespace() + ".test-signer";
+ id = SigningSubmissionId.create(namespace, createdAt, new SecureRandom()).id();
+ SignWorkflowStore.Record created = intent(id, createdAt,
+ new EncodedObject(Encoding.BINARY, new byte[] { 4 }), TEST_ALGORITHM);
+ store.createSignIntent(created);
+ SignWorkflowStore.Record claimed = store.tryClaimSign(id, 0L, Duration.ofSeconds(10)).orElseThrow();
+ SignWorkflowStore.Record dispatched = store.transitionSign(id, claimed.revision(), claimed.fence(),
+ SignWorkflowStore.State.DISPATCHED, Optional.of("DISPATCHED"), Optional.empty(),
+ Optional.empty()).orElseThrow();
+ clock.set(createdAt.plusSeconds(1));
+ store.transitionSign(id, dispatched.revision(), dispatched.fence(), SignWorkflowStore.State.SUCCEEDED,
+ Optional.of("SIGNED"), Optional.of(new EncodedObject(Encoding.BINARY, new byte[] { 9 })),
+ Optional.of(createdAt.plusSeconds(1)))
+ .orElseThrow();
+ clock.set(createdAt.plusSeconds(30));
+ assertEquals(createdAt.plusSeconds(30), store.signingNow());
+ clock.set(createdAt.minusSeconds(30));
+ assertEquals(createdAt.plusSeconds(30), store.signingNow());
+ // Signing safety metadata deliberately reflects export time, even when the
+ // requested historical instant predates the operation.
+ store.exportSnapshot(snapshot, createdAt.minusSeconds(60));
+ }
+ try (FilesystemPkiStore restored = new FilesystemPkiStore(snapshot, FsPkiStoreOptions.defaults(), clock)) {
+ SignWorkflowStore.Record record = restored.getSignRecord(id).orElseThrow();
+ assertEquals(namespace.substring(0, 32), restored.signingNamespace());
+ assertEquals(SignWorkflowStore.State.SUCCEEDED, record.state());
+ assertEquals(3L, record.revision());
+ assertEquals(1L, record.fence());
+ assertEquals(9, record.result().orElseThrow().bytes()[0]);
+ assertEquals(createdAt.plusSeconds(30), restored.signingNow());
+ }
+ }
+
+ @Test
+ void concurrentClaimsHaveOneWinnerAndDifferentIdsRemainIndependent(@TempDir java.nio.file.Path root)
+ throws Exception {
+ Instant createdAt = Instant.parse("2026-01-02T03:04:05.123Z");
+ MutableClock clock = new MutableClock(createdAt);
+ EncodedObject request = new EncodedObject(Encoding.BINARY, new byte[] { 1 });
+ try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock);
+ ExecutorService executor = Executors.newFixedThreadPool(8)) {
+ String namespace = store.signingNamespace() + ".test-signer";
+ PkiId first = SigningSubmissionId.create(namespace, createdAt, new SecureRandom()).id();
+ PkiId second = SigningSubmissionId.create(namespace, createdAt, new SecureRandom()).id();
+ store.createSignIntent(intent(first, createdAt, request, TEST_ALGORITHM));
+ store.createSignIntent(intent(second, createdAt, request, TEST_ALGORITHM));
+ Callable claim = () -> store.tryClaimSign(first, 0L, Duration.ofSeconds(30)).isPresent();
+ long winners = executor.invokeAll(java.util.Collections.nCopies(32, claim)).stream().filter(future -> {
+ try {
+ return future.get();
+ } catch (Exception ex) {
+ throw new IllegalStateException(ex);
+ }
+ }).count();
+ assertEquals(1L, winners);
+ assertTrue(store.tryClaimSign(second, 0L, Duration.ofSeconds(30)).isPresent());
+ }
+ }
+
+ @Test
+ void concurrentWatermarkWritesNeverRegressAcrossRestart(@TempDir Path root) throws Exception {
+ Instant base = Instant.parse("2026-01-02T03:04:05.123Z");
+ Instant high = base.plusSeconds(60);
+ MutableClock clock = new MutableClock(base);
+ try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock);
+ ExecutorService executor = Executors.newFixedThreadPool(2)) {
+ clock.arm(high);
+ Future highRead = executor.submit(store::signingNow);
+ assertTrue(clock.observed.await(5, TimeUnit.SECONDS));
+ clock.set(base.minusSeconds(60));
+ Future rollbackRead = executor.submit(store::signingNow);
+ clock.release.countDown();
+ assertEquals(high, highRead.get(5, TimeUnit.SECONDS));
+ assertEquals(high, rollbackRead.get(5, TimeUnit.SECONDS));
+ }
+ clock.set(base.minusSeconds(120));
+ try (FilesystemPkiStore restarted = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock)) {
+ assertEquals(high, restarted.signingNow());
+ }
+ }
+
+ @Test
+ void cancellingStateUsesStableCodeAndSurvivesRestart(@TempDir Path root) throws Exception {
+ Instant createdAt = Instant.parse("2026-01-02T03:04:05.123Z");
+ MutableClock clock = new MutableClock(createdAt);
+ PkiId id;
+ try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock)) {
+ String namespace = store.signingNamespace() + ".test-signer";
+ id = SigningSubmissionId.create(namespace, createdAt, new SecureRandom()).id();
+ store.createSignIntent(intent(id, createdAt,
+ new EncodedObject(Encoding.BINARY, new byte[] { 8 }), TEST_ALGORITHM));
+ SignWorkflowStore.Record claimed = store.tryClaimSign(id, 0L, Duration.ofSeconds(30)).orElseThrow();
+ SignWorkflowStore.Record dispatched = store.transitionSign(id, claimed.revision(), claimed.fence(),
+ SignWorkflowStore.State.DISPATCHED, Optional.of("DISPATCHED"), Optional.empty(),
+ Optional.empty()).orElseThrow();
+ store.transitionSign(id, dispatched.revision(), dispatched.fence(),
+ SignWorkflowStore.State.CANCELLING, Optional.of("CANCEL_REQUESTED"), Optional.empty(),
+ Optional.empty())
+ .orElseThrow();
+ assertEquals(30, SignWorkflowStore.State.CANCELLING.persistentCode());
+ assertEquals(SignWorkflowStore.State.CANCELLING,
+ FsCodec.decode(FsCodec.encode(SignWorkflowStore.State.CANCELLING),
+ SignWorkflowStore.State.class));
+ assertThrows(IllegalArgumentException.class,
+ () -> SignWorkflowStore.State.fromPersistentCode(999));
+ }
+ try (FilesystemPkiStore restarted = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock)) {
+ assertEquals(SignWorkflowStore.State.CANCELLING, restarted.getSignRecord(id).orElseThrow().state());
+ }
+ }
+
+ @Test
+ void everyCurrentSigningStateSurvivesSemanticValidationOnRestart(@TempDir Path root) throws Exception {
+ System.out.println("everyCurrentSigningStateSurvivesSemanticValidationOnRestart");
+ Instant createdAt = Instant.parse("2026-01-02T03:04:05.123Z");
+ MutableClock clock = new MutableClock(createdAt);
+ java.util.LinkedHashMap expected = new java.util.LinkedHashMap<>();
+ try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock)) {
+ expected.put(persistIntent(store, createdAt, 1), SignWorkflowStore.State.INTENT);
+
+ PkiId claimedId = persistIntent(store, createdAt, 2);
+ store.tryClaimSign(claimedId, 0L, Duration.ofSeconds(30)).orElseThrow();
+ expected.put(claimedId, SignWorkflowStore.State.INTENT);
+
+ PkiId dispatchedId = persistDispatched(store, createdAt, 3);
+ expected.put(dispatchedId, SignWorkflowStore.State.DISPATCHED);
+
+ PkiId cancellingId = persistDispatched(store, createdAt, 4);
+ SignWorkflowStore.Record cancellingSource = store.getSignRecord(cancellingId).orElseThrow();
+ store.transitionSign(cancellingId, cancellingSource.revision(), cancellingSource.fence(),
+ SignWorkflowStore.State.CANCELLING, Optional.of("CANCEL_REQUESTED"), Optional.empty(),
+ Optional.empty()).orElseThrow();
+ expected.put(cancellingId, SignWorkflowStore.State.CANCELLING);
+
+ PkiId succeededId = persistDispatched(store, createdAt, 5);
+ SignWorkflowStore.Record successSource = store.getSignRecord(succeededId).orElseThrow();
+ store.transitionSign(succeededId, successSource.revision(), successSource.fence(),
+ SignWorkflowStore.State.SUCCEEDED, Optional.of("SIGNED"),
+ Optional.of(new EncodedObject(Encoding.BINARY, new byte[] { 5 })),
+ Optional.of(createdAt.plusSeconds(1))).orElseThrow();
+ expected.put(succeededId, SignWorkflowStore.State.SUCCEEDED);
+
+ PkiId failedId = persistClaimed(store, createdAt, 6);
+ SignWorkflowStore.Record failureSource = store.getSignRecord(failedId).orElseThrow();
+ store.transitionSign(failedId, failureSource.revision(), failureSource.fence(),
+ SignWorkflowStore.State.FAILED, Optional.of("PROVIDER_SUBMISSION_FAILED"), Optional.empty(),
+ Optional.empty()).orElseThrow();
+ expected.put(failedId, SignWorkflowStore.State.FAILED);
+
+ PkiId cancelledId = persistIntent(store, createdAt, 7);
+ SignWorkflowStore.Record cancelSource = store.getSignRecord(cancelledId).orElseThrow();
+ SignWorkflowStore.Record cancelling = store.transitionSign(cancelledId, cancelSource.revision(),
+ cancelSource.fence(), SignWorkflowStore.State.CANCELLING, Optional.of("CANCEL_REQUESTED"),
+ Optional.empty(), Optional.empty()).orElseThrow();
+ store.transitionSign(cancelledId, cancelling.revision(), cancelling.fence(),
+ SignWorkflowStore.State.CANCELLED, Optional.of("CANCELLED"), Optional.empty(), Optional.empty())
+ .orElseThrow();
+ expected.put(cancelledId, SignWorkflowStore.State.CANCELLED);
+
+ PkiId expiredId = persistIntent(store, createdAt, 8);
+ SignWorkflowStore.Record expirySource = store.getSignRecord(expiredId).orElseThrow();
+ store.transitionSign(expiredId, expirySource.revision(), expirySource.fence(),
+ SignWorkflowStore.State.EXPIRED, Optional.of("EXPIRED"), Optional.empty(), Optional.empty())
+ .orElseThrow();
+ expected.put(expiredId, SignWorkflowStore.State.EXPIRED);
+
+ PkiId retiredId = persistDispatched(store, createdAt, 9);
+ SignWorkflowStore.Record retiredSuccessSource = store.getSignRecord(retiredId).orElseThrow();
+ SignWorkflowStore.Record retiredSuccess = store.transitionSign(retiredId,
+ retiredSuccessSource.revision(), retiredSuccessSource.fence(), SignWorkflowStore.State.SUCCEEDED,
+ Optional.of("SIGNED"), Optional.of(new EncodedObject(Encoding.BINARY, new byte[] { 9 })),
+ Optional.of(createdAt.plusSeconds(1))).orElseThrow();
+ store.retireSign(retiredId, retiredSuccess.revision(), retiredSuccess.fence()).orElseThrow();
+ expected.put(retiredId, SignWorkflowStore.State.RETIRED);
+ }
+
+ try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock)) {
+ for (Map.Entry entry : expected.entrySet()) {
+ SignWorkflowStore.Record restored = reopened.getSignRecord(entry.getKey()).orElseThrow();
+ System.out.println("...state=" + restored.state());
+ assertEquals(entry.getValue(), restored.state());
+ if (restored.state() == SignWorkflowStore.State.SUCCEEDED
+ || restored.state() == SignWorkflowStore.State.RETIRED) {
+ assertTrue(restored.result().isPresent());
+ }
+ }
+ }
+ System.out.println("...ok");
+ }
+
+ @Test
+ void semanticCorruptionCannotActivateRecoverOrExposeInjectedResult(@TempDir Path root) throws Exception {
+ System.out.println("semanticCorruptionCannotActivateRecoverOrExposeInjectedResult");
+ List cases = List.of(
+ new CorruptionCase("request-changed", record -> copy(record,
+ request(record.submissionId(), new byte[] { 99 }, TEST_ALGORITHM),
+ record.fingerprint(), record.state(), record.revision(), record.fence(), record.leaseUntil(),
+ record.detailCode(), record.result(), record.providerUpdatedAt()), "FINGERPRINT_MISMATCH"),
+ new CorruptionCase("fingerprint-changed", record -> copy(record, record.request(),
+ flipFingerprint(record.fingerprint()), record.state(), record.revision(), record.fence(),
+ record.leaseUntil(), record.detailCode(), record.result(), record.providerUpdatedAt()),
+ "FINGERPRINT_MISMATCH"),
+ new CorruptionCase("fingerprint-length", record -> copy(record, record.request(), "signfp:v1:00",
+ record.state(), record.revision(), record.fence(), record.leaseUntil(), record.detailCode(),
+ record.result(), record.providerUpdatedAt()), "FINGERPRINT_FORMAT_INVALID"),
+ new CorruptionCase("success-no-result", record -> copy(record, record.request(),
+ record.fingerprint(), record.state(), record.revision(), record.fence(), record.leaseUntil(),
+ record.detailCode(), Optional.empty(), record.providerUpdatedAt()),
+ "SUCCESS_EVIDENCE_MISSING"),
+ new CorruptionCase("success-no-provider-time", record -> copy(record, record.request(),
+ record.fingerprint(), record.state(), record.revision(), record.fence(), record.leaseUntil(),
+ record.detailCode(), record.result(), Optional.empty()), "SUCCESS_EVIDENCE_MISSING"),
+ new CorruptionCase("success-at-deadline", record -> copy(record, record.request(),
+ record.fingerprint(), record.state(), record.revision(), record.fence(), record.leaseUntil(),
+ record.detailCode(), record.result(), Optional.of(record.deadline())),
+ "PROVIDER_TIME_INVALID"),
+ new CorruptionCase("success-after-deadline", record -> copy(record, record.request(),
+ record.fingerprint(), record.state(), record.revision(), record.fence(), record.leaseUntil(),
+ record.detailCode(), record.result(), Optional.of(record.deadline().plusNanos(1))),
+ "PROVIDER_TIME_INVALID"),
+ new CorruptionCase("success-active-lease", record -> copy(record, record.request(),
+ record.fingerprint(), record.state(), record.revision(), record.fence(),
+ Optional.of(record.createdAt().plusSeconds(30)), record.detailCode(), record.result(),
+ record.providerUpdatedAt()), "SUCCESS_EVIDENCE_MISSING"),
+ new CorruptionCase("success-expiry-code", record -> copy(record, record.request(),
+ record.fingerprint(), record.state(), record.revision(), record.fence(), record.leaseUntil(),
+ Optional.of("EXPIRED"), record.result(), record.providerUpdatedAt()),
+ "SUCCESS_DETAIL_CONTRADICTORY"),
+ new CorruptionCase("success-stale-fence", record -> copy(record, record.request(),
+ record.fingerprint(), record.state(), record.revision(), record.revision(),
+ record.leaseUntil(), record.detailCode(), record.result(), record.providerUpdatedAt()),
+ "SUCCESS_REVISION_INVALID"),
+ new CorruptionCase("injected-result-fingerprint-mismatch", record -> copy(record, record.request(),
+ flipFingerprint(record.fingerprint()), record.state(), record.revision(), record.fence(),
+ record.leaseUntil(), record.detailCode(),
+ Optional.of(new EncodedObject(Encoding.BINARY, "injected-signature".getBytes(
+ java.nio.charset.StandardCharsets.UTF_8))),
+ record.providerUpdatedAt()), "FINGERPRINT_MISMATCH"),
+ new CorruptionCase("failed-with-result", record -> copy(record, record.request(),
+ record.fingerprint(), SignWorkflowStore.State.FAILED, record.revision(), record.fence(),
+ record.leaseUntil(), Optional.of("FAILED"), record.result(), record.providerUpdatedAt()),
+ "FAILURE_RESULT_OR_LEASE_PRESENT"),
+ new CorruptionCase("claim-without-lease", record -> copy(record, record.request(),
+ record.fingerprint(), SignWorkflowStore.State.INTENT, 1L, 1L, Optional.empty(),
+ Optional.of("INTENT"), Optional.empty(), Optional.empty()), "INTENT_CLAIM_INVALID"),
+ new CorruptionCase("terminal-with-lease", record -> copy(record, record.request(),
+ record.fingerprint(), record.state(), record.revision(), record.fence(),
+ Optional.of(record.createdAt().plusSeconds(30)), record.detailCode(), record.result(),
+ record.providerUpdatedAt()), "SUCCESS_EVIDENCE_MISSING"),
+ new CorruptionCase("lease-before-creation", record -> copy(record, record.request(),
+ record.fingerprint(), SignWorkflowStore.State.INTENT, 1L, 1L,
+ Optional.of(record.createdAt().minusNanos(1)), Optional.of("INTENT"), Optional.empty(),
+ Optional.empty()), "LEASE_TIME_INVALID"),
+ new CorruptionCase("dispatched-zero-fence", record -> copy(record, record.request(),
+ record.fingerprint(), SignWorkflowStore.State.DISPATCHED, 1L, 0L, Optional.empty(),
+ Optional.of("DISPATCHED"), Optional.empty(), Optional.empty()),
+ "DISPATCH_REVISION_INVALID"));
+
+ int index = 0;
+ for (CorruptionCase corruption : cases) {
+ Path caseRoot = root.resolve("case-" + index++);
+ assertSemanticCorruptionRejected(caseRoot, corruption);
+ System.out.println("...case=" + corruption.name());
+ }
+ System.out.println("...ok");
+ }
+
+ private static SignWorkflowStore.Record intent(PkiId id, Instant createdAt, EncodedObject request,
+ String algorithmId) {
+ Instant deadline = createdAt.plusSeconds(60);
+ PkiSigningBus.SignContinuation continuation = continuation(id, request, algorithmId);
+ String namespace = SigningSubmissionId.parse(id).namespace();
+ String fingerprint = continuation.semanticFingerprint(namespace, deadline);
+ return new SignWorkflowStore.Record(id, namespace, fingerprint, TEST_OWNER,
+ createdAt, deadline, continuation.encode(), SignWorkflowStore.State.INTENT, 0L, 0L,
+ Optional.empty(), Optional.of("INTENT"), Optional.empty(), Optional.empty());
+ }
+
+ private static PkiId persistIntent(FilesystemPkiStore store, Instant createdAt, int marker) {
+ String namespace = store.signingNamespace() + ".test-signer";
+ PkiId id = SigningSubmissionId.create(namespace, createdAt, new SecureRandom()).id();
+ store.createSignIntent(intent(id, createdAt,
+ new EncodedObject(Encoding.BINARY, new byte[] { (byte) marker }), TEST_ALGORITHM));
+ return id;
+ }
+
+ private static PkiId persistClaimed(FilesystemPkiStore store, Instant createdAt, int marker) {
+ PkiId id = persistIntent(store, createdAt, marker);
+ store.tryClaimSign(id, 0L, Duration.ofSeconds(30)).orElseThrow();
+ return id;
+ }
+
+ private static PkiId persistDispatched(FilesystemPkiStore store, Instant createdAt, int marker) {
+ PkiId id = persistClaimed(store, createdAt, marker);
+ SignWorkflowStore.Record claimed = store.getSignRecord(id).orElseThrow();
+ store.transitionSign(id, claimed.revision(), claimed.fence(), SignWorkflowStore.State.DISPATCHED,
+ Optional.of("DISPATCHED"), Optional.empty(), Optional.empty()).orElseThrow();
+ return id;
+ }
+
+ private static PkiSigningBus.SignContinuation continuation(PkiId id, EncodedObject payload,
+ String algorithmId) {
+ return new PkiSigningBus.SignContinuation(TEST_ACCESS, algorithmId, payload, TEST_KEY, Encoding.BINARY,
+ Optional.of(id));
+ }
+
+ private static EncodedObject request(PkiId id, byte[] payload, String algorithmId) {
+ return continuation(id, new EncodedObject(Encoding.BINARY, payload), algorithmId).encode();
+ }
+
+ private static SignWorkflowStore.Record copy(SignWorkflowStore.Record source, EncodedObject request,
+ String fingerprint, SignWorkflowStore.State state, long revision, long fence,
+ Optional leaseUntil, Optional detailCode, Optional result,
+ Optional providerUpdatedAt) {
+ return new SignWorkflowStore.Record(source.submissionId(), source.namespace(), fingerprint, source.owner(),
+ source.createdAt(), source.deadline(), request, state, revision, fence, leaseUntil, detailCode, result,
+ providerUpdatedAt);
+ }
+
+ private static String flipFingerprint(String fingerprint) {
+ int index = "signfp:v1:".length();
+ char replacement = fingerprint.charAt(index) == '0' ? '1' : '0';
+ return fingerprint.substring(0, index) + replacement + fingerprint.substring(index + 1);
+ }
+
+ private static void assertSemanticCorruptionRejected(Path root, CorruptionCase corruption) throws Exception {
+ Instant createdAt = Instant.parse("2026-01-02T03:04:05.123Z");
+ MutableClock clock = new MutableClock(createdAt);
+ PkiId id;
+ SignWorkflowStore.Record succeeded;
+ try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock)) {
+ id = persistDispatched(store, createdAt, 42);
+ SignWorkflowStore.Record dispatched = store.getSignRecord(id).orElseThrow();
+ clock.set(createdAt.plusSeconds(1));
+ succeeded = store.transitionSign(id, dispatched.revision(), dispatched.fence(),
+ SignWorkflowStore.State.SUCCEEDED, Optional.of("SIGNED"),
+ Optional.of(new EncodedObject(Encoding.BINARY, "valid-signature".getBytes(
+ java.nio.charset.StandardCharsets.UTF_8))),
+ Optional.of(createdAt.plusSeconds(1))).orElseThrow();
+ }
+
+ SignWorkflowStore.Record corrupted = corruption.mutation().apply(succeeded);
+ writeRawCurrentRecord(root, id, corrupted);
+ String sensitiveRequest = "injected-signature";
+ String sensitiveFingerprint = corrupted.fingerprint();
+ Path busLog = root.resolve("corrupt-bus.log");
+ try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock);
+ InMemorySignatureWorkflow signer = new InMemorySignatureWorkflow(Map.of(), false)) {
+ IllegalStateException readFailure = assertThrows(IllegalStateException.class,
+ () -> reopened.getSignRecord(id));
+ assertTrue(readFailure.getMessage().contains("code=" + corruption.expectedCode()));
+ assertFalse(readFailure.toString().contains(sensitiveRequest));
+ assertFalse(readFailure.toString().contains(sensitiveFingerprint));
+
+ IllegalStateException activationFailure = assertThrows(IllegalStateException.class,
+ () -> new PkiSigningBus(reopened, signer, busLog));
+ assertTrue(activationFailure.getMessage().contains("code=" + corruption.expectedCode()));
+ assertEquals(0, signer.submittedSignCount());
+ assertThrows(IllegalStateException.class, () -> reopened.listSignRecords());
+ }
+ assertFalse(Files.exists(busLog) && Files.size(busLog) > 0L);
+ }
+
+ 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(record);
+ ByteBuffer replacement = ByteBuffer.allocate(Integer.BYTES * 2 + payload.length);
+ replacement.put(existing, 0, Integer.BYTES * 2);
+ replacement.put(payload);
+ Files.write(path, replacement.array());
+ }
+
+ private static int recordComponentCountOffset(byte[] encoded) throws Exception {
+ ByteArrayInputStream input = new ByteArrayInputStream(encoded);
+ input.read();
+ input.read();
+ Util.readUTF8(input, 4096);
+ return encoded.length - input.available();
+ }
+
+ private static void assertRedactedStructuralFailure(byte[] encoded, String sensitiveFingerprint) {
+ IllegalStateException failure = assertThrows(IllegalStateException.class,
+ () -> FsCodec.decode(encoded, SignWorkflowStore.Record.class));
+ assertFalse(failure.toString().contains(sensitiveFingerprint));
+ }
+
+ private record CorruptionCase(String name,
+ java.util.function.UnaryOperator mutation, String expectedCode) {
+ }
+
+ private static final class MutableClock extends Clock {
+ private final AtomicBoolean block = new AtomicBoolean();
+ private final CountDownLatch observed = new CountDownLatch(1);
+ private final CountDownLatch release = new CountDownLatch(1);
+ private volatile Instant instant;
+
+ private MutableClock(Instant instant) {
+ this.instant = instant;
+ }
+
+ private void set(Instant value) {
+ this.instant = value;
+ }
+
+ private void arm(Instant value) {
+ this.instant = value;
+ block.set(true);
+ }
+
+ @Override
+ public ZoneId getZone() {
+ return ZoneId.of("UTC");
+ }
+
+ @Override
+ public Clock withZone(ZoneId zone) {
+ return this;
+ }
+
+ @Override
+ public Instant instant() {
+ Instant observedValue = instant;
+ if (block.compareAndSet(true, false)) {
+ observed.countDown();
+ try {
+ release.await();
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(ex);
+ }
+ }
+ return observedValue;
+ }
+ }
+}
diff --git a/pki/src/test/java/zeroecho/pki/spi/bootstrap/PkiBootstrapTest.java b/pki/src/test/java/zeroecho/pki/spi/bootstrap/PkiBootstrapTest.java
index 5cd249b..2cbc2c2 100644
--- a/pki/src/test/java/zeroecho/pki/spi/bootstrap/PkiBootstrapTest.java
+++ b/pki/src/test/java/zeroecho/pki/spi/bootstrap/PkiBootstrapTest.java
@@ -263,6 +263,8 @@ public final class PkiBootstrapTest {
System.setProperty("zeroecho.pki.crypto.workflow", "zeroecho-lib");
System.setProperty("zeroecho.pki.crypto.workflow.keyringPath",
this.tempDir.resolve("workflow").resolve("keyring.zek").toString());
+ System.setProperty("zeroecho.pki.crypto.workflow.operationRoot",
+ this.tempDir.resolve("workflow").resolve("operations").toString());
System.setProperty("zeroecho.pki.crypto.workflow.keyRefPrefix", "test-prefix:");
System.setProperty("zeroecho.pki.crypto.workflow.requireComponentSuffix", "false");
diff --git a/pki/src/test/java/zeroecho/pki/spi/crypto/SignRequestCleanupTest.java b/pki/src/test/java/zeroecho/pki/spi/crypto/SignRequestCleanupTest.java
new file mode 100644
index 0000000..730f950
--- /dev/null
+++ b/pki/src/test/java/zeroecho/pki/spi/crypto/SignRequestCleanupTest.java
@@ -0,0 +1,96 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ ******************************************************************************/
+package zeroecho.pki.spi.crypto;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.security.MessageDigest;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import org.junit.jupiter.api.Test;
+
+import zeroecho.pki.api.EncodedObject;
+import zeroecho.pki.api.Encoding;
+import zeroecho.pki.api.KeyRef;
+import zeroecho.pki.api.PkiId;
+import zeroecho.pki.api.audit.AccessContext;
+import zeroecho.pki.api.audit.Principal;
+import zeroecho.pki.api.audit.Purpose;
+
+final class SignRequestCleanupTest {
+
+ @Test
+ void fingerprintClearsPayloadAndDigestCopiesAfterSuccessAndRepeatedUse() throws Exception {
+ System.out.println("fingerprintClearsPayloadAndDigestCopiesAfterSuccessAndRepeatedUse");
+ List cleared = new ArrayList<>();
+ String first = fingerprint(MessageDigest.getInstance("SHA-256"), cleared);
+ String second = fingerprint(MessageDigest.getInstance("SHA-256"), cleared);
+ System.out.println("...fingerprint=" + first.substring(0, 20) + "...");
+ assertEquals(first, second);
+ assertEquals(4, cleared.size());
+ assertTrue(cleared.stream().allMatch(SignRequestCleanupTest::isCleared));
+ System.out.println("fingerprintClearsPayloadAndDigestCopiesAfterSuccessAndRepeatedUse...ok");
+ }
+
+ @Test
+ void fingerprintClearsPayloadCopyAfterDigestFailure() {
+ System.out.println("fingerprintClearsPayloadCopyAfterDigestFailure");
+ List cleared = new ArrayList<>();
+ assertThrows(IllegalStateException.class, () -> fingerprint(new FailingDigest(), cleared));
+ assertEquals(1, cleared.size());
+ assertTrue(isCleared(cleared.get(0)));
+ System.out.println("...clearedBuffers=" + cleared.size());
+ System.out.println("fingerprintClearsPayloadCopyAfterDigestFailure...ok");
+ }
+
+ private static String fingerprint(MessageDigest digest, List cleared) {
+ AccessContext context = new AccessContext(new Principal("SYSTEM", "cleanup"), new Purpose("SIGN"),
+ Optional.of(new PkiId("cleanup-operation")), Optional.empty());
+ return SignatureWorkflow.SignRequest.fingerprintWithDigest("cleanup.namespace", context,
+ new KeyRef("provider:key"), "SHA256withRSA",
+ new EncodedObject(Encoding.BINARY, new byte[] { 1, 2, 3, 4 }), Optional.of(Encoding.BINARY),
+ Optional.of(Instant.parse("2026-08-01T00:00:00Z")), digest, cleared::add);
+ }
+
+ private static boolean isCleared(byte[] bytes) {
+ for (byte value : bytes) {
+ if (value != 0) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static final class FailingDigest extends MessageDigest {
+ private FailingDigest() {
+ super("FAILING");
+ }
+
+ @Override
+ protected void engineUpdate(byte input) {
+ throw new IllegalStateException("controlled digest failure");
+ }
+
+ @Override
+ protected void engineUpdate(byte[] input, int offset, int length) {
+ throw new IllegalStateException("controlled digest failure");
+ }
+
+ @Override
+ protected byte[] engineDigest() {
+ return new byte[32];
+ }
+
+ @Override
+ protected void engineReset() {
+ // No state.
+ }
+ }
+}
diff --git a/pki/src/test/java/zeroecho/pki/testkit/DurableDelayedSignatureWorkflow.java b/pki/src/test/java/zeroecho/pki/testkit/DurableDelayedSignatureWorkflow.java
index 5c7d3bb..ec467f2 100644
--- a/pki/src/test/java/zeroecho/pki/testkit/DurableDelayedSignatureWorkflow.java
+++ b/pki/src/test/java/zeroecho/pki/testkit/DurableDelayedSignatureWorkflow.java
@@ -73,12 +73,14 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow,
private static final String FILE_SIGNATURE = "signature";
private final Path root;
+ private final TestSignIdentityRegistry identities;
private final Duration signingDelay;
private final Map keysByRef;
private final Map sinks;
public DurableDelayedSignatureWorkflow(Path root, Duration signingDelay, Map keysByRef) {
this.root = Objects.requireNonNull(root, "root");
+ this.identities = new TestSignIdentityRegistry(root);
this.signingDelay = Objects.requireNonNull(signingDelay, "signingDelay");
this.keysByRef = Objects.requireNonNull(keysByRef, "keysByRef");
this.sinks = new ConcurrentHashMap();
@@ -102,7 +104,10 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow,
public PkiId submitSign(SignRequest request) {
Objects.requireNonNull(request, "request");
- PkiId opId = new PkiId("sig-" + UUID.randomUUID());
+ PkiId opId = request.submissionId();
+ if (!identities.begin(request)) {
+ return opId;
+ }
Instant now = Instant.now();
persistRequest(opId, request);
@@ -133,7 +138,15 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow,
}
if (st.state() == State.PENDING) {
- OperationStatus running = new OperationStatus(State.RUNNING, Instant.now(), Optional.of("SIGNING"),
+ PersistedSignRequest request = loadRequest(operationId);
+ Instant startedAt = Instant.now();
+ if (deadlineReached(request.deadline, startedAt)) {
+ OperationStatus expired = expired(startedAt);
+ persistStatus(operationId, expired);
+ notifySink(operationId, expired);
+ return expired;
+ }
+ OperationStatus running = new OperationStatus(State.RUNNING, startedAt, Optional.of("SIGNING"),
Optional.empty());
persistStatus(operationId, running);
notifySink(operationId, running);
@@ -148,12 +161,10 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow,
// Best-effort: complete if signature file exists.
Optional sig = loadSignature(operationId);
if (sig.isPresent()) {
- OperationResult res = new OperationResult(Optional.of(sig.get()), Optional.empty());
- OperationStatus done = new OperationStatus(State.SUCCEEDED, Instant.now(), Optional.of("SIGNED"),
- Optional.of(res));
- persistStatus(operationId, done);
- notifySink(operationId, done);
- return done;
+ OperationStatus expired = expired(Instant.now());
+ persistStatus(operationId, expired);
+ notifySink(operationId, expired);
+ return expired;
}
}
@@ -161,12 +172,18 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow,
}
@Override
- public boolean cancel(PkiId operationId, String reason) {
+ public boolean cancel(PkiId operationId, long fencingToken, String reason) {
Objects.requireNonNull(operationId, "operationId");
Objects.requireNonNull(reason, "reason");
+ if (fencingToken <= 0L) {
+ throw new IllegalArgumentException("fencingToken must be positive");
+ }
if (reason.isBlank()) {
throw new IllegalArgumentException("reason must not be blank");
}
+ if (!identities.acceptFence(operationId, fencingToken)) {
+ return false;
+ }
OperationStatus st = status(operationId);
if (st.isTerminal()) {
return false;
@@ -203,6 +220,10 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow,
private OperationStatus performSign(PkiId opId) {
PersistedSignRequest req = loadRequest(opId);
+ Instant startedAt = Instant.now();
+ if (deadlineReached(req.deadline, startedAt)) {
+ return expired(startedAt);
+ }
PrivateKey key = keysByRef.get(req.keyRef);
if (key == null) {
return new OperationStatus(State.FAILED, Instant.now(), Optional.of("UNKNOWN_KEY"), Optional.empty());
@@ -226,11 +247,15 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow,
sig.update(req.payload);
byte[] signature = sig.sign();
+ Instant completedAt = Instant.now();
+ if (deadlineReached(req.deadline, completedAt)) {
+ return expired(completedAt);
+ }
persistSignature(opId, signature);
EncodedObject enc = new EncodedObject(Encoding.BINARY, signature);
OperationResult res = new OperationResult(Optional.of(enc), Optional.empty());
- return new OperationStatus(State.SUCCEEDED, Instant.now(), Optional.of("SIGNED"), Optional.of(res));
+ return new OperationStatus(State.SUCCEEDED, completedAt, Optional.of("SIGNED"), Optional.of(res));
} catch (Exception ex) {
return new OperationStatus(State.FAILED, Instant.now(), Optional.of("SIGN_ERROR"), Optional.empty());
}
@@ -255,7 +280,8 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow,
try {
Files.createDirectories(dir);
String line = req.keyRef().value() + "\n" + req.algorithmId() + "\n"
- + Base64.getEncoder().encodeToString(req.payload().bytes());
+ + Base64.getEncoder().encodeToString(req.payload().bytes()) + "\n"
+ + req.deadline().map(Instant::toString).orElse("");
Files.writeString(dir.resolve(FILE_REQUEST), line, StandardCharsets.UTF_8);
} catch (IOException ex) {
throw new IllegalStateException("Cannot persist request", ex);
@@ -266,13 +292,15 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow,
Path f = opDir(opId).resolve(FILE_REQUEST);
try {
String s = Files.readString(f, StandardCharsets.UTF_8);
- String[] parts = s.split("\n", 3);
+ String[] parts = s.split("\n", 4);
String keyRef = parts[0];
String alg = parts[1];
byte[] payload = Base64.getDecoder().decode(parts[2]);
- return new PersistedSignRequest(keyRef, alg, payload);
+ Optional deadline = parts.length == 4 && !parts[3].isBlank()
+ ? Optional.of(Instant.parse(parts[3])) : Optional.empty();
+ return new PersistedSignRequest(keyRef, alg, payload, deadline);
} catch (IOException ex) {
- return new PersistedSignRequest("", "", new byte[0]);
+ return new PersistedSignRequest("", "", new byte[0], Optional.empty());
}
}
@@ -339,11 +367,21 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow,
private final String keyRef;
private final String algorithmId;
private final byte[] payload;
+ private final Optional deadline;
- private PersistedSignRequest(String keyRef, String algorithmId, byte[] payload) {
+ private PersistedSignRequest(String keyRef, String algorithmId, byte[] payload, Optional deadline) {
this.keyRef = keyRef;
this.algorithmId = algorithmId;
this.payload = payload;
+ this.deadline = deadline;
}
}
+
+ private static boolean deadlineReached(Optional deadline, Instant observedAt) {
+ return deadline.isPresent() && !observedAt.isBefore(deadline.get());
+ }
+
+ private static OperationStatus expired(Instant observedAt) {
+ return new OperationStatus(State.EXPIRED, observedAt, Optional.of("EXPIRED"), Optional.empty());
+ }
}
diff --git a/pki/src/test/java/zeroecho/pki/testkit/DurableOperatorApprovalSignatureWorkflow.java b/pki/src/test/java/zeroecho/pki/testkit/DurableOperatorApprovalSignatureWorkflow.java
index e9a25c6..f54e3b1 100644
--- a/pki/src/test/java/zeroecho/pki/testkit/DurableOperatorApprovalSignatureWorkflow.java
+++ b/pki/src/test/java/zeroecho/pki/testkit/DurableOperatorApprovalSignatureWorkflow.java
@@ -89,6 +89,7 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature
private static final String DECISION_DENY = "DENY";
private final Path root;
+ private final TestSignIdentityRegistry identities;
private final Duration approvalWindow;
private final Duration signingDelay;
private final Map keysByRef;
@@ -105,6 +106,7 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature
public DurableOperatorApprovalSignatureWorkflow(Path root, Duration approvalWindow, Duration signingDelay,
Map keysByRef) {
this.root = Objects.requireNonNull(root, "root");
+ this.identities = new TestSignIdentityRegistry(root);
this.approvalWindow = Objects.requireNonNull(approvalWindow, "approvalWindow");
this.signingDelay = Objects.requireNonNull(signingDelay, "signingDelay");
this.keysByRef = Objects.requireNonNull(keysByRef, "keysByRef");
@@ -137,9 +139,15 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature
public PkiId submitSign(SignRequest request) {
Objects.requireNonNull(request, "request");
- PkiId opId = new PkiId("sig-" + UUID.randomUUID());
+ PkiId opId = request.submissionId();
+ if (!identities.begin(request)) {
+ return opId;
+ }
Instant now = Instant.now();
Instant deadline = now.plus(approvalWindow);
+ if (request.deadline().isPresent() && request.deadline().get().isBefore(deadline)) {
+ deadline = request.deadline().get();
+ }
persistRequest(opId, request);
persistDeadline(opId, deadline);
@@ -165,8 +173,9 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature
public void approve(PkiId operationId) {
Objects.requireNonNull(operationId, "operationId");
persistDecision(operationId, DECISION_APPROVE);
- OperationStatus st = new OperationStatus(State.PENDING, Instant.now(), Optional.of("APPROVED"),
- Optional.empty());
+ Instant now = Instant.now();
+ OperationStatus st = deadlineReached(loadDeadline(operationId), now)
+ ? expired(now) : new OperationStatus(State.PENDING, now, Optional.of("APPROVED"), Optional.empty());
persistStatus(operationId, st);
notifySink(operationId, st);
}
@@ -196,9 +205,9 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature
if (st.state() == State.WAITING_APPROVAL) {
// expire if past deadline
Instant deadline = loadDeadline(operationId).orElse(Instant.EPOCH);
- if (Instant.now().isAfter(deadline)) {
- OperationStatus expired = new OperationStatus(State.EXPIRED, Instant.now(),
- Optional.of("APPROVAL_EXPIRED"), Optional.empty());
+ Instant now = Instant.now();
+ if (deadlineReached(Optional.of(deadline), now)) {
+ OperationStatus expired = expired(now);
persistStatus(operationId, expired);
notifySink(operationId, expired);
return expired;
@@ -225,7 +234,14 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature
}
if (st.state() == State.PENDING) {
- OperationStatus running = new OperationStatus(State.RUNNING, Instant.now(), Optional.of("SIGNING"),
+ Instant startedAt = Instant.now();
+ if (deadlineReached(loadDeadline(operationId), startedAt)) {
+ OperationStatus expired = expired(startedAt);
+ persistStatus(operationId, expired);
+ notifySink(operationId, expired);
+ return expired;
+ }
+ OperationStatus running = new OperationStatus(State.RUNNING, startedAt, Optional.of("SIGNING"),
Optional.empty());
persistStatus(operationId, running);
notifySink(operationId, running);
@@ -240,12 +256,18 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature
}
@Override
- public boolean cancel(PkiId operationId, String reason) {
+ public boolean cancel(PkiId operationId, long fencingToken, String reason) {
Objects.requireNonNull(operationId, "operationId");
Objects.requireNonNull(reason, "reason");
+ if (fencingToken <= 0L) {
+ throw new IllegalArgumentException("fencingToken must be positive");
+ }
if (reason.isBlank()) {
throw new IllegalArgumentException("reason must not be blank");
}
+ if (!identities.acceptFence(operationId, fencingToken)) {
+ return false;
+ }
OperationStatus st = loadStatus(operationId);
if (st.isTerminal()) {
@@ -279,6 +301,10 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature
private OperationStatus performSign(PkiId opId) {
PersistedSignRequest req = loadRequest(opId);
+ Instant startedAt = Instant.now();
+ if (deadlineReached(loadDeadline(opId), startedAt)) {
+ return expired(startedAt);
+ }
PrivateKey key = keysByRef.get(req.keyRef);
if (key == null) {
@@ -303,11 +329,15 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature
sig.update(req.payload);
byte[] signature = sig.sign();
+ Instant completedAt = Instant.now();
+ if (deadlineReached(loadDeadline(opId), completedAt)) {
+ return expired(completedAt);
+ }
persistSignature(opId, signature);
EncodedObject enc = new EncodedObject(Encoding.BINARY, signature);
OperationResult res = new OperationResult(Optional.of(enc), Optional.empty());
- return new OperationStatus(State.SUCCEEDED, Instant.now(), Optional.of("SIGNED"), Optional.of(res));
+ return new OperationStatus(State.SUCCEEDED, completedAt, Optional.of("SIGNED"), Optional.of(res));
} catch (Exception ex) {
return new OperationStatus(State.FAILED, Instant.now(), Optional.of("SIGN_ERROR"), Optional.empty());
}
@@ -375,6 +405,14 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature
}
}
+ private static boolean deadlineReached(Optional deadline, Instant observedAt) {
+ return deadline.isPresent() && !observedAt.isBefore(deadline.get());
+ }
+
+ private static OperationStatus expired(Instant observedAt) {
+ return new OperationStatus(State.EXPIRED, observedAt, Optional.of("EXPIRED"), Optional.empty());
+ }
+
private void persistDecision(PkiId opId, String decision) {
try {
Files.writeString(opDir(opId).resolve(FILE_DECISION), decision, StandardCharsets.UTF_8);
diff --git a/pki/src/test/java/zeroecho/pki/testkit/InMemorySignatureWorkflow.java b/pki/src/test/java/zeroecho/pki/testkit/InMemorySignatureWorkflow.java
index abfb1b1..c03b3d0 100644
--- a/pki/src/test/java/zeroecho/pki/testkit/InMemorySignatureWorkflow.java
+++ b/pki/src/test/java/zeroecho/pki/testkit/InMemorySignatureWorkflow.java
@@ -40,9 +40,13 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.atomic.AtomicInteger;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding;
+import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.spi.crypto.SignatureWorkflow;
@@ -53,11 +57,25 @@ public final class InMemorySignatureWorkflow implements SignatureWorkflow {
private final Map keys;
private final Map status;
+ private final boolean completeImmediately;
+ private final ConcurrentMap fingerprints;
+ private final ConcurrentMap fences;
+ private final ConcurrentMap operationLocks;
private long counter;
+ private final AtomicInteger submittedSignCount;
public InMemorySignatureWorkflow(Map keys) {
+ this(keys, true);
+ }
+
+ public InMemorySignatureWorkflow(Map keys, boolean completeImmediately) {
this.keys = new HashMap<>(Objects.requireNonNull(keys, "keys"));
this.status = new HashMap<>();
+ this.completeImmediately = completeImmediately;
+ this.fingerprints = new ConcurrentHashMap<>();
+ this.fences = new ConcurrentHashMap<>();
+ this.operationLocks = new ConcurrentHashMap<>();
+ this.submittedSignCount = new AtomicInteger();
this.counter = 1L;
}
@@ -69,14 +87,39 @@ public final class InMemorySignatureWorkflow implements SignatureWorkflow {
@Override
public PkiId submitSign(SignRequest request) {
Objects.requireNonNull(request, "request");
- PkiId opId = new PkiId("sig:" + (counter++));
+ PkiId opId = request.submissionId();
+ Object lock = operationLocks.computeIfAbsent(opId, ignored -> new Object());
+ synchronized (lock) { // test provider operation-local state only; no external call inside
+ String fingerprint = fingerprints.get(opId);
+ if (fingerprint != null) {
+ if (!fingerprint.equals(request.semanticFingerprint())) {
+ throw new IllegalStateException("conflicting signing request");
+ }
+ if (request.fencingToken() < fences.get(opId)) {
+ throw new IllegalStateException("stale signing fence");
+ }
+ return opId;
+ }
+ fingerprints.put(opId, request.semanticFingerprint());
+ fences.put(opId, request.fencingToken());
+ status.put(opId, new OperationStatus(State.RUNNING, Instant.now(), Optional.of("PENDING"),
+ Optional.empty()));
+ submittedSignCount.incrementAndGet();
+ }
+ if (!completeImmediately) {
+ return opId;
+ }
try {
+ if (deadlineReached(request, Instant.now())) {
+ complete(request, expired(Instant.now()));
+ return opId;
+ }
KeyPair kp = keys.get(request.keyRef().value());
if (kp == null) {
OperationStatus st = new OperationStatus(State.FAILED, Instant.now(), Optional.of("UNKNOWN_KEY"),
Optional.empty());
- status.put(opId, st);
+ complete(request, st);
return opId;
}
@@ -85,20 +128,46 @@ public final class InMemorySignatureWorkflow implements SignatureWorkflow {
sig.update(request.payload().bytes());
byte[] s = sig.sign();
+ Instant completedAt = Instant.now();
+ if (deadlineReached(request, completedAt)) {
+ complete(request, expired(completedAt));
+ return opId;
+ }
OperationResult res = new OperationResult(Optional.of(new EncodedObject(Encoding.BINARY, s)),
Optional.empty());
- OperationStatus st = new OperationStatus(State.SUCCEEDED, Instant.now(), Optional.of("SIGNED"),
+ OperationStatus st = new OperationStatus(State.SUCCEEDED, completedAt, Optional.of("SIGNED"),
Optional.of(res));
- status.put(opId, st);
+ complete(request, st);
return opId;
} catch (Exception ex) {
OperationStatus st = new OperationStatus(State.FAILED, Instant.now(), Optional.of("SIGN_FAILED"),
Optional.empty());
- status.put(opId, st);
+ complete(request, st);
return opId;
}
}
+ private void complete(SignRequest request, OperationStatus terminal) {
+ Object lock = operationLocks.get(request.submissionId());
+ synchronized (lock) {
+ if (status.get(request.submissionId()).state() == State.RUNNING
+ && fences.get(request.submissionId()) == request.fencingToken()) {
+ if (terminal.state() == State.SUCCEEDED && deadlineReached(request, terminal.updatedAt())) {
+ terminal = expired(terminal.updatedAt());
+ }
+ status.put(request.submissionId(), terminal);
+ }
+ }
+ }
+
+ private static boolean deadlineReached(SignRequest request, Instant observedAt) {
+ return request.deadline().isPresent() && !observedAt.isBefore(request.deadline().get());
+ }
+
+ private static OperationStatus expired(Instant observedAt) {
+ return new OperationStatus(State.EXPIRED, observedAt, Optional.of("EXPIRED"), Optional.empty());
+ }
+
@Override
public PkiId submitVerify(VerifyRequest request) {
Objects.requireNonNull(request, "request");
@@ -120,10 +189,23 @@ public final class InMemorySignatureWorkflow implements SignatureWorkflow {
}
@Override
- public boolean cancel(PkiId operationId, String reason) {
+ public boolean cancel(PkiId operationId, long fencingToken, String reason) {
Objects.requireNonNull(operationId, "operationId");
Objects.requireNonNull(reason, "reason");
- return false;
+ if (fencingToken <= 0L) {
+ throw new IllegalArgumentException("fencingToken must be positive");
+ }
+ Object lock = operationLocks.computeIfAbsent(operationId, ignored -> new Object());
+ synchronized (lock) {
+ OperationStatus existing = status.get(operationId);
+ if (existing == null || existing.isTerminal() || fencingToken < fences.get(operationId)) {
+ return false;
+ }
+ fences.put(operationId, fencingToken);
+ status.put(operationId, new OperationStatus(State.CANCELLED, Instant.now(), Optional.of("CANCELLED"),
+ Optional.empty()));
+ return true;
+ }
}
@Override
@@ -139,6 +221,18 @@ public final class InMemorySignatureWorkflow implements SignatureWorkflow {
return Set.of("SHA256withRSA");
}
+ public int submittedSignCount() {
+ return submittedSignCount.get();
+ }
+
+ public void putKeyPair(KeyRef keyRef, KeyPair keyPair) {
+ keys.put(Objects.requireNonNull(keyRef, "keyRef").value(), Objects.requireNonNull(keyPair, "keyPair"));
+ }
+
+ public boolean hasRunningOperations() {
+ return status.values().stream().anyMatch(operation -> !operation.isTerminal());
+ }
+
@Override
public void close() {
// no-op
diff --git a/pki/src/test/java/zeroecho/pki/testkit/OperatorApprovalSignatureWorkflow.java b/pki/src/test/java/zeroecho/pki/testkit/OperatorApprovalSignatureWorkflow.java
index 44b79ea..393b037 100644
--- a/pki/src/test/java/zeroecho/pki/testkit/OperatorApprovalSignatureWorkflow.java
+++ b/pki/src/test/java/zeroecho/pki/testkit/OperatorApprovalSignatureWorkflow.java
@@ -90,11 +90,13 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo
private static final String K_DETAIL = "detailCode";
private static final String K_KEYREF = "keyRef";
private static final String K_ALG = "algorithmId";
+ private static final String K_DEADLINE = "deadline";
private static final String K_APPROVED_AT = "approvedAt";
private static final String K_DENIED_AT = "deniedAt";
private final String id;
private final Path root;
+ private final TestSignIdentityRegistry identities;
private final Map keysByKeyRef;
private final Duration approvalWindow;
private final Duration signDelay;
@@ -114,6 +116,7 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo
Duration approvalWindow, Duration signDelay) {
this.id = Objects.requireNonNull(id, "id");
this.root = Objects.requireNonNull(root, "root");
+ this.identities = new TestSignIdentityRegistry(root);
this.keysByKeyRef = Objects.requireNonNull(keysByKeyRef, "keysByKeyRef");
this.approvalWindow = Objects.requireNonNull(approvalWindow, "approvalWindow");
this.signDelay = Objects.requireNonNull(signDelay, "signDelay");
@@ -142,7 +145,10 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo
long n = seq.incrementAndGet();
persistSeq(root, n);
- PkiId opId = new PkiId("swf:test:" + Long.toUnsignedString(n));
+ PkiId opId = request.submissionId();
+ if (!identities.begin(request)) {
+ return opId;
+ }
Path dir = opDir(opId);
try {
@@ -151,8 +157,13 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo
java.util.Properties p = new java.util.Properties();
Instant now = Instant.now();
+ Instant deadline = now.plus(approvalWindow);
+ if (request.deadline().isPresent() && request.deadline().get().isBefore(deadline)) {
+ deadline = request.deadline().get();
+ }
p.setProperty(K_CREATED_AT, now.toString());
p.setProperty(K_UPDATED_AT, now.toString());
+ p.setProperty(K_DEADLINE, deadline.toString());
p.setProperty(K_STATE, State.WAITING_APPROVAL.name());
p.setProperty(K_DETAIL, "WAITING_APPROVAL");
p.setProperty(K_KEYREF, request.keyRef().value());
@@ -196,6 +207,14 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo
return;
}
Instant now = Instant.now();
+ if (deadlineReached(p, now)) {
+ p.setProperty(K_UPDATED_AT, now.toString());
+ p.setProperty(K_STATE, State.EXPIRED.name());
+ p.setProperty(K_DETAIL, "EXPIRED");
+ writePropsSafe(dir.resolve(FILE_META), p);
+ notifySink(operationId);
+ return;
+ }
p.setProperty(K_APPROVED_AT, now.toString());
p.setProperty(K_UPDATED_AT, now.toString());
p.setProperty(K_STATE, State.RUNNING.name());
@@ -236,15 +255,13 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo
return new OperationStatus(State.FAILED, Instant.now(), Optional.of("UNKNOWN"), Optional.empty());
}
- Instant createdAt = Instant.parse(p.getProperty(K_CREATED_AT));
Instant updatedAt = Instant.parse(p.getProperty(K_UPDATED_AT));
State state = parseState(p.getProperty(K_STATE));
String detail = p.getProperty(K_DETAIL, "");
// Expire while waiting for approval
- Instant approvalDeadline = createdAt.plus(approvalWindow);
if (!isTerminalState(state) && (state == State.WAITING_APPROVAL || state == State.PENDING)
- && Instant.now().isAfter(approvalDeadline)) {
+ && deadlineReached(p, Instant.now())) {
state = State.EXPIRED;
detail = "APPROVAL_EXPIRED";
updatedAt = Instant.now();
@@ -261,6 +278,16 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo
if (!approvedAtRaw.isBlank()) {
Instant approvedAt = Instant.parse(approvedAtRaw);
if (Instant.now().isAfter(approvedAt.plus(signDelay))) {
+ Instant startedAt = Instant.now();
+ if (deadlineReached(p, startedAt)) {
+ p.setProperty(K_STATE, State.EXPIRED.name());
+ p.setProperty(K_DETAIL, "EXPIRED");
+ p.setProperty(K_UPDATED_AT, startedAt.toString());
+ writePropsSafe(dir.resolve(FILE_META), p);
+ notifySink(operationId);
+ return new OperationStatus(State.EXPIRED, startedAt, Optional.of("EXPIRED"),
+ Optional.empty());
+ }
try {
byte[] payload = Files.readAllBytes(dir.resolve(FILE_REQUEST));
String keyRef = p.getProperty(K_KEYREF);
@@ -270,10 +297,15 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo
return fail(operationId, dir, p, "UNKNOWN_KEY");
}
byte[] sigBytes = sign(alg, pk, payload);
- Files.write(dir.resolve(FILE_SIGNATURE), sigBytes);
- state = State.SUCCEEDED;
- detail = "SIGNED";
updatedAt = Instant.now();
+ if (deadlineReached(p, updatedAt)) {
+ state = State.EXPIRED;
+ detail = "EXPIRED";
+ } else {
+ Files.write(dir.resolve(FILE_SIGNATURE), sigBytes);
+ state = State.SUCCEEDED;
+ detail = "SIGNED";
+ }
p.setProperty(K_STATE, state.name());
p.setProperty(K_DETAIL, detail);
p.setProperty(K_UPDATED_AT, updatedAt.toString());
@@ -297,12 +329,18 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo
}
@Override
- public boolean cancel(PkiId operationId, String reason) {
+ public boolean cancel(PkiId operationId, long fencingToken, String reason) {
Objects.requireNonNull(operationId, "operationId");
Objects.requireNonNull(reason, "reason");
+ if (fencingToken <= 0L) {
+ throw new IllegalArgumentException("fencingToken must be positive");
+ }
if (reason.isBlank()) {
throw new IllegalArgumentException("reason must not be blank");
}
+ if (!identities.acceptFence(operationId, fencingToken)) {
+ return false;
+ }
Path dir = opDir(operationId);
java.util.Properties p = readPropsSafe(dir.resolve(FILE_META));
if (p.isEmpty()) {
@@ -414,6 +452,11 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo
return s == State.SUCCEEDED || s == State.FAILED || s == State.CANCELLED || s == State.EXPIRED;
}
+ private static boolean deadlineReached(java.util.Properties properties, Instant observedAt) {
+ String value = properties.getProperty(K_DEADLINE, "");
+ return !value.isBlank() && !observedAt.isBefore(Instant.parse(value));
+ }
+
private static long loadSeq(Path root) {
Path f = root.resolve("seq.txt");
try {
diff --git a/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java b/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java
index 13b6e74..f30fbbf 100644
--- a/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java
+++ b/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java
@@ -36,10 +36,12 @@ package zeroecho.pki.testkit;
import java.io.IOException;
import java.nio.file.Path;
import java.security.KeyPair;
+import java.security.PublicKey;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
+import java.util.Optional;
import zeroecho.pki.api.CaService;
import zeroecho.pki.api.CertificationRequestService;
@@ -56,6 +58,7 @@ import zeroecho.pki.impl.core.DefaultRevocationService;
import zeroecho.pki.impl.core.DefaultStatusObjectService;
import zeroecho.pki.impl.core.async.PkiSigningBus;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
+import zeroecho.pki.impl.audit.InMemoryAuditSink;
import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialFramework;
import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialIssuerBackend;
import zeroecho.pki.impl.framework.x509.bc.BcX509StatusObjectGenerator;
@@ -63,6 +66,8 @@ import zeroecho.pki.impl.fs.FilesystemPkiStore;
import zeroecho.pki.impl.fs.FsPkiStoreOptions;
import zeroecho.pki.spi.crypto.SignatureWorkflow;
import zeroecho.pki.spi.framework.CredentialFramework;
+import zeroecho.pki.spi.framework.CredentialIssuerBackend;
+import zeroecho.pki.spi.framework.ProofOfPossessionVerifier;
import zeroecho.pki.spi.store.PkiStore;
/**
@@ -79,8 +84,10 @@ public final class PkiTestRuntime implements AutoCloseable {
private final FilesystemPkiStore store;
private final PkiSigningBus signingBus;
private final SignatureWorkflow signatureWorkflow;
+ private final InMemoryAuditSink auditSink;
private final CredentialFramework framework;
+ private final CredentialIssuerBackend issuerBackend;
private final CaService caService;
private final CertificationRequestService certificationRequestService;
@@ -88,23 +95,29 @@ public final class PkiTestRuntime implements AutoCloseable {
private final RevocationService revocationService;
private final StatusObjectService statusObjectService;
- private final Map keyPairsByKeyRef;
+ private final Map publicKeysByKeyRef;
+ private Runnable publicKeyResolveHook;
private PkiTestRuntime(FilesystemPkiStore store, PkiSigningBus signingBus, SignatureWorkflow signatureWorkflow,
- CredentialFramework framework, Map keyPairsByKeyRef) {
+ CredentialFramework framework, CredentialIssuerBackend issuerBackend,
+ Map publicKeysByKeyRef, Duration signingTtl) {
this.store = store;
this.signingBus = signingBus;
this.signatureWorkflow = signatureWorkflow;
+ this.auditSink = new InMemoryAuditSink();
this.framework = framework;
- this.keyPairsByKeyRef = keyPairsByKeyRef;
+ this.issuerBackend = issuerBackend;
+ this.publicKeysByKeyRef = publicKeysByKeyRef;
+ this.publicKeyResolveHook = () -> {
+ };
this.certificationRequestService = new DefaultCertificationRequestService(store, framework);
- this.issuanceService = new DefaultIssuanceService(store, framework);
+ this.issuanceService = new DefaultIssuanceService(store, framework, issuerBackend, auditSink);
this.revocationService = new DefaultRevocationService(store);
this.statusObjectService = new DefaultStatusObjectService(store, framework);
- this.caService = new DefaultCaService(store, framework, this::resolvePublicKeyInfo, signingBus, "SHA256withRSA",
- Duration.ofSeconds(2));
+ this.caService = new DefaultCaService(store, framework, issuerBackend, this::resolvePublicKeyInfo, signingBus,
+ auditSink, "SHA256withRSA", signingTtl);
}
/**
@@ -116,9 +129,36 @@ public final class PkiTestRuntime implements AutoCloseable {
* @return runtime
*/
public static PkiTestRuntime create(Path rootDir, Path busFile, Map keyPairs) {
+ Map publicKeys = new HashMap<>();
+ for (Map.Entry entry : keyPairs.entrySet()) {
+ publicKeys.put(entry.getKey(), entry.getValue().getPublic());
+ }
+ return create(rootDir, busFile, keyPairs, publicKeys, Optional.empty());
+ }
+
+ /**
+ * Creates a test runtime with independently controlled signing keys, resolved
+ * public keys, and proof verifier.
+ *
+ * @param rootDir working root for the filesystem store
+ * @param busFile durable bus line store file path
+ * @param signingKeys signing workflow key pairs indexed by key reference
+ * @param resolvedKeys public keys returned by managed-key resolution
+ * @param proofVerifier proof verifier used by the credential framework
+ * @return runtime
+ */
+ public static PkiTestRuntime create(Path rootDir, Path busFile, Map signingKeys,
+ Map