fix(pki): unify signing workflow cleanup lifecycle

Consolidate synchronous signing onto PkiBusContentSigner.
Ensure deterministic staging cleanup, operation retirement, and restart recovery.
Add lifecycle regression tests and document retained terminal evidence.
This commit is contained in:
2026-08-11 23:24:45 +02:00
parent 4f01c57360
commit 67989b232f
13 changed files with 612 additions and 325 deletions

View File

@@ -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

View File

@@ -129,9 +129,10 @@ 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 (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)
@@ -140,6 +141,7 @@ final class DefaultOcspResponseService implements OcspResponseService {
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,12 +194,14 @@ 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);
: 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));
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();
@@ -208,8 +212,8 @@ final class DefaultOcspResponseService implements OcspResponseService {
|| 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))
|| !decoded.isSignatureValid(
new org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder().build(responder))
|| !matchesResponses(decoded.getResponses(), resolved, command)
|| !matchesCertificates(decoded.getCerts(), chain)
|| !matchesNonce(decoded, command.nonce())) {
@@ -217,6 +221,7 @@ final class DefaultOcspResponseService implements OcspResponseService {
}
return encoded;
}
}
private static boolean matchesResponses(SingleResp[] decoded, List<Resolved> expected, Command command) {
if (decoded.length != expected.size()) return false;

View File

@@ -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<String> 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<AsyncStatus> status = bus.status(opId);
if (status.isPresent() && status.get().state() == AsyncState.SUCCEEDED) {
Optional<EncodedObject> 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.
}
}
}
}

View File

@@ -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(),

View File

@@ -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,19 +396,29 @@ 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<EncodedObject> workflowPayload) {
Objects.requireNonNull(content, "content");
PkiId baseOpId = null;
try {
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");
}
@@ -421,14 +433,15 @@ public final class PkiSigningBus implements AutoCloseable {
if (workflowPayload.isEmpty()) {
throw new IllegalArgumentException("workflowPayload must be present for new sign operation");
}
PkiId baseOpId = normalizeBaseOperationId(opId);
PkiId normalizedBaseOpId = normalizeBaseOperationId(opId);
SigningSubmissionId parsed = SigningSubmissionId.parse(normalizedBaseOpId);
baseOpId = normalizedBaseOpId;
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<SignatureWorkflow> continuationPlan = authority.planSigning(continuation.algorithmId,
workflowImplementationId(signer), SignatureWorkflow.class);
X509ExecutionPlan<SignatureWorkflow> 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)
@@ -439,16 +452,44 @@ public final class PkiSigningBus implements AutoCloseable {
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.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.
}
}
/**
@@ -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.
* </p>
*
* @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;
}
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;
}

View File

@@ -229,7 +229,7 @@ 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());
try (PkiBusContentSigner signer = signer(issuerKeyRef, request.algorithmBindingPolicy())) {
X509CertificateHolder leaf;
try {
leaf = builder.build(signer);
@@ -259,6 +259,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
java.util.Arrays.fill(certDer, (byte) 0);
}
}
}
private static void addLeafExtensions(X509v3CertificateBuilder builder, ValidatedCertificateRequest request) {
try {
@@ -389,7 +390,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
throw new PkiException("X.509 extension construction failed: code=EXTENSION_BUILD_FAILED");
}
PkiBusContentSigner signer = signer(issuerKeyRef, request.algorithmBindingPolicy());
try (PkiBusContentSigner signer = signer(issuerKeyRef, request.algorithmBindingPolicy())) {
X509CertificateHolder certificate;
try {
certificate = builder.build(signer);
@@ -409,14 +410,14 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
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());
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);
}
}
}
private DurableContentReference stageCertificate(byte[] certificate) {
if (certificate.length > MAXIMUM_CERTIFICATE_BYTES) {

View File

@@ -270,10 +270,11 @@ 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);
: new PkiBusContentSigner(signingBus, issuerMaterial.keyRef(), signatureIdentity, signingTtl)) {
byte[] algorithm = signer.getAlgorithmIdentifier().getEncoded();
tbs = encodeTbs(issuerMaterial.issuerHolder(), thisUpdate, nextUpdate, entries, algorithm);
copyToSigner(tbs, signer);
@@ -282,10 +283,10 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
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());
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) {

View File

@@ -96,6 +96,25 @@ import zeroecho.pki.util.async.AsyncState;
* returns or throws.</li>
* </ul>
*
* <h2>Lifecycle and ownership</h2>
* <p>
* 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.
* </p>
* <p>
* 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.
* </p>
*
* <h2>Security considerations</h2>
* <ul>
* <li>This class never accesses private key material directly.</li>
@@ -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.
* </p>
*
* <h2>Complexity</h2>
* <p>
* 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.
* </p>
*/
// 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 {
* </p>
*
* @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 {
* <p>
* 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.
* </p>
*
* <p>
@@ -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,15 +356,52 @@ public final class PkiBusContentSigner implements ContentSigner {
try {
if (retirementRequired) {
retirePreservingFailure(opId, primaryFailure);
} else if (content != null) {
releasePreservingFailure(content, primaryFailure);
}
} finally {
try {
closeSink(primaryFailure);
} finally {
closed = true;
if (consumedResult != null) {
java.util.Arrays.fill(consumedResult, (byte) 0);
}
}
}
}
}
/**
* Aborts incomplete staged content owned by this signer.
*
* <p>
* 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.
* </p>
*
* @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 {
@@ -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)) {

View File

@@ -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);
}

View File

@@ -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")));

View File

@@ -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());
}
}

View File

@@ -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,8 +190,8 @@ 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));
try (PkiBusContentSigner contentSigner = new PkiBusContentSigner(bus, issuerKeyRef, "SHA256withRSA",
Duration.ofSeconds(5))) {
X509ExecutionPlan<SignatureWorkflow> plan = contentSigner.executionPlan();
BcX509StatusObjectGenerator.requireAuthorizedSigningPlan(owning, plan,
@@ -100,8 +205,10 @@ final class PkiBusContentSignerCleanupTest {
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));
try (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);
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<Path> 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);

View File

@@ -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 {