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:
@@ -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
|
||||
|
||||
@@ -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<Resolved> expected, Command command) {
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<EncodedObject> 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<SignatureWorkflow> 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<SignatureWorkflow> 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<SignatureWorkflow> 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<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)
|
||||
|| !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.
|
||||
* </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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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.
|
||||
*
|
||||
* <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 {
|
||||
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)) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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")));
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SignatureWorkflow> plan = contentSigner.executionPlan();
|
||||
try (PkiBusContentSigner contentSigner = new PkiBusContentSigner(bus, issuerKeyRef, "SHA256withRSA",
|
||||
Duration.ofSeconds(5))) {
|
||||
X509ExecutionPlan<SignatureWorkflow> 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<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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user