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 {