diff --git a/docs/pki-server-architecture.md b/docs/pki-server-architecture.md
index a049011..4edff45 100644
--- a/docs/pki-server-architecture.md
+++ b/docs/pki-server-architecture.md
@@ -219,6 +219,34 @@ Production `pki` and `pki-server` MUST NOT materialize or expose CA private keys
Signing MUST use `KeyRef` and the established signing workflow boundary.
+### 6.4 Signing staging, retirement, and recovery
+
+Synchronous X.509 adapters are one-shot resources. They stream to disposable,
+operation-lifecycle staging and MUST be closed on every path. Closing before the
+signature request completes aborts partial staging. Once submission to the signing
+bus begins, the bus owns disposition of the completed reference: it deletes the
+reference only after proving that no durable intent exists, and retains it when a
+commit is present or durability is uncertain.
+
+Operation retirement separates live payload from durable evidence. After an
+immutable provider terminal state is observed, retirement removes staged
+to-be-signed content and workflow continuation state. The authoritative signing
+record remains through the signing horizon with content-free commitment metadata
+and, for a trustworthy on-time success, the signature result. A provider that is
+not yet terminal remains `CANCELLING`; it is not falsely retired.
+
+If staged-content deletion fails after the content-free `RETIRED` record commits,
+the store retirement boundary reports only the stable redacted cleanup marker
+`SIGNING_CONTENT_CLEANUP_FAILED`; higher-level adapters may map it to their stable
+cleanup marker. Logical retirement still deletes workflow continuation state and
+retires advisory state before reporting that marker. The retained record does not
+regain a live content reference. Store restart recovery uses authoritative live
+references to reclaim the orphaned staged file safely.
+
+Payload staging remains streaming `O(n)` time and `O(1)` aggregate auxiliary heap
+excluding the signature. Synchronous waiting performs
+`O(TTL / polling interval)` status observations.
+
## 7. Authorization architecture
### 7.1 Model
diff --git a/pki/src/main/java/zeroecho/pki/application/DefaultOcspResponseService.java b/pki/src/main/java/zeroecho/pki/application/DefaultOcspResponseService.java
index de84ee4..b38d802 100644
--- a/pki/src/main/java/zeroecho/pki/application/DefaultOcspResponseService.java
+++ b/pki/src/main/java/zeroecho/pki/application/DefaultOcspResponseService.java
@@ -129,16 +129,18 @@ final class DefaultOcspResponseService implements OcspResponseService {
try {
X509CertificateHolder responder = certificate(responderCredentialId);
AlgorithmIdentity identity = bus.authority().resolveIdentity(signatureAlgorithm);
- PkiBusContentSigner signer = signatureBindingId.isPresent()
- ? new PkiBusContentSigner(bus, signingKeyRef, identity, signatureBindingId.orElseThrow(), signingTtl)
- : new PkiBusContentSigner(bus, signingKeyRef, identity, signingTtl);
- try (java.io.OutputStream output = signer.getOutputStream()) { output.write(challenge); }
- org.bouncycastle.operator.ContentVerifier verifier =
- new org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder().build(responder)
- .get(signer.getAlgorithmIdentifier());
- try (java.io.OutputStream output = verifier.getOutputStream()) { output.write(challenge); }
- if (!verifier.verify(signer.getSignature())) {
- throw new IOException("OCSP signing binding differs");
+ try (PkiBusContentSigner signer = signatureBindingId.isPresent()
+ ? new PkiBusContentSigner(bus, signingKeyRef, identity, signatureBindingId.orElseThrow(),
+ signingTtl)
+ : new PkiBusContentSigner(bus, signingKeyRef, identity, signingTtl)) {
+ try (java.io.OutputStream output = signer.getOutputStream()) { output.write(challenge); }
+ org.bouncycastle.operator.ContentVerifier verifier =
+ new org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder().build(responder)
+ .get(signer.getAlgorithmIdentifier());
+ try (java.io.OutputStream output = verifier.getOutputStream()) { output.write(challenge); }
+ if (!verifier.verify(signer.getSignature())) {
+ throw new IOException("OCSP signing binding differs");
+ }
}
} catch (Exception failure) {
throw new PkiException("OCSP signing binding validation failed: code=OCSP_SIGNING_BINDING_FAILED");
@@ -192,30 +194,33 @@ final class DefaultOcspResponseService implements OcspResponseService {
false, new DEROctetString(new DEROctetString(command.nonce().orElseThrow()).getEncoded()))));
}
AlgorithmIdentity identity = bus.authority().resolveIdentity(command.signatureAlgorithm());
- PkiBusContentSigner signer = command.signatureBindingId().isPresent()
+ try (PkiBusContentSigner signer = command.signatureBindingId().isPresent()
? new PkiBusContentSigner(bus, command.signingKeyRef(), identity,
command.signatureBindingId().orElseThrow(), signingTtl)
- : new PkiBusContentSigner(bus, command.signingKeyRef(), identity, signingTtl);
- X509CertificateHolder[] chain = new X509CertificateHolder[command.responseChain().size()];
- for (int index = 0; index < chain.length; index++) chain[index] = certificate(command.responseChain().get(index));
- BasicOCSPResp basic = builder.build(signer, chain, Date.from(command.producedAt()));
- OCSPResp outer = new OCSPRespBuilder().build(OCSPRespBuilder.SUCCESSFUL, basic);
- byte[] encoded = outer.getEncoded();
- OCSPResp decodedOuter = new OCSPResp(encoded);
- BasicOCSPResp decoded = (BasicOCSPResp) decodedOuter.getResponseObject();
- if (!Arrays.equals(encoded, decodedOuter.getEncoded())
- || decodedOuter.getStatus() != OCSPRespBuilder.SUCCESSFUL
- || decoded == null || !decoded.getProducedAt().equals(Date.from(command.producedAt()))
- || !decoded.getResponderId().equals(basic.getResponderId())
- || !decoded.getSignatureAlgorithmID().equals(signer.getAlgorithmIdentifier())
- || !decoded.isSignatureValid(new org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder()
- .build(responder))
- || !matchesResponses(decoded.getResponses(), resolved, command)
- || !matchesCertificates(decoded.getCerts(), chain)
- || !matchesNonce(decoded, command.nonce())) {
- throw new IOException("Generated OCSP response validation failed");
+ : new PkiBusContentSigner(bus, command.signingKeyRef(), identity, signingTtl)) {
+ X509CertificateHolder[] chain = new X509CertificateHolder[command.responseChain().size()];
+ for (int index = 0; index < chain.length; index++) {
+ chain[index] = certificate(command.responseChain().get(index));
+ }
+ BasicOCSPResp basic = builder.build(signer, chain, Date.from(command.producedAt()));
+ OCSPResp outer = new OCSPRespBuilder().build(OCSPRespBuilder.SUCCESSFUL, basic);
+ byte[] encoded = outer.getEncoded();
+ OCSPResp decodedOuter = new OCSPResp(encoded);
+ BasicOCSPResp decoded = (BasicOCSPResp) decodedOuter.getResponseObject();
+ if (!Arrays.equals(encoded, decodedOuter.getEncoded())
+ || decodedOuter.getStatus() != OCSPRespBuilder.SUCCESSFUL
+ || decoded == null || !decoded.getProducedAt().equals(Date.from(command.producedAt()))
+ || !decoded.getResponderId().equals(basic.getResponderId())
+ || !decoded.getSignatureAlgorithmID().equals(signer.getAlgorithmIdentifier())
+ || !decoded.isSignatureValid(
+ new org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder().build(responder))
+ || !matchesResponses(decoded.getResponses(), resolved, command)
+ || !matchesCertificates(decoded.getCerts(), chain)
+ || !matchesNonce(decoded, command.nonce())) {
+ throw new IOException("Generated OCSP response validation failed");
+ }
+ return encoded;
}
- return encoded;
}
private static boolean matchesResponses(SingleResp[] decoded, List expected, Command command) {
diff --git a/pki/src/main/java/zeroecho/pki/impl/core/CaProofGate.java b/pki/src/main/java/zeroecho/pki/impl/core/CaProofGate.java
index 9be8184..55ccb61 100644
--- a/pki/src/main/java/zeroecho/pki/impl/core/CaProofGate.java
+++ b/pki/src/main/java/zeroecho/pki/impl/core/CaProofGate.java
@@ -34,7 +34,6 @@
package zeroecho.pki.impl.core;
import java.io.IOException;
-import java.io.OutputStream;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Duration;
@@ -44,10 +43,8 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
-import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.bouncycastle.cert.X509CertificateHolder;
-import org.bouncycastle.operator.ContentSigner;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.io.ImmutableByteContent;
@@ -58,8 +55,6 @@ import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId;
-import zeroecho.pki.api.content.DurableContentReference;
-import zeroecho.pki.api.audit.AccessContext;
import zeroecho.pki.api.audit.AuditEvent;
import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.audit.Purpose;
@@ -71,9 +66,6 @@ import zeroecho.pki.impl.framework.x509.bc.BcX509VerificationExecutor;
import zeroecho.pki.impl.framework.x509.bc.BcX509AlgorithmAdapter;
import zeroecho.pki.impl.framework.x509.bc.PkiBusContentSigner;
import zeroecho.pki.spi.audit.AuditSink;
-import zeroecho.pki.util.async.AsyncState;
-import zeroecho.pki.util.async.AsyncStatus;
-import zeroecho.pki.spi.store.ContentSink;
/**
* Internal fail-closed proof gate for CA signing keys.
@@ -119,21 +111,21 @@ final class CaProofGate {
this.signingTtl = Objects.requireNonNull(signingTtl, "signingTtl");
}
- /* default */ ContentSigner signer(ManagedKeyProof proof) {
+ /* default */ PkiBusContentSigner signer(ManagedKeyProof proof) {
return signatureBindingId.isPresent()
? new PkiBusContentSigner(signingBus, proof.keyRef(), signatureIdentity,
signatureBindingId.orElseThrow(), signingTtl)
- : new BusBackedContentSigner(signingBus, proof.keyRef(), signatureIdentity, signingTtl);
+ : new PkiBusContentSigner(signingBus, proof.keyRef(), signatureIdentity, signingTtl);
}
- /* default */ ContentSigner signer(ManagedKeyProof proof,
+ /* default */ PkiBusContentSigner signer(ManagedKeyProof proof,
zeroecho.pki.api.profile.X509AlgorithmBindingPolicy bindingPolicy) {
Optional bindingId = bindingPolicy.certificateSignature()
.map(zeroecho.pki.api.profile.X509AlgorithmBindingPolicy.BindingReference::bindingId);
return bindingId.isPresent()
? new PkiBusContentSigner(signingBus, proof.keyRef(), signatureIdentity, bindingId.orElseThrow(),
signingTtl)
- : new BusBackedContentSigner(signingBus, proof.keyRef(), signatureIdentity, signingTtl);
+ : new PkiBusContentSigner(signingBus, proof.keyRef(), signatureIdentity, signingTtl);
}
/* default */ SubjectPublicKeyInfo parseRootSpki(EncodedObject spki, FormatId formatId) {
@@ -240,13 +232,13 @@ final class CaProofGate {
}
private byte[] signManagedKeyChallenge(KeyRef keyRef, byte[] challenge) {
- ContentSigner contentSigner = new BusBackedContentSigner(signingBus, keyRef, signatureIdentity, signingTtl);
- try {
+ try (PkiBusContentSigner contentSigner = new PkiBusContentSigner(signingBus, keyRef, signatureIdentity,
+ signingTtl)) {
contentSigner.getOutputStream().write(challenge);
+ return contentSigner.getSignature();
} catch (IOException ex) {
throw new PkiException("Managed-key challenge buffering failed: code=CHALLENGE_BUFFER_FAILED");
}
- return contentSigner.getSignature();
}
private boolean verifyChallenge(byte[] spkiDer, byte[] challenge, byte[] signature) {
@@ -305,140 +297,4 @@ final class CaProofGate {
}
}
- /**
- * Bounded synchronous adapter over the existing asynchronous signing bus.
- */
- private static final class BusBackedContentSigner implements ContentSigner {
-
- private final PkiSigningBus bus;
- private final KeyRef keyRef;
- private final AlgorithmIdentity algorithmIdentity;
- private final Duration ttl;
- private final ContentSink sink;
- private final OutputStream output;
-
- private BusBackedContentSigner(PkiSigningBus bus, KeyRef keyRef, AlgorithmIdentity algorithmIdentity,
- Duration ttl) {
- this.bus = bus;
- this.keyRef = keyRef;
- this.algorithmIdentity = algorithmIdentity;
- this.ttl = ttl;
- this.sink = bus.beginSigningContent(Encoding.BINARY);
- try {
- this.output = sink.outputStream();
- } catch (IOException exception) {
- closeSinkPreserving(exception);
- throw new PkiException("Signing content staging failed: code=SPOOL_STORAGE_FAILED", exception);
- }
- }
-
- @Override
- public AlgorithmIdentifier getAlgorithmIdentifier() {
- return new BcX509AlgorithmAdapter(bus.authority().bindings()).encode(algorithmIdentity,
- X509AlgorithmRole.SIGNATURE_ALGORITHM);
- }
-
- @Override
- public OutputStream getOutputStream() {
- return output;
- }
-
- @Override
- public byte[] getSignature() {
- DurableContentReference content;
- try {
- output.close();
- content = sink.complete();
- } catch (IOException exception) {
- closeSinkPreserving(exception);
- throw new PkiException("Signing content staging failed: code=SPOOL_STORAGE_FAILED", exception);
- }
- Principal owner = new Principal("SYSTEM", "pki");
- PkiId opId = bus.newSubmissionId();
- AccessContext accessContext = new AccessContext(owner, new Purpose("X509_SIGN"), Optional.empty(),
- Optional.empty());
- String canonicalIdentity = algorithmIdentity.canonicalForm();
- PkiSigningBus.SignContinuation continuation = new PkiSigningBus.SignContinuation(accessContext,
- canonicalIdentity, content, keyRef, Encoding.BINARY, Optional.empty());
- boolean submitted = false;
- try {
- bus.submitSign(opId, owner, keyRef, canonicalIdentity, content, ttl,
- Optional.of(continuation.encode()));
- submitted = true;
- } finally {
- if (!submitted) {
- deletePreservingFailure(opId);
- bus.releaseContent(content);
- }
- }
- return awaitSignature(opId);
- }
-
- private void closeSinkPreserving(IOException primaryFailure) {
- try {
- sink.close();
- } catch (IOException cleanupFailure) {
- primaryFailure.addSuppressed(cleanupFailure);
- }
- }
-
- @SuppressWarnings("PMD.AvoidCatchingGenericException")
- private byte[] awaitSignature(PkiId opId) {
- byte[] signature;
- try {
- signature = awaitSignatureBeforeCleanup(opId);
- } catch (RuntimeException failure) {
- deletePreservingFailure(opId);
- throw failure;
- }
- bus.retireSignOperation(opId, "completed");
- return signature;
- }
-
- private byte[] awaitSignatureBeforeCleanup(PkiId opId) {
- Instant deadline = Instant.now().plus(ttl);
- while (Instant.now().isBefore(deadline)) {
- bus.sweep(Instant.now());
- 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 result.get().bytes();
- }
- 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);
- }
- }
-
- private void deletePreservingFailure(PkiId opId) {
- try {
- bus.retireSignOperation(opId, "failed-or-expired");
- } catch (RuntimeException cleanupFailure) { // NOPMD - preserve primary failure
- // The primary safe failure remains authoritative; provider diagnostics are
- // discarded.
- }
- }
-
- }
}
diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java
index 37d60b4..160f70c 100644
--- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java
+++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java
@@ -56,7 +56,6 @@ import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.bouncycastle.cert.CertException;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.X509v3CertificateBuilder;
-import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder;
@@ -94,6 +93,7 @@ import zeroecho.pki.api.profile.ActiveCertificateProfile;
import zeroecho.pki.api.profile.CertificateProfileKind;
import zeroecho.pki.impl.core.async.PkiSigningBus;
import zeroecho.pki.impl.framework.x509.X509ExecutionPlan;
+import zeroecho.pki.impl.framework.x509.bc.PkiBusContentSigner;
import zeroecho.pki.spi.crypto.SignatureWorkflow;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
import zeroecho.pki.spi.audit.AuditSink;
@@ -353,9 +353,8 @@ public final class DefaultCaService implements CaService {
throw new PkiException("Root extension construction failed: code=ROOT_EXTENSION_BUILD_FAILED");
}
- ContentSigner signer = proofGate.signer(proof, request.algorithmBindingPolicy());
X509CertificateHolder cert;
- try {
+ try (PkiBusContentSigner signer = proofGate.signer(proof, request.algorithmBindingPolicy())) {
cert = b.build(signer);
} catch (RuntimeException ex) { // NOPMD
throw proofGate.rejection(CREATE_ROOT_REJECTED, command.formatId(), Optional.empty(),
diff --git a/pki/src/main/java/zeroecho/pki/impl/core/async/PkiSigningBus.java b/pki/src/main/java/zeroecho/pki/impl/core/async/PkiSigningBus.java
index aea52a6..7394e2d 100644
--- a/pki/src/main/java/zeroecho/pki/impl/core/async/PkiSigningBus.java
+++ b/pki/src/main/java/zeroecho/pki/impl/core/async/PkiSigningBus.java
@@ -105,6 +105,8 @@ public final class PkiSigningBus implements AutoCloseable {
public static final String TYPE_SIGN = "PKI.SIGN";
private static final String ENDPOINT_SIGNER = "signer";
+ private static final String SIGNING_CONTENT_CLEANUP_FAILED =
+ "Signing content cleanup failed: code=SIGNING_CONTENT_CLEANUP_FAILED";
private static final Duration CLAIM_LEASE = Duration.ofSeconds(30);
private static final long INITIAL_FENCE = 0L;
@@ -394,61 +396,100 @@ public final class PkiSigningBus implements AutoCloseable {
* @param content durable repeatable content to sign
* @param ttl time-to-live
* @param workflowPayload minimal continuation payload
+ *
+ * @apiNote Once this method is invoked with a non-null {@code content}
+ * reference, the bus owns its disposition. A failure before durable
+ * intent creation retires content only after the store proves that no
+ * record exists. Committed or durability-uncertain content remains
+ * owned by the bus for reconciliation and recovery. Callers must not
+ * release the reference after invoking this method.
*/
+ @SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.ExceptionAsFlowControl" })
public void submitSign(PkiId opId, Principal owner, KeyRef keyRef, String algorithmId,
DurableContentReference content,
Duration ttl, Optional workflowPayload) {
- Objects.requireNonNull(opId, "opId");
- Objects.requireNonNull(owner, "owner");
- Objects.requireNonNull(keyRef, "keyRef");
- Objects.requireNonNull(algorithmId, "algorithmId");
Objects.requireNonNull(content, "content");
- Objects.requireNonNull(ttl, "ttl");
- Objects.requireNonNull(workflowPayload, "workflowPayload");
- if (algorithmId.isBlank()) {
- throw new IllegalArgumentException("algorithmId must not be blank");
- }
- X509ExecutionPlan submittedPlan = authority.planSigning(algorithmId,
- workflowImplementationId(signer), SignatureWorkflow.class);
- authority.authorize(submittedPlan, signer, AlgorithmExecutionCapability.Direction.SIGN);
- AlgorithmIdentity submittedIdentity = submittedPlan.selection().requested();
- if (ttl.isZero() || ttl.isNegative()) {
- throw new IllegalArgumentException("ttl must be positive");
- }
-
- if (workflowPayload.isEmpty()) {
- throw new IllegalArgumentException("workflowPayload must be present for new sign operation");
- }
- PkiId baseOpId = normalizeBaseOperationId(opId);
- SignWorkflowStore.Record authoritative;
- try (OperationCoordinator.Lease ignored = coordinator.acquire(baseOpId)) {
- SigningSubmissionId parsed = SigningSubmissionId.parse(baseOpId);
- Instant deadline = parsed.createdAt().plus(ttl);
- SignContinuation continuation = SignContinuation.decode(workflowPayload.get(), store.stagedContent());
- X509ExecutionPlan continuationPlan = authority.planSigning(continuation.algorithmId,
+ PkiId baseOpId = null;
+ try {
+ Objects.requireNonNull(opId, "opId");
+ Objects.requireNonNull(owner, "owner");
+ Objects.requireNonNull(keyRef, "keyRef");
+ Objects.requireNonNull(algorithmId, "algorithmId");
+ Objects.requireNonNull(ttl, "ttl");
+ Objects.requireNonNull(workflowPayload, "workflowPayload");
+ if (algorithmId.isBlank()) {
+ throw new IllegalArgumentException("algorithmId must not be blank");
+ }
+ X509ExecutionPlan submittedPlan = authority.planSigning(algorithmId,
workflowImplementationId(signer), SignatureWorkflow.class);
- authority.authorize(continuationPlan, signer, AlgorithmExecutionCapability.Direction.SIGN);
- AlgorithmIdentity continuationIdentity = continuationPlan.selection().requested();
- if (!owner.equals(continuation.accessContext.principal()) || !keyRef.equals(continuation.keyRef)
- || !submittedIdentity.equals(continuationIdentity)
- || !content.equals(continuation.content())) {
- throw new IllegalArgumentException("Sign continuation does not match the submitted request");
+ authority.authorize(submittedPlan, signer, AlgorithmExecutionCapability.Direction.SIGN);
+ AlgorithmIdentity submittedIdentity = submittedPlan.selection().requested();
+ if (ttl.isZero() || ttl.isNegative()) {
+ throw new IllegalArgumentException("ttl must be positive");
}
- continuation = continuation.withAlgorithmId(submittedIdentity.canonicalForm());
- String fingerprint = continuation.semanticFingerprint(namespace, deadline);
- EncodedObject persistedRequest = continuation.withSignerOpId(baseOpId).encode();
- SignWorkflowStore.Record intent = new SignWorkflowStore.Record(baseOpId, namespace, fingerprint, owner,
- parsed.createdAt(), deadline, persistedRequest, SignWorkflowStore.State.INTENT, 0L, 0L,
- Optional.empty(), Optional.of("INTENT"), Optional.empty(), Optional.empty());
- SignWorkflowStore.CreateResult created = store.createSignIntent(intent);
- if (created == SignWorkflowStore.CreateResult.CONFLICT) {
- throw new PkiException("Signing submission identifier conflicts with a different request");
+
+ if (workflowPayload.isEmpty()) {
+ throw new IllegalArgumentException("workflowPayload must be present for new sign operation");
}
- authoritative = store.getSignRecord(baseOpId).orElseThrow();
+ PkiId normalizedBaseOpId = normalizeBaseOperationId(opId);
+ SigningSubmissionId parsed = SigningSubmissionId.parse(normalizedBaseOpId);
+ baseOpId = normalizedBaseOpId;
+ SignWorkflowStore.Record authoritative;
+ try (OperationCoordinator.Lease ignored = coordinator.acquire(baseOpId)) {
+ Instant deadline = parsed.createdAt().plus(ttl);
+ SignContinuation continuation = SignContinuation.decode(workflowPayload.get(), store.stagedContent());
+ X509ExecutionPlan continuationPlan = authority.planSigning(
+ continuation.algorithmId, workflowImplementationId(signer), SignatureWorkflow.class);
+ authority.authorize(continuationPlan, signer, AlgorithmExecutionCapability.Direction.SIGN);
+ AlgorithmIdentity continuationIdentity = continuationPlan.selection().requested();
+ if (!owner.equals(continuation.accessContext.principal()) || !keyRef.equals(continuation.keyRef)
+ || !submittedIdentity.equals(continuationIdentity)
+ || !content.equals(continuation.content())) {
+ throw new IllegalArgumentException("Sign continuation does not match the submitted request");
+ }
+ continuation = continuation.withAlgorithmId(submittedIdentity.canonicalForm());
+ String fingerprint = continuation.semanticFingerprint(namespace, deadline);
+ EncodedObject persistedRequest = continuation.withSignerOpId(baseOpId).encode();
+ SignWorkflowStore.Record intent = new SignWorkflowStore.Record(baseOpId, namespace, fingerprint,
+ owner, parsed.createdAt(), deadline, persistedRequest, SignWorkflowStore.State.INTENT, 0L,
+ 0L, Optional.empty(), Optional.of("INTENT"), Optional.empty(), Optional.empty());
+ SignWorkflowStore.CreateResult created = store.createSignIntent(intent);
+ if (created == SignWorkflowStore.CreateResult.CONFLICT) {
+ releaseAttachedOrConflictingContent(content);
+ throw new PkiException("Signing submission identifier conflicts with a different request");
+ }
+ if (created == SignWorkflowStore.CreateResult.ATTACHED) {
+ releaseAttachedOrConflictingContent(content);
+ }
+ authoritative = store.getSignRecord(baseOpId).orElseThrow();
+ }
+ project(authoritative);
+ } catch (RuntimeException | Error failure) {
+ releaseSubmissionContentIfUnowned(baseOpId, content);
+ throw failure;
+ }
+ }
+
+ private void releaseSubmissionContentIfUnowned(PkiId operationId, DurableContentReference content) {
+ try {
+ if (operationId == null || store.getSignRecord(operationId).isEmpty()) {
+ releaseContent(content);
+ }
+ } catch (RuntimeException | Error cleanupOrDurabilityFailure) { // NOPMD - preserve primary boundary failure
+ // An unreadable durability state is not proof of abandonment. Restart
+ // recovery reconciles committed references and reclaims true orphans.
+ }
+ }
+
+ private void releaseAttachedOrConflictingContent(DurableContentReference content) {
+ try {
+ releaseContent(content);
+ } catch (RuntimeException | Error retainedOrCleanupFailure) { // NOPMD - authoritative record wins
+ // An identical attachment may already own this exact reference. A
+ // cleanup failure leaves an orphan for restart recovery.
}
- project(authoritative);
}
/**
@@ -525,6 +566,9 @@ public final class PkiSigningBus implements AutoCloseable {
* still-running operation remains durably {@code CANCELLING} for a later retry.
* Only an observed immutable provider terminal state is changed to
* {@code RETIRED}, and an on-time successful result is preserved.
+ * If physical staged-content deletion fails after {@code RETIRED} commits,
+ * workflow and advisory state are still removed before a stable cleanup failure
+ * is reported; restart recovery reclaims the resulting orphan.
*
*
* @param opId operation identifier; must not be {@code null}
@@ -533,6 +577,7 @@ public final class PkiSigningBus implements AutoCloseable {
* @throws IllegalArgumentException if {@code reason} is {@code null} or blank
* @throws PkiException if locally owned cleanup fails
*/
+ @SuppressWarnings("PMD.AvoidCatchingGenericException")
public void retireSignOperation(PkiId opId, String reason) {
PkiId baseOpId = normalizeBaseOperationId(opId);
if (reason == null || reason.isBlank()) {
@@ -553,18 +598,42 @@ public final class PkiSigningBus implements AutoCloseable {
return;
}
}
+ Throwable postCommitCleanupFailure = null;
try (OperationCoordinator.Lease ignored = coordinator.acquire(baseOpId)) {
state = store.getSignRecord(baseOpId).orElse(state);
if (!isTerminalSignState(state.state())) {
return;
}
- state = confirmRetirement(baseOpId, state);
+ try {
+ state = confirmRetirement(baseOpId, state);
+ } catch (RuntimeException | Error failure) {
+ SignWorkflowStore.Record observed = store.getSignRecord(baseOpId)
+ .orElseThrow(() -> rethrowRetirementFailure(failure));
+ if (observed.state() != SignWorkflowStore.State.RETIRED) {
+ throw failure;
+ }
+ state = observed;
+ postCommitCleanupFailure = failure;
+ }
store.deleteWorkflowState(baseOpId);
}
AsyncState advisoryState = state.result().isPresent() ? AsyncState.SUCCEEDED : AsyncState.CANCELLED;
bus.update(baseOpId, new AsyncStatus(advisoryState, store.signingNow(), Optional.of("RETIRED"),
Map.of("reason", "local-retirement")), state.result());
bus.retire(baseOpId);
+ if (postCommitCleanupFailure instanceof Error error) {
+ throw error;
+ }
+ if (postCommitCleanupFailure != null) {
+ throw new PkiException(SIGNING_CONTENT_CLEANUP_FAILED);
+ }
+ }
+
+ private static RuntimeException rethrowRetirementFailure(Throwable failure) {
+ if (failure instanceof Error error) {
+ throw error;
+ }
+ return (RuntimeException) failure;
}
diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialIssuerBackend.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialIssuerBackend.java
index 79a0e5c..b12aaad 100644
--- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialIssuerBackend.java
+++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialIssuerBackend.java
@@ -229,34 +229,35 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
Date.from(request.validity().notBefore()), Date.from(request.validity().notAfter()), subjectDn, spki);
addLeafExtensions(builder, request);
- PkiBusContentSigner signer = signer(issuerKeyRef, request.algorithmBindingPolicy());
- X509CertificateHolder leaf;
- try {
- leaf = builder.build(signer);
- } catch (Exception ex) {
- throw new PkiException("Certificate signing failed: code=CERTIFICATE_SIGN_FAILED");
- }
+ try (PkiBusContentSigner signer = signer(issuerKeyRef, request.algorithmBindingPolicy())) {
+ X509CertificateHolder leaf;
+ try {
+ leaf = builder.build(signer);
+ } catch (Exception ex) {
+ throw new PkiException("Certificate signing failed: code=CERTIFICATE_SIGN_FAILED");
+ }
- byte[] certDer;
- try {
- certDer = leaf.getEncoded();
- } catch (Exception ex) {
- throw new PkiException("Certificate encoding failed: code=CERTIFICATE_ENCODE_FAILED");
- }
+ byte[] certDer;
+ try {
+ certDer = leaf.getEncoded();
+ } catch (Exception ex) {
+ throw new PkiException("Certificate encoding failed: code=CERTIFICATE_ENCODE_FAILED");
+ }
- PkiId credId = new PkiId("x509:" + sha256Hex(certDer));
- PkiId publicKeyId = new PkiId("spki:" + fingerprintEncoded(request.exactPublicKey()));
+ PkiId credId = new PkiId("x509:" + sha256Hex(certDer));
+ PkiId publicKeyId = new PkiId("spki:" + fingerprintEncoded(request.exactPublicKey()));
- try {
- DurableContentReference content = stageCertificate(certDer);
- validateGeneratedCertificate(content, signer);
- Credential credential = new Credential(credId, BcX509CredentialFramework.FORMAT_ID,
- new IssuerRef(request.issuerCaId()), request.subjectRef(), request.validity(), serial.toString(),
- publicKeyId, new EndEntityProfileBinding(request.profileReference()), CredentialStatus.ISSUED,
- content, SimpleAttributeSet.builder().build());
- return new CredentialBundle(credential, java.util.List.of(issuerCertificate));
- } finally {
- java.util.Arrays.fill(certDer, (byte) 0);
+ try {
+ DurableContentReference content = stageCertificate(certDer);
+ validateGeneratedCertificate(content, signer);
+ Credential credential = new Credential(credId, BcX509CredentialFramework.FORMAT_ID,
+ new IssuerRef(request.issuerCaId()), request.subjectRef(), request.validity(), serial.toString(),
+ publicKeyId, new EndEntityProfileBinding(request.profileReference()), CredentialStatus.ISSUED,
+ content, SimpleAttributeSet.builder().build());
+ return new CredentialBundle(credential, java.util.List.of(issuerCertificate));
+ } finally {
+ java.util.Arrays.fill(certDer, (byte) 0);
+ }
}
}
@@ -389,32 +390,32 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
throw new PkiException("X.509 extension construction failed: code=EXTENSION_BUILD_FAILED");
}
- PkiBusContentSigner signer = signer(issuerKeyRef, request.algorithmBindingPolicy());
- X509CertificateHolder certificate;
- try {
- certificate = builder.build(signer);
- } catch (Exception ex) {
- throw new PkiException("Certificate signing failed: code=CERTIFICATE_SIGN_FAILED");
- }
+ try (PkiBusContentSigner signer = signer(issuerKeyRef, request.algorithmBindingPolicy())) {
+ X509CertificateHolder certificate;
+ try {
+ certificate = builder.build(signer);
+ } catch (Exception ex) {
+ throw new PkiException("Certificate signing failed: code=CERTIFICATE_SIGN_FAILED");
+ }
- byte[] certDer;
- try {
- certDer = certificate.getEncoded();
- } catch (Exception ex) {
- throw new PkiException("Certificate encoding failed: code=CERTIFICATE_ENCODE_FAILED");
- }
+ byte[] certDer;
+ try {
+ certDer = certificate.getEncoded();
+ } catch (Exception ex) {
+ throw new PkiException("Certificate encoding failed: code=CERTIFICATE_ENCODE_FAILED");
+ }
- PkiId credId = new PkiId("x509:" + sha256Hex(certDer));
+ PkiId credId = new PkiId("x509:" + sha256Hex(certDer));
- try {
- DurableContentReference content = stageCertificate(certDer);
- validateGeneratedCertificate(content, signer);
- return new Credential(credId, request.formatId(), new IssuerRef(request.issuerCaId()), subjectRef, validity,
- serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()),
- CredentialStatus.ISSUED, content,
- SimpleAttributeSet.builder().build());
- } finally {
- java.util.Arrays.fill(certDer, (byte) 0);
+ try {
+ DurableContentReference content = stageCertificate(certDer);
+ validateGeneratedCertificate(content, signer);
+ return new Credential(credId, request.formatId(), new IssuerRef(request.issuerCaId()), subjectRef,
+ validity, serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()),
+ CredentialStatus.ISSUED, content, SimpleAttributeSet.builder().build());
+ } finally {
+ java.util.Arrays.fill(certDer, (byte) 0);
+ }
}
}
diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509StatusObjectGenerator.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509StatusObjectGenerator.java
index f82b592..a0bef0d 100644
--- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509StatusObjectGenerator.java
+++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509StatusObjectGenerator.java
@@ -270,22 +270,23 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
boolean accepted = false;
try {
entries = encodeEntries(crlEntries);
- PkiBusContentSigner signer = signatureBindingId.isPresent()
+ X509SignedObjectCompletion completion;
+ try (PkiBusContentSigner signer = signatureBindingId.isPresent()
? new PkiBusContentSigner(signingBus, issuerMaterial.keyRef(), signatureIdentity,
signatureBindingId.orElseThrow(), signingTtl)
- : new PkiBusContentSigner(signingBus, issuerMaterial.keyRef(), signatureIdentity, signingTtl);
- byte[] algorithm = signer.getAlgorithmIdentifier().getEncoded();
- tbs = encodeTbs(issuerMaterial.issuerHolder(), thisUpdate, nextUpdate, entries, algorithm);
- copyToSigner(tbs, signer);
- byte[] signature = signer.getSignature();
- crl = encodeOuter(tbs, algorithm, signature);
- requireValidResult(crl, tbs, signer.executionPlan(), signer.getAlgorithmIdentifier(), signature,
- issuerMaterial.issuerHolder());
- StatusObject result = new StatusObject(new PkiId("crl:" + crl.sha256()), command.formatId(),
- command.issuerCaId(),
- command.type(), thisUpdate, Optional.of(nextUpdate), crl, command.attributes());
- X509SignedObjectCompletion completion = signingBus.authority().completeStatusObject(result,
- signer.executionPlan());
+ : new PkiBusContentSigner(signingBus, issuerMaterial.keyRef(), signatureIdentity, signingTtl)) {
+ byte[] algorithm = signer.getAlgorithmIdentifier().getEncoded();
+ tbs = encodeTbs(issuerMaterial.issuerHolder(), thisUpdate, nextUpdate, entries, algorithm);
+ copyToSigner(tbs, signer);
+ byte[] signature = signer.getSignature();
+ crl = encodeOuter(tbs, algorithm, signature);
+ requireValidResult(crl, tbs, signer.executionPlan(), signer.getAlgorithmIdentifier(), signature,
+ issuerMaterial.issuerHolder());
+ StatusObject result = new StatusObject(new PkiId("crl:" + crl.sha256()), command.formatId(),
+ command.issuerCaId(), command.type(), thisUpdate, Optional.of(nextUpdate), crl,
+ command.attributes());
+ completion = signingBus.authority().completeStatusObject(result, signer.executionPlan());
+ }
accepted = true;
return completion;
} catch (IOException | ArithmeticException exception) {
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 b2e0e1a..afb6ebe 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
@@ -96,6 +96,25 @@ import zeroecho.pki.util.async.AsyncState;
* returns or throws.
*
*
+ * Lifecycle and ownership
+ *
+ * Each instance is a one-shot resource and must be closed. Closing before
+ * {@link #getSignature()} aborts incomplete staging. Calling
+ * {@code getSignature()} atomically consumes the instance: content completed
+ * before bus submission remains signer-owned, while invoking
+ * {@link PkiSigningBus#submitSign(PkiId, Principal, KeyRef, String, DurableContentReference, Duration, Optional)}
+ * transfers disposition to the bus. The bus releases content only when it can
+ * prove no durable intent exists; committed or durability-uncertain content is
+ * retained for reconciliation.
+ *
+ *
+ * Retirement removes disposable staged to-be-signed bytes and live workflow
+ * continuation state. The store retains content-free terminal evidence, including
+ * immutable content commitment metadata and any trustworthy on-time result,
+ * through its configured signing horizon. Restart recovery reclaims orphaned
+ * staged files without treating retained terminal evidence as live content.
+ *
+ *
* Security considerations
*
* - This class never accesses private key material directly.
@@ -111,10 +130,17 @@ import zeroecho.pki.util.async.AsyncState;
* Instances of this class are not thread-safe. Each instance owns one sequential
* staged-content sink and is intended for one certificate or CRL signing flow.
*
+ *
+ * Complexity
+ *
+ * Staging is streaming {@code O(n)} time with {@code O(1)} aggregate auxiliary
+ * heap, excluding the returned signature. Polling performs
+ * {@code O(TTL / interval)} status observations.
+ *
*/
// PMD cannot infer that retaining provider causes would violate the redaction contract.
@SuppressWarnings("PMD.PreserveStackTrace")
-public final class PkiBusContentSigner implements ContentSigner {
+public final class PkiBusContentSigner implements ContentSigner, AutoCloseable {
private final PkiSigningBus bus;
private final KeyRef keyRef;
@@ -124,6 +150,8 @@ public final class PkiBusContentSigner implements ContentSigner {
private final ContentSink contentSink;
private final OutputStream contentOutput;
+ private boolean signatureRequested;
+ private boolean closed;
/**
* Creates a signer that routes signature generation through the PKI signing
@@ -237,9 +265,12 @@ public final class PkiBusContentSigner implements ContentSigner {
*
*
* @return sequential staged-content output stream
+ * @throws IllegalStateException if signing has already been requested or this
+ * signer has been closed
*/
@Override
public OutputStream getOutputStream() {
+ requireWritable();
return contentOutput;
}
@@ -269,10 +300,11 @@ 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. 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.
+ * available, the method fails explicitly. Invoking this method consumes the
+ * signer; a second invocation fails. Every operation for which submission is
+ * attempted is retired before the method returns or throws. Cleanup failures
+ * are discarded when a safe primary signing failure already exists and
+ * otherwise become a stable, redacted cleanup failure.
*
*
*
@@ -285,16 +317,21 @@ public final class PkiBusContentSigner implements ContentSigner {
* @throws PkiException if the signing workflow fails, if a successful workflow
* produces no signature result, or if signing does not
* complete within the configured TTL
+ * @throws IllegalStateException if signing has already been requested or this
+ * signer has been closed
*/
@Override
@SuppressWarnings("PMD.AvoidCatchingGenericException")
public byte[] getSignature() {
+ requireWritable();
+ signatureRequested = true;
byte[] consumedResult = null;
+ DurableContentReference content = null;
PkiId opId = null;
boolean retirementRequired = false;
Throwable primaryFailure = null;
try {
- DurableContentReference content = completeContent();
+ content = completeContent();
Principal owner = new Principal("SYSTEM", "pki");
opId = bus.newSubmissionId();
@@ -302,9 +339,10 @@ public final class PkiBusContentSigner implements ContentSigner {
String canonicalIdentity = algorithmIdentity.canonicalForm();
SignContinuation cont = new SignContinuation(ac, canonicalIdentity, content, keyRef, Encoding.BINARY,
Optional.empty());
+ EncodedObject encodedContinuation = cont.encode();
retirementRequired = true;
- bus.submitSign(opId, owner, keyRef, canonicalIdentity, content, ttl, Optional.of(cont.encode()));
+ bus.submitSign(opId, owner, keyRef, canonicalIdentity, content, ttl, Optional.of(encodedContinuation));
consumedResult = awaitSignature(opId);
return consumedResult.clone();
} catch (RuntimeException failure) {
@@ -318,16 +356,53 @@ public final class PkiBusContentSigner implements ContentSigner {
try {
if (retirementRequired) {
retirePreservingFailure(opId, primaryFailure);
+ } else if (content != null) {
+ releasePreservingFailure(content, primaryFailure);
}
} finally {
- closeSink(primaryFailure);
- if (consumedResult != null) {
- java.util.Arrays.fill(consumedResult, (byte) 0);
+ try {
+ closeSink(primaryFailure);
+ } finally {
+ closed = true;
+ if (consumedResult != null) {
+ java.util.Arrays.fill(consumedResult, (byte) 0);
+ }
}
}
}
}
+ /**
+ * Aborts incomplete staged content owned by this signer.
+ *
+ *
+ * Closing is idempotent. Closing before {@link #getSignature()} permanently
+ * consumes the signer and removes its partial staged content. Calling this
+ * method after a signing attempt is a no-op because that attempt has already
+ * disposed of or transferred every resource it owned.
+ *
+ *
+ * @throws PkiException if incomplete staging cannot be removed
+ */
+ @Override
+ public void close() {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ try {
+ contentSink.close();
+ } catch (IOException cleanupFailure) {
+ throw new PkiException("Signing cleanup failed: code=SIGNING_CLEANUP_FAILED");
+ }
+ }
+
+ private void requireWritable() {
+ if (closed || signatureRequested) {
+ throw new IllegalStateException("Content signer is already consumed");
+ }
+ }
+
private DurableContentReference completeContent() {
try {
return contentSink.complete();
@@ -346,6 +421,17 @@ public final class PkiBusContentSigner implements ContentSigner {
}
}
+ @SuppressWarnings("PMD.AvoidCatchingGenericException")
+ private void releasePreservingFailure(DurableContentReference content, Throwable primaryFailure) {
+ try {
+ bus.releaseContent(content);
+ } catch (RuntimeException cleanupFailure) {
+ if (primaryFailure == null) {
+ throw new PkiException("Signing cleanup failed: code=SIGNING_CLEANUP_FAILED");
+ }
+ }
+ }
+
private byte[] awaitSignature(PkiId opId) {
Instant deadline = Instant.now().plus(ttl);
while (Instant.now().isBefore(deadline)) {
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 c93e070..32863ab 100644
--- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java
@@ -221,6 +221,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
private final CredentialContentTransaction credentialContentTransactions;
private final PosixTransactionalMetadataStore metadataStore;
private final FilesystemRevocationAuthority revocations;
+ private final SigningContentRetirementFaultInjector signingContentRetirementFaults;
private final StoreOwnership ownership;
@@ -256,12 +257,19 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
/* package */ FilesystemPkiStore(final Path root, final FsPkiStoreOptions options, final Clock clock,
final FilesystemRevocationAuthority.IndexUpdateFaultInjector indexUpdateFaults) {
- this(root, options, clock, indexUpdateFaults, false);
+ this(root, options, clock, indexUpdateFaults, SigningContentRetirementFaultInjector.NONE, false);
+ }
+
+ /* package */ FilesystemPkiStore(final Path root, final FsPkiStoreOptions options, final Clock clock,
+ final FilesystemRevocationAuthority.IndexUpdateFaultInjector indexUpdateFaults,
+ final SigningContentRetirementFaultInjector signingContentRetirementFaults) {
+ this(root, options, clock, indexUpdateFaults, signingContentRetirementFaults, false);
}
@SuppressWarnings("PMD.CloseResource")
private FilesystemPkiStore(final Path root, final FsPkiStoreOptions options, final Clock clock,
final FilesystemRevocationAuthority.IndexUpdateFaultInjector indexUpdateFaults,
+ final SigningContentRetirementFaultInjector signingContentRetirementFaults,
final boolean snapshotAssembly) {
this.options = Objects.requireNonNull(options, "options");
Objects.requireNonNull(root, "root");
@@ -270,6 +278,8 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
this.revocationLocks = new ConcurrentHashMap<>();
this.profileLocks = new ConcurrentHashMap<>();
this.durabilityUncertain = new AtomicBoolean();
+ this.signingContentRetirementFaults = Objects.requireNonNull(signingContentRetirementFaults,
+ "signingContentRetirementFaults");
this.signingTimeLock = new ReentrantLock();
this.paths = new FsPaths(root);
@@ -350,7 +360,8 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
/* package */ static FilesystemPkiStore openSnapshotAssembly(final Path root,
final FsPkiStoreOptions options) {
return new FilesystemPkiStore(root, options, Clock.systemUTC(),
- FilesystemRevocationAuthority.IndexUpdateFaultInjector.NONE, true);
+ FilesystemRevocationAuthority.IndexUpdateFaultInjector.NONE,
+ SigningContentRetirementFaultInjector.NONE, true);
}
private boolean requireSnapshotBoundary() throws IOException {
@@ -1892,10 +1903,12 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
stored.reference().ifPresent(reference -> {
try {
if (reference.lifecycle() == DurableContentReference.Lifecycle.OPERATION) {
+ signingContentRetirementFaults.beforeRetirement(reference);
stagedContent.retireSigningContent(reference);
}
} catch (IOException exception) {
LOG.log(Level.WARNING, "Signing content retirement cleanup failed");
+ throw new PkiException("Signing content cleanup failed: code=SIGNING_CONTENT_CLEANUP_FAILED");
}
});
return Optional.of(retired);
@@ -2532,6 +2545,19 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
REPLACE_AFTER_COMMIT_AS_UNKNOWN
}
+ /** Test seam at the post-RETIRED physical content-deletion boundary. */
+ /* default */
+ @FunctionalInterface
+ interface SigningContentRetirementFaultInjector {
+ /** Production no-op. */
+ SigningContentRetirementFaultInjector NONE = reference -> {
+ // No fault.
+ };
+
+ /** Invoked immediately before physical signing-content retirement. */
+ void beforeRetirement(DurableContentReference reference) throws IOException;
+ }
+
private static RepeatableContent byteContent(byte[] value) {
return new ByteValueContent(value);
}
diff --git a/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java b/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java
index e2d9e42..efee461 100644
--- a/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java
+++ b/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java
@@ -142,6 +142,13 @@ final class PkiProofGateE2eTest {
Class> managedKeyProof = Class.forName("zeroecho.pki.impl.core.CaProofGate$ManagedKeyProof");
assertTrue(java.util.Arrays.stream(managedKeyProof.getDeclaredConstructors())
.noneMatch(constructor -> Modifier.isPublic(constructor.getModifiers())));
+ Class> caProofGate = Class.forName("zeroecho.pki.impl.core.CaProofGate");
+ assertTrue(java.util.Arrays.stream(caProofGate.getDeclaredClasses())
+ .noneMatch(type -> type.getSimpleName().equals("BusBackedContentSigner")));
+ assertTrue(java.util.Arrays.stream(caProofGate.getDeclaredMethods())
+ .filter(method -> method.getName().equals("signer"))
+ .allMatch(method -> method.getReturnType().getName()
+ .equals("zeroecho.pki.impl.framework.x509.bc.PkiBusContentSigner")));
assertTrue(java.util.Arrays.stream(CredentialFramework.class.getMethods())
.noneMatch(method -> method.getName().equals("issuerBackend")));
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
index 1a76c42..5db11ad 100644
--- a/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusFailureTest.java
+++ b/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusFailureTest.java
@@ -159,6 +159,33 @@ final class PkiSigningBusFailureTest {
System.out.println("...ok");
}
+ @Test
+ void preIntentValidationFailureReleasesTransferredContent(@TempDir Path tempDir) throws Exception {
+ InMemorySignatureWorkflow signer = new InMemorySignatureWorkflow(Map.of(), false);
+ try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"),
+ FsPkiStoreOptions.defaults());
+ PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"),
+ signingAuthority(signer))) {
+ Principal owner = new Principal("TEST", "owner");
+ PkiId operationId = bus.newSubmissionId();
+ DurableContentReference content = stage(bus,
+ 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", content, new KeyRef("test"), Encoding.BINARY, Optional.empty());
+
+ assertThrows(IllegalArgumentException.class,
+ () -> bus.submitSign(operationId, owner, new KeyRef("test"), "unsupported-signature",
+ content, Duration.ofSeconds(5), Optional.of(continuation.encode())));
+
+ assertTrue(store.getSignRecord(operationId).isEmpty());
+ assertThrows(PkiException.class, () -> bus.openContent(content));
+ assertEquals(0, signer.submittedSignCount());
+ } finally {
+ signer.close();
+ }
+ }
+
@Test
void acceptedCancellationWaitsForObservedTerminalProviderState(@TempDir Path tempDir) throws Exception {
AcceptedDelayedCancellationWorkflow signer = new AcceptedDelayedCancellationWorkflow();
@@ -263,12 +290,17 @@ final class PkiSigningBusFailureTest {
SignWorkflowStore.Record retired = store.getSignRecord(id).orElseThrow();
assertEquals(SignWorkflowStore.State.RETIRED, retired.state());
assertArrayEquals(new byte[] { 11, 12 }, retired.result().orElseThrow().bytes());
+ assertFalse(PkiSigningBus.SignContinuation.decode(retired.request(), store.stagedContent())
+ .hasLiveContent());
assertEquals(1, signer.cancellations.get());
}
try (FilesystemPkiStore reopened = new FilesystemPkiStore(storeRoot, FsPkiStoreOptions.defaults());
PkiSigningBus replayed = new PkiSigningBus(reopened, signer, busLog, signingAuthority(signer))) {
assertEquals(AsyncState.SUCCEEDED, replayed.status(id).orElseThrow().state());
assertArrayEquals(new byte[] { 11, 12 }, replayed.consumeResult(id).orElseThrow().bytes());
+ assertFalse(PkiSigningBus.SignContinuation
+ .decode(reopened.getSignRecord(id).orElseThrow().request(), reopened.stagedContent())
+ .hasLiveContent());
}
}
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
index d435b0c..e040f2f 100644
--- 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
@@ -35,6 +35,7 @@ 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.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static zeroecho.pki.testkit.PkiTestRuntime.signingAuthority;
@@ -74,6 +75,110 @@ final class PkiBusContentSignerCleanupTest {
assertCleanup(tempDir, true);
}
+ @Test
+ void closeBeforeCompletionAbortsStagingAndConsumesSigner(@TempDir Path tempDir) throws Exception {
+ KeyRef issuerKeyRef = new KeyRef("kref:v1:keyring:test:issuer");
+ InMemorySignatureWorkflow workflow = new InMemorySignatureWorkflow(Map.of(), false);
+ Path storeRoot = tempDir.resolve("store");
+ try (FilesystemPkiStore store = new FilesystemPkiStore(storeRoot, FsPkiStoreOptions.defaults());
+ PkiSigningBus bus = new PkiSigningBus(store, workflow, tempDir.resolve("bus.log"),
+ signingAuthority(workflow))) {
+ PkiBusContentSigner signer = new PkiBusContentSigner(bus, issuerKeyRef, "SHA256withRSA",
+ Duration.ofSeconds(5));
+ signer.getOutputStream().write(new byte[] { 1, 2, 3 });
+ signer.close();
+ signer.close();
+
+ assertThrows(IllegalStateException.class, signer::getOutputStream);
+ assertThrows(IllegalStateException.class, signer::getSignature);
+ assertEquals(0L, stagedFileCount(storeRoot));
+ assertEquals(0, workflow.submittedSignCount());
+ } finally {
+ workflow.close();
+ }
+ }
+
+ @Test
+ void continuationEncodingFailureReleasesCompletedContentAndConsumesSigner(@TempDir Path tempDir) throws Exception {
+ KeyRef oversizedKeyRef = new KeyRef("k".repeat(65_536));
+ InMemorySignatureWorkflow workflow = new InMemorySignatureWorkflow(Map.of(), false);
+ Path storeRoot = tempDir.resolve("store");
+ try (FilesystemPkiStore store = new FilesystemPkiStore(storeRoot, FsPkiStoreOptions.defaults());
+ PkiSigningBus bus = new PkiSigningBus(store, workflow, tempDir.resolve("bus.log"),
+ signingAuthority(workflow));
+ PkiBusContentSigner signer = new PkiBusContentSigner(bus, oversizedKeyRef, "SHA256withRSA",
+ Duration.ofSeconds(5))) {
+ signer.getOutputStream().write(new byte[] { 1, 2, 3 });
+
+ PkiException failure = assertThrows(PkiException.class, signer::getSignature);
+
+ assertEquals("Signing workflow failed: code=SIGNING_WORKFLOW_FAILED", failure.getMessage());
+ assertNull(failure.getCause());
+ assertEquals(0, failure.getSuppressed().length);
+ assertThrows(IllegalStateException.class, signer::getOutputStream);
+ assertThrows(IllegalStateException.class, signer::getSignature);
+ assertEquals(0, workflow.submittedSignCount());
+ assertTrue(store.listSignRecords().isEmpty());
+ assertTrue(store.listWorkflowStates().isEmpty());
+ assertEquals(0L, stagedFileCount(storeRoot));
+ } finally {
+ workflow.close();
+ }
+ }
+
+ @Test
+ void interruptionPreservesFlagAndRetiresOperation(@TempDir Path tempDir) throws Exception {
+ KeyRef issuerKeyRef = new KeyRef("kref:v1:keyring:test:issuer");
+ InMemorySignatureWorkflow workflow = new InMemorySignatureWorkflow(Map.of(), false);
+ Path storeRoot = tempDir.resolve("store");
+ try (FilesystemPkiStore store = new FilesystemPkiStore(storeRoot, FsPkiStoreOptions.defaults());
+ PkiSigningBus bus = new PkiSigningBus(store, workflow, tempDir.resolve("bus.log"),
+ signingAuthority(workflow));
+ PkiBusContentSigner signer = new PkiBusContentSigner(bus, issuerKeyRef, "SHA256withRSA",
+ Duration.ofSeconds(5))) {
+ signer.getOutputStream().write(new byte[] { 1, 2, 3 });
+ Thread.currentThread().interrupt();
+ try {
+ PkiException failure = assertThrows(PkiException.class, signer::getSignature);
+ assertEquals("Signing workflow failed: code=SIGNING_WORKFLOW_FAILED", failure.getMessage());
+ assertNull(failure.getCause());
+ assertEquals(0, failure.getSuppressed().length);
+ assertTrue(Thread.currentThread().isInterrupted());
+ } finally {
+ Thread.interrupted();
+ }
+ assertTrue(store.listSignRecords().isEmpty());
+ assertFalse(workflow.hasRunningOperations());
+ assertEquals(0L, stagedFileCount(storeRoot));
+ } finally {
+ workflow.close();
+ }
+ }
+
+ @Test
+ void timeoutRetiresOperationAndStagedContent(@TempDir Path tempDir) throws Exception {
+ KeyRef issuerKeyRef = new KeyRef("kref:v1:keyring:test:issuer");
+ InMemorySignatureWorkflow workflow = new InMemorySignatureWorkflow(Map.of(), false);
+ Path storeRoot = tempDir.resolve("store");
+ try (FilesystemPkiStore store = new FilesystemPkiStore(storeRoot, FsPkiStoreOptions.defaults());
+ PkiSigningBus bus = new PkiSigningBus(store, workflow, tempDir.resolve("bus.log"),
+ signingAuthority(workflow));
+ PkiBusContentSigner signer = new PkiBusContentSigner(bus, issuerKeyRef, "SHA256withRSA",
+ Duration.ofMillis(1))) {
+ signer.getOutputStream().write(new byte[] { 1, 2, 3 });
+
+ PkiException failure = assertThrows(PkiException.class, signer::getSignature);
+
+ assertEquals("Signing workflow failed: code=SIGNING_WORKFLOW_FAILED", failure.getMessage());
+ assertNull(failure.getCause());
+ assertEquals(0, failure.getSuppressed().length);
+ assertFalse(workflow.hasRunningOperations());
+ assertEquals(0L, stagedFileCount(storeRoot));
+ } finally {
+ workflow.close();
+ }
+ }
+
@Test
void crlPostconditionRejectsEqualFingerprintForeignPlan(@TempDir Path tempDir) throws Exception {
System.out.println("crlPostconditionRejectsEqualFingerprintForeignPlan");
@@ -85,23 +190,25 @@ final class PkiBusContentSignerCleanupTest {
try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"),
FsPkiStoreOptions.defaults());
PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"), owning)) {
- PkiBusContentSigner contentSigner = new PkiBusContentSigner(bus, issuerKeyRef, "SHA256withRSA",
- Duration.ofSeconds(5));
- X509ExecutionPlan plan = contentSigner.executionPlan();
+ try (PkiBusContentSigner contentSigner = new PkiBusContentSigner(bus, issuerKeyRef, "SHA256withRSA",
+ Duration.ofSeconds(5))) {
+ X509ExecutionPlan plan = contentSigner.executionPlan();
- BcX509StatusObjectGenerator.requireAuthorizedSigningPlan(owning, plan,
- plan.selection().requested());
- assertEquals(owning.semanticFingerprint(), foreign.semanticFingerprint());
- assertThrows(IllegalArgumentException.class,
- () -> BcX509StatusObjectGenerator.requireAuthorizedSigningPlan(foreign, plan,
- plan.selection().requested()));
- assertThrows(PkiException.class,
- () -> BcX509StatusObjectGenerator.requireAuthorizedSigningPlan(owning, plan,
- BootstrapAlgorithmIdentities.RSA_PKCS1_SHA384));
- contentSigner.getOutputStream().write(1);
- contentSigner.getSignature();
- System.out.println("...fingerprint=" + owning.semanticFingerprint().substring(0, 16));
- System.out.println("crlPostconditionRejectsEqualFingerprintForeignPlan...ok");
+ BcX509StatusObjectGenerator.requireAuthorizedSigningPlan(owning, plan,
+ plan.selection().requested());
+ assertEquals(owning.semanticFingerprint(), foreign.semanticFingerprint());
+ assertThrows(IllegalArgumentException.class,
+ () -> BcX509StatusObjectGenerator.requireAuthorizedSigningPlan(foreign, plan,
+ plan.selection().requested()));
+ assertThrows(PkiException.class,
+ () -> BcX509StatusObjectGenerator.requireAuthorizedSigningPlan(owning, plan,
+ BootstrapAlgorithmIdentities.RSA_PKCS1_SHA384));
+ contentSigner.getOutputStream().write(1);
+ contentSigner.getSignature();
+ assertThrows(IllegalStateException.class, contentSigner::getSignature);
+ System.out.println("...fingerprint=" + owning.semanticFingerprint().substring(0, 16));
+ System.out.println("crlPostconditionRejectsEqualFingerprintForeignPlan...ok");
+ }
} finally {
signer.close();
}
@@ -116,11 +223,15 @@ final class PkiBusContentSignerCleanupTest {
try (FilesystemPkiStore store = new FilesystemPkiStore(storeRoot, FsPkiStoreOptions.defaults());
PkiSigningBus bus = new PkiSigningBus(store, signer, busLog, signingAuthority(signer))) {
- 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);
+ try (PkiBusContentSigner contentSigner = new PkiBusContentSigner(bus, issuerKeyRef, "SHA256withRSA",
+ Duration.ofSeconds(5))) {
+ contentSigner.getOutputStream()
+ .write(intermediate ? subjectKey.getPublic().getEncoded() : new byte[] { 1, 2, 3 });
+ PkiException failure = assertThrows(PkiException.class, contentSigner::getSignature);
+ assertEquals("Signing workflow failed: code=SIGNING_WORKFLOW_FAILED", failure.getMessage());
+ assertNull(failure.getCause());
+ assertEquals(0, failure.getSuppressed().length);
+ }
assertEquals(1, signer.submittedSignCount());
assertTrue(store.listWorkflowStates().isEmpty());
@@ -148,6 +259,12 @@ final class PkiBusContentSignerCleanupTest {
throw new AssertionError("No durable signing operation snapshot found");
}
+ private static long stagedFileCount(Path storeRoot) throws Exception {
+ try (java.util.stream.Stream paths = Files.walk(storeRoot.resolve("staged-content"))) {
+ return paths.filter(Files::isRegularFile).count();
+ }
+ }
+
private static KeyPair generateRsa() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemSignWorkflowStoreTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemSignWorkflowStoreTest.java
index e886b61..1622b61 100644
--- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemSignWorkflowStoreTest.java
+++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemSignWorkflowStoreTest.java
@@ -69,6 +69,7 @@ import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.KeyRef;
+import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.audit.AccessContext;
import zeroecho.pki.api.audit.Principal;
@@ -229,6 +230,65 @@ final class FilesystemSignWorkflowStoreTest {
}
}
+ @Test
+ void postRetirementContentFailureStillDeletesWorkflowAndRecoversOnRestart(@TempDir Path root) throws Exception {
+ Path busLog = root.resolveSibling(root.getFileName() + "-bus.log");
+ AtomicBoolean failRetirement = new AtomicBoolean(true);
+ InMemorySignatureWorkflow signer = new InMemorySignatureWorkflow(Map.of());
+ PkiId operationId;
+ DurableContentReference content;
+ try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), Clock.systemUTC(),
+ FilesystemRevocationAuthority.IndexUpdateFaultInjector.NONE, reference -> {
+ if (failRetirement.compareAndSet(true, false)) {
+ throw new java.io.IOException("injected physical cleanup detail");
+ }
+ }); PkiSigningBus bus = new PkiSigningBus(store, signer, busLog, signingAuthority(signer))) {
+ operationId = bus.newSubmissionId();
+ try (zeroecho.pki.spi.store.ContentSink sink = store.stagedContent().beginContent(Encoding.BINARY,
+ DurableContentReference.Lifecycle.OPERATION);
+ java.io.OutputStream output = sink.outputStream()) {
+ output.write(new byte[] { 1, 2, 3 });
+ content = sink.complete();
+ }
+ AccessContext access = new AccessContext(TEST_OWNER, new Purpose("SIGN"), Optional.empty(),
+ Optional.empty());
+ PkiSigningBus.SignContinuation continuation = new PkiSigningBus.SignContinuation(access,
+ TEST_ALGORITHM, content, TEST_KEY, Encoding.BINARY, Optional.empty());
+ bus.submitSign(operationId, TEST_OWNER, TEST_KEY, TEST_ALGORITHM, content, Duration.ofSeconds(30),
+ Optional.of(continuation.encode()));
+ assertTrue(bus.status(operationId).isPresent());
+ assertEquals(1, store.listWorkflowStates().size());
+
+ PkiException failure = assertThrows(PkiException.class,
+ () -> bus.retireSignOperation(operationId, "test-retirement"));
+
+ assertEquals("Signing content cleanup failed: code=SIGNING_CONTENT_CLEANUP_FAILED",
+ failure.getMessage());
+ assertFalse(failure.toString().contains("injected physical cleanup detail"));
+ assertEquals(0, failure.getSuppressed().length);
+ SignWorkflowStore.Record retired = store.getSignRecord(operationId).orElseThrow();
+ assertEquals(SignWorkflowStore.State.RETIRED, retired.state());
+ assertFalse(PkiSigningBus.SignContinuation.decode(retired.request(), store.stagedContent())
+ .hasLiveContent());
+ assertTrue(store.listWorkflowStates().isEmpty());
+ try (RepeatableContent orphan = bus.openContent(content)) {
+ assertEquals(3L, orphan.length().orElseThrow());
+ }
+ }
+
+ try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults());
+ PkiSigningBus replayed = new PkiSigningBus(reopened, signer, busLog, signingAuthority(signer))) {
+ SignWorkflowStore.Record retained = reopened.getSignRecord(operationId).orElseThrow();
+ assertEquals(SignWorkflowStore.State.RETIRED, retained.state());
+ assertFalse(PkiSigningBus.SignContinuation.decode(retained.request(), reopened.stagedContent())
+ .hasLiveContent());
+ assertTrue(reopened.listWorkflowStates().isEmpty());
+ assertThrows(PkiException.class, () -> replayed.openContent(content));
+ } finally {
+ signer.close();
+ }
+ }
+
@Test
void snapshotRetainsNamespaceResultRevisionFenceAndRollbackWatermark(@TempDir java.nio.file.Path root)
throws Exception {