From 07e04e0eed5c45fe2aa9962b3272a0a5bc96009f Mon Sep 17 00:00:00 2001 From: Leo Galambos Date: Wed, 29 Jul 2026 14:20:21 +0200 Subject: [PATCH] security(pki): enforce proof gates and durable signing workflows --- .../pki/api/CertificationRequestService.java | 8 + .../java/zeroecho/pki/api/EncodedObject.java | 11 + .../zeroecho/pki/api/IssuanceService.java | 8 + .../pki/api/issuance/VerificationPolicy.java | 6 + .../pki/api/orch/SigningSubmissionId.java | 164 +++ .../zeroecho/pki/impl/core/CaProofGate.java | 344 +++++ .../pki/impl/core/CredentialSnapshots.java | 86 ++ .../pki/impl/core/DefaultCaService.java | 436 ++++--- .../pki/impl/core/DefaultIssuanceService.java | 301 ++++- .../pki/impl/core/ManagedCaIssuance.java | 194 +++ .../impl/core/VerifiedIssuanceCandidate.java | 191 +++ .../pki/impl/core/async/PkiSigningBus.java | 1054 ++++++++++++--- .../ZeroEchoLibSignatureWorkflow.java | 976 ++++++++++++-- .../ZeroEchoLibSignatureWorkflowProvider.java | 23 +- .../framework/x509/bc/BcX509Attributes.java | 11 + .../bc/BcX509CertificationRequestParser.java | 76 +- .../x509/bc/BcX509CredentialFramework.java | 135 +- .../bc/BcX509CredentialIssuerBackend.java | 212 ++- .../bc/BcX509ProofOfPossessionVerifier.java | 9 +- .../x509/bc/PkiBusContentSigner.java | 124 +- .../pki/impl/fs/FilesystemPkiStore.java | 789 +++++++++++- .../java/zeroecho/pki/impl/fs/FsCodec.java | 41 +- .../java/zeroecho/pki/impl/fs/FsPaths.java | 18 + .../pki/impl/fs/FsPkiStoreOptions.java | 17 +- .../pki/impl/fs/FsSnapshotExporter.java | 15 + .../pki/spi/crypto/SignatureWorkflow.java | 194 ++- .../spi/framework/CredentialFramework.java | 12 +- .../framework/CredentialIssuerBackend.java | 41 +- .../java/zeroecho/pki/spi/store/PkiStore.java | 2 +- .../pki/spi/store/SignWorkflowStore.java | 293 +++++ .../pki/util/async/impl/DurableAsyncBus.java | 106 +- .../zeroecho/pki/e2e/PkiProofGateE2eTest.java | 1114 ++++++++++++++++ .../core/async/PkiSigningBusFailureTest.java | 1136 +++++++++++++++++ .../PkiSigningBusOperatorApprovalTest.java | 25 + .../async/PkiSigningBusResilienceTest.java | 5 +- .../ZeroEchoLibKeyRefParsingTest.java | 23 +- ...hoLibSignatureWorkflowPersistenceTest.java | 490 +++++++ ...gnatureWorkflowVerifyEncodedEcdsaTest.java | 5 +- ...LibSignatureWorkflowVerifyEncodedTest.java | 5 +- .../bc/PkiBusContentSignerCleanupTest.java | 119 ++ ...WorkflowProofOfPossessionVerifierTest.java | 3 +- .../fs/FilesystemPkiStoreOwnershipTest.java | 312 +++++ .../pki/impl/fs/FilesystemPkiStoreTest.java | 6 +- .../fs/FilesystemSignWorkflowStoreTest.java | 677 ++++++++++ .../pki/spi/bootstrap/PkiBootstrapTest.java | 2 + .../spi/crypto/SignRequestCleanupTest.java | 96 ++ .../DurableDelayedSignatureWorkflow.java | 68 +- ...ableOperatorApprovalSignatureWorkflow.java | 56 +- .../testkit/InMemorySignatureWorkflow.java | 108 +- .../OperatorApprovalSignatureWorkflow.java | 59 +- .../zeroecho/pki/testkit/PkiTestRuntime.java | 148 ++- .../pki/testkit/TestSignIdentityRegistry.java | 135 ++ .../pki/util/async/DurableAsyncBusTest.java | 89 ++ 53 files changed, 9628 insertions(+), 950 deletions(-) create mode 100644 pki/src/main/java/zeroecho/pki/api/orch/SigningSubmissionId.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/core/CaProofGate.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/core/CredentialSnapshots.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/core/ManagedCaIssuance.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/core/VerifiedIssuanceCandidate.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/store/SignWorkflowStore.java create mode 100644 pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java create mode 100644 pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusFailureTest.java create mode 100644 pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowPersistenceTest.java create mode 100644 pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSignerCleanupTest.java create mode 100644 pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreOwnershipTest.java create mode 100644 pki/src/test/java/zeroecho/pki/impl/fs/FilesystemSignWorkflowStoreTest.java create mode 100644 pki/src/test/java/zeroecho/pki/spi/crypto/SignRequestCleanupTest.java create mode 100644 pki/src/test/java/zeroecho/pki/testkit/TestSignIdentityRegistry.java diff --git a/pki/src/main/java/zeroecho/pki/api/CertificationRequestService.java b/pki/src/main/java/zeroecho/pki/api/CertificationRequestService.java index 06907a2..41fb3de 100644 --- a/pki/src/main/java/zeroecho/pki/api/CertificationRequestService.java +++ b/pki/src/main/java/zeroecho/pki/api/CertificationRequestService.java @@ -81,6 +81,14 @@ public interface CertificationRequestService { * Verifies proof-of-possession (PoP) for the private key corresponding to the * requested public key. * + *

+ * This operation is diagnostic and may be used by request-processing workflows + * before issuance. A successful result does not grant issuance authority: + * {@link IssuanceService#issueEndEntity} independently reparses the original + * request payload and requires proof of possession immediately before invoking + * a privileged issuer backend. + *

+ * * @param parsed parsed request * @param policy verification policy * @return PoP verification result diff --git a/pki/src/main/java/zeroecho/pki/api/EncodedObject.java b/pki/src/main/java/zeroecho/pki/api/EncodedObject.java index 94910a6..8a770e5 100644 --- a/pki/src/main/java/zeroecho/pki/api/EncodedObject.java +++ b/pki/src/main/java/zeroecho/pki/api/EncodedObject.java @@ -67,5 +67,16 @@ public record EncodedObject(Encoding encoding, byte[] bytes) { if (bytes == null || bytes.length == 0) { throw new IllegalArgumentException("bytes must not be null/empty"); } + bytes = bytes.clone(); + } + + /** + * Returns a defensive copy of the encoded payload. + * + * @return newly allocated payload copy + */ + @Override + public byte[] bytes() { + return bytes.clone(); } } diff --git a/pki/src/main/java/zeroecho/pki/api/IssuanceService.java b/pki/src/main/java/zeroecho/pki/api/IssuanceService.java index 3e59969..d708762 100644 --- a/pki/src/main/java/zeroecho/pki/api/IssuanceService.java +++ b/pki/src/main/java/zeroecho/pki/api/IssuanceService.java @@ -55,6 +55,14 @@ public interface IssuanceService { /** * Issues a new end-entity credential. * + *

+ * Implementations must treat a supplied normalized request as untrusted + * workflow state. Issuance reparses the original structural request, requires + * proof of possession, and binds the exact request identity, subject, and + * public key before privileged backend signing. Frameworks without verifiable + * structural request evidence are not authorized by this operation. + *

+ * * @param command issuance command * @return credential bundle (credential plus supporting artifacts) * @throws IllegalArgumentException if {@code command} is invalid diff --git a/pki/src/main/java/zeroecho/pki/api/issuance/VerificationPolicy.java b/pki/src/main/java/zeroecho/pki/api/issuance/VerificationPolicy.java index 926e6aa..5020cc8 100644 --- a/pki/src/main/java/zeroecho/pki/api/issuance/VerificationPolicy.java +++ b/pki/src/main/java/zeroecho/pki/api/issuance/VerificationPolicy.java @@ -43,6 +43,12 @@ import java.util.Optional; * framework-specific verification modes via optional hints. *

* + *

+ * Setting {@code requireProofOfPossession} to {@code false} is suitable only for + * diagnostic or policy-evaluation workflows. It cannot authorize credential + * issuance; the issuance boundary always applies its own mandatory proof policy. + *

+ * * @param requireProofOfPossession whether proof-of-possession is required * @param compatibilityProfileId optional compatibility profile hint for * parsers/verifiers diff --git a/pki/src/main/java/zeroecho/pki/api/orch/SigningSubmissionId.java b/pki/src/main/java/zeroecho/pki/api/orch/SigningSubmissionId.java new file mode 100644 index 0000000..76d2a06 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/orch/SigningSubmissionId.java @@ -0,0 +1,164 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.api.orch; + +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.HexFormat; +import java.util.Objects; + +import zeroecho.pki.api.PkiId; + +/** + * Versioned ZeroEcho signing-submission identifier. + * + *

The format is {@code zsign:v1:::<128-bit-random>}. + * Creation time remains store-authoritative: stores must validate the embedded + * time against their own clock and persist it with the intent.

+ * + * @param id identifier value + * @param namespace provider/store namespace + * @param createdAt embedded creation time with millisecond precision + */ +public record SigningSubmissionId(PkiId id, String namespace, Instant createdAt) { + + private static final String PREFIX = "zsign:v1:"; + private static final int RANDOM_BYTES = 16; + + public SigningSubmissionId { + Objects.requireNonNull(id, "id"); + requireNamespace(namespace); + Objects.requireNonNull(createdAt, "createdAt"); + } + + /** + * Creates a new identifier using a cryptographically strong random source. + * + * @param namespace provider/store namespace + * @param now trusted caller creation time + * @param random secure random source + * @return new identifier + */ + public static SigningSubmissionId create(String namespace, Instant now, SecureRandom random) { + requireNamespace(namespace); + Objects.requireNonNull(now, "now"); + Objects.requireNonNull(random, "random"); + Instant created = Instant.ofEpochMilli(now.toEpochMilli()); + byte[] nonce = new byte[RANDOM_BYTES]; + random.nextBytes(nonce); + String value = PREFIX + namespace + ":" + created.toEpochMilli() + ":" + HexFormat.of().formatHex(nonce); + return new SigningSubmissionId(new PkiId(value), namespace, created); + } + + /** + * Parses and validates the version, namespace, timestamp, and random length. + * + * @param id identifier to parse + * @return parsed identifier + * @throws IllegalArgumentException if the identifier is malformed + */ + public static SigningSubmissionId parse(PkiId id) { + Objects.requireNonNull(id, "id"); + String[] parts = id.value().split(":", -1); + if (parts.length != 5 || !"zsign".equals(parts[0]) || !"v1".equals(parts[1])) { + throw new IllegalArgumentException("Unsupported signing submission id"); + } + requireNamespace(parts[2]); + long epochMilli; + try { + epochMilli = Long.parseLong(parts[3]); + } catch (NumberFormatException ex) { + throw new IllegalArgumentException("Invalid signing submission timestamp", ex); + } + if (parts[4].length() != RANDOM_BYTES * 2) { + throw new IllegalArgumentException("Invalid signing submission random length"); + } + try { + HexFormat.of().parseHex(parts[4]); + } catch (IllegalArgumentException ex) { + throw new IllegalArgumentException("Invalid signing submission random value", ex); + } + return new SigningSubmissionId(id, parts[2], Instant.ofEpochMilli(epochMilli)); + } + + /** + * Validates namespace and lifetime against store-authoritative time. + * + * @param requiredNamespace expected namespace + * @param now store-authoritative time + * @param horizon maximum accepted age + * @param permittedSkew allowed future clock skew + * @throws IllegalArgumentException if namespace or lifetime is invalid + */ + public void validate(String requiredNamespace, Instant now, Duration horizon, Duration permittedSkew) { + requireNamespace(requiredNamespace); + Objects.requireNonNull(now, "now"); + requirePositive(horizon, "horizon"); + Objects.requireNonNull(permittedSkew, "permittedSkew"); + if (permittedSkew.isNegative()) { + throw new IllegalArgumentException("permittedSkew must not be negative"); + } + if (!namespace.equals(requiredNamespace)) { + throw new IllegalArgumentException("Signing submission namespace mismatch"); + } + if (createdAt.isAfter(now.plus(permittedSkew))) { + throw new IllegalArgumentException("Signing submission timestamp is in the future"); + } + if (!createdAt.plus(horizon).isAfter(now)) { + throw new IllegalArgumentException("Signing submission id has expired"); + } + } + + private static void requireNamespace(String namespace) { + if (namespace == null || namespace.isBlank() || namespace.length() > 128) { + throw new IllegalArgumentException("namespace must contain 1 to 64 characters"); + } + for (int index = 0; index < namespace.length(); index++) { + char value = namespace.charAt(index); + boolean valid = value >= 'a' && value <= 'z' || value >= '0' && value <= '9' + || value == '.' || value == '_' || value == '-'; + if (!valid) { + throw new IllegalArgumentException("namespace contains an unsupported character"); + } + } + } + + private static void requirePositive(Duration value, String name) { + Objects.requireNonNull(value, name); + if (value.isZero() || value.isNegative()) { + throw new IllegalArgumentException(name + " must be positive"); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/core/CaProofGate.java b/pki/src/main/java/zeroecho/pki/impl/core/CaProofGate.java new file mode 100644 index 0000000..137a2e7 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/core/CaProofGate.java @@ -0,0 +1,344 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.impl.core; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +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 org.bouncycastle.operator.ContentVerifier; +import org.bouncycastle.operator.DefaultSignatureAlgorithmIdentifierFinder; +import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder; + +import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.Encoding; +import zeroecho.pki.api.FormatId; +import zeroecho.pki.api.KeyRef; +import zeroecho.pki.api.PkiException; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.SubjectRef; +import zeroecho.pki.api.Validity; +import zeroecho.pki.api.attr.AttributeSet; +import zeroecho.pki.api.audit.AccessContext; +import zeroecho.pki.api.audit.AuditEvent; +import zeroecho.pki.api.audit.Principal; +import zeroecho.pki.api.audit.Purpose; +import zeroecho.pki.impl.core.async.PkiSigningBus; +import zeroecho.pki.spi.audit.AuditSink; +import zeroecho.pki.util.async.AsyncState; +import zeroecho.pki.util.async.AsyncStatus; + +/** + * Internal fail-closed proof gate for CA signing keys. + */ +// PMD cannot infer that retaining boundary causes would violate the redaction contract. +@SuppressWarnings("PMD.PreserveStackTrace") +final class CaProofGate { + + private static final Principal SYSTEM_PKI = new Principal("SYSTEM", "pki"); + private static final Purpose CA_ISSUANCE_PURPOSE = new Purpose("CA_ISSUANCE"); + private static final byte[] MANAGED_KEY_CHALLENGE_DOMAIN = + "ZeroEcho/PKI/managed-key-possession/v1\0".getBytes(java.nio.charset.StandardCharsets.US_ASCII); + private static final int CHALLENGE_NONCE_BYTES = 32; + private static final SecureRandom CHALLENGE_RANDOM = new SecureRandom(); + + private final PublicKeyInfoResolver publicKeyResolver; + private final PkiSigningBus signingBus; + private final AuditSink auditSink; + private final String signatureAlgorithmId; + private final Duration signingTtl; + + /* default */ CaProofGate(PublicKeyInfoResolver publicKeyResolver, PkiSigningBus signingBus, AuditSink auditSink, + String signatureAlgorithmId, Duration signingTtl) { + this.publicKeyResolver = Objects.requireNonNull(publicKeyResolver, "publicKeyResolver"); + this.signingBus = Objects.requireNonNull(signingBus, "signingBus"); + this.auditSink = Objects.requireNonNull(auditSink, "auditSink"); + this.signatureAlgorithmId = Objects.requireNonNull(signatureAlgorithmId, "signatureAlgorithmId"); + this.signingTtl = Objects.requireNonNull(signingTtl, "signingTtl"); + } + + /* default */ ContentSigner signer(ManagedKeyProof proof) { + return new BusBackedContentSigner(signingBus, proof.keyRef(), signatureAlgorithmId, signingTtl); + } + + /* default */ SubjectPublicKeyInfo parseRootSpki(EncodedObject spki, FormatId formatId) { + try { + return SubjectPublicKeyInfo.getInstance(spki.bytes()); + } catch (RuntimeException ex) { // NOPMD - malformed resolver output fails closed + throw rejection("CREATE_ROOT_REJECTED", formatId, Optional.empty(), "ROOT_MANAGED_KEY_INVALID"); + } + } + + /* default */ boolean rootProofIsValid(X509CertificateHolder certificate, EncodedObject expectedSpki) { + if (expectedSpki.encoding() != Encoding.DER) { + return false; + } + try { + byte[] embeddedSpki = certificate.getSubjectPublicKeyInfo().getEncoded(); + return MessageDigest.isEqual(expectedSpki.bytes(), embeddedSpki) && certificate.isSignatureValid( + new JcaContentVerifierProviderBuilder().build(certificate.getSubjectPublicKeyInfo())); + } catch (Exception ex) { + return false; + } + } + + /* default */ ManagedKeyProof proveManagedKey(KeyRef keyRef, FormatId formatId, String auditAction, + Optional subjectCaId) { + EncodedObject resolved; + try { + resolved = publicKeyResolver.resolveSpkiDer(keyRef); + } catch (RuntimeException ex) { // NOPMD - managed-key resolution boundary fails closed + throw rejection(auditAction, formatId, subjectCaId, "MANAGED_KEY_UNAVAILABLE"); + } + if (resolved == null || resolved.encoding() != Encoding.DER) { + throw rejection(auditAction, formatId, subjectCaId, "MANAGED_KEY_INVALID"); + } + + byte[] exactSpki = resolved.bytes().clone(); + byte[] challenge = new byte[MANAGED_KEY_CHALLENGE_DOMAIN.length + CHALLENGE_NONCE_BYTES]; + CHALLENGE_RANDOM.nextBytes(challenge); + System.arraycopy(MANAGED_KEY_CHALLENGE_DOMAIN, 0, challenge, 0, MANAGED_KEY_CHALLENGE_DOMAIN.length); + byte[] signature = null; + try { + try { + signature = signManagedKeyChallenge(keyRef, challenge); + } catch (RuntimeException ex) { // NOPMD - signing boundary failures reject with a stable code + throw rejection(auditAction, formatId, subjectCaId, "MANAGED_KEY_PROOF_FAILED"); + } + if (!verifyChallenge(exactSpki, challenge, signature)) { + throw rejection(auditAction, formatId, subjectCaId, "MANAGED_KEY_PROOF_FAILED"); + } + return new ManagedKeyProof(keyRef, formatId, new EncodedObject(Encoding.DER, exactSpki.clone())); + } finally { + Arrays.fill(challenge, (byte) 0); + if (signature != null) { + Arrays.fill(signature, (byte) 0); + } + } + } + + /* default */ ManagedCaIssuance authorizeIntermediate(ManagedKeyProof proof, + ManagedCaIssuance.Operation operation, PkiId issuerCaId, PkiId subjectCaId, String profileId, + Optional requestedValidity, AttributeSet attributes, SubjectRef subjectRef) { + Objects.requireNonNull(proof, "proof"); + return new ManagedCaIssuance(proof, operation, issuerCaId, subjectCaId, profileId, requestedValidity, + attributes, subjectRef); + } + + /* default */ PkiException rejection(String action, FormatId formatId, Optional objectId, String code) { + PkiException rejection = new PkiException("CA proof rejected: " + code); + try { + auditSink.record(new AuditEvent(Instant.now(), "CA", action, SYSTEM_PKI, CA_ISSUANCE_PURPOSE, objectId, + Optional.of(formatId), Map.of("code", code))); + } catch (RuntimeException auditFailure) { // NOPMD - preserve the stable domain rejection + // Best-effort auditing must not expose listener-controlled diagnostics. + } + return rejection; + } + + private byte[] signManagedKeyChallenge(KeyRef keyRef, byte[] challenge) { + ContentSigner contentSigner = new BusBackedContentSigner(signingBus, keyRef, signatureAlgorithmId, signingTtl); + try { + contentSigner.getOutputStream().write(challenge); + } 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) { + try { + SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(spkiDer); + ContentVerifier verifier = new JcaContentVerifierProviderBuilder().build(spki) + .get(new DefaultSignatureAlgorithmIdentifierFinder().find(signatureAlgorithmId)); + verifier.getOutputStream().write(challenge); + return verifier.verify(signature); + } catch (Exception ex) { + return false; + } + } + + /** + * Unforgeable package-local result of a completed managed-key challenge. + */ + static final class ManagedKeyProof { + + private final KeyRef keyRef; + private final FormatId formatId; + private final EncodedObject exactPublicKey; + + private ManagedKeyProof(KeyRef keyRef, FormatId formatId, EncodedObject exactPublicKey) { + this.keyRef = Objects.requireNonNull(keyRef, "keyRef"); + this.formatId = Objects.requireNonNull(formatId, "formatId"); + EncodedObject publicKey = Objects.requireNonNull(exactPublicKey, "exactPublicKey"); + this.exactPublicKey = new EncodedObject(publicKey.encoding(), publicKey.bytes()); + } + + /* default */ KeyRef keyRef() { + return keyRef; + } + + /* default */ FormatId formatId() { + return formatId; + } + + /* default */ EncodedObject exactPublicKey() { + return new EncodedObject(exactPublicKey.encoding(), exactPublicKey.bytes()); + } + } + + /** + * 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 String algorithmId; + private final Duration ttl; + private final ByteArrayOutputStream output; + + private BusBackedContentSigner(PkiSigningBus bus, KeyRef keyRef, String algorithmId, Duration ttl) { + this.bus = bus; + this.keyRef = keyRef; + this.algorithmId = algorithmId; + this.ttl = ttl; + this.output = new ByteArrayOutputStream(); + } + + @Override + public AlgorithmIdentifier getAlgorithmIdentifier() { + return new DefaultSignatureAlgorithmIdentifierFinder().find(algorithmId); + } + + @Override + public OutputStream getOutputStream() { + return output; + } + + @Override + public byte[] getSignature() { + byte[] tbs = output.toByteArray(); + try { + Principal owner = new Principal("SYSTEM", "pki"); + PkiId opId = bus.newSubmissionId(); + EncodedObject payload = new EncodedObject(Encoding.BINARY, tbs); + AccessContext accessContext = new AccessContext(owner, new Purpose("X509_SIGN"), Optional.empty(), + Optional.empty()); + PkiSigningBus.SignContinuation continuation = new PkiSigningBus.SignContinuation(accessContext, + algorithmId, payload, keyRef, Encoding.BINARY, Optional.empty()); + try { + bus.submitSign(opId, owner, keyRef, algorithmId, payload, ttl, + Optional.of(continuation.encode())); + } catch (RuntimeException failure) { // NOPMD - delete state if submission partially persisted it + deletePreservingFailure(opId); + throw failure; + } + return awaitSignature(opId); + } finally { + Arrays.fill(tbs, (byte) 0); + } + } + + @SuppressWarnings("PMD.AvoidCatchingGenericException") + private byte[] awaitSignature(PkiId opId) { + byte[] signature; + try { + signature = awaitSignatureBeforeCleanup(opId); + } catch (RuntimeException failure) { + deletePreservingFailure(opId); + throw failure; + } + bus.retireSignOperation(opId, "completed"); + return signature; + } + + private byte[] awaitSignatureBeforeCleanup(PkiId opId) { + Instant deadline = Instant.now().plus(ttl); + while (Instant.now().isBefore(deadline)) { + bus.sweep(Instant.now()); + Optional status = bus.status(opId); + if (status.isPresent() && status.get().state() == AsyncState.SUCCEEDED) { + Optional result = bus.consumeResult(opId); + if (result.isEmpty()) { + throw new PkiException("Missing signature result"); + } + return result.get().bytes(); + } + if (status.isPresent() && isTerminalFailure(status.get().state())) { + throw new PkiException("Signing failed: " + status.get().state()); + } + boundedWait(deadline); + } + throw new PkiException("Signing did not complete before TTL"); + } + + private static boolean isTerminalFailure(AsyncState state) { + return state == AsyncState.FAILED || state == AsyncState.CANCELLED || state == AsyncState.EXPIRED; + } + + @SuppressWarnings("PMD.DoNotUseThreads") + private static void boundedWait(Instant deadline) { + long remainingMillis = Duration.between(Instant.now(), deadline).toMillis(); + long waitMillis = Math.min(10L, Math.max(1L, remainingMillis)); + try { + Thread.sleep(waitMillis); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new PkiException("Interrupted while awaiting signing", ex); + } + } + + private void deletePreservingFailure(PkiId opId) { + try { + bus.retireSignOperation(opId, "failed-or-expired"); + } catch (RuntimeException cleanupFailure) { // NOPMD - preserve primary failure + // The primary safe failure remains authoritative; provider diagnostics are discarded. + } + } + + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/core/CredentialSnapshots.java b/pki/src/main/java/zeroecho/pki/impl/core/CredentialSnapshots.java new file mode 100644 index 0000000..97fe283 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/core/CredentialSnapshots.java @@ -0,0 +1,86 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.impl.core; + +import java.util.ArrayList; +import java.util.List; + +import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.attr.AttributeId; +import zeroecho.pki.api.attr.AttributeSet; +import zeroecho.pki.api.attr.AttributeValue; +import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.credential.CredentialBundle; +import zeroecho.pki.impl.core.attr.SimpleAttributeSet; + +/** + * Internal defensive snapshot boundary for framework-produced credentials. + */ +final class CredentialSnapshots { + + private CredentialSnapshots() { + } + + /* default */ static CredentialBundle copy(CredentialBundle source) { + Credential credential = copy(source.credential()); + List supporting = source.supportingObjects().stream().map(CredentialSnapshots::copy).toList(); + return new CredentialBundle(credential, supporting); + } + + /* default */ static Credential copy(Credential source) { + return new Credential(source.credentialId(), source.formatId(), source.issuerRef(), source.subjectRef(), + source.validity(), source.serialOrUniqueId(), source.publicKeyId(), source.profileId(), source.status(), + copy(source.encoded()), copy(source.attributes())); + } + + private static EncodedObject copy(EncodedObject source) { + return new EncodedObject(source.encoding(), source.bytes().clone()); + } + + private static AttributeSet copy(AttributeSet source) { + List entries = new ArrayList<>(); + for (AttributeId id : source.ids()) { + List values = source.getAll(id).stream().map(CredentialSnapshots::copy).toList(); + entries.add(new SimpleAttributeSet.Entry(id, values)); + } + return new SimpleAttributeSet(entries); + } + + private static AttributeValue copy(AttributeValue source) { + if (source instanceof AttributeValue.BytesValue bytesValue) { + return new AttributeValue.BytesValue(bytesValue.value().clone()); + } + return source; + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java index 9372742..099d9b1 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java @@ -33,6 +33,7 @@ ******************************************************************************/ package zeroecho.pki.impl.core; +import java.io.IOException; import java.math.BigInteger; import java.security.MessageDigest; import java.time.Duration; @@ -50,20 +51,24 @@ import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.asn1.x509.BasicConstraints; import org.bouncycastle.asn1.x509.Extension; import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.cert.X509v3CertificateBuilder; import org.bouncycastle.operator.ContentSigner; -import org.bouncycastle.operator.DefaultSignatureAlgorithmIdentifierFinder; +import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder; import zeroecho.pki.api.CaService; import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.Encoding; +import zeroecho.pki.api.FormatId; import zeroecho.pki.api.IssuerRef; import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiId; import zeroecho.pki.api.SubjectRef; import zeroecho.pki.api.Validity; +import zeroecho.pki.api.attr.AttributeSet; +import zeroecho.pki.api.attr.AttributeValue; import zeroecho.pki.api.ca.CaCreateCommand; import zeroecho.pki.api.ca.CaImportCommand; import zeroecho.pki.api.ca.CaKeyRotationCommand; @@ -77,7 +82,11 @@ import zeroecho.pki.api.ca.IntermediateCreateCommand; import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.CredentialStatus; import zeroecho.pki.impl.core.async.PkiSigningBus; +import zeroecho.pki.impl.core.attr.SimpleAttributeSet; +import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes; +import zeroecho.pki.spi.audit.AuditSink; import zeroecho.pki.spi.framework.CredentialFramework; +import zeroecho.pki.spi.framework.CredentialIssuerBackend; import zeroecho.pki.spi.store.PkiStore; /** @@ -133,16 +142,16 @@ import zeroecho.pki.spi.store.PkiStore; * model. *

*/ +// PMD cannot infer that retaining boundary causes would violate the redaction contract. +@SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.PreserveStackTrace" }) public final class DefaultCaService implements CaService { private static final Logger LOG = Logger.getLogger(DefaultCaService.class.getName()); private final PkiStore store; private final CredentialFramework framework; - private final PublicKeyInfoResolver publicKeyResolver; - private final PkiSigningBus signingBus; - private final String signatureAlgorithmId; - private final Duration signingTtl; + private final CredentialIssuerBackend issuerBackend; + private final CaProofGate proofGate; /** * Creates a CA service bound to a specific store, credential framework, and @@ -169,12 +178,17 @@ public final class DefaultCaService implements CaService { * @param framework credential framework responsible for * format-specific issuance and validation paths; * must not be {@code null} + * @param issuerBackend privileged issuer implementation accepting only + * proof-gated CA issuance inputs; must not be + * {@code null} * @param publicKeyResolver resolver used to obtain subject public key * material in SPKI DER form for key references; * must not be {@code null} * @param signingBus signing orchestration component used to request * delegated signing operations; must not be * {@code null} + * @param auditSink required sink for safe CA proof rejection events; + * must not be {@code null} * @param signatureAlgorithmId non-blank JCA signature algorithm identifier used * for certificate signing requests initiated by * this service @@ -187,21 +201,23 @@ public final class DefaultCaService implements CaService { * {@code signingTtl} is {@code null}, zero, or * negative */ - public DefaultCaService(PkiStore store, CredentialFramework framework, PublicKeyInfoResolver publicKeyResolver, - PkiSigningBus signingBus, String signatureAlgorithmId, Duration signingTtl) { + public DefaultCaService(PkiStore store, CredentialFramework framework, CredentialIssuerBackend issuerBackend, + PublicKeyInfoResolver publicKeyResolver, PkiSigningBus signingBus, AuditSink auditSink, + String signatureAlgorithmId, Duration signingTtl) { this.store = Objects.requireNonNull(store, "store"); this.framework = Objects.requireNonNull(framework, "framework"); - this.publicKeyResolver = Objects.requireNonNull(publicKeyResolver, "publicKeyResolver"); - this.signingBus = Objects.requireNonNull(signingBus, "signingBus"); + this.issuerBackend = Objects.requireNonNull(issuerBackend, "issuerBackend"); + Objects.requireNonNull(publicKeyResolver, "publicKeyResolver"); + Objects.requireNonNull(signingBus, "signingBus"); + Objects.requireNonNull(auditSink, "auditSink"); if (signatureAlgorithmId == null || signatureAlgorithmId.isBlank()) { throw new IllegalArgumentException("signatureAlgorithmId must not be null/blank"); } if (signingTtl == null || signingTtl.isZero() || signingTtl.isNegative()) { throw new IllegalArgumentException("signingTtl must be positive"); } - this.signatureAlgorithmId = signatureAlgorithmId; - this.signingTtl = signingTtl; + this.proofGate = new CaProofGate(publicKeyResolver, signingBus, auditSink, signatureAlgorithmId, signingTtl); } /** @@ -244,7 +260,10 @@ public final class DefaultCaService implements CaService { KeyRef keyRef = command.keyRef().get(); SubjectRef subjectRef = command.subjectRef(); - EncodedObject spki = publicKeyResolver.resolveSpkiDer(keyRef); + CaProofGate.ManagedKeyProof proof = proofGate.proveManagedKey(keyRef, command.formatId(), + "CREATE_ROOT_REJECTED", Optional.empty()); + EncodedObject spki = proof.exactPublicKey(); + SubjectPublicKeyInfo rootPublicKeyInfo = proofGate.parseRootSpki(spki, command.formatId()); Instant now = Instant.now(); Validity validity = new Validity(now.minus(Duration.ofMinutes(1)), now.plus(Duration.ofDays(3650))); @@ -252,28 +271,33 @@ public final class DefaultCaService implements CaService { BigInteger serial = BigInteger.valueOf(Math.abs(now.toEpochMilli()) + 1L); X509v3CertificateBuilder b = new X509v3CertificateBuilder(dn, serial, Date.from(validity.notBefore()), - Date.from(validity.notAfter()), dn, - org.bouncycastle.asn1.x509.SubjectPublicKeyInfo.getInstance(spki.bytes())); + Date.from(validity.notAfter()), dn, rootPublicKeyInfo); try { b.addExtension(Extension.basicConstraints, true, new BasicConstraints(true)); b.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign)); } catch (Exception ex) { - throw new PkiException("Failed to build root certificate extensions", ex); + throw new PkiException("Root extension construction failed: code=ROOT_EXTENSION_BUILD_FAILED"); } - ContentSigner signer = new BusBackedContentSigner(signingBus, keyRef, signatureAlgorithmId, signingTtl); + ContentSigner signer = proofGate.signer(proof); X509CertificateHolder cert; try { cert = b.build(signer); } catch (RuntimeException ex) { // NOPMD - throw new PkiException("Root certificate signing failed", ex); + throw proofGate.rejection("CREATE_ROOT_REJECTED", command.formatId(), Optional.empty(), + "ROOT_SIGNING_FAILED"); + } + + if (!proofGate.rootProofIsValid(cert, spki)) { + throw proofGate.rejection("CREATE_ROOT_REJECTED", command.formatId(), Optional.empty(), + "ROOT_SELF_SIGNATURE_INVALID"); } byte[] certDer; try { certDer = cert.getEncoded(); } catch (Exception ex) { - throw new PkiException("Root certificate encoding failed", ex); + throw new PkiException("Root certificate encoding failed: code=ROOT_CERTIFICATE_ENCODE_FAILED"); } PkiId credId = new PkiId("x509:" + sha256Hex(certDer)); @@ -303,9 +327,10 @@ public final class DefaultCaService implements CaService { *

* *

- * The imported root may optionally carry a {@link KeyRef}. When absent, the CA - * is still represented in the store, but later signing operations may be - * unavailable depending on higher-level policy and runtime wiring. + * The imported certificate must be a self-issued, validly self-signed CA + * certificate whose subject matches the command. The command's managed + * {@link KeyRef} must also complete a signing challenge and resolve to the exact + * certificate SPKI before either record is persisted. *

* * @param command root CA import command; must not be {@code null} @@ -325,27 +350,29 @@ public final class DefaultCaService implements CaService { throw new PkiException("Only DER import supported by this runtime"); } + byte[] certDer = command.existingCaCredential().bytes().clone(); X509CertificateHolder holder; try { - holder = new X509CertificateHolder(command.existingCaCredential().bytes()); + holder = new X509CertificateHolder(certDer); } catch (Exception ex) { - throw new PkiException("Invalid X.509 credential payload", ex); + throw new PkiException("Invalid X.509 credential: code=CREDENTIAL_INVALID"); } + requireValidImportedRoot(command, holder); + Instant notBefore = holder.getNotBefore().toInstant(); Instant notAfter = holder.getNotAfter().toInstant(); Validity validity = new Validity(notBefore, notAfter); - byte[] certDer = command.existingCaCredential().bytes(); PkiId credId = new PkiId("x509:" + sha256Hex(certDer)); PkiId caId = new PkiId("ca:" + sha256Hex(certDer).substring(0, 16)); byte[] spkiDer; try { spkiDer = holder.getSubjectPublicKeyInfo().getEncoded(); - } catch (java.io.IOException ex) { - throw new PkiException("Failed to encode subject public key info", ex); + } catch (IOException ex) { + throw new PkiException("Subject public key encoding failed: code=SPKI_ENCODE_FAILED"); } PkiId publicKeyId = new PkiId("spki:" + sha256Hex(spkiDer)); @@ -355,7 +382,6 @@ public final class DefaultCaService implements CaService { validity, serial.toString(), publicKeyId, command.profileId(), CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, certDer), command.attributes()); store.putCredential(credential); - CaRecord ca = new CaRecord(caId, CaKind.ROOT, CaState.ACTIVE, command.keyRef(), command.subjectRef(), List.of(credential)); store.putCa(ca); @@ -400,14 +426,35 @@ public final class DefaultCaService implements CaService { if (issuer.caCredentials().isEmpty()) { throw new PkiException("Issuer CA has no credentials"); } + if (!framework.formatId().equals(command.formatId())) { + throw proofGate.rejection("CREATE_INTERMEDIATE_REJECTED", command.formatId(), Optional.empty(), + "FORMAT_UNSUPPORTED"); + } PkiId caId = new PkiId("ca:" + sha256Hex((issuer.caId().value() + "\n" + command.subjectRef().value()) .getBytes(java.nio.charset.StandardCharsets.UTF_8)).substring(0, 16)); - IntermediateCertIssueCommand issue = new IntermediateCertIssueCommand(command.formatId(), command.issuerCaId(), - caId, command.profileId(), Optional.empty(), command.attributes()); + CaProofGate.ManagedKeyProof subjectProof = proofGate.proveManagedKey(command.keyRef().get(), + command.formatId(), "CREATE_INTERMEDIATE_REJECTED", Optional.of(caId)); + EncodedObject subjectSpki = subjectProof.exactPublicKey(); + Credential issuerCredential = CredentialSnapshots.copy(selectIssuerCredential(issuer, command.formatId())); + requireIssuerKeyBinding(issuer, issuerCredential, command.formatId(), "CREATE_INTERMEDIATE_REJECTED", + Optional.of(caId)); + AttributeSet authoritative = authoritativeIntermediateAttributes(command.attributes(), issuer, + issuerCredential, subjectSpki, command.subjectRef()); + ManagedCaIssuance issue = proofGate.authorizeIntermediate(subjectProof, + ManagedCaIssuance.Operation.CREATE_INTERMEDIATE, command.issuerCaId(), caId, command.profileId(), + Optional.empty(), authoritative, command.subjectRef()); - Credential cred = framework.issuerBackend().issueIntermediateCertificate(issue); + Credential cred; + try { + cred = CredentialSnapshots.copy(issuerBackend.issueIntermediateCertificate(issue)); + } catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output + throw proofGate.rejection("CREATE_INTERMEDIATE_REJECTED", command.formatId(), Optional.of(caId), + "BACKEND_CREDENTIAL_MISMATCH"); + } + requireIntermediateCredentialMatches(cred, issuerCredential, subjectSpki, command.subjectRef(), + command.issuerCaId(), caId, command.profileId(), "CREATE_INTERMEDIATE_REJECTED"); store.putCredential(cred); CaRecord subject = new CaRecord(caId, CaKind.INTERMEDIATE, CaState.ACTIVE, command.keyRef().get(), @@ -440,8 +487,32 @@ public final class DefaultCaService implements CaService { ensureActive(issuer, "issuer"); CaRecord subject = getCa(command.subjectCaId()); ensureActive(subject, "subject"); + if (!framework.formatId().equals(command.formatId())) { + throw proofGate.rejection("ISSUE_INTERMEDIATE_REJECTED", command.formatId(), Optional.of(subject.caId()), + "FORMAT_UNSUPPORTED"); + } - Credential cred = framework.issuerBackend().issueIntermediateCertificate(command); + CaProofGate.ManagedKeyProof subjectProof = proofGate.proveManagedKey(subject.issuerKeyRef(), + command.formatId(), "ISSUE_INTERMEDIATE_REJECTED", Optional.of(subject.caId())); + EncodedObject subjectSpki = subjectProof.exactPublicKey(); + Credential issuerCredential = CredentialSnapshots.copy(selectIssuerCredential(issuer, command.formatId())); + requireIssuerKeyBinding(issuer, issuerCredential, command.formatId(), "ISSUE_INTERMEDIATE_REJECTED", + Optional.of(subject.caId())); + AttributeSet authoritative = authoritativeIntermediateAttributes(command.attributes(), issuer, + issuerCredential, subjectSpki, subject.subjectRef()); + ManagedCaIssuance gated = proofGate.authorizeIntermediate(subjectProof, + ManagedCaIssuance.Operation.ISSUE_INTERMEDIATE, command.issuerCaId(), command.subjectCaId(), + command.profileId(), command.requestedValidity(), authoritative, subject.subjectRef()); + + Credential cred; + try { + cred = CredentialSnapshots.copy(issuerBackend.issueIntermediateCertificate(gated)); + } catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output + throw proofGate.rejection("ISSUE_INTERMEDIATE_REJECTED", command.formatId(), Optional.of(subject.caId()), + "BACKEND_CREDENTIAL_MISMATCH"); + } + requireIntermediateCredentialMatches(cred, issuerCredential, subjectSpki, subject.subjectRef(), + command.issuerCaId(), subject.caId(), command.profileId(), "ISSUE_INTERMEDIATE_REJECTED"); store.putCredential(cred); List updated = new ArrayList<>(subject.caCredentials()); @@ -598,6 +669,140 @@ public final class DefaultCaService implements CaService { } } + private static Credential selectIssuerCredential(CaRecord issuer, FormatId formatId) { + Instant now = Instant.now(); + for (Credential credential : issuer.caCredentials()) { + if (credential != null && formatId.equals(credential.formatId()) + && credential.status() == CredentialStatus.ISSUED + && !now.isBefore(credential.validity().notBefore()) + && !now.isAfter(credential.validity().notAfter())) { + return credential; + } + } + throw new PkiException("Issuer CA has no current issued credential for formatId " + formatId.value()); + } + + private void requireValidImportedRoot(CaImportCommand command, X509CertificateHolder holder) { + try { + BasicConstraints constraints = BasicConstraints + .getInstance(holder.getExtension(Extension.basicConstraints).getParsedValue()); + if (!holder.getSubject().equals(holder.getIssuer()) || !constraints.isCA() + || !holder.getSubject().equals(new X500Name(command.subjectRef().value())) + || !holder.isSignatureValid( + new JcaContentVerifierProviderBuilder().build(holder.getSubjectPublicKeyInfo()))) { + throw proofGate.rejection("IMPORT_ROOT_REJECTED", command.formatId(), Optional.empty(), + "ROOT_CREDENTIAL_INVALID"); + } + CaProofGate.ManagedKeyProof proof = proofGate.proveManagedKey(command.keyRef(), command.formatId(), + "IMPORT_ROOT_REJECTED", Optional.empty()); + if (!MessageDigest.isEqual(proof.exactPublicKey().bytes(), holder.getSubjectPublicKeyInfo().getEncoded())) { + throw proofGate.rejection("IMPORT_ROOT_REJECTED", command.formatId(), Optional.empty(), + "ROOT_MANAGED_KEY_MISMATCH"); + } + } catch (PkiException ex) { + throw ex; + } catch (Exception ex) { + throw proofGate.rejection("IMPORT_ROOT_REJECTED", command.formatId(), Optional.empty(), + "ROOT_CREDENTIAL_INVALID"); + } + } + + private void requireIssuerKeyBinding(CaRecord issuer, Credential credential, FormatId formatId, String action, + Optional objectId) { + try { + if (credential.encoded().encoding() != Encoding.DER) { + throw proofGate.rejection(action, formatId, objectId, "ISSUER_CREDENTIAL_INVALID"); + } + X509CertificateHolder holder = new X509CertificateHolder(credential.encoded().bytes()); + CaProofGate.ManagedKeyProof proof = proofGate.proveManagedKey(issuer.issuerKeyRef(), formatId, action, + objectId); + if (!MessageDigest.isEqual(proof.exactPublicKey().bytes(), + holder.getSubjectPublicKeyInfo().getEncoded())) { + throw proofGate.rejection(action, formatId, objectId, "ISSUER_MANAGED_KEY_MISMATCH"); + } + } catch (PkiException ex) { + throw ex; + } catch (Exception ex) { + throw proofGate.rejection(action, formatId, objectId, "ISSUER_CREDENTIAL_INVALID"); + } + } + + private void requireIntermediateCredentialMatches(Credential credential, Credential issuerCredential, + EncodedObject exactSubjectSpki, SubjectRef subjectRef, PkiId issuerCaId, PkiId subjectCaId, + String profileId, String action) { + try { + if (!framework.formatId().equals(credential.formatId()) + || credential.encoded().encoding() != Encoding.DER + || credential.status() != CredentialStatus.ISSUED + || !credential.profileId().equals(profileId) + || !credential.subjectRef().equals(subjectRef) + || !credential.issuerRef().equals(new IssuerRef(issuerCaId))) { + throw proofGate.rejection(action, framework.formatId(), Optional.of(subjectCaId), + "BACKEND_CREDENTIAL_MISMATCH"); + } + X509CertificateHolder holder = new X509CertificateHolder(credential.encoded().bytes()); + X509CertificateHolder issuerHolder = new X509CertificateHolder(issuerCredential.encoded().bytes()); + byte[] actualSpki = holder.getSubjectPublicKeyInfo().getEncoded(); + Extension constraintsExtension = holder.getExtension(Extension.basicConstraints); + BasicConstraints constraints = constraintsExtension == null ? null + : BasicConstraints.getInstance(constraintsExtension.getParsedValue()); + Extension keyUsageExtension = holder.getExtension(Extension.keyUsage); + KeyUsage keyUsage = keyUsageExtension == null ? null + : KeyUsage.getInstance(keyUsageExtension.getParsedValue()); + if (!MessageDigest.isEqual(exactSubjectSpki.bytes(), actualSpki) + || !holder.getSubject().equals(new X500Name(subjectRef.value())) + || !holder.getIssuer().equals(issuerHolder.getSubject()) + || !holder.isSignatureValid( + new JcaContentVerifierProviderBuilder().build(issuerHolder.getSubjectPublicKeyInfo())) + || constraintsExtension == null || !constraintsExtension.isCritical() + || constraints == null || !constraints.isCA() + || !BigInteger.ZERO.equals(constraints.getPathLenConstraint()) + || keyUsageExtension == null || !keyUsageExtension.isCritical() + || !hasIntermediateKeyUsage(keyUsage) + || !credential.publicKeyId().equals(new PkiId("spki:" + sha256Hex(actualSpki))) + || !credential.credentialId() + .equals(new PkiId("x509:" + sha256Hex(credential.encoded().bytes()))) + || !credential.serialOrUniqueId().equals(holder.getSerialNumber().toString()) + || credential.validity().notBefore().getEpochSecond() != holder.getNotBefore().toInstant() + .getEpochSecond() + || credential.validity().notAfter().getEpochSecond() != holder.getNotAfter().toInstant() + .getEpochSecond()) { + throw proofGate.rejection(action, framework.formatId(), Optional.of(subjectCaId), + "BACKEND_CREDENTIAL_MISMATCH"); + } + } catch (PkiException ex) { + throw ex; + } catch (Exception ex) { + throw proofGate.rejection(action, framework.formatId(), Optional.of(subjectCaId), + "BACKEND_CREDENTIAL_MISMATCH"); + } + } + + private static boolean hasIntermediateKeyUsage(KeyUsage keyUsage) { + if (keyUsage == null || !keyUsage.hasUsages(KeyUsage.keyCertSign | KeyUsage.cRLSign)) { + return false; + } + return !keyUsage.hasUsages(KeyUsage.digitalSignature) + && !keyUsage.hasUsages(KeyUsage.nonRepudiation) + && !keyUsage.hasUsages(KeyUsage.keyEncipherment) + && !keyUsage.hasUsages(KeyUsage.dataEncipherment) + && !keyUsage.hasUsages(KeyUsage.keyAgreement) + && !keyUsage.hasUsages(KeyUsage.encipherOnly) + && !keyUsage.hasUsages(KeyUsage.decipherOnly); + } + + private static AttributeSet authoritativeIntermediateAttributes(AttributeSet callerAttributes, CaRecord issuer, + Credential issuerCredential, EncodedObject subjectSpki, SubjectRef subjectRef) { + SimpleAttributeSet.Builder builder = SimpleAttributeSet.builder().putAll(callerAttributes); + builder.put(BcX509Attributes.ISSUER_CERT_DER, + new AttributeValue.BytesValue(issuerCredential.encoded().bytes().clone())); + builder.put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue(issuer.issuerKeyRef().value())); + builder.put(BcX509Attributes.SUBJECT_SPKI_DER, + new AttributeValue.BytesValue(subjectSpki.bytes().clone())); + builder.put(BcX509Attributes.SUBJECT_DN, new AttributeValue.StringValue(subjectRef.value())); + return builder.build(); + } + private static String sha256Hex(byte[] in) { try { MessageDigest md = MessageDigest.getInstance("SHA-256"); @@ -607,173 +812,4 @@ public final class DefaultCaService implements CaService { } } - /** - * {@link ContentSigner} implementation that delegates the actual signature - * operation to {@link PkiSigningBus}. - * - *

- * This signer acts as an adapter between the Bouncy Castle certificate - * construction pipeline and the ZeroEcho asynchronous signing infrastructure. - * The to-be-signed bytes written through {@link #getOutputStream()} are - * buffered in memory and, when {@link #getSignature()} is invoked, are - * submitted to the signing bus as a delegated signing request bound to the - * configured {@link KeyRef}. - *

- * - *

- * Although the underlying signing subsystem is asynchronous, this adapter - * exposes the synchronous {@link ContentSigner} contract required by Bouncy - * Castle. It therefore performs bounded polling until the signing workflow - * completes successfully, fails, or reaches the configured timeout. - *

- * - *

Security considerations

- *
    - *
  • The private key is never accessed directly by this class.
  • - *
  • The signer only transports the to-be-signed payload and consumes the - * resulting signature bytes.
  • - *
  • The buffered payload may contain certificate TBSCertificate data and must - * therefore be treated as sensitive operational material even though it is not - * secret key material.
  • - *
  • Callers should ensure that the configured {@code algId} is compatible - * with the referenced key material and with the expectations of the downstream - * signing implementation.
  • - *
- * - *

Thread-safety

- *

- * Instances of this class are not thread-safe. A signer instance is intended - * for a single certificate-building flow and maintains mutable in-memory state - * through its internal output buffer. - *

- */ - private static final class BusBackedContentSigner implements ContentSigner { - - private final PkiSigningBus bus; - private final KeyRef keyRef; - private final String algId; - private final Duration ttl; - private final java.io.ByteArrayOutputStream baos; - - /** - * Creates a signer that routes signing requests through the supplied signing - * bus. - * - *

- * The signer buffers all bytes written to its output stream and later submits - * them as a single signing request. The {@code ttl} defines the maximum time - * window allowed for the delegated signing workflow to complete. - *

- * - * @param bus signing bus used to submit and track the delegated signing - * workflow; must not be {@code null} - * @param keyRef opaque reference identifying the signing key to be used by the - * downstream signing implementation; must not be {@code null} - * @param algId signature algorithm identifier understood both by Bouncy Castle - * and the downstream signing path; must not be {@code null} or - * blank - * @param ttl positive maximum duration to wait for signing completion - */ - private BusBackedContentSigner(PkiSigningBus bus, KeyRef keyRef, String algId, Duration ttl) { - this.bus = bus; - this.keyRef = keyRef; - this.algId = algId; - this.ttl = ttl; - this.baos = new java.io.ByteArrayOutputStream(); - } - - /** - * Returns the algorithm identifier corresponding to the configured signature - * algorithm name. - * - *

- * This value is consumed by Bouncy Castle during certificate construction to - * encode the signature algorithm metadata into the resulting certificate - * structure. - *

- * - * @return ASN.1 algorithm identifier for the configured signature algorithm - * @throws RuntimeException if the configured algorithm identifier cannot be - * resolved by the Bouncy Castle finder implementation - */ - @Override - public org.bouncycastle.asn1.x509.AlgorithmIdentifier getAlgorithmIdentifier() { - return new DefaultSignatureAlgorithmIdentifierFinder().find(algId); - } - - /** - * Returns the output stream that collects the to-be-signed bytes. - * - *

- * Data written to this stream is retained in memory until - * {@link #getSignature()} is called. The returned stream is backed by the - * signer instance itself and should be used only within the active signing - * flow. - *

- * - * @return mutable in-memory output stream receiving the to-be-signed payload - */ - @Override - public java.io.OutputStream getOutputStream() { - return baos; - } - - /** - * Submits the buffered to-be-signed payload to the signing bus and waits for - * the resulting signature bytes. - * - *

- * This method bridges the synchronous {@link ContentSigner} API to the - * asynchronous ZeroEcho signing workflow. It creates an operation identifier, - * submits the signing request, repeatedly sweeps and polls the signing bus, and - * returns the produced signature once the workflow reaches the successful - * terminal state. - *

- * - *

- * On successful completion or explicit workflow failure, the associated - * workflow state is deleted from the bus. If the timeout expires before a - * terminal state is observed, a {@link PkiException} is raised. - *

- * - * @return raw signature bytes produced by the delegated signing operation - * @throws PkiException if the signing workflow fails, if no signature result is - * available after a reported success state, or if signing - * does not complete within the configured TTL - */ - @Override - public byte[] getSignature() { - byte[] tbs = baos.toByteArray(); - PkiId clientOpId = new PkiId("sign:root:" + Math.abs(System.nanoTime())); - zeroecho.pki.api.audit.Principal owner = new zeroecho.pki.api.audit.Principal("SYSTEM", "pki"); - PkiId opId = bus.canonicalizeOperationId(clientOpId, owner); - EncodedObject payload = new EncodedObject(Encoding.BINARY, tbs); - - zeroecho.pki.api.audit.AccessContext ac = new zeroecho.pki.api.audit.AccessContext(owner, - new zeroecho.pki.api.audit.Purpose("X509_SIGN"), Optional.empty(), Optional.empty()); - PkiSigningBus.SignContinuation cont = new PkiSigningBus.SignContinuation(ac, algId, payload, keyRef, - Encoding.BINARY, Optional.empty()); - - bus.submitSign(opId, owner, keyRef, algId, payload, ttl, Optional.of(cont.encode())); - - Instant deadline = Instant.now().plus(ttl); - while (Instant.now().isBefore(deadline)) { - bus.sweep(Instant.now()); - Optional st = bus.status(opId); - if (st.isPresent() && st.get().state() == zeroecho.pki.util.async.AsyncState.SUCCEEDED) { - Optional res = bus.consumeResult(opId); - bus.deleteWorkflowState(opId); - if (res.isEmpty()) { - throw new PkiException("Missing signature result"); - } - return res.get().bytes(); - } - if (st.isPresent() && st.get().state() == zeroecho.pki.util.async.AsyncState.FAILED) { - bus.deleteWorkflowState(opId); - throw new PkiException("Signing failed"); - } - } - throw new PkiException("Signing did not complete before TTL"); - } - } } diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java index da555a7..36ac46c 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java @@ -33,10 +33,20 @@ ******************************************************************************/ package zeroecho.pki.impl.core; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.HexFormat; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.Optional; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder; import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.Encoding; import zeroecho.pki.api.FormatId; import zeroecho.pki.api.IssuanceService; import zeroecho.pki.api.KeyRef; @@ -44,6 +54,9 @@ import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiId; import zeroecho.pki.api.attr.AttributeSet; import zeroecho.pki.api.attr.AttributeValue; +import zeroecho.pki.api.audit.AuditEvent; +import zeroecho.pki.api.audit.Principal; +import zeroecho.pki.api.audit.Purpose; import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.ca.CaState; import zeroecho.pki.api.credential.Credential; @@ -54,9 +67,16 @@ import zeroecho.pki.api.issuance.IssueEndEntityCommand; import zeroecho.pki.api.issuance.ReissueCommand; import zeroecho.pki.api.issuance.RenewCommand; import zeroecho.pki.api.issuance.ReplaceCommand; +import zeroecho.pki.api.issuance.VerificationPolicy; +import zeroecho.pki.api.request.CertificationRequest; +import zeroecho.pki.api.request.ParsedCertificationRequest; +import zeroecho.pki.api.request.ProofOfPossessionResult; +import zeroecho.pki.api.request.ProofOfPossessionStatus; import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes; +import zeroecho.pki.spi.audit.AuditSink; import zeroecho.pki.spi.framework.CredentialFramework; +import zeroecho.pki.spi.framework.CredentialIssuerBackend; import zeroecho.pki.spi.store.PkiStore; /** @@ -78,11 +98,15 @@ import zeroecho.pki.spi.store.PkiStore; *
    *
  • the issuer CA must exist,
  • *
  • the issuer CA must be in {@link CaState#ACTIVE} state,
  • - *
  • the issuer CA must expose at least one credential,
  • - *
  • a suitable issuer credential must be resolvable for the active framework + *
  • the issuer CA must expose a currently valid + * {@link CredentialStatus#ISSUED} credential for the active framework * {@link FormatId},
  • *
  • issuer material required by the current X.509 runtime wiring must be - * present in issuance overrides before the backend is invoked.
  • + * present in issuance overrides before the backend is invoked, + *
  • the backend result is defensively snapshotted and its X.509 subject key, + * subject, issuer, signature, identifiers, status, profile, serial, and validity + * metadata must match the verified request and selected issuer before + * persistence.
  • *
* *

@@ -110,10 +134,22 @@ import zeroecho.pki.spi.store.PkiStore; * concurrency model. *

*/ +// PMD cannot infer that retaining boundary causes would violate the redaction contract. +@SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.PreserveStackTrace" }) public final class DefaultIssuanceService implements IssuanceService { + /** + * Maximum accepted encoded PKCS#10 request size (one mebibyte). + */ + /* default */ static final int MAX_CSR_DER_BYTES = 1024 * 1024; + + private static final Principal SYSTEM_PKI = new Principal("SYSTEM", "pki"); + private static final Purpose ISSUANCE_PURPOSE = new Purpose("ISSUANCE"); + private final PkiStore store; private final CredentialFramework framework; + private final CredentialIssuerBackend issuerBackend; + private final AuditSink auditSink; /** * Creates the issuance service bound to the supplied persistence and framework @@ -122,13 +158,19 @@ public final class DefaultIssuanceService implements IssuanceService { * @param store PKI store used for issuer CA lookup and credential * persistence; must not be {@code null} * @param framework credential framework providing format-specific issuance - * backends; must not be {@code null} - * @throws NullPointerException if {@code store} or {@code framework} is - * {@code null} + * parsing and proof verification; must not be {@code null} + * @param issuerBackend privileged issuer implementation that accepts only + * gate-produced candidates; must not be {@code null} + * @param auditSink required sink for safe rejection audit events; must not be + * {@code null} + * @throws NullPointerException if an argument is {@code null} */ - public DefaultIssuanceService(PkiStore store, CredentialFramework framework) { + public DefaultIssuanceService(PkiStore store, CredentialFramework framework, + CredentialIssuerBackend issuerBackend, AuditSink auditSink) { this.store = Objects.requireNonNull(store, "store"); this.framework = Objects.requireNonNull(framework, "framework"); + this.issuerBackend = Objects.requireNonNull(issuerBackend, "issuerBackend"); + this.auditSink = Objects.requireNonNull(auditSink, "auditSink"); } /** @@ -139,7 +181,8 @@ public final class DefaultIssuanceService implements IssuanceService { * issuance proceeds. The method then selects a suitable issuer credential for * the active framework format, enriches the supplied issuance overrides with * issuer material required by the X.509 backend wiring, delegates issuance to - * the framework backend, and persists only the issued leaf credential. + * the framework backend, defensively snapshots and validates the result, and + * persists only the validated leaf credential. *

* *

@@ -149,17 +192,19 @@ public final class DefaultIssuanceService implements IssuanceService { *

* * @param command end-entity issuance command; must not be {@code null} - * @return issued credential bundle produced by the active framework backend + * @return defensive snapshot of the validated credential bundle * @throws NullPointerException if {@code command} is {@code null} * @throws PkiException if the issuer CA does not exist, is not active, * has no credentials, no compatible issuer - * credential can be selected, issuer material - * enrichment fails, backend issuance fails, or - * persistence of the issued leaf credential fails + * current issued credential can be selected, + * issuer material enrichment fails, backend + * issuance or result validation fails, or + * persistence of the validated leaf fails */ @Override public CredentialBundle issueEndEntity(IssueEndEntityCommand command) { Objects.requireNonNull(command, "command"); + VerifiedIssuanceCandidate candidate = verifyIssuanceCandidate(command); CaRecord issuer = store.getCa(command.issuerCaId()).orElseThrow(() -> new PkiException("Issuer CA not found")); if (issuer.state() != CaState.ACTIVE) { @@ -169,7 +214,7 @@ public final class DefaultIssuanceService implements IssuanceService { throw new PkiException("Issuer CA has no credentials"); } - Credential issuerCred = selectIssuerCredential(issuer, framework.formatId()); + Credential issuerCred = CredentialSnapshots.copy(selectIssuerCredential(issuer, framework.formatId())); AttributeSet enrichedOverrides = enrichOverrides(command.overrides(), issuerCred.encoded(), issuer.issuerKeyRef()); @@ -180,11 +225,15 @@ public final class DefaultIssuanceService implements IssuanceService { if (enrichedOverrides.get(BcX509Attributes.ISSUER_KEYREF).isEmpty()) { throw new PkiException("Issuer material wiring failed: missing issuer keyref override"); } + candidate = candidate.withAuthoritativeOverrides(command, enrichedOverrides); - IssueEndEntityCommand enriched = new IssueEndEntityCommand(command.issuerCaId(), command.request(), - command.profileId(), command.validityOverride(), enrichedOverrides); - - CredentialBundle bundle = framework.issuerBackend().issueEndEntity(enriched); + CredentialBundle bundle; + try { + bundle = CredentialSnapshots.copy(issuerBackend.issueEndEntity(candidate)); + } catch (RuntimeException ex) { // NOPMD - framework output must cross the snapshot boundary + throw rejection(candidate.request(), "BACKEND_CREDENTIAL_MISMATCH"); + } + requireIssuedCredentialMatches(candidate, command, issuerCred, bundle); store.putCredential(bundle.credential()); return bundle; } @@ -194,16 +243,15 @@ public final class DefaultIssuanceService implements IssuanceService { * format. * *

- * The selection strategy prefers a credential whose {@link FormatId} matches - * the requested format and whose status is {@link CredentialStatus#ISSUED}. If - * no such credential exists, the method falls back to the first non-null - * credential whose format matches, regardless of status. + * The selected credential must match the requested {@link FormatId}, have + * {@link CredentialStatus#ISSUED} status, and contain the current instant + * within its inclusive validity interval. No status or validity fallback is + * permitted. *

* *

- * This method does not evaluate validity windows, revocation state external to - * {@link CredentialStatus}, or profile suitability. It performs only the - * minimal runtime selection required by the current implementation. + * This method does not evaluate profile suitability or revocation information + * external to {@link CredentialStatus}. *

* * @param issuer issuer CA record containing candidate credentials; must not @@ -213,27 +261,24 @@ public final class DefaultIssuanceService implements IssuanceService { * @return selected issuer credential * @throws NullPointerException if {@code issuer} or {@code formatId} is * {@code null} - * @throws PkiException if the issuer CA has no credential compatible - * with the requested format + * @throws PkiException if the issuer CA has no current issued + * credential compatible with the requested format */ private static Credential selectIssuerCredential(CaRecord issuer, FormatId formatId) { Objects.requireNonNull(issuer, "issuer"); Objects.requireNonNull(formatId, "formatId"); + Instant now = Instant.now(); for (Credential c : issuer.caCredentials()) { if (c == null) { continue; } - if (formatId.equals(c.formatId()) && c.status() == CredentialStatus.ISSUED) { + if (formatId.equals(c.formatId()) && c.status() == CredentialStatus.ISSUED + && !now.isBefore(c.validity().notBefore()) && !now.isAfter(c.validity().notAfter())) { return c; } } - for (Credential c : issuer.caCredentials()) { - if (c != null && formatId.equals(c.formatId())) { - return c; - } - } - throw new PkiException("Issuer CA has no credential for formatId " + formatId.value()); + throw new PkiException("Issuer CA has no current issued credential for formatId " + formatId.value()); } /** @@ -241,10 +286,10 @@ public final class DefaultIssuanceService implements IssuanceService { * the current X.509 backend wiring. * *

- * Existing override values take precedence. Missing values for + * Store-authoritative issuer values take precedence. Values for * {@link BcX509Attributes#ISSUER_CERT_DER} and - * {@link BcX509Attributes#ISSUER_KEYREF} are populated from the supplied issuer - * credential encoding and issuer key reference. + * {@link BcX509Attributes#ISSUER_KEYREF} are overwritten from the supplied + * issuer credential encoding and issuer key reference. *

* *

@@ -253,13 +298,11 @@ public final class DefaultIssuanceService implements IssuanceService { *

* * @param overrides original issuance overrides; must not be {@code null} - * @param issuerCertDer DER-encoded issuer credential payload to inject when the - * corresponding override is absent; must not be + * @param issuerCertDer DER-encoded authoritative issuer credential payload; + * must not be {@code null} + * @param issuerKeyRef authoritative issuer key reference; must not be * {@code null} - * @param issuerKeyRef issuer key reference to inject when the corresponding - * override is absent; must not be {@code null} - * @return enriched attribute set containing the original overrides plus any - * missing issuer wiring attributes + * @return enriched attribute set containing authoritative issuer wiring * @throws NullPointerException if any argument is {@code null} */ private static AttributeSet enrichOverrides(AttributeSet overrides, EncodedObject issuerCertDer, @@ -269,16 +312,178 @@ public final class DefaultIssuanceService implements IssuanceService { Objects.requireNonNull(issuerKeyRef, "issuerKeyRef"); SimpleAttributeSet.Builder b = SimpleAttributeSet.builder().putAll(overrides); - - if (overrides.get(BcX509Attributes.ISSUER_CERT_DER).isEmpty()) { - b.put(BcX509Attributes.ISSUER_CERT_DER, new AttributeValue.BytesValue(issuerCertDer.bytes())); - } - if (overrides.get(BcX509Attributes.ISSUER_KEYREF).isEmpty()) { - b.put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue(issuerKeyRef.value())); + byte[] issuerBytes = issuerCertDer.bytes(); + try { + b.put(BcX509Attributes.ISSUER_CERT_DER, new AttributeValue.BytesValue(issuerBytes.clone())); + } finally { + java.util.Arrays.fill(issuerBytes, (byte) 0); } + b.put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue(issuerKeyRef.value())); return b.build(); } + private VerifiedIssuanceCandidate verifyIssuanceCandidate(IssueEndEntityCommand command) { + ParsedCertificationRequest supplied = command.request(); + byte[] csrDer = extractCsrDer(supplied); + try { + ParsedCertificationRequest reparsed = reparse(supplied, csrDer); + requireExactMatch(supplied, reparsed); + ProofOfPossessionStatus proofKind = requireVerifiedProof(supplied, reparsed); + return new VerifiedIssuanceCandidate(reparsed, reparsed.requestId(), reparsed.publicKeyInfo(), proofKind, + command, command.overrides()); + } finally { + java.util.Arrays.fill(csrDer, (byte) 0); + } + } + + private byte[] extractCsrDer(ParsedCertificationRequest supplied) { + Optional value; + try { + value = supplied.attributes().get(BcX509Attributes.CSR_DER); + } catch (RuntimeException ex) { // NOPMD - untrusted AttributeSet implementations fail closed + throw rejection(supplied, "CSR_MALFORMED"); + } + if (value.isEmpty()) { + throw rejection(supplied, "CSR_MISSING"); + } + AttributeValue csrValue = value.get(); + if (!(csrValue instanceof AttributeValue.BytesValue bytesValue)) { + throw rejection(supplied, "CSR_MALFORMED"); + } + byte[] encoded = bytesValue.value(); + if (encoded.length > MAX_CSR_DER_BYTES) { + throw rejection(supplied, "CSR_TOO_LARGE"); + } + return encoded.clone(); + } + + private ParsedCertificationRequest reparse(ParsedCertificationRequest supplied, byte[] csrDer) { + if (!framework.formatId().equals(supplied.formatId())) { + throw rejection(supplied, "FORMAT_UNSUPPORTED"); + } + try { + CertificationRequest diagnostic = new CertificationRequest(supplied.formatId(), + new EncodedObject(Encoding.DER, csrDer)); + return framework.requestParser().parse(diagnostic); + } catch (RuntimeException ex) { // NOPMD - framework parsing of hostile input fails closed + throw rejection(supplied, "CSR_MALFORMED"); + } + } + + private void requireExactMatch(ParsedCertificationRequest supplied, ParsedCertificationRequest reparsed) { + if (!supplied.formatId().equals(reparsed.formatId())) { + throw rejection(supplied, "FORMAT_MISMATCH"); + } + if (!supplied.requestId().equals(reparsed.requestId())) { + throw rejection(supplied, "REQUEST_ID_MISMATCH"); + } + if (!supplied.subjectRef().equals(reparsed.subjectRef())) { + throw rejection(supplied, "SUBJECT_MISMATCH"); + } + byte[] suppliedSpki = supplied.publicKeyInfo().bytes(); + byte[] reparsedSpki = reparsed.publicKeyInfo().bytes(); + try { + if (supplied.publicKeyInfo().encoding() != Encoding.DER + || reparsed.publicKeyInfo().encoding() != Encoding.DER + || !MessageDigest.isEqual(suppliedSpki, reparsedSpki)) { + throw rejection(supplied, "SPKI_MISMATCH"); + } + } finally { + java.util.Arrays.fill(suppliedSpki, (byte) 0); + java.util.Arrays.fill(reparsedSpki, (byte) 0); + } + } + + private ProofOfPossessionStatus requireVerifiedProof(ParsedCertificationRequest supplied, + ParsedCertificationRequest reparsed) { + ParsedCertificationRequest verificationRequest = VerifiedIssuanceCandidate.snapshot(reparsed); + ProofOfPossessionResult proof; + try { + proof = framework.proofOfPossessionVerifier().verify(verificationRequest, + new VerificationPolicy(true, Optional.empty())); + } catch (RuntimeException ex) { // NOPMD - verifier boundary failures reject issuance + throw rejection(supplied, "PROOF_FAILED"); + } + if (proof == null) { + throw rejection(supplied, "PROOF_FAILED"); + } + if (proof.status() != ProofOfPossessionStatus.VERIFIED) { + throw rejection(supplied, proofCode(proof.status())); + } + + return proof.status(); + } + + private PkiException rejection(ParsedCertificationRequest request, String code) { + PkiException rejection = new PkiException("End-entity issuance rejected: " + code); + try { + auditSink.record(new AuditEvent(Instant.now(), "ISSUANCE", "ISSUE_END_ENTITY_REJECTED", SYSTEM_PKI, + ISSUANCE_PURPOSE, Optional.empty(), Optional.of(request.formatId()), + Map.of("code", code))); + } catch (RuntimeException auditFailure) { // NOPMD - preserve stable rejection and fail closed + // Best-effort auditing must not expose listener-controlled diagnostics. + } + return rejection; + } + + private void requireIssuedCredentialMatches(VerifiedIssuanceCandidate candidate, IssueEndEntityCommand command, + Credential issuerCredential, CredentialBundle bundle) { + ParsedCertificationRequest verifiedRequest = candidate.request(); + if (bundle == null || bundle.credential() == null) { + throw rejection(verifiedRequest, "BACKEND_CREDENTIAL_MISMATCH"); + } + Credential credential = bundle.credential(); + try { + if (!framework.formatId().equals(credential.formatId()) + || credential.encoded().encoding() != Encoding.DER + || !credential.subjectRef().equals(verifiedRequest.subjectRef()) + || !credential.issuerRef().equals(new zeroecho.pki.api.IssuerRef(command.issuerCaId())) + || credential.status() != CredentialStatus.ISSUED + || !credential.profileId().equals(command.profileId())) { + throw rejection(verifiedRequest, "BACKEND_CREDENTIAL_MISMATCH"); + } + X509CertificateHolder holder = new X509CertificateHolder(credential.encoded().bytes()); + X509CertificateHolder issuerHolder = new X509CertificateHolder(issuerCredential.encoded().bytes()); + byte[] actualSpki = holder.getSubjectPublicKeyInfo().getEncoded(); + if (!MessageDigest.isEqual(candidate.exactPublicKey().bytes(), actualSpki) + || !holder.getSubject().equals(new X500Name(verifiedRequest.subjectRef().value())) + || !holder.getIssuer().equals(issuerHolder.getSubject()) + || !holder.isSignatureValid( + new JcaContentVerifierProviderBuilder().build(issuerHolder.getSubjectPublicKeyInfo())) + || !credential.publicKeyId().equals(new PkiId("spki:" + sha256Hex(actualSpki))) + || !credential.credentialId() + .equals(new PkiId("x509:" + sha256Hex(credential.encoded().bytes()))) + || !credential.serialOrUniqueId().equals(holder.getSerialNumber().toString()) + || credential.validity().notBefore().getEpochSecond() != holder.getNotBefore().toInstant() + .getEpochSecond() + || credential.validity().notAfter().getEpochSecond() != holder.getNotAfter().toInstant() + .getEpochSecond()) { + throw rejection(verifiedRequest, "BACKEND_CREDENTIAL_MISMATCH"); + } + } catch (PkiException ex) { + throw ex; + } catch (Exception ex) { + throw rejection(verifiedRequest, "BACKEND_CREDENTIAL_MISMATCH"); + } + } + + private static String sha256Hex(byte[] input) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input)); + } catch (java.security.NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 not available", ex); + } + } + + private static String proofCode(ProofOfPossessionStatus status) { + return switch (status) { + case FAILED -> "PROOF_FAILED"; + case NOT_PRESENT -> "PROOF_NOT_PRESENT"; + case NOT_SUPPORTED -> "PROOF_NOT_SUPPORTED"; + case VERIFIED -> throw new IllegalArgumentException("VERIFIED is not a rejection status"); + }; + } + /** * Requests credential renewal. * diff --git a/pki/src/main/java/zeroecho/pki/impl/core/ManagedCaIssuance.java b/pki/src/main/java/zeroecho/pki/impl/core/ManagedCaIssuance.java new file mode 100644 index 0000000..a39f96b --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/core/ManagedCaIssuance.java @@ -0,0 +1,194 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.impl.core; + +import java.util.Objects; +import java.util.Optional; + +import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.FormatId; +import zeroecho.pki.api.KeyRef; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.SubjectRef; +import zeroecho.pki.api.Validity; +import zeroecho.pki.api.attr.AttributeSet; + +/** + * Immutable opaque authority for an intermediate CA issuance operation. + * + *

+ * Instances are constructed only inside the core CA proof gate after a successful + * managed-key possession challenge. The class has no public constructor or + * factory, so callers outside the core implementation cannot convert raw + * attributes or public-key bytes into issuance authority. + *

+ * + *

+ * All mutable values are defensively snapshotted. Accessors return defensive + * copies where necessary, allowing a backend to consume the object concurrently + * without observing caller mutation. + *

+ */ +@SuppressWarnings("PMD.DataClass") +public final class ManagedCaIssuance { + + /** + * Supported proof-bound CA issuance operations. + */ + public enum Operation { + /** Creates the initial credential for a newly defined intermediate CA. */ + CREATE_INTERMEDIATE, + /** Issues an additional credential for an existing intermediate CA. */ + ISSUE_INTERMEDIATE + } + + private final Operation operation; + private final FormatId formatId; + private final PkiId issuerCaId; + private final PkiId subjectCaId; + private final String profileId; + private final Optional requestedValidity; + private final AttributeSet attributes; + private final SubjectRef subjectRef; + private final KeyRef subjectKeyRef; + private final EncodedObject exactPublicKey; + + /* default */ ManagedCaIssuance(CaProofGate.ManagedKeyProof proof, Operation operation, PkiId issuerCaId, + PkiId subjectCaId, String profileId, Optional requestedValidity, AttributeSet attributes, + SubjectRef subjectRef) { + CaProofGate.ManagedKeyProof managedKeyProof = Objects.requireNonNull(proof, "proof"); + this.operation = Objects.requireNonNull(operation, "operation"); + this.formatId = managedKeyProof.formatId(); + this.issuerCaId = Objects.requireNonNull(issuerCaId, "issuerCaId"); + this.subjectCaId = Objects.requireNonNull(subjectCaId, "subjectCaId"); + this.profileId = Objects.requireNonNull(profileId, "profileId"); + this.requestedValidity = Objects.requireNonNull(requestedValidity, "requestedValidity"); + this.attributes = VerifiedIssuanceCandidate.snapshotAttributes(Objects.requireNonNull(attributes, + "attributes")); + this.subjectRef = Objects.requireNonNull(subjectRef, "subjectRef"); + this.subjectKeyRef = managedKeyProof.keyRef(); + EncodedObject publicKey = managedKeyProof.exactPublicKey(); + this.exactPublicKey = new EncodedObject(publicKey.encoding(), publicKey.bytes()); + } + + /** + * Returns the authorized operation. + * + * @return authorized operation, never {@code null} + */ + public Operation operation() { + return operation; + } + + /** + * Returns the credential format. + * + * @return format identifier, never {@code null} + */ + public FormatId formatId() { + return formatId; + } + + /** + * Returns the issuing CA identifier. + * + * @return issuing CA identifier, never {@code null} + */ + public PkiId issuerCaId() { + return issuerCaId; + } + + /** + * Returns the subject CA identifier. + * + * @return subject CA identifier, never {@code null} + */ + public PkiId subjectCaId() { + return subjectCaId; + } + + /** + * Returns the validated profile identifier. + * + * @return profile identifier, never blank + */ + public String profileId() { + return profileId; + } + + /** + * Returns the optional validated validity request. + * + * @return optional validity, never {@code null} + */ + public Optional requestedValidity() { + return requestedValidity; + } + + /** + * Returns a defensive snapshot of authoritative CA attributes. + * + * @return immutable attribute snapshot, never {@code null} + */ + public AttributeSet attributes() { + return VerifiedIssuanceCandidate.snapshotAttributes(attributes); + } + + /** + * Returns the subject bound to the possession proof. + * + * @return exact subject, never {@code null} + */ + public SubjectRef subjectRef() { + return subjectRef; + } + + /** + * Returns the managed key reference that completed the challenge. + * + * @return proven managed key reference, never {@code null} + */ + public KeyRef subjectKeyRef() { + return subjectKeyRef; + } + + /** + * Returns a defensive copy of the exact public key bound to the proof. + * + * @return DER-encoded public key, never {@code null} + */ + public EncodedObject exactPublicKey() { + return new EncodedObject(exactPublicKey.encoding(), exactPublicKey.bytes()); + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/core/VerifiedIssuanceCandidate.java b/pki/src/main/java/zeroecho/pki/impl/core/VerifiedIssuanceCandidate.java new file mode 100644 index 0000000..ad35cf1 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/core/VerifiedIssuanceCandidate.java @@ -0,0 +1,191 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.impl.core; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.Validity; +import zeroecho.pki.api.attr.AttributeId; +import zeroecho.pki.api.attr.AttributeSet; +import zeroecho.pki.api.attr.AttributeValue; +import zeroecho.pki.api.issuance.IssueEndEntityCommand; +import zeroecho.pki.api.request.ParsedCertificationRequest; +import zeroecho.pki.api.request.ProofOfPossessionStatus; +import zeroecho.pki.impl.core.attr.SimpleAttributeSet; + +/** + * Immutable internal authority for a request that passed the issuance PoP gate. + * + *

+ * Mutable byte-array components are copied on construction and whenever a + * request snapshot is exposed. This prevents caller mutation of an earlier + * diagnostic parse result from changing the privileged backend command. + *

+ */ +@SuppressWarnings("PMD.DataClass") +public final class VerifiedIssuanceCandidate { + + private final ParsedCertificationRequest request; + private final PkiId fingerprint; + private final EncodedObject exactPublicKey; + private final ProofOfPossessionStatus proofKind; + private final PkiId issuerCaId; + private final String profileId; + private final Optional validityOverride; + private final AttributeSet overrides; + + /* default */ VerifiedIssuanceCandidate(ParsedCertificationRequest request, PkiId fingerprint, + EncodedObject exactPublicKey, ProofOfPossessionStatus proofKind, IssueEndEntityCommand command, + AttributeSet authoritativeOverrides) { + this.request = snapshot(Objects.requireNonNull(request, "request")); + this.fingerprint = Objects.requireNonNull(fingerprint, "fingerprint"); + this.exactPublicKey = copy(Objects.requireNonNull(exactPublicKey, "exactPublicKey")); + this.proofKind = Objects.requireNonNull(proofKind, "proofKind"); + IssueEndEntityCommand checkedCommand = Objects.requireNonNull(command, "command"); + this.issuerCaId = checkedCommand.issuerCaId(); + this.profileId = checkedCommand.profileId(); + this.validityOverride = checkedCommand.validityOverride(); + this.overrides = snapshotAttributes(Objects.requireNonNull(authoritativeOverrides, "authoritativeOverrides")); + } + + /** + * Returns an immutable snapshot of the cryptographically verified request. + * + * @return verified request snapshot, never {@code null} + */ + public ParsedCertificationRequest request() { + return snapshot(request); + } + + /** + * Returns the identity bound to the verified request. + * + * @return verified request identity, never {@code null} + */ + public PkiId fingerprint() { + return fingerprint; + } + + /** + * Returns a defensive copy of the exact verified subject public key. + * + * @return DER-encoded verified subject public key, never {@code null} + */ + public EncodedObject exactPublicKey() { + return copy(exactPublicKey); + } + + /** + * Returns the proof result that authorized construction. + * + * @return {@link ProofOfPossessionStatus#VERIFIED} + */ + public ProofOfPossessionStatus proofKind() { + return proofKind; + } + + /** + * Returns the store-authoritative issuing CA identifier. + * + * @return issuing CA identifier, never {@code null} + */ + public PkiId issuerCaId() { + return issuerCaId; + } + + /** + * Returns the validated profile identifier. + * + * @return profile identifier, never blank + */ + public String profileId() { + return profileId; + } + + /** + * Returns the optional validated validity override. + * + * @return optional validity override, never {@code null} + */ + public Optional validityOverride() { + return validityOverride; + } + + /** + * Returns a defensive snapshot of authoritative issuance overrides. + * + * @return immutable authoritative overrides, never {@code null} + */ + public AttributeSet overrides() { + return snapshotAttributes(overrides); + } + + /* default */ VerifiedIssuanceCandidate withAuthoritativeOverrides(IssueEndEntityCommand command, + AttributeSet authoritativeOverrides) { + return new VerifiedIssuanceCandidate(request, fingerprint, exactPublicKey, proofKind, command, + authoritativeOverrides); + } + + /* default */ static ParsedCertificationRequest snapshot(ParsedCertificationRequest source) { + Objects.requireNonNull(source, "source"); + return new ParsedCertificationRequest(source.requestId(), source.formatId(), source.subjectRef(), + copy(source.publicKeyInfo()), source.requestedValidity(), source.requestedProfileId(), + snapshotAttributes(source.attributes())); + } + + private static EncodedObject copy(EncodedObject source) { + return new EncodedObject(source.encoding(), source.bytes().clone()); + } + + /* default */ static AttributeSet snapshotAttributes(AttributeSet source) { + List entries = new ArrayList<>(); + for (AttributeId id : source.ids()) { + List values = source.getAll(id).stream().map(VerifiedIssuanceCandidate::copy).toList(); + entries.add(new SimpleAttributeSet.Entry(id, values)); + } + return new SimpleAttributeSet(entries); + } + + private static AttributeValue copy(AttributeValue value) { + if (value instanceof AttributeValue.BytesValue bytesValue) { + return new AttributeValue.BytesValue(bytesValue.value().clone()); + } + return value; + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/core/async/PkiSigningBus.java b/pki/src/main/java/zeroecho/pki/impl/core/async/PkiSigningBus.java index aa07f45..02952f2 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/async/PkiSigningBus.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/async/PkiSigningBus.java @@ -34,11 +34,17 @@ package zeroecho.pki.impl.core.async; import java.nio.file.Path; +import java.security.SecureRandom; import java.time.Duration; import java.time.Instant; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.ReentrantLock; import java.util.logging.Level; import java.util.logging.Logger; @@ -49,10 +55,11 @@ import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiId; import zeroecho.pki.api.audit.Principal; import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy; +import zeroecho.pki.api.orch.SigningSubmissionId; import zeroecho.pki.api.orch.WorkflowStateRecord; import zeroecho.pki.spi.crypto.SignatureWorkflow; import zeroecho.pki.spi.store.PkiStore; -import zeroecho.pki.util.async.AsyncBus; +import zeroecho.pki.spi.store.SignWorkflowStore; import zeroecho.pki.util.async.AsyncEndpoint; import zeroecho.pki.util.async.AsyncState; import zeroecho.pki.util.async.AsyncStatus; @@ -75,6 +82,10 @@ import zeroecho.pki.util.async.impl.DurableAsyncBus; * poll by storing signer operation id into continuation state. *

*/ +// The collaborators counted here form one durable signing lifecycle; splitting +// them would obscure the coordinator/reservation boundary that protects it. +@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity", + "PMD.PreserveStackTrace" }) public final class PkiSigningBus implements AutoCloseable { private static final Logger LOG = Logger.getLogger(PkiSigningBus.class.getName()); @@ -85,6 +96,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 Duration CLAIM_LEASE = Duration.ofSeconds(30); + private static final long INITIAL_FENCE = 0L; /** * System property controlling the maximum number of characters appended after @@ -98,9 +111,15 @@ public final class PkiSigningBus implements AutoCloseable { public static final int DEFAULT_DISPLAY_SUFFIX_MAX_LEN = 64; private final PkiStore store; - private final AsyncBus bus; + private final DurableAsyncBus bus; + private final SignatureWorkflow signer; + private final SecureRandom random; + private final String namespace; + private final OperationCoordinator coordinator; + private final ExternalActionCoordinator externalActions; + private final SignatureWorkflowEndpoint endpoint; + private final SignatureWorkflow.Registration signerRegistration; - private final int displaySuffixMaxLen; private final OrchestrationDurabilityPolicy durabilityPolicy; /** @@ -155,18 +174,38 @@ public final class PkiSigningBus implements AutoCloseable { Objects.requireNonNull(durabilityPolicy, "durabilityPolicy"); this.store = store; - this.displaySuffixMaxLen = resolveDisplaySuffixMaxLen(Optional.of(displaySuffixMaxLen)); + this.signer = signer; + this.random = new SecureRandom(); + this.namespace = store.signingNamespace() + "." + signer.id(); + signer.validateSigningDomain(this.namespace, store.signingHorizon(), store.signingPermittedSkew()); + this.coordinator = new OperationCoordinator(); + this.externalActions = new ExternalActionCoordinator(); + resolveDisplaySuffixMaxLen(Optional.of(displaySuffixMaxLen)); this.durabilityPolicy = durabilityPolicy; AppendOnlyLineStore ls = new AppendOnlyLineStore(durableLineStorePath); this.bus = new DurableAsyncBus<>(new PkiAsyncCodecs.PkiIdCodec(), new PkiAsyncCodecs.PrincipalCodec(), new PkiAsyncCodecs.StringIdCodec(), new PkiAsyncCodecs.EncodedObjectResultCodec(true), ls); - SignatureWorkflowEndpoint endpoint = new SignatureWorkflowEndpoint(store, signer); + for (WorkflowStateRecord state : store.listWorkflowStates()) { + if (TYPE_SIGN.equals(state.type()) && state.payload().isPresent()) { + try { + SignContinuation.decode(state.payload().orElseThrow()); + } catch (RuntimeException ex) { // NOPMD - malformed persisted state must fail closed + throw new PkiException("Invalid persisted sign continuation: code=CONTINUATION_INVALID"); + } + } + } + this.endpoint = new SignatureWorkflowEndpoint(store, signer, coordinator, externalActions); this.bus.registerEndpoint(ENDPOINT_SIGNER, endpoint); - - // IMPORTANT: no auto-dispatch here; execution is driven by sweep() polling - // endpoint.status(). + this.signerRegistration = signer.register(endpoint::onProviderStatusChanged); + for (SignWorkflowStore.Record record : store.listSignRecords()) { + if (record.state() == SignWorkflowStore.State.INTENT + || record.state() == SignWorkflowStore.State.DISPATCHED + || record.state() == SignWorkflowStore.State.CANCELLING) { + project(record); + } + } } /** @@ -186,8 +225,16 @@ public final class PkiSigningBus implements AutoCloseable { public PkiId canonicalizeOperationId(PkiId clientOpId, Principal owner) { Objects.requireNonNull(clientOpId, "clientOpId"); Objects.requireNonNull(owner, "owner"); - return OperationIdCodec.toDisplay(OperationIdCodec.toCanonicalBase(clientOpId, owner), owner, - displaySuffixMaxLen); + return newSubmissionId(); + } + + /** + * Creates a stable provider-namespaced signing submission identifier. + * + * @return new stable identifier + */ + public PkiId newSubmissionId() { + return SigningSubmissionId.create(namespace, store.signingNow(), random).id(); } /** @@ -219,57 +266,78 @@ public final class PkiSigningBus implements AutoCloseable { throw new IllegalArgumentException("ttl must be positive"); } - PkiId baseOpId = normalizeBaseOperationId(opId); - - Optional existing = store.getWorkflowState(baseOpId); - if (existing.isPresent()) { - WorkflowStateRecord wsExisting = existing.get(); - if (!TYPE_SIGN.equals(wsExisting.type())) { - throw new PkiException("OperationId already exists for different type"); - } - if (!owner.equals(wsExisting.owner())) { - throw new PkiException("OperationId already exists for different owner"); - } - - // Idempotent re-submit / re-attach: do not overwrite store. - try { - Instant nowExisting = Instant.now(); - this.bus.submit(baseOpId, TYPE_SIGN, owner, ENDPOINT_SIGNER, nowExisting, ttl); - } catch (RuntimeException ex) { // NOPMD - if (LOG.isLoggable(Level.FINE)) { - LOG.log(Level.FINE, "Re-submit ignored (bus likely already contains the op)", ex); - } - } - return; - } - if (workflowPayload.isEmpty()) { throw new IllegalArgumentException("workflowPayload must be present for new sign operation"); } - - Instant now = Instant.now(); - Encoding payloadEncoding = workflowPayload.get().encoding(); - - WorkflowStateRecord ws = new WorkflowStateRecord(baseOpId, TYPE_SIGN, owner, durabilityPolicy, now, now, - now.plus(ttl), payloadEncoding, workflowPayload); - - store.putWorkflowState(ws); - - this.bus.submit(baseOpId, TYPE_SIGN, owner, ENDPOINT_SIGNER, now, ttl); + 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()); + if (!owner.equals(continuation.accessContext.principal()) || !keyRef.equals(continuation.keyRef) + || !algorithmId.equals(continuation.algorithmId) + || payload.encoding() != continuation.payload.encoding() + || !java.util.Arrays.equals(payload.bytes(), continuation.payload.bytes())) { + throw new IllegalArgumentException("Sign continuation does not match the submitted request"); + } + 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"); + } + authoritative = store.getSignRecord(baseOpId).orElseThrow(); + } + project(authoritative); } /** * Returns current status if known. */ public Optional status(PkiId opId) { - return bus.status(normalizeBaseOperationId(opId)); + PkiId normalized = normalizeBaseOperationId(opId); + try (OperationCoordinator.Lease ignored = coordinator.acquire(normalized)) { + Optional retained = store.getSignRecord(normalized); + if (retained.isEmpty()) { + try { + SigningSubmissionId parsed = SigningSubmissionId.parse(normalized); + parsed.validate(namespace, store.signingNow(), store.signingHorizon(), + store.signingPermittedSkew()); + } catch (IllegalArgumentException expiredOrForeign) { + try { + SigningSubmissionId parsed = SigningSubmissionId.parse(normalized); + if (namespace.equals(parsed.namespace()) + && !store.signingNow().isBefore(parsed.createdAt().plus(store.signingHorizon()))) { + return Optional.of(new AsyncStatus(AsyncState.EXPIRED, store.signingNow(), + Optional.of("EXPIRED"), Map.of())); + } + } catch (IllegalArgumentException malformed) { + return Optional.empty(); + } + } + } + } + return endpoint.status(normalized); } /** * Consumes result if present. */ public Optional consumeResult(PkiId opId) { - return bus.consumeResult(normalizeBaseOperationId(opId)); + PkiId normalized = normalizeBaseOperationId(opId); + endpoint.reconcileProviderStatus(normalized); + try (OperationCoordinator.Lease ignored = coordinator.acquire(normalized)) { + return store.getSignRecord(normalized) + .filter(record -> record.state() == SignWorkflowStore.State.SUCCEEDED + || record.state() == SignWorkflowStore.State.RETIRED && record.result().isPresent()) + .filter(PkiSigningBus::hasTrustworthyOnTimeCompletion) + .flatMap(SignWorkflowStore.Record::result) + .map(result -> new EncodedObject(result.encoding(), result.bytes())); + } } /** @@ -277,13 +345,248 @@ public final class PkiSigningBus implements AutoCloseable { */ public void sweep(Instant now) { bus.sweep(now); + reconcileExpiredOperations(); + store.purgeExpiredSignRecords(); } /** * Deletes workflow continuation state once finished. */ public void deleteWorkflowState(PkiId opId) { - store.deleteWorkflowState(normalizeBaseOperationId(opId)); + PkiId normalized = normalizeBaseOperationId(opId); + try (OperationCoordinator.Lease ignored = coordinator.acquire(normalized)) { + store.deleteWorkflowState(normalized); + } + } + + /** + * Best-effort retirement of a sign operation and all locally owned state. + * + *

+ * The operation is first reconciled with the provider. If it remains active, + * the store durably enters non-terminal + * {@link SignWorkflowStore.State#CANCELLING} before the provider is called. + * Regardless of whether cancellation is accepted, the provider is read again. + * A 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. + *

+ * + * @param opId operation identifier; must not be {@code null} + * @param reason non-sensitive cancellation reason; must not be blank + * @throws NullPointerException if {@code opId} is {@code null} + * @throws IllegalArgumentException if {@code reason} is {@code null} or blank + * @throws PkiException if locally owned cleanup fails + */ + public void retireSignOperation(PkiId opId, String reason) { + PkiId baseOpId = normalizeBaseOperationId(opId); + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("reason must not be null/blank"); + } + endpoint.reconcileProviderStatus(baseOpId); + SignWorkflowStore.Record state; + try (OperationCoordinator.Lease ignored = coordinator.acquire(baseOpId)) { + Optional optional = store.getSignRecord(baseOpId); + if (optional.isEmpty()) { + return; + } + state = optional.get(); + } + if (!isTerminalSignState(state.state())) { + state = cancelForRetirement(baseOpId, state, reason); + if (!isTerminalSignState(state.state())) { + return; + } + } + try (OperationCoordinator.Lease ignored = coordinator.acquire(baseOpId)) { + state = store.getSignRecord(baseOpId).orElse(state); + if (!isTerminalSignState(state.state())) { + return; + } + state = confirmRetirement(baseOpId, state); + 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); + } + + private void reconcileExpiredOperations() { + Instant current = store.signingNow(); + for (SignWorkflowStore.Record candidate : store.listSignRecords()) { + boolean deadlineExpired = !candidate.deadline().isAfter(current); + boolean horizonExpired = !candidate.createdAt().plus(store.signingHorizon()).isAfter(current); + if (!deadlineExpired && !horizonExpired) { + continue; + } + if (candidate.state() == SignWorkflowStore.State.RETIRED && !horizonExpired) { + continue; + } + PkiId operationId = candidate.submissionId(); + retireSignOperation(operationId, deadlineExpired ? "deadline-expired" : "retention-expired"); + } + } + + @SuppressWarnings("PMD.AvoidCatchingGenericException") + private SignWorkflowStore.Record cancelForRetirement(PkiId operationId, SignWorkflowStore.Record state, + String reason) { + SignWorkflowStore.Record cancelling; + Optional reserved = Optional.empty(); + try (OperationCoordinator.Lease ignored = coordinator.acquire(operationId)) { + SignWorkflowStore.Record current = store.getSignRecord(operationId).orElse(state); + cancelling = persistCancelling(operationId, current); + if (cancelling.state() != SignWorkflowStore.State.CANCELLING) { + return cancelling; + } + if (cancelling.fence() == 0) { + return cancelUnsubmitted(operationId, cancelling); + } + if (cancelling.detailCode().filter("CANCEL_SUBMITTED"::equals).isEmpty()) { + reserved = externalActions.tryReserve(operationId, ExternalAction.CANCEL); + } + } + if (cancelling.detailCode().filter("CANCEL_SUBMITTED"::equals).isPresent()) { + endpoint.reconcileProviderStatus(operationId); + return store.getSignRecord(operationId).orElse(cancelling); + } + if (reserved.isEmpty()) { + return cancelling; + } + try (ExternalActionCoordinator.Reservation ignored = reserved.get()) { + signer.cancel(operationId, cancelling.fence(), reason); + } catch (RuntimeException ex) { + endpoint.reconcileProviderStatus(operationId); + SignWorkflowStore.Record current = store.getSignRecord(operationId).orElse(cancelling); + if (current.state() != SignWorkflowStore.State.CANCELLING) { + return current; + } + throw new PkiException("Provider cancellation failed: code=PROVIDER_CANCEL_FAILED"); + } + markCancellationSubmitted(operationId, cancelling); + return reconcileCancellationOutcome(operationId); + } + + private void markCancellationSubmitted(PkiId operationId, SignWorkflowStore.Record expected) { + try (OperationCoordinator.Lease ignored = coordinator.acquire(operationId)) { + Optional currentOptional = store.getSignRecord(operationId); + if (currentOptional.isEmpty()) { + return; + } + SignWorkflowStore.Record current = currentOptional.get(); + if (current.state() == SignWorkflowStore.State.CANCELLING + && current.revision() == expected.revision() && current.fence() == expected.fence()) { + store.transitionSign(operationId, current.revision(), current.fence(), + SignWorkflowStore.State.CANCELLING, Optional.of("CANCEL_SUBMITTED"), Optional.empty(), + Optional.empty()); + } + } + } + + private SignWorkflowStore.Record persistCancelling(PkiId operationId, SignWorkflowStore.Record initial) { + SignWorkflowStore.Record state = initial; + for (int attempt = 0; attempt < 4; attempt++) { + if (state.state() == SignWorkflowStore.State.CANCELLING || isTerminalSignState(state.state())) { + return state; + } + if (state.state() != SignWorkflowStore.State.INTENT + && state.state() != SignWorkflowStore.State.DISPATCHED) { + throw new PkiException("Signing operation cannot enter CANCELLING from " + state.state()); + } + Optional transitioned = store.transitionSign(operationId, state.revision(), + state.fence(), SignWorkflowStore.State.CANCELLING, Optional.of("CANCEL_REQUESTED"), + Optional.empty(), Optional.empty()); + if (transitioned.isPresent()) { + return transitioned.get(); + } + state = requireSignRecordDuringCancellation(operationId); + } + throw new PkiException("Unable to persist authoritative CANCELLING state"); + } + + private SignWorkflowStore.Record cancelUnsubmitted(PkiId operationId, + SignWorkflowStore.Record cancellationRequest) { + SignWorkflowStore.Record state = cancellationRequest; + for (int attempt = 0; attempt < 4; attempt++) { + if (state.state() != SignWorkflowStore.State.CANCELLING) { + return state; + } + if (state.fence() != cancellationRequest.fence()) { + return state; + } + Optional cancelled = store.transitionSign(operationId, state.revision(), + state.fence(), SignWorkflowStore.State.CANCELLED, Optional.of("CANCELLED"), Optional.empty(), + Optional.empty()); + if (cancelled.isPresent()) { + return cancelled.get(); + } + state = requireSignRecordDuringCancellation(operationId); + } + throw new PkiException("Unable to confirm authoritative signing cancellation"); + } + + private SignWorkflowStore.Record reconcileCancellationOutcome(PkiId operationId) { + SignWorkflowStore.Record state = requireSignRecordDuringCancellation(operationId); + for (int attempt = 0; attempt < 4; attempt++) { + endpoint.reconcileProviderStatus(operationId); + state = requireSignRecordDuringCancellation(operationId); + if (state.state() != SignWorkflowStore.State.CANCELLING) { + return state; + } + } + return state; + } + + private SignWorkflowStore.Record requireSignRecordDuringCancellation(PkiId operationId) { + return store.getSignRecord(operationId) + .orElseThrow(() -> new PkiException( + "Authoritative signing record disappeared during cancellation")); + } + + private static boolean isTerminalSignState(SignWorkflowStore.State state) { + return state == SignWorkflowStore.State.RETIRED || state == SignWorkflowStore.State.SUCCEEDED + || state == SignWorkflowStore.State.FAILED || state == SignWorkflowStore.State.CANCELLED + || state == SignWorkflowStore.State.EXPIRED; + } + + private static boolean hasTrustworthyOnTimeCompletion(SignWorkflowStore.Record record) { + return record.providerUpdatedAt().isPresent() + && record.providerUpdatedAt().get().isBefore(record.deadline()); + } + + private SignWorkflowStore.Record confirmRetirement(PkiId operationId, SignWorkflowStore.Record initial) { + SignWorkflowStore.Record state = initial; + for (int attempt = 0; attempt < 4 && state.state() != SignWorkflowStore.State.RETIRED; attempt++) { + Optional retired = store.retireSign(operationId, state.revision(), state.fence()); + if (retired.isPresent()) { + state = retired.get(); + break; + } + Optional refreshed = store.getSignRecord(operationId); + if (refreshed.isEmpty()) { + throw new PkiException("Authoritative signing record disappeared during retirement"); + } + state = refreshed.get(); + } + if (state.state() != SignWorkflowStore.State.RETIRED) { + throw new PkiException("Unable to confirm authoritative signing retirement"); + } + return state; + } + + private void project(SignWorkflowStore.Record record) { + try { + store.putWorkflowState(new WorkflowStateRecord(record.submissionId(), TYPE_SIGN, record.owner(), + durabilityPolicy, record.createdAt(), store.signingNow(), record.deadline(), + record.request().encoding(), Optional.of(record.request()))); + Duration ttl = Duration.between(record.createdAt(), record.deadline()); + bus.submit(record.submissionId(), TYPE_SIGN, record.owner(), ENDPOINT_SIGNER, record.createdAt(), ttl); + } catch (RuntimeException ex) { // NOPMD - projection is advisory + if (LOG.isLoggable(Level.FINE)) { + LOG.log(Level.FINE, "Advisory projection failed: code={0}, exception={1}", + new Object[] { "PROJECTION_REFRESH_FAILED", ex.getClass().getName() }); + } + } } private static PkiId normalizeBaseOperationId(PkiId opId) { @@ -328,8 +631,123 @@ public final class PkiSigningBus implements AutoCloseable { @Override public void close() { - // No-op: this class does not own store/signer. DurableAsyncBus does not require - // explicit close. + signerRegistration.close(); + endpoint.closeAdvisories(); + } + + /** + * Lifecycle-bounded per-operation critical-section coordinator. + * + *

+ * The lock protects only short local and durable-state decisions. Provider, + * callback, listener, and advisory projection code must run after the lease is + * closed. Different operation identifiers never share an entry. + *

+ */ + private static final class OperationCoordinator { + private final ConcurrentMap entries = new ConcurrentHashMap<>(); + + private Lease acquire(PkiId operationId) { + Entry entry = entries.compute(operationId, (ignored, current) -> { + Entry selected = current == null ? new Entry() : current; + selected.references.incrementAndGet(); + return selected; + }); + entry.enter(); + return new Lease(operationId, entry); + } + + /** + * Thread-confined ownership handle. + */ + private final class Lease implements AutoCloseable { + private final PkiId operationId; + private final Entry entry; + private boolean closed; + + private Lease(PkiId operationId, Entry entry) { + this.operationId = operationId; + this.entry = entry; + } + + @Override + public void close() { + if (closed) { + return; + } + closed = true; + entry.exit(); + entries.computeIfPresent(operationId, (ignored, current) -> { + if (!current.equals(entry)) { + return current; + } + return current.references.decrementAndGet() == 0 ? null : current; + }); + } + } + + /** + * Reentrant owner state for one operation identifier. + */ + private static final class Entry { + private final ReentrantLock lock = new ReentrantLock(); + private final AtomicInteger references = new AtomicInteger(); + + private void enter() { + lock.lock(); + } + + private void exit() { + lock.unlock(); + } + } + } + + /** + * External provider actions subject to per-operation single-flight control. + */ + private enum ExternalAction { + /** Provider submission. */ + SUBMIT, + /** Provider status retrieval. */ + STATUS, + /** Provider cancellation. */ + CANCEL + } + + /** + * Lifecycle-bounded, non-blocking single-flight reservations for provider calls. + */ + private static final class ExternalActionCoordinator { + private final ConcurrentMap actions = new ConcurrentHashMap<>(); + + private Optional tryReserve(PkiId operationId, ExternalAction action) { + return actions.putIfAbsent(operationId, action) == null + ? Optional.of(new Reservation(operationId, action)) + : Optional.empty(); + } + + /** + * Reservation that releases one external action on close. + */ + private final class Reservation implements AutoCloseable { + private final PkiId operationId; + private final ExternalAction action; + private boolean closed; + + private Reservation(PkiId operationId, ExternalAction action) { + this.operationId = operationId; + this.action = action; + } + + @Override + public void close() { + if (!closed) { + closed = true; + actions.remove(operationId, action); + } + } + } } /** @@ -377,25 +795,32 @@ public final class PkiSigningBus implements AutoCloseable { * state is internally inconsistent. *
  • Downstream signer failure is reported through the mapped async status * returned by {@link #status(PkiId)}.
  • + *
  • If downstream submission succeeds but continuation persistence fails, + * the downstream handle is cancelled, the stale continuation is deleted, and + * the endpoint reports a terminal local failure.
  • * * *

    Thread-safety

    *

    - * This endpoint is effectively stateless apart from its collaborators. - * Concurrent behavior therefore depends primarily on the thread-safety and - * consistency guarantees of the configured {@link PkiStore} and - * {@link SignatureWorkflow}. + * Per-operation coordinator leases protect only short durable-state decisions. + * An action-tagged reservation prevents concurrent provider calls for the same + * identifier while every provider invocation runs without a coordinator lease. + * Unrelated identifiers therefore overlap, and synchronous provider callbacks + * can re-enter status reconciliation without deadlock. Callbacks coalesce + * identifiers into a bounded advisory set; polling remains authoritative. *

    */ private static final class SignatureWorkflowEndpoint implements AsyncEndpoint { - private static final String DC_SUBMITTED = "SUBMITTED"; - private static final String DC_RUNNING = "RUNNING"; - private static final String DC_SIGNED = "SIGNED"; - private static final String DC_FAILED = "FAILED"; + private static final int MAX_PENDING_ADVISORIES = 1024; private final PkiStore store; private final SignatureWorkflow signer; + private final OperationCoordinator coordinator; + private final ExternalActionCoordinator externalActions; + private final ConcurrentMap pendingAdvisories; + private final AtomicInteger pendingAdvisoryCount; + private final AtomicBoolean closed; /** * Creates the endpoint instance. @@ -405,9 +830,15 @@ public final class PkiSigningBus implements AutoCloseable { * @param signer downstream signature workflow used to execute the actual sign * operation and to query its status; must not be {@code null} */ - private SignatureWorkflowEndpoint(PkiStore store, SignatureWorkflow signer) { + private SignatureWorkflowEndpoint(PkiStore store, SignatureWorkflow signer, + OperationCoordinator coordinator, ExternalActionCoordinator externalActions) { this.store = store; this.signer = signer; + this.coordinator = coordinator; + this.externalActions = externalActions; + this.pendingAdvisories = new ConcurrentHashMap<>(); + this.pendingAdvisoryCount = new AtomicInteger(); + this.closed = new AtomicBoolean(); } /** @@ -444,31 +875,109 @@ public final class PkiSigningBus implements AutoCloseable { */ public void execute(PkiId opId) { Objects.requireNonNull(opId, "opId"); - - Optional wsOpt = store.getWorkflowState(opId); - if (wsOpt.isEmpty()) { + Optional prepared = prepareSubmission(opId); + if (prepared.isEmpty()) { return; } - - WorkflowStateRecord ws = wsOpt.get(); - Optional payloadOpt = ws.payload(); - if (payloadOpt.isEmpty()) { - throw new PkiException("Missing workflow payload for sign operation"); + SubmissionCall call = prepared.get(); + PkiId returned; + try (ExternalActionCoordinator.Reservation ignored = call.reservation()) { + returned = signer.submitSign(call.request()); + } catch (RuntimeException ambiguousFailure) { // NOPMD - provider acceptance is unknown + return; } + recordSubmissionAcceptance(call, returned); + reconcileProviderStatus(opId); + } - SignContinuation cont = SignContinuation.decode(payloadOpt.get()); + // On success the reservation ownership moves into SubmissionCall and spans + // the provider call; try-with-resources here would release single-flight early. + @SuppressWarnings({ "PMD.CloseResource", "PMD.UseTryWithResources" }) + private Optional prepareSubmission(PkiId opId) { + try (OperationCoordinator.Lease ignored = coordinator.acquire(opId)) { + Optional currentOptional = store.getSignRecord(opId); + if (currentOptional.isEmpty() || currentOptional.get().state() != SignWorkflowStore.State.INTENT) { + return Optional.empty(); + } + SignWorkflowStore.Record current = currentOptional.get(); + if (!current.deadline().isAfter(store.signingNow())) { + if (current.fence() == INITIAL_FENCE) { + store.transitionSign(opId, current.revision(), current.fence(), + SignWorkflowStore.State.EXPIRED, Optional.of("EXPIRED"), Optional.empty(), + Optional.empty()); + } + return Optional.empty(); + } + Optional reserved = externalActions.tryReserve(opId, + ExternalAction.SUBMIT); + if (reserved.isEmpty()) { + return Optional.empty(); + } + Optional claimedOptional = store.tryClaimSign(opId, current.revision(), + CLAIM_LEASE); + if (claimedOptional.isEmpty()) { + reserved.get().close(); + return Optional.empty(); + } + SignWorkflowStore.Record claimed = claimedOptional.get(); + ExternalActionCoordinator.Reservation reservation = reserved.get(); + boolean reservationTransferred = false; + try { + SignContinuation continuation = SignContinuation.decode(claimed.request()); + SignatureWorkflow.SignRequest request = SignatureWorkflow.SignRequest.create(opId, + claimed.namespace(), claimed.fence(), continuation.accessContext, continuation.keyRef, + continuation.algorithmId, continuation.payload, + Optional.of(continuation.preferredSignatureEncoding), Optional.of(claimed.deadline())); + if (!constantTimeAsciiEquals(claimed.fingerprint(), request.semanticFingerprint())) { + store.transitionSign(opId, claimed.revision(), claimed.fence(), + SignWorkflowStore.State.FAILED, Optional.of("REQUEST_INTEGRITY_FAILURE"), + Optional.empty(), Optional.empty()); + return Optional.empty(); + } + SubmissionCall call = new SubmissionCall(claimed, request, reservation); + reservationTransferred = true; + return Optional.of(call); + } finally { + if (!reservationTransferred) { + reservation.close(); + } + } + } + } - SignatureWorkflow.SignRequest req = new SignatureWorkflow.SignRequest(cont.accessContext, cont.keyRef, - cont.algorithmId, cont.payload, Optional.of(cont.preferredSignatureEncoding), - Optional.of(ws.expiresAt())); + private static boolean constantTimeAsciiEquals(String left, String right) { + byte[] leftBytes = left.getBytes(java.nio.charset.StandardCharsets.US_ASCII); + byte[] rightBytes = right.getBytes(java.nio.charset.StandardCharsets.US_ASCII); + try { + return java.security.MessageDigest.isEqual(leftBytes, rightBytes); + } finally { + java.util.Arrays.fill(leftBytes, (byte) 0); + java.util.Arrays.fill(rightBytes, (byte) 0); + } + } - PkiId signerOpId = signer.submitSign(req); + private void recordSubmissionAcceptance(SubmissionCall call, PkiId returned) { + try (OperationCoordinator.Lease ignored = coordinator.acquire(call.record().submissionId())) { + Optional afterCall = store.getSignRecord(call.record().submissionId()); + if (afterCall.isEmpty() || afterCall.get().revision() != call.record().revision() + || afterCall.get().fence() != call.record().fence() + || afterCall.get().state() != SignWorkflowStore.State.INTENT) { + return; + } + if (!call.record().submissionId().equals(returned)) { + store.transitionSign(call.record().submissionId(), call.record().revision(), + call.record().fence(), SignWorkflowStore.State.FAILED, + Optional.of("PROVIDER_ID_MISMATCH"), Optional.empty(), Optional.empty()); + return; + } + store.transitionSign(call.record().submissionId(), call.record().revision(), call.record().fence(), + SignWorkflowStore.State.DISPATCHED, Optional.of("DISPATCHED"), Optional.empty(), + Optional.empty()); + } + } - SignContinuation withSigner = cont.withSignerOpId(signerOpId); - WorkflowStateRecord updated = new WorkflowStateRecord(ws.opId(), ws.type(), ws.owner(), - ws.durabilityPolicy(), ws.createdAt(), Instant.now(), ws.expiresAt(), ws.payloadEncoding(), - Optional.of(withSigner.encode())); - store.putWorkflowState(updated); + private record SubmissionCall(SignWorkflowStore.Record record, SignatureWorkflow.SignRequest request, + ExternalActionCoordinator.Reservation reservation) { } /** @@ -515,45 +1024,146 @@ public final class PkiSigningBus implements AutoCloseable { @Override public Optional status(PkiId opId) { Objects.requireNonNull(opId, "opId"); - Optional wsOpt = store.getWorkflowState(opId); - if (wsOpt.isEmpty()) { - return Optional.empty(); + removeAdvisory(opId); + SignWorkflowStore.Record record; + Instant now; + try (OperationCoordinator.Lease ignored = coordinator.acquire(opId)) { + Optional recordOptional = store.getSignRecord(opId); + if (recordOptional.isEmpty()) { + return Optional.empty(); + } + record = recordOptional.get(); + now = store.signingNow(); + if (!record.deadline().isAfter(now) && record.state() == SignWorkflowStore.State.INTENT + && record.fence() == INITIAL_FENCE) { + store.transitionSign(opId, record.revision(), record.fence(), SignWorkflowStore.State.EXPIRED, + Optional.of("EXPIRED"), Optional.empty(), Optional.empty()); + record = store.getSignRecord(opId).orElse(record); + } } - - WorkflowStateRecord ws = wsOpt.get(); - Optional payloadOpt = ws.payload(); - if (payloadOpt.isEmpty()) { - return Optional.of(new AsyncStatus(AsyncState.FAILED, Instant.now(), Optional.of(DC_FAILED), - Map.of("reason", "missing-payload"))); - } - - SignContinuation cont = SignContinuation.decode(payloadOpt.get()); - if (cont.signerOpId.isEmpty()) { - // First poll drives submit to signer (this is the "async boundary"). + if (record.state() == SignWorkflowStore.State.INTENT && record.deadline().isAfter(now)) { execute(opId); - return Optional - .of(new AsyncStatus(AsyncState.RUNNING, Instant.now(), Optional.of(DC_SUBMITTED), Map.of())); } - - SignatureWorkflow.OperationStatus st = signer.status(cont.signerOpId.get()); - if (st == null) { - return Optional.of(new AsyncStatus(AsyncState.RUNNING, Instant.now(), Optional.of(DC_RUNNING), - Map.of("reason", "no-status"))); + record = store.getSignRecord(opId).orElse(record); + if (record.state() == SignWorkflowStore.State.DISPATCHED + || record.state() == SignWorkflowStore.State.CANCELLING) { + reconcileProviderStatus(opId); + record = store.getSignRecord(opId).orElse(record); } - - if (!st.isTerminal()) { - return Optional - .of(new AsyncStatus(AsyncState.RUNNING, st.updatedAt(), Optional.of(DC_RUNNING), Map.of())); + if (!record.deadline().isAfter(now) + && (record.state() == SignWorkflowStore.State.DISPATCHED + || record.state() == SignWorkflowStore.State.CANCELLING)) { + return Optional.of(new AsyncStatus(AsyncState.EXPIRED, now, Optional.of("EXPIRED"), Map.of())); } + return Optional.of(mapStoreStatus(record)); + } - if (st.state() == SignatureWorkflow.State.SUCCEEDED && st.result().isPresent() - && st.result().get().signature().isPresent()) { - return Optional - .of(new AsyncStatus(AsyncState.SUCCEEDED, st.updatedAt(), Optional.of(DC_SIGNED), Map.of())); + private void onProviderStatusChanged(PkiId operationId, SignatureWorkflow.OperationStatus advisory) { + Objects.requireNonNull(operationId, "operationId"); + Objects.requireNonNull(advisory, "advisory"); + if (closed.get() || pendingAdvisories.containsKey(operationId)) { + return; } + int current; + do { + current = pendingAdvisoryCount.get(); + if (current >= MAX_PENDING_ADVISORIES) { + return; + } + } while (!pendingAdvisoryCount.compareAndSet(current, current + 1)); + if (closed.get()) { + pendingAdvisoryCount.decrementAndGet(); + return; + } + if (pendingAdvisories.putIfAbsent(operationId, Boolean.TRUE) != null) { + pendingAdvisoryCount.decrementAndGet(); + return; + } + if (closed.get()) { + removeAdvisory(operationId); + } + } - return Optional.of(new AsyncStatus(AsyncState.FAILED, st.updatedAt(), Optional.of(DC_FAILED), - Map.of("detailCode", st.detailCode().orElse("")))); + // Provider implementations are an untrusted boundary and may throw any runtime failure. + @SuppressWarnings("PMD.AvoidCatchingGenericException") + private void reconcileProviderStatus(PkiId operationId) { + removeAdvisory(operationId); + Optional prepared = prepareStatusCall(operationId); + if (prepared.isEmpty()) { + return; + } + StatusCall call = prepared.get(); + try (ExternalActionCoordinator.Reservation ignored = call.reservation()) { + try { + SignatureWorkflow.OperationStatus providerStatus = signer.status(operationId); + applyProviderStatus(call, providerStatus); + } catch (RuntimeException providerFailure) { + throw new PkiException("Provider status failed: code=PROVIDER_STATUS_FAILED"); + } + } + } + + private Optional prepareStatusCall(PkiId operationId) { + try (OperationCoordinator.Lease ignored = coordinator.acquire(operationId)) { + Optional beforeCall = store.getSignRecord(operationId); + if (beforeCall.isEmpty() || beforeCall.get().state() != SignWorkflowStore.State.DISPATCHED + && beforeCall.get().state() != SignWorkflowStore.State.CANCELLING) { + return Optional.empty(); + } + Optional reserved = externalActions.tryReserve(operationId, + ExternalAction.STATUS); + return reserved.map(reservation -> new StatusCall(beforeCall.get(), reservation)); + } + } + + private void applyProviderStatus(StatusCall call, SignatureWorkflow.OperationStatus providerStatus) { + if (!providerStatus.isTerminal()) { + return; + } + try (OperationCoordinator.Lease ignored = coordinator.acquire(call.record().submissionId())) { + Optional currentOptional = store.getSignRecord(call.record().submissionId()); + if (currentOptional.isEmpty() + || currentOptional.get().state() != SignWorkflowStore.State.DISPATCHED + && currentOptional.get().state() != SignWorkflowStore.State.CANCELLING + || currentOptional.get().fence() != call.record().fence()) { + return; + } + SignWorkflowStore.Record current = currentOptional.get(); + Optional result = providerStatus.result() + .flatMap(SignatureWorkflow.OperationResult::signature); + SignWorkflowStore.State target = mapProviderState(providerStatus.state()); + Optional detail = sanitizeProviderDetail(providerStatus.detailCode()); + if (target == SignWorkflowStore.State.SUCCEEDED && result.isEmpty()) { + target = SignWorkflowStore.State.FAILED; + detail = Optional.of("PROVIDER_RESULT_MISSING"); + } + if (target == SignWorkflowStore.State.SUCCEEDED + && !providerStatus.updatedAt().isBefore(current.deadline())) { + target = SignWorkflowStore.State.EXPIRED; + detail = Optional.of("LATE_PROVIDER_SUCCESS"); + result = Optional.empty(); + } + store.transitionSign(call.record().submissionId(), + current.revision(), current.fence(), target, detail, result, + Optional.of(providerStatus.updatedAt())); + } + } + + private record StatusCall(SignWorkflowStore.Record record, + ExternalActionCoordinator.Reservation reservation) { + } + + private void removeAdvisory(PkiId operationId) { + if (pendingAdvisories.remove(operationId) != null) { + pendingAdvisoryCount.decrementAndGet(); + } + } + + private void closeAdvisories() { + closed.set(true); + for (PkiId operationId : pendingAdvisories.keySet()) { + removeAdvisory(operationId); + } } /** @@ -589,24 +1199,46 @@ public final class PkiSigningBus implements AutoCloseable { @Override public Optional result(PkiId opId) { Objects.requireNonNull(opId, "opId"); + reconcileProviderStatus(opId); + try (OperationCoordinator.Lease ignored = coordinator.acquire(opId)) { + return store.getSignRecord(opId) + .filter(record -> record.state() == SignWorkflowStore.State.SUCCEEDED + || record.state() == SignWorkflowStore.State.RETIRED + && record.result().isPresent()) + .flatMap(SignWorkflowStore.Record::result); + } + } - Optional wsOpt = store.getWorkflowState(opId); - if (wsOpt.isEmpty()) { + private static SignWorkflowStore.State mapProviderState(SignatureWorkflow.State state) { + return switch (state) { + case SUCCEEDED -> SignWorkflowStore.State.SUCCEEDED; + case CANCELLED -> SignWorkflowStore.State.CANCELLED; + case EXPIRED -> SignWorkflowStore.State.EXPIRED; + default -> SignWorkflowStore.State.FAILED; + }; + } + + private static Optional sanitizeProviderDetail(Optional detail) { + if (detail.isEmpty()) { return Optional.empty(); } - Optional payloadOpt = wsOpt.get().payload(); - if (payloadOpt.isEmpty()) { - return Optional.empty(); + String value = detail.orElseThrow(); + if (value.length() > 64 || !value.matches("[A-Z0-9_]+")) { + return Optional.of("PROVIDER_DETAIL_INVALID"); } - SignContinuation cont = SignContinuation.decode(payloadOpt.get()); - if (cont.signerOpId.isEmpty()) { - return Optional.empty(); - } - SignatureWorkflow.OperationStatus st = signer.status(cont.signerOpId.get()); - if (st == null || st.state() != SignatureWorkflow.State.SUCCEEDED || st.result().isEmpty()) { - return Optional.empty(); - } - return st.result().get().signature(); + return detail; + } + + private static AsyncStatus mapStoreStatus(SignWorkflowStore.Record record) { + AsyncState state = switch (record.state()) { + case INTENT, DISPATCHED, CANCELLING -> AsyncState.RUNNING; + case SUCCEEDED -> AsyncState.SUCCEEDED; + case CANCELLED -> AsyncState.CANCELLED; + case EXPIRED -> AsyncState.EXPIRED; + case RETIRED -> record.result().isPresent() ? AsyncState.SUCCEEDED : AsyncState.CANCELLED; + case FAILED -> AsyncState.FAILED; + }; + return new AsyncStatus(state, record.createdAt(), record.detailCode(), Map.of()); } } @@ -662,7 +1294,7 @@ public final class PkiSigningBus implements AutoCloseable { */ public static final class SignContinuation { - private static final byte VERSION = 1; + private static final byte VERSION = 2; private final zeroecho.pki.api.audit.AccessContext accessContext; private final String algorithmId; @@ -741,6 +1373,43 @@ public final class PkiSigningBus implements AutoCloseable { return signerOpId; } + /** + * Computes the canonical semantic fingerprint for this persisted request. + * + *

    + * The calculation delegates to the same versioned algorithm used by + * {@link SignatureWorkflow.SignRequest}. It does not include the mutable + * fencing token. + *

    + * + * @param namespace authoritative provider/store namespace; must not be + * {@code null} + * @param deadline authoritative signing deadline; must not be {@code null} + * @return canonical versioned semantic fingerprint + * @throws NullPointerException if either argument is {@code null} + */ + public String semanticFingerprint(String namespace, Instant deadline) { + Objects.requireNonNull(deadline, "deadline"); + return SignatureWorkflow.SignRequest.fingerprint(namespace, accessContext, keyRef, algorithmId, payload, + Optional.of(preferredSignatureEncoding), Optional.of(deadline)); + } + + /** + * Tests whether this continuation is bound to the durable operation identity. + * + * @param submissionId authoritative submission identifier; must not be + * {@code null} + * @param owner authoritative request owner; must not be {@code null} + * @return {@code true} only when the stored provider operation identifier and + * access principal exactly match the supplied identity + * @throws NullPointerException if either argument is {@code null} + */ + public boolean isBoundTo(PkiId submissionId, Principal owner) { + Objects.requireNonNull(submissionId, "submissionId"); + Objects.requireNonNull(owner, "owner"); + return owner.equals(accessContext.principal()) && signerOpId.filter(submissionId::equals).isPresent(); + } + /** * Encodes this continuation into a versioned binary payload suitable for * persistence inside a {@link WorkflowStateRecord}. @@ -767,26 +1436,45 @@ public final class PkiSigningBus implements AutoCloseable { * @return binary encoded continuation payload */ public EncodedObject encode() { - byte[] algBytes = algorithmId.getBytes(java.nio.charset.StandardCharsets.UTF_8); - byte[] keyBytes = keyRef.value().getBytes(java.nio.charset.StandardCharsets.UTF_8); - byte[] opBytes = signerOpId.map(x -> x.value().getBytes(java.nio.charset.StandardCharsets.UTF_8)) - .orElse(new byte[0]); - - java.nio.ByteBuffer bb = java.nio.ByteBuffer.allocate(1 + 2 + algBytes.length + 1 + 4 - + payload.bytes().length + 2 + keyBytes.length + 1 + 1 + 2 + opBytes.length); - bb.put(VERSION); - bb.putShort((short) algBytes.length); - bb.put(algBytes); - bb.put((byte) payload.encoding().ordinal()); - bb.putInt(payload.bytes().length); - bb.put(payload.bytes()); - bb.putShort((short) keyBytes.length); - bb.put(keyBytes); - bb.put((byte) preferredSignatureEncoding.ordinal()); - bb.put((byte) (signerOpId.isPresent() ? 1 : 0)); - bb.putShort((short) opBytes.length); - bb.put(opBytes); - return new EncodedObject(Encoding.BINARY, bb.array()); + WipeableByteArrayOutputStream bytes = new WipeableByteArrayOutputStream(); + byte[] payloadBytes = payload.bytes(); + byte[] encoded = null; + try { + try (java.io.DataOutputStream output = new java.io.DataOutputStream(bytes)) { + output.writeByte(VERSION); + output.writeUTF(accessContext.principal().type()); + output.writeUTF(accessContext.principal().name()); + output.writeUTF(accessContext.purpose().value()); + output.writeBoolean(accessContext.objectId().isPresent()); + if (accessContext.objectId().isPresent()) { + output.writeUTF(accessContext.objectId().get().value()); + } + output.writeBoolean(accessContext.formatId().isPresent()); + if (accessContext.formatId().isPresent()) { + output.writeUTF(accessContext.formatId().get().value()); + } + output.writeUTF(algorithmId); + output.writeUTF(keyRef.value()); + output.writeByte(payload.encoding().ordinal()); + output.writeInt(payloadBytes.length); + output.write(payloadBytes); + output.writeByte(preferredSignatureEncoding.ordinal()); + output.writeBoolean(signerOpId.isPresent()); + if (signerOpId.isPresent()) { + output.writeUTF(signerOpId.get().value()); + } + } + encoded = bytes.toByteArray(); + return new EncodedObject(Encoding.BINARY, encoded); + } catch (java.io.IOException ex) { + throw new PkiException("Failed to encode sign continuation: code=CONTINUATION_ENCODE_FAILED"); + } finally { + java.util.Arrays.fill(payloadBytes, (byte) 0); + if (encoded != null) { + java.util.Arrays.fill(encoded, (byte) 0); + } + bytes.wipe(); + } } /** @@ -823,43 +1511,57 @@ public final class PkiSigningBus implements AutoCloseable { if (obj.encoding() != Encoding.BINARY) { throw new IllegalArgumentException("Expected BINARY continuation payload"); } - java.nio.ByteBuffer bb = java.nio.ByteBuffer.wrap(obj.bytes()); - byte v = bb.get(); - if (v != VERSION) { - throw new IllegalArgumentException("Unsupported continuation version"); + byte[] encoded = obj.bytes(); + byte[] payloadBytes = null; + try (java.io.DataInputStream input = new java.io.DataInputStream( + new java.io.ByteArrayInputStream(encoded))) { + int version = input.readUnsignedByte(); + if (version != VERSION) { + throw new PkiException("Unsupported sign continuation version"); + } + Principal principal = new Principal(input.readUTF(), input.readUTF()); + zeroecho.pki.api.audit.Purpose purpose = new zeroecho.pki.api.audit.Purpose(input.readUTF()); + Optional objectId = input.readBoolean() ? Optional.of(new PkiId(input.readUTF())) + : Optional.empty(); + Optional formatId = input.readBoolean() + ? Optional.of(new zeroecho.pki.api.FormatId(input.readUTF())) + : Optional.empty(); + String algId = input.readUTF(); + KeyRef key = new KeyRef(input.readUTF()); + Encoding payloadEncoding = Encoding.values()[input.readUnsignedByte()]; + int payloadLength = input.readInt(); + if (payloadLength <= 0 || payloadLength > 16 * 1024 * 1024) { + throw new PkiException("Invalid sign continuation payload length"); + } + payloadBytes = input.readNBytes(payloadLength); + if (payloadBytes.length != payloadLength) { + throw new PkiException("Truncated sign continuation payload"); + } + Encoding preferred = Encoding.values()[input.readUnsignedByte()]; + Optional signerId = input.readBoolean() ? Optional.of(new PkiId(input.readUTF())) + : Optional.empty(); + zeroecho.pki.api.audit.AccessContext access = new zeroecho.pki.api.audit.AccessContext(principal, + purpose, objectId, formatId); + return new SignContinuation(access, algId, new EncodedObject(payloadEncoding, payloadBytes), key, + preferred, signerId); + } catch (java.io.IOException | IndexOutOfBoundsException ex) { + throw new PkiException("Malformed sign continuation: code=CONTINUATION_MALFORMED"); + } finally { + java.util.Arrays.fill(encoded, (byte) 0); + if (payloadBytes != null) { + java.util.Arrays.fill(payloadBytes, (byte) 0); + } } - int algLen = Short.toUnsignedInt(bb.getShort()); - byte[] algBytes = new byte[algLen]; - bb.get(algBytes); - String algId = new String(algBytes, java.nio.charset.StandardCharsets.UTF_8); + } - Encoding payloadEnc = Encoding.values()[Byte.toUnsignedInt(bb.get())]; - int payloadLen = bb.getInt(); - byte[] payloadBytes = new byte[payloadLen]; - bb.get(payloadBytes); - - int keyLen = Short.toUnsignedInt(bb.getShort()); - byte[] keyBytes = new byte[keyLen]; - bb.get(keyBytes); - KeyRef keyRef = new KeyRef(new String(keyBytes, java.nio.charset.StandardCharsets.UTF_8)); - - Encoding prefEnc = Encoding.values()[Byte.toUnsignedInt(bb.get())]; - boolean hasOp = bb.get() != 0; - int opLen = Short.toUnsignedInt(bb.getShort()); - byte[] opBytes = new byte[opLen]; - bb.get(opBytes); - - Optional signerOpId = Optional.empty(); - if (hasOp) { - signerOpId = Optional.of(new PkiId(new String(opBytes, java.nio.charset.StandardCharsets.UTF_8))); + /** + * Output stream whose retained continuation bytes can be overwritten. + */ + private static final class WipeableByteArrayOutputStream extends java.io.ByteArrayOutputStream { + private void wipe() { + java.util.Arrays.fill(buf, (byte) 0); + reset(); } - - Principal sys = new Principal("SYSTEM", "pki"); - zeroecho.pki.api.audit.Purpose purpose = new zeroecho.pki.api.audit.Purpose("SIGN"); - zeroecho.pki.api.audit.AccessContext ac = new zeroecho.pki.api.audit.AccessContext(sys, purpose, - Optional.empty(), Optional.empty()); - return new SignContinuation(ac, algId, new EncodedObject(payloadEnc, payloadBytes), keyRef, prefEnc, - signerOpId); } } } diff --git a/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflow.java b/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflow.java index 17b8034..5ef64b1 100644 --- a/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflow.java +++ b/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflow.java @@ -34,20 +34,45 @@ package zeroecho.pki.impl.crypto.zeroecholib; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.charset.StandardCharsets; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.PosixFilePermissions; import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.PublicKey; +import java.time.Clock; +import java.time.Duration; import java.time.Instant; +import java.util.Arrays; import java.util.Base64; import java.util.Collections; -import java.util.HashMap; +import java.util.HexFormat; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.locks.ReentrantLock; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; import java.util.logging.Level; import java.util.logging.Logger; @@ -71,6 +96,7 @@ import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.Encoding; import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.orch.SigningSubmissionId; import zeroecho.pki.spi.crypto.SignatureWorkflow; /** @@ -90,8 +116,9 @@ import zeroecho.pki.spi.crypto.SignatureWorkflow; * perspective, but it still conforms to the {@link SignatureWorkflow} contract * by returning an operation identifier and exposing the terminal outcome * through {@link #status(PkiId)}. Each submitted operation is executed - * immediately in the caller thread, and the resulting terminal status is stored - * in an internal in-memory registry. + * immediately in the caller thread. Signing requests and terminal outcomes are + * retained durably in the configured operation root for the configured operation + * horizon. *

    * *

    Supported operations

    @@ -104,13 +131,26 @@ import zeroecho.pki.spi.crypto.SignatureWorkflow; * a key resolved from {@link VerifyRequest#publicKeyRef()} or against a caller- * supplied encoded public key from * {@link VerifyRequest#publicKeyEncoded()}. - *
  • {@link #status(PkiId)} returns the last known operation status from the - * in-memory status registry, or a stable failed status for unknown operation - * identifiers.
  • + *
  • {@link #status(PkiId)} returns the retained operation status, or a stable + * failed status for unknown operation identifiers.
  • *
  • {@link #register(NotificationSink)} installs an in-memory notification * sink that is called whenever an operation status changes.
  • * * + *

    Signing lifecycle and recovery

    + *

    + * A repeated signing submission with the same stable identifier and semantic + * fingerprint attaches to the retained operation and does not repeat + * cryptographic work. Conflicting fingerprints and stale fencing tokens fail + * closed. On restart, retained terminal operations remain queryable, while a + * retained {@link State#RUNNING} operation is durably converted to + * {@link State#FAILED} with detail {@code RECOVERY_INCOMPLETE}; cryptographic + * execution is never replayed implicitly. Signing records are purged after the + * configured horizon, after which their stable identifiers report + * {@link State#EXPIRED}. Only notification registrations are memory-only and + * must be re-established after restart. + *

    + * *

    Failure model

    *

    * Validation failures, policy mismatches, lookup problems, and cryptographic @@ -163,12 +203,19 @@ import zeroecho.pki.spi.crypto.SignatureWorkflow; * *

    Persistence and lifecycle

    *
      - *
    • Operation statuses and notification registrations are stored only in - * memory and are lost when the provider is closed or the process restarts.
    • + *
    • Signing request identity, fencing token, request snapshot, status, and + * signature result are stored durably until the configured operation horizon. + * Exact submissions attach to these records after restart.
    • + *
    • A signing record found in {@link State#RUNNING} during restart is changed + * durably to {@link State#FAILED} with detail + * {@code RECOVERY_INCOMPLETE}; signing is not replayed.
    • + *
    • Verification statuses and notification registrations are memory-only and + * are lost when the provider is closed or the process restarts.
    • *
    • The underlying {@link KeyringStore} is loaded lazily on first use and * then cached in the provider instance.
    • - *
    • {@link #close()} clears in-memory operation state and discards the cached - * keyring reference.
    • + *
    • {@link #close()} clears memory-resident caches and registrations and + * releases the operation-root lock. It does not delete retained signing + * records.
    • *
    * *

    Security considerations

    @@ -184,15 +231,16 @@ import zeroecho.pki.spi.crypto.SignatureWorkflow; * *

    Thread-safety

    *

    - * The internal status and sink registries are backed by synchronized maps. This - * provides basic shared-state safety for the current implementation, but - * callbacks are invoked inline from the status update path and may therefore - * affect latency or local failure behavior of the calling thread. This class - * should be treated as safe for ordinary concurrent access, not as a high- - * throughput lock-free implementation. + * Concurrent registries and lifecycle-bounded per-operation locks protect + * mutable state. Cryptographic work and notification callbacks run without an + * operation lock held, so unrelated operation identifiers can progress + * independently. Callbacks are invoked inline after durable status changes and + * may therefore affect caller latency, but callback failures are isolated. *

    */ -public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { // NOPMD +// The provider deliberately centralizes operation lifecycle and cleanup in one implementation. +@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity", "PMD.TooManyMethods" }) +public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { private static final Logger LOG = Logger.getLogger(ZeroEchoLibSignatureWorkflow.class.getName()); @@ -216,37 +264,111 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { / private static final OperationStatus UNKNOWN_OPERATION_STATUS = new OperationStatus(State.FAILED, Instant.EPOCH, Optional.of(DC_UNKNOWN_OPERATION), Optional.empty()); + private static final int OPERATION_RECORD_VERSION = 3; + private static final long MIN_FENCING_TOKEN = 1L; private final String id; - private final java.nio.file.Path keyringPath; + private final Path keyringPath; + private final Path operationRoot; + private final Clock clock; + private final Duration operationHorizon; private final String keyRefPrefix; private final boolean requireComponentSuffix; private final ZeroEchoSession session; - private final Map statuses; - private final Map sinks; + private final ConcurrentMap statuses; + private final ConcurrentMap fingerprints; + private final ConcurrentMap fences; + private final ConcurrentMap requests; + private final ConcurrentMap sinks; + private final ConcurrentMap operationLocks; + private final FileChannel ownershipChannel; + private final FileLock ownershipLock; + private final AtomicLong timeWatermark; + private final ReentrantLock timeWatermarkLock; + private final AtomicReference boundNamespace; + private final ReentrantLock domainLock; + private final BiConsumer cleanupObserver; private volatile KeyringStore keyringOrNull; // NOPMD - /* default */ ZeroEchoLibSignatureWorkflow(String id, java.nio.file.Path keyringPath, String keyRefPrefix, - boolean requireComponentSuffix) { + /* default */ ZeroEchoLibSignatureWorkflow(String id, Path keyringPath, Path operationRoot, Clock clock, + Duration operationHorizon, String keyRefPrefix, boolean requireComponentSuffix) { + this(id, keyringPath, operationRoot, clock, operationHorizon, keyRefPrefix, requireComponentSuffix, + (category, cleared) -> { + }); + } + + /* default */ ZeroEchoLibSignatureWorkflow(String id, Path keyringPath, Path operationRoot, Clock clock, + Duration operationHorizon, String keyRefPrefix, boolean requireComponentSuffix, + BiConsumer cleanupObserver) { if (id == null || id.isBlank()) { throw new IllegalArgumentException("id must not be blank"); } if (keyringPath == null) { throw new IllegalArgumentException("keyringPath must not be null"); } + if (operationRoot == null) { + throw new IllegalArgumentException("operationRoot must not be null"); + } + if (clock == null) { + throw new IllegalArgumentException("clock must not be null"); + } + if (operationHorizon == null || operationHorizon.isZero() || operationHorizon.isNegative()) { + throw new IllegalArgumentException("operationHorizon must be positive"); + } if (keyRefPrefix == null) { throw new IllegalArgumentException("keyRefPrefix must not be null"); } + if (cleanupObserver == null) { + throw new IllegalArgumentException("cleanupObserver must not be null"); + } this.id = id; this.keyringPath = keyringPath; + this.operationRoot = operationRoot.toAbsolutePath().normalize(); + this.clock = clock; + this.operationHorizon = operationHorizon; this.keyRefPrefix = keyRefPrefix; this.requireComponentSuffix = requireComponentSuffix; + this.cleanupObserver = cleanupObserver; this.session = new ZeroEchoSession(); - this.statuses = Collections.synchronizedMap(new HashMap<>()); - this.sinks = Collections.synchronizedMap(new HashMap<>()); + this.statuses = new ConcurrentHashMap<>(); + this.fingerprints = new ConcurrentHashMap<>(); + this.fences = new ConcurrentHashMap<>(); + this.requests = new ConcurrentHashMap<>(); + this.sinks = new ConcurrentHashMap<>(); + this.operationLocks = new ConcurrentHashMap<>(); + this.domainLock = new ReentrantLock(); + this.timeWatermarkLock = new ReentrantLock(); + try { + Files.createDirectories(this.operationRoot); + restrictPermissions(this.operationRoot, true); + Path owner = this.operationRoot.resolve("OWNER"); + if (Files.exists(owner)) { + String storedOwner = Files.readString(owner); + if (!id.equals(storedOwner)) { + throw new IllegalStateException("Signing operation root belongs to a different provider"); + } + } else { + Files.writeString(owner, id, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); + } + this.ownershipChannel = FileChannel.open(this.operationRoot.resolve(".lock"), StandardOpenOption.CREATE, + StandardOpenOption.WRITE); + this.ownershipLock = this.ownershipChannel.tryLock(); + if (this.ownershipLock == null) { + this.ownershipChannel.close(); + throw new IllegalStateException("Signing operation root is already in use"); + } + this.timeWatermark = new AtomicLong(loadTimeWatermark()); + Path domain = this.operationRoot.resolve("DOMAIN"); + this.boundNamespace = new AtomicReference<>(Files.exists(domain) + ? Files.readString(domain, StandardCharsets.US_ASCII).trim() : null); + loadOperationRecords(); + purgeExpiredOperations(); + } catch (IOException ex) { + throw new IllegalStateException("Cannot initialize signing operation root", ex); + } } @Override @@ -254,6 +376,31 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { / return this.id; } + @Override + public void validateSigningDomain(String namespace, Duration horizon, Duration permittedSkew) { + if (namespace == null || !namespace.endsWith("." + id) || !operationHorizon.equals(horizon) + || !Duration.ZERO.equals(permittedSkew)) { + throw new IllegalArgumentException("ZeroEchoLib signing domain mismatch"); + } + domainLock.lock(); + try { + String current = boundNamespace.get(); + if (current != null && !current.equals(namespace)) { + throw new IllegalArgumentException("ZeroEchoLib operation root belongs to a different signing domain"); + } + if (current == null) { + try { + writeSmallAtomic(operationRoot.resolve("DOMAIN"), namespace.getBytes(StandardCharsets.US_ASCII)); + boundNamespace.set(namespace); + } catch (IOException ex) { + throw new IllegalStateException("Cannot persist ZeroEchoLib signing domain", ex); + } + } + } finally { + domainLock.unlock(); + } + } + /** * Returns the set of signature algorithm identifiers known to this provider. * @@ -309,9 +456,20 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { / throw new IllegalArgumentException("request must not be null"); } - PkiId opId = newOperationId(); - putStatus(opId, new OperationStatus(State.RUNNING, Instant.now(), Optional.of(DC_SUBMITTED), Optional.empty())); + PkiId opId = request.submissionId(); + if (!request.namespace().endsWith("." + id)) { + throw new IllegalArgumentException("Signing request namespace mismatch"); + } + validateSigningDomain(request.namespace(), operationHorizon, Duration.ZERO); + SigningSubmissionId parsed = SigningSubmissionId.parse(opId); + purgeExpiredOperations(); + parsed.validate(request.namespace(), now(), operationHorizon, Duration.ZERO); + if (!beginSign(request)) { + return opId; + } + byte[] payloadBytes = null; + byte[] signatureBytes = null; try { KeyRefParts parts = parseKeyRefOrThrow(request.keyRef(), true); @@ -332,45 +490,133 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { / enforceAlgorithmMatchOrThrow(request.algorithmId(), prv.algorithm()); - byte[] signatureBytes = signStreaming(request.algorithmId(), prv.key(), pub.key(), - request.payload().bytes()); + if (deadlineReached(request.deadline(), now())) { + completeSign(request, expiredStatus()); + return opId; + } + payloadBytes = request.payload().bytes(); + signatureBytes = signStreaming(request.algorithmId(), prv.key(), pub.key(), payloadBytes); Encoding outEnc = request.preferredSignatureEncoding().orElse(Encoding.BINARY); EncodedObject signature = encodeSignatureOrThrow(outEnc, signatureBytes); + Instant completedAt = now(); + if (deadlineReached(request.deadline(), completedAt)) { + completeSign(request, expiredStatus(completedAt)); + return opId; + } OperationResult result = new OperationResult(Optional.of(signature), Optional.empty()); - putStatus(opId, - new OperationStatus(State.SUCCEEDED, Instant.now(), Optional.of(DC_SIGNED), Optional.of(result))); + completeSign(request, new OperationStatus(State.SUCCEEDED, completedAt, Optional.of(DC_SIGNED), + Optional.of(result))); return opId; } catch (InvalidRequestException inv) { // NOPMD - putStatus(opId, - new OperationStatus(State.FAILED, Instant.now(), Optional.of(inv.detailCode), Optional.empty())); + completeSign(request, + new OperationStatus(State.FAILED, now(), Optional.of(inv.detailCode), Optional.empty())); return opId; } catch (IOException io) { - putStatus(opId, new OperationStatus(State.FAILED, Instant.now(), Optional.of(DC_KEYRING_IO_ERROR), - Optional.empty())); - if (LOG.isLoggable(Level.FINE)) { - LOG.log(Level.FINE, "Sign failed due to IO (details suppressed)", io); - } + completeSign(request, new OperationStatus(State.FAILED, now(), + Optional.of(DC_KEYRING_IO_ERROR), Optional.empty())); + logSafeFailure("SIGN", DC_KEYRING_IO_ERROR, io); return opId; } catch (GeneralSecurityException sec) { - putStatus(opId, - new OperationStatus(State.FAILED, Instant.now(), Optional.of(DC_CRYPTO_FAILURE), Optional.empty())); - if (LOG.isLoggable(Level.FINE)) { - LOG.log(Level.FINE, "Sign failed due to security exception (details suppressed)", sec); - } + completeSign(request, new OperationStatus(State.FAILED, now(), + Optional.of(DC_CRYPTO_FAILURE), Optional.empty())); + logSafeFailure("SIGN", DC_CRYPTO_FAILURE, sec); return opId; } catch (RuntimeException ex) { // NOPMD - putStatus(opId, - new OperationStatus(State.FAILED, Instant.now(), Optional.of(DC_CRYPTO_FAILURE), Optional.empty())); - if (LOG.isLoggable(Level.FINE)) { - LOG.log(Level.FINE, "Sign failed due to runtime exception (details suppressed)", ex); - } + completeSign(request, new OperationStatus(State.FAILED, now(), + Optional.of(DC_CRYPTO_FAILURE), Optional.empty())); + logSafeFailure("SIGN", DC_CRYPTO_FAILURE, ex); return opId; + } finally { + clearOwned("sign-payload", payloadBytes); + clearOwned("sign-result-copy", signatureBytes); + } + } + + private boolean beginSign(SignRequest request) { + PkiId operationId = request.submissionId(); + SignLockEntry entry = acquireOperationLock(operationId); + OperationStatus event = null; + try { + String existingFingerprint = fingerprints.get(operationId); + if (existingFingerprint != null) { + if (!constantTimeEquals(existingFingerprint, request.semanticFingerprint())) { + throw new IllegalStateException("Signing submission ID conflicts with an existing request"); + } + long existingFence = fences.getOrDefault(operationId, 0L); + if (request.fencingToken() < existingFence) { + throw new IllegalStateException("Stale signing fencing token"); + } + OperationStatus existing = statuses.get(operationId); + if (request.fencingToken() > existingFence && existing != null + && existing.state() == State.RUNNING) { + event = new OperationStatus(State.FAILED, now(), Optional.of("FENCE_SUPERSEDED"), + Optional.empty()); + fences.put(operationId, request.fencingToken()); + statuses.put(operationId, event); + persistOperationRecord(operationId, event); + } + return false; + } + fingerprints.put(operationId, request.semanticFingerprint()); + fences.put(operationId, request.fencingToken()); + requests.put(operationId, request); + event = new OperationStatus(State.RUNNING, now(), Optional.of(DC_SUBMITTED), Optional.empty()); + statuses.put(operationId, event); + persistOperationRecord(operationId, event); + return true; + } finally { + releaseOperationLock(operationId, entry); + if (event != null) { + notifySinks(operationId, event); + } + } + } + + private void completeSign(SignRequest request, OperationStatus terminal) { + PkiId operationId = request.submissionId(); + SignLockEntry entry = acquireOperationLock(operationId); + boolean committed = false; + OperationStatus notification = terminal; + try { + OperationStatus current = statuses.get(operationId); + if (current != null && current.state() == State.RUNNING + && fences.getOrDefault(operationId, 0L) == request.fencingToken() + && constantTimeEquals(fingerprints.get(operationId), request.semanticFingerprint())) { + OperationStatus committedStatus = terminal; + if (terminal.state() == State.SUCCEEDED + && deadlineReached(request.deadline(), terminal.updatedAt())) { + committedStatus = expiredStatus(terminal.updatedAt()); + } + statuses.put(operationId, committedStatus); + persistOperationRecord(operationId, committedStatus); + notification = committedStatus; + committed = true; + } + } finally { + releaseOperationLock(operationId, entry); + } + if (committed) { + notifySinks(operationId, notification); + } + } + + private static boolean constantTimeEquals(String left, String right) { + if (left == null || right == null) { + return false; + } + byte[] leftBytes = left.getBytes(StandardCharsets.US_ASCII); + byte[] rightBytes = right.getBytes(StandardCharsets.US_ASCII); + try { + return MessageDigest.isEqual(leftBytes, rightBytes); + } finally { + Arrays.fill(leftBytes, (byte) 0); + Arrays.fill(rightBytes, (byte) 0); } } @@ -404,8 +650,10 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { / } PkiId opId = newOperationId(); - putStatus(opId, new OperationStatus(State.RUNNING, Instant.now(), Optional.of(DC_SUBMITTED), Optional.empty())); + putStatus(opId, new OperationStatus(State.RUNNING, now(), Optional.of(DC_SUBMITTED), Optional.empty())); + byte[] signatureBytes = null; + byte[] payloadBytes = null; try { if (request.algorithmId() == null || request.algorithmId().isBlank()) { throw new InvalidRequestException(DC_INVALID_ALGORITHM_ID); @@ -413,42 +661,49 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { / PublicKey pub = resolvePublicKeyOrThrow(request); - byte[] sigBytes = decodeSignatureOrThrow(request.signature()); - boolean ok = verifyStreaming(request.algorithmId(), pub, request.payload().bytes(), sigBytes); + signatureBytes = decodeSignatureOrThrow(request.signature()); + if (deadlineReached(request.deadline(), now())) { + putStatus(opId, expiredStatus()); + return opId; + } + payloadBytes = request.payload().bytes(); + boolean ok = verifyStreaming(request.algorithmId(), pub, payloadBytes, signatureBytes); + Instant completedAt = now(); + if (deadlineReached(request.deadline(), completedAt)) { + putStatus(opId, expiredStatus(completedAt)); + return opId; + } OperationResult result = new OperationResult(Optional.empty(), Optional.of(ok)); String dc = ok ? DC_VERIFIED : DC_REJECTED; - putStatus(opId, new OperationStatus(State.SUCCEEDED, Instant.now(), Optional.of(dc), Optional.of(result))); + putStatus(opId, new OperationStatus(State.SUCCEEDED, completedAt, Optional.of(dc), Optional.of(result))); return opId; } catch (InvalidRequestException inv) { // NOPMD putStatus(opId, - new OperationStatus(State.FAILED, Instant.now(), Optional.of(inv.detailCode), Optional.empty())); + new OperationStatus(State.FAILED, now(), Optional.of(inv.detailCode), Optional.empty())); return opId; } catch (IOException io) { - putStatus(opId, new OperationStatus(State.FAILED, Instant.now(), Optional.of(DC_KEYRING_IO_ERROR), + putStatus(opId, new OperationStatus(State.FAILED, now(), Optional.of(DC_KEYRING_IO_ERROR), Optional.empty())); - if (LOG.isLoggable(Level.FINE)) { - LOG.log(Level.FINE, "Verify failed due to IO (details suppressed)", io); - } + logSafeFailure("VERIFY", DC_KEYRING_IO_ERROR, io); return opId; } catch (GeneralSecurityException sec) { putStatus(opId, - new OperationStatus(State.FAILED, Instant.now(), Optional.of(DC_CRYPTO_FAILURE), Optional.empty())); - if (LOG.isLoggable(Level.FINE)) { - LOG.log(Level.FINE, "Verify failed due to security exception (details suppressed)", sec); - } + new OperationStatus(State.FAILED, now(), Optional.of(DC_CRYPTO_FAILURE), Optional.empty())); + logSafeFailure("VERIFY", DC_CRYPTO_FAILURE, sec); return opId; } catch (RuntimeException ex) { // NOPMD putStatus(opId, - new OperationStatus(State.FAILED, Instant.now(), Optional.of(DC_CRYPTO_FAILURE), Optional.empty())); - if (LOG.isLoggable(Level.FINE)) { - LOG.log(Level.FINE, "Verify failed due to runtime exception (details suppressed)", ex); - } + new OperationStatus(State.FAILED, now(), Optional.of(DC_CRYPTO_FAILURE), Optional.empty())); + logSafeFailure("VERIFY", DC_CRYPTO_FAILURE, ex); return opId; + } finally { + clearOwned("verify-payload", payloadBytes); + clearOwned("verify-signature", signatureBytes); } } @@ -456,10 +711,12 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { / * Returns the current status of a previously submitted operation. * *

    - * Because this implementation executes operations eagerly, returned statuses - * are typically already terminal by the time callers first query them. Unknown - * operation identifiers are mapped to a stable synthetic failed status with - * detail code {@link #DC_UNKNOWN_OPERATION}. + * Signing statuses are read from the retained in-memory view loaded from the + * durable operation root and remain available across restart until horizon + * purge. Verification statuses are memory-only. Unknown operation identifiers + * are mapped to a stable synthetic failed status with detail code + * {@link #DC_UNKNOWN_OPERATION}; purged signing identifiers in the bound + * namespace report {@link State#EXPIRED}. *

    * * @param operationId workflow operation identifier; must not be {@code null} @@ -471,8 +728,19 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { / if (operationId == null) { throw new IllegalArgumentException("operationId must not be null"); } + purgeExpiredOperations(); OperationStatus st = this.statuses.get(operationId); if (st == null) { + try { + SigningSubmissionId parsed = SigningSubmissionId.parse(operationId); + String namespace = boundNamespace.get(); + if (namespace != null && namespace.equals(parsed.namespace()) + && !now().isBefore(parsed.createdAt().plus(operationHorizon))) { + return new OperationStatus(State.EXPIRED, now(), Optional.of("EXPIRED"), Optional.empty()); + } + } catch (IllegalArgumentException ignored) { // unknown verify operation identifiers remain synthetic failed + // handled by the stable unknown status below + } return UNKNOWN_OPERATION_STATUS; } return st; @@ -482,10 +750,9 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { / * Attempts to cancel a non-terminal operation. * *

    - * In the current implementation operations are executed eagerly and usually - * reach a terminal state before cancellation is attempted. Cancellation - * therefore succeeds only when a matching non-terminal status is still present - * in the in-memory registry. + * Cancellation is serialized with completion for the same signing identifier, + * validates the fencing token, and durably records {@link State#CANCELLED}. + * It succeeds only while the retained operation is non-terminal. *

    * * @param operationId workflow operation identifier; must not be {@code null} @@ -497,19 +764,38 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { / * {@code reason} is {@code null} or blank */ @Override - public boolean cancel(PkiId operationId, String reason) { + public boolean cancel(PkiId operationId, long fencingToken, String reason) { if (operationId == null) { throw new IllegalArgumentException("operationId must not be null"); } if (reason == null || reason.isBlank()) { throw new IllegalArgumentException("reason must not be blank"); } - OperationStatus st = this.statuses.get(operationId); - if (st == null || st.isTerminal()) { - return false; + if (fencingToken < MIN_FENCING_TOKEN) { + throw new IllegalArgumentException("fencingToken must be positive"); + } + SignLockEntry entry = acquireOperationLock(operationId); + OperationStatus cancelled = null; + try { + OperationStatus st = this.statuses.get(operationId); + if (st == null || st.isTerminal()) { + return false; + } + long currentFence = this.fences.getOrDefault(operationId, 0L); + if (fencingToken < currentFence) { + return false; + } + this.fences.put(operationId, fencingToken); + cancelled = new OperationStatus(State.CANCELLED, now(), Optional.of(DC_CANCELLED), + Optional.empty()); + statuses.put(operationId, cancelled); + persistOperationRecord(operationId, cancelled); + } finally { + releaseOperationLock(operationId, entry); + if (cancelled != null) { + notifySinks(operationId, cancelled); + } } - putStatus(operationId, - new OperationStatus(State.CANCELLED, Instant.now(), Optional.of(DC_CANCELLED), Optional.empty())); return true; } @@ -542,18 +828,28 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { / } /** - * Clears in-memory workflow state and releases the cached keyring reference. + * Clears memory-resident state and releases the operation-root ownership lock. * *

    - * This method does not delete the underlying keyring file and does not attempt - * to persist or flush in-memory operation status. + * This method does not delete the underlying keyring file or durable signing + * operation records. A subsequent provider instance reloads retained signing + * operations and applies the documented restart recovery rules. *

    */ @Override public void close() { this.statuses.clear(); + this.fingerprints.clear(); + this.fences.clear(); + this.requests.clear(); this.sinks.clear(); this.keyringOrNull = null; + try { + this.ownershipLock.release(); + this.ownershipChannel.close(); + } catch (IOException ex) { + throw new IllegalStateException("Cannot release signing operation root", ex); + } } private KeyringStore requireKeyringOrThrow() throws IOException { @@ -625,12 +921,14 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { / if (request.publicKeyEncoded().isPresent()) { byte[] spki = decodePublicKeyOrThrow(request.publicKeyEncoded().get()); - AlgorithmKeySpec spec = createPublicKeySpecOrThrow(request.algorithmId(), spki); - String keyAlg = keyAlgorithmId(request.algorithmId()); try { + AlgorithmKeySpec spec = createPublicKeySpecOrThrow(request.algorithmId(), spki); + String keyAlg = keyAlgorithmId(request.algorithmId()); return importPublic(keyAlg, spec); } catch (GeneralSecurityException ex) { throw new InvalidRequestException(DC_CRYPTO_FAILURE, ex); + } finally { + clearOwned("verify-public-key", spki); } } @@ -644,14 +942,19 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { / return session.keyBuilders().asymmetric().publicImporter(algorithmId, specType).importPublic(spec); } - private static byte[] decodePublicKeyOrThrow(EncodedObject publicKey) throws InvalidRequestException { + private byte[] decodePublicKeyOrThrow(EncodedObject publicKey) throws InvalidRequestException { + byte[] encoded = publicKey.bytes(); if (publicKey.encoding() == Encoding.DER || publicKey.encoding() == Encoding.BINARY) { - return publicKey.bytes().clone(); + return encoded; } if (publicKey.encoding() == Encoding.PEM) { - String text = new String(publicKey.bytes(), java.nio.charset.StandardCharsets.US_ASCII); - return pemUnwrap(text); + try { + return pemUnwrap(new String(encoded, StandardCharsets.US_ASCII)); + } finally { + clearOwned("verify-public-key-encoding", encoded); + } } + clearOwned("verify-public-key-encoding", encoded); throw new InvalidRequestException(DC_UNSUPPORTED_PUBLICKEY_FORM); } @@ -731,14 +1034,19 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { / in.transferTo(OutputStream.nullOutputStream()); } - byte[] sig = sigHolder[0]; - if (sig == null || sig.length == 0) { - throw new GeneralSecurityException("Signature trailer missing."); + byte[] internalSignature = sigHolder[0]; + try { + if (internalSignature == null || internalSignature.length == 0) { + throw new GeneralSecurityException("Signature trailer missing."); + } + if (profile.isPresent()) { + return profile.get().internalToExternalSignature(internalSignature); + } + sigHolder[0] = null; + return internalSignature; + } finally { + clearOwned("sign-internal-signature", sigHolder[0]); } - if (profile.isPresent()) { - return profile.get().internalToExternalSignature(sig); - } - return sig; } } @@ -749,8 +1057,10 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { / String contextAlgorithmId = profile.map(SignatureInteropProfile::contextAlgorithmId).orElse(algorithmId); ContextSpec contextSpec = profile.map(SignatureInteropProfile::contextSpec).orElse(null); byte[] internalSignature = signature; + boolean convertedSignature = false; if (profile.isPresent()) { internalSignature = profile.get().externalToInternalSignature(signature); + convertedSignature = true; } try (SignatureContext verifier = session.createContext(contextAlgorithmId, KeyUsage.VERIFY, pub, @@ -762,29 +1072,43 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { / return true; } catch (Exception mismatch) { return false; + } finally { + if (convertedSignature) { + clearOwned("verify-internal-signature", internalSignature); + } } } - private static EncodedObject encodeSignatureOrThrow(Encoding encoding, byte[] sigBytes) + private EncodedObject encodeSignatureOrThrow(Encoding encoding, byte[] sigBytes) throws InvalidRequestException { if (encoding == Encoding.BINARY || encoding == Encoding.DER) { return new EncodedObject(encoding, sigBytes); } if (encoding == Encoding.PEM) { String pem = pemWrap("ZEROECHO SIGNATURE", sigBytes); - return new EncodedObject(Encoding.PEM, pem.getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + byte[] encoded = pem.getBytes(StandardCharsets.US_ASCII); + try { + return new EncodedObject(Encoding.PEM, encoded); + } finally { + clearOwned("sign-encoded-result", encoded); + } } throw new InvalidRequestException(DC_UNSUPPORTED_SIGNATURE_ENCODING); } - private static byte[] decodeSignatureOrThrow(EncodedObject signature) throws InvalidRequestException { + private byte[] decodeSignatureOrThrow(EncodedObject signature) throws InvalidRequestException { + byte[] encoded = signature.bytes(); if (signature.encoding() == Encoding.BINARY || signature.encoding() == Encoding.DER) { - return signature.bytes().clone(); + return encoded; } if (signature.encoding() == Encoding.PEM) { - String text = new String(signature.bytes(), java.nio.charset.StandardCharsets.US_ASCII); - return pemUnwrap(text); + try { + return pemUnwrap(new String(encoded, StandardCharsets.US_ASCII)); + } finally { + clearOwned("verify-signature-encoding", encoded); + } } + clearOwned("verify-signature-encoding", encoded); throw new InvalidRequestException(DC_UNSUPPORTED_SIGNATURE_ENCODING); } @@ -810,20 +1134,470 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { / private void putStatus(PkiId id, OperationStatus st) { this.statuses.put(id, st); + if (this.fingerprints.containsKey(id)) { + persistOperationRecord(id, st); + } + notifySinks(id, st); + } + + private void notifySinks(PkiId id, OperationStatus st) { for (NotificationSink sink : this.sinks.values()) { try { sink.onStatusChanged(id, st); } catch (Throwable ignore) { // NOPMD // sink must not break provider - LOG.log(Level.FINE, "cannot put status, ignoring", ignore); + logSafeFailure("CALLBACK", "NOTIFICATION_FAILED", ignore); } } } + private static void logSafeFailure(String operation, String code, Throwable failure) { + if (LOG.isLoggable(Level.FINE)) { + LOG.log(Level.FINE, "{0} failed: code={1}, exception={2}", + new Object[] { operation, code, failure.getClass().getName() }); + } + } + + private void clearOwned(String category, byte[] owned) { + if (owned != null) { + Arrays.fill(owned, (byte) 0); + cleanupObserver.accept(category, owned); + } + } + + private SignLockEntry acquireOperationLock(PkiId operationId) { + SignLockEntry entry = operationLocks.compute(operationId, (ignored, current) -> { + SignLockEntry selected = current == null ? new SignLockEntry() : current; + selected.references.incrementAndGet(); + return selected; + }); + entry.lock.lock(); + return entry; + } + + private void releaseOperationLock(PkiId operationId, SignLockEntry entry) { + entry.lock.unlock(); + operationLocks.computeIfPresent(operationId, (ignored, current) -> { + if (!current.equals(entry)) { + return current; + } + return current.references.decrementAndGet() == 0 ? null : current; + }); + } + + /** + * Reference-counted lock entry removed after the last operation user exits. + */ + private static final class SignLockEntry { + private final ReentrantLock lock = new ReentrantLock(); + private final AtomicInteger references = new AtomicInteger(); + } + + private long loadTimeWatermark() throws IOException { + Path path = operationRoot.resolve("TIME_WATERMARK"); + long observed = clock.instant().toEpochMilli(); + if (!Files.exists(path)) { + writeSmallAtomic(path, Long.toString(observed).getBytes(StandardCharsets.US_ASCII)); + return observed; + } + try { + return Math.max(observed, Long.parseLong(Files.readString(path, StandardCharsets.US_ASCII).trim())); + } catch (NumberFormatException ex) { + throw new IllegalStateException("Invalid provider time watermark", ex); + } + } + + private Instant now() { + timeWatermarkLock.lock(); + try { + long monotonic = Math.max(timeWatermark.get(), clock.instant().toEpochMilli()); + try { + writeSmallAtomic(operationRoot.resolve("TIME_WATERMARK"), + Long.toString(monotonic).getBytes(StandardCharsets.US_ASCII)); + } catch (IOException ex) { + throw new IllegalStateException("Cannot persist provider time watermark", ex); + } + timeWatermark.set(monotonic); + return Instant.ofEpochMilli(monotonic); + } finally { + timeWatermarkLock.unlock(); + } + } + + private static boolean deadlineReached(Optional deadline, Instant observedAt) { + return deadline.isPresent() && !observedAt.isBefore(deadline.get()); + } + + private OperationStatus expiredStatus() { + return expiredStatus(now()); + } + + private static OperationStatus expiredStatus(Instant completedAt) { + return new OperationStatus(State.EXPIRED, completedAt, Optional.of("EXPIRED"), Optional.empty()); + } + + private void purgeExpiredOperations() { + Instant current = now(); + for (Map.Entry mapEntry : requests.entrySet()) { + PkiId operationId = mapEntry.getKey(); + if (!current.isBefore(SigningSubmissionId.parse(operationId).createdAt().plus(operationHorizon))) { + SignLockEntry lock = acquireOperationLock(operationId); + try { + if (requests.containsKey(operationId) && !current.isBefore( + SigningSubmissionId.parse(operationId).createdAt().plus(operationHorizon))) { + requests.remove(operationId); + fingerprints.remove(operationId); + fences.remove(operationId); + statuses.remove(operationId); + Files.deleteIfExists(operationRecordPath(operationId)); + } + } catch (IOException ex) { + throw new IllegalStateException("Cannot purge expired provider signing operation", ex); + } finally { + releaseOperationLock(operationId, lock); + } + } + } + } + + private static void writeSmallAtomic(Path target, byte[] data) throws IOException { + Files.createDirectories(target.getParent()); + Path temporary = Files.createTempFile(target.getParent(), "." + target.getFileName(), ".tmp"); + try { + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) { + ByteBuffer bytes = ByteBuffer.wrap(data); + while (bytes.hasRemaining()) { + channel.write(bytes); + } + channel.force(true); + } + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException ex) { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); + } + restrictPermissions(target, false); + try (FileChannel directory = FileChannel.open(target.getParent(), StandardOpenOption.READ)) { + directory.force(true); + } + } finally { + Files.deleteIfExists(temporary); + } + } + + private static void restrictPermissions(Path path, boolean directory) throws IOException { + try { + Files.setPosixFilePermissions(path, PosixFilePermissions.fromString( + directory ? "rwx------" : "rw-------")); + } catch (UnsupportedOperationException ex) { + // Non-POSIX platforms rely on their native access-control mechanism. + if (LOG.isLoggable(Level.FINEST)) { + LOG.log(Level.FINEST, "Filesystem permission adjustment unavailable: code={0}, exception={1}", + new Object[] { "POSIX_UNAVAILABLE", ex.getClass().getName() }); + } + } + } + + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + private void loadOperationRecords() throws IOException { + Path records = this.operationRoot.resolve("records"); + if (!Files.isDirectory(records)) { + return; + } + try (java.util.stream.Stream paths = Files.list(records)) { + for (Path path : paths.filter(Files::isRegularFile).toList()) { + try (DataInputStream input = new DataInputStream(Files.newInputStream(path))) { + if (input.readInt() != OPERATION_RECORD_VERSION) { + throw new IllegalStateException("Unsupported signing operation record version"); + } + PkiId operationId = new PkiId(input.readUTF()); + SigningSubmissionId parsedId = SigningSubmissionId.parse(operationId); + String fingerprint = input.readUTF(); + long fence = input.readLong(); + SignRequest request = readSignRequest(input); + if (!operationId.equals(request.submissionId()) + || !constantTimeEquals(fingerprint, request.semanticFingerprint()) + || !parsedId.namespace().equals(request.namespace()) + || !operationRecordPath(operationId).equals(path)) { + throw new IllegalStateException("Signing operation record identity mismatch"); + } + State state = stateFromCode(input.readInt()); + Instant updatedAt = Instant.ofEpochSecond(input.readLong(), input.readInt()); + Optional detail = input.readBoolean() ? Optional.of(input.readUTF()) : Optional.empty(); + Optional result = readOperationResult(input); + if (input.available() != 0) { + throw new IllegalStateException("Trailing data in signing operation record"); + } + if (state == State.SUCCEEDED != result.isPresent()) { + throw new IllegalStateException("Signing operation record result/state mismatch"); + } + OperationStatus loaded = new OperationStatus(state, updatedAt, detail, result); + boolean repaired = false; + if (state == State.SUCCEEDED + && (updatedAt.isBefore(parsedId.createdAt()) + || deadlineReached(request.deadline(), updatedAt))) { + loaded = expiredStatus(updatedAt); + repaired = true; + } + if (state == State.RUNNING) { + loaded = new OperationStatus(State.FAILED, now(), Optional.of("RECOVERY_INCOMPLETE"), + Optional.empty()); + repaired = true; + } + this.fingerprints.put(operationId, fingerprint); + this.fences.put(operationId, fence); + this.requests.put(operationId, request); + this.statuses.put(operationId, loaded); + if (repaired) { + persistOperationRecord(operationId, loaded); + } + } + } + } + } + + private void persistOperationRecord(PkiId operationId, OperationStatus status) { + WipeableByteArrayOutputStream bytes = new WipeableByteArrayOutputStream(); + byte[] encodedRecord = null; + try { + try (DataOutputStream output = new DataOutputStream(bytes)) { + output.writeInt(OPERATION_RECORD_VERSION); + output.writeUTF(operationId.value()); + output.writeUTF(this.fingerprints.get(operationId)); + output.writeLong(this.fences.getOrDefault(operationId, 0L)); + writeSignRequest(output, requests.get(operationId)); + output.writeInt(stateCode(status.state())); + output.writeLong(status.updatedAt().getEpochSecond()); + output.writeInt(status.updatedAt().getNano()); + output.writeBoolean(status.detailCode().isPresent()); + if (status.detailCode().isPresent()) { + output.writeUTF(status.detailCode().orElseThrow()); + } + writeOperationResult(output, status.result()); + } + encodedRecord = bytes.toByteArray(); + Path records = this.operationRoot.resolve("records"); + Files.createDirectories(records); + restrictPermissions(records, true); + Path target = operationRecordPath(operationId); + Path temporary = Files.createTempFile(records, ".operation-", ".tmp"); + try { + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) { + channel.write(ByteBuffer.wrap(encodedRecord)); + channel.force(true); + } + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException ex) { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); + } + restrictPermissions(target, false); + try (FileChannel directory = FileChannel.open(records, StandardOpenOption.READ)) { + directory.force(true); + } + } finally { + Files.deleteIfExists(temporary); + } + } catch (IOException ex) { + throw new IllegalStateException("Cannot persist signing operation status", ex); + } finally { + clearOwned("persisted-operation-copy", encodedRecord); + bytes.wipe(); + } + } + + private Path operationRecordPath(PkiId operationId) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(operationId.value().getBytes(StandardCharsets.UTF_8)); + return this.operationRoot.resolve("records").resolve(HexFormat.of().formatHex(digest) + ".bin"); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 is unavailable", ex); + } + } + + private void writeSignRequest(DataOutputStream output, SignRequest request) throws IOException { + if (request == null) { + throw new IllegalStateException("Missing signing request snapshot"); + } + output.writeUTF(request.submissionId().value()); + output.writeUTF(request.namespace()); + output.writeUTF(request.accessContext().principal().type()); + output.writeUTF(request.accessContext().principal().name()); + output.writeUTF(request.accessContext().purpose().value()); + output.writeBoolean(request.accessContext().objectId().isPresent()); + if (request.accessContext().objectId().isPresent()) { + output.writeUTF(request.accessContext().objectId().orElseThrow().value()); + } + output.writeBoolean(request.accessContext().formatId().isPresent()); + if (request.accessContext().formatId().isPresent()) { + output.writeUTF(request.accessContext().formatId().orElseThrow().value()); + } + output.writeUTF(request.keyRef().value()); + output.writeUTF(request.algorithmId()); + output.writeInt(encodingCode(request.payload().encoding())); + byte[] payload = request.payload().bytes(); + try { + output.writeInt(payload.length); + output.write(payload); + } finally { + clearOwned("persisted-request-payload", payload); + } + output.writeBoolean(request.preferredSignatureEncoding().isPresent()); + if (request.preferredSignatureEncoding().isPresent()) { + output.writeInt(encodingCode(request.preferredSignatureEncoding().orElseThrow())); + } + output.writeBoolean(request.deadline().isPresent()); + if (request.deadline().isPresent()) { + Instant deadline = request.deadline().orElseThrow(); + output.writeLong(deadline.getEpochSecond()); + output.writeInt(deadline.getNano()); + } + } + + private SignRequest readSignRequest(DataInputStream input) throws IOException { + PkiId submissionId = new PkiId(input.readUTF()); + String namespace = input.readUTF(); + zeroecho.pki.api.audit.Principal principal = new zeroecho.pki.api.audit.Principal(input.readUTF(), + input.readUTF()); + zeroecho.pki.api.audit.Purpose purpose = new zeroecho.pki.api.audit.Purpose(input.readUTF()); + Optional objectId = input.readBoolean() ? Optional.of(new PkiId(input.readUTF())) : Optional.empty(); + Optional formatId = input.readBoolean() + ? Optional.of(new zeroecho.pki.api.FormatId(input.readUTF())) + : Optional.empty(); + zeroecho.pki.api.audit.AccessContext access = new zeroecho.pki.api.audit.AccessContext(principal, purpose, + objectId, formatId); + KeyRef keyRef = new KeyRef(input.readUTF()); + String algorithmId = input.readUTF(); + Encoding payloadEncoding = encodingFromCode(input.readInt()); + int payloadLength = input.readInt(); + if (payloadLength < 1 || payloadLength > 16 * 1024 * 1024) { + throw new IllegalStateException("Invalid persisted signing payload length"); + } + byte[] payload = input.readNBytes(payloadLength); + if (payload.length != payloadLength) { + throw new IllegalStateException("Truncated persisted signing payload"); + } + try { + Optional preferred = input.readBoolean() + ? Optional.of(encodingFromCode(input.readInt())) + : Optional.empty(); + Optional deadline = input.readBoolean() + ? Optional.of(Instant.ofEpochSecond(input.readLong(), input.readInt())) + : Optional.empty(); + return SignRequest.create(submissionId, namespace, 1L, access, keyRef, algorithmId, + new EncodedObject(payloadEncoding, payload), preferred, deadline); + } finally { + clearOwned("loaded-request-payload", payload); + } + } + + private static Encoding encodingFromCode(int code) { + return switch (code) { + case 10 -> Encoding.DER; + case 20 -> Encoding.PEM; + case 30 -> Encoding.BINARY; + default -> throw new IllegalStateException("Invalid persisted encoding code"); + }; + } + + private static int encodingCode(Encoding encoding) { + return switch (encoding) { + case DER -> 10; + case PEM -> 20; + case BINARY -> 30; + }; + } + + private static int stateCode(State state) { + return switch (state) { + case PENDING -> 10; + case WAITING_APPROVAL -> 20; + case RUNNING -> 30; + case SUCCEEDED -> 40; + case FAILED -> 50; + case CANCELLED -> 60; + case EXPIRED -> 70; + }; + } + + private static State stateFromCode(int code) { + return switch (code) { + case 10 -> State.PENDING; + case 20 -> State.WAITING_APPROVAL; + case 30 -> State.RUNNING; + case 40 -> State.SUCCEEDED; + case 50 -> State.FAILED; + case 60 -> State.CANCELLED; + case 70 -> State.EXPIRED; + default -> throw new IllegalStateException("Invalid persisted signing state code"); + }; + } + + private void writeOperationResult(DataOutputStream output, Optional result) + throws IOException { + output.writeBoolean(result.isPresent()); + if (result.isEmpty()) { + return; + } + OperationResult value = result.orElseThrow(); + output.writeBoolean(value.signature().isPresent()); + if (value.signature().isPresent()) { + EncodedObject signature = value.signature().orElseThrow(); + output.writeInt(encodingCode(signature.encoding())); + byte[] signatureBytes = signature.bytes(); + try { + output.writeInt(signatureBytes.length); + output.write(signatureBytes); + } finally { + clearOwned("persisted-signature-result", signatureBytes); + } + } + output.writeBoolean(value.verified().isPresent()); + if (value.verified().isPresent()) { + output.writeBoolean(value.verified().orElseThrow()); + } + } + + private Optional readOperationResult(DataInputStream input) throws IOException { + if (!input.readBoolean()) { + return Optional.empty(); + } + Optional signature = Optional.empty(); + if (input.readBoolean()) { + Encoding encoding = encodingFromCode(input.readInt()); + int length = input.readInt(); + if (length < 0 || length > 16 * 1024 * 1024) { + throw new IllegalStateException("Invalid persisted signature length"); + } + byte[] signatureBytes = input.readNBytes(length); + try { + if (signatureBytes.length != length) { + throw new IllegalStateException("Truncated persisted signature"); + } + signature = Optional.of(new EncodedObject(encoding, signatureBytes)); + } finally { + clearOwned("loaded-signature-result", signatureBytes); + } + } + Optional verification = input.readBoolean() ? Optional.of(input.readBoolean()) : Optional.empty(); + return Optional.of(new OperationResult(signature, verification)); + } + private static PkiId newOperationId() { return new PkiId(UUID.randomUUID().toString()); } + /** + * Output stream whose retained serialized signing material can be overwritten. + */ + private final class WipeableByteArrayOutputStream extends ByteArrayOutputStream { + private void wipe() { + clearOwned("persisted-operation-buffer", buf); + reset(); + } + } + /** * Parsed, provider-local representation of a {@link KeyRef}. * diff --git a/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowProvider.java b/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowProvider.java index b66afc7..f99e6bc 100644 --- a/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowProvider.java +++ b/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowProvider.java @@ -34,6 +34,8 @@ package zeroecho.pki.impl.crypto.zeroecholib; import java.nio.file.Path; +import java.time.Clock; +import java.time.Duration; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; @@ -52,6 +54,10 @@ import zeroecho.pki.spi.crypto.SignatureWorkflowProvider; *
      *
    • {@code keyringPath} (required): filesystem path to the KeyringStore * file
    • + *
    • {@code operationRoot} (required): exclusively owned durable signing + * operation directory
    • + *
    • {@code operationHorizon} (optional ISO-8601 duration, default + * {@code P90D})
    • *
    • {@code keyRefPrefix} (optional, default {@code "zeroecho-lib:"})
    • *
    • {@code requireComponentSuffix} (optional, default {@code true})
    • *
    @@ -66,6 +72,8 @@ public final class ZeroEchoLibSignatureWorkflowProvider implements SignatureWork private static final Logger LOG = Logger.getLogger(ZeroEchoLibSignatureWorkflowProvider.class.getName()); private static final String KEY_KEYRING_PATH = "keyringPath"; + private static final String KEY_OPERATION_ROOT = "operationRoot"; + private static final String KEY_OPERATION_HORIZON = "operationHorizon"; private static final String KEY_KEYREF_PREFIX = "keyRefPrefix"; private static final String KEY_REQUIRE_SUFFIX = "requireComponentSuffix"; @@ -76,7 +84,8 @@ public final class ZeroEchoLibSignatureWorkflowProvider implements SignatureWork @Override public Set supportedKeys() { - return Set.of(KEY_KEYRING_PATH, KEY_KEYREF_PREFIX, KEY_REQUIRE_SUFFIX); + return Set.of(KEY_KEYRING_PATH, KEY_OPERATION_ROOT, KEY_OPERATION_HORIZON, KEY_KEYREF_PREFIX, + KEY_REQUIRE_SUFFIX); } /** @@ -91,6 +100,13 @@ public final class ZeroEchoLibSignatureWorkflowProvider implements SignatureWork SignatureWorkflowProvider.super.validateConfig(config); String keyringPath = config.require(KEY_KEYRING_PATH); Path.of(keyringPath); + Path.of(config.require(KEY_OPERATION_ROOT)); + config.get(KEY_OPERATION_HORIZON).ifPresent(value -> { + Duration horizon = Duration.parse(value); + if (horizon.isZero() || horizon.isNegative()) { + throw new IllegalArgumentException("Configuration key '" + KEY_OPERATION_HORIZON + "' must be positive."); + } + }); config.get(KEY_KEYREF_PREFIX).ifPresent(value -> { if (value.isBlank()) { throw new IllegalArgumentException("Configuration key '" + KEY_KEYREF_PREFIX + "' must not be blank."); @@ -113,9 +129,12 @@ public final class ZeroEchoLibSignatureWorkflowProvider implements SignatureWork public SignatureWorkflow allocate(final ProviderConfig config) { validateConfig(config); String keyringPath = config.require(KEY_KEYRING_PATH); + Path operationRoot = Path.of(config.require(KEY_OPERATION_ROOT)); + Duration operationHorizon = config.get(KEY_OPERATION_HORIZON).map(Duration::parse).orElse(Duration.ofDays(90)); String prefix = config.get(KEY_KEYREF_PREFIX).orElse("zeroecho-lib:"); boolean requireSuffix = config.get(KEY_REQUIRE_SUFFIX).map(Boolean::parseBoolean).orElse(Boolean.TRUE); - return new ZeroEchoLibSignatureWorkflow(id(), Path.of(keyringPath), prefix, requireSuffix); + return new ZeroEchoLibSignatureWorkflow(id(), Path.of(keyringPath), operationRoot, Clock.systemUTC(), + operationHorizon, prefix, requireSuffix); } } diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509Attributes.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509Attributes.java index ab2d357..784d08b 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509Attributes.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509Attributes.java @@ -148,6 +148,17 @@ public final class BcX509Attributes { */ public static final AttributeId SUBJECT_SPKI_DER = new AttributeId("urn:zeroecho:pki:x509:subject:spki"); + /** + * Attribute identifier for the authoritative X.509 subject distinguished name. + * + *

    + * The associated value is a {@code StringValue}. Core CA orchestration + * overwrites this reserved value from its authoritative subject record before + * invoking the privileged issuer backend. + *

    + */ + public static final AttributeId SUBJECT_DN = new AttributeId("urn:zeroecho:pki:x509:subject:dn"); + private BcX509Attributes() { // utility } diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CertificationRequestParser.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CertificationRequestParser.java index f5046f8..9f1bd82 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CertificationRequestParser.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CertificationRequestParser.java @@ -108,6 +108,8 @@ import zeroecho.pki.spi.framework.CertificationRequestParser; * This class is stateless and thread-safe. *

    */ +// PMD cannot infer that retaining parser causes would violate the redaction contract. +@SuppressWarnings("PMD.PreserveStackTrace") public final class BcX509CertificationRequestParser implements CertificationRequestParser { /** * Parses a PKCS#10 certification request into the normalized PKI request @@ -153,31 +155,38 @@ public final class BcX509CertificationRequestParser implements CertificationRequ EncodedObject enc = request.encoded(); byte[] csrDer = toDer(enc); - PKCS10CertificationRequest csr; + byte[] spki = null; try { - csr = new PKCS10CertificationRequest(csrDer); - } catch (Exception ex) { - throw new PkiException("Invalid PKCS#10 certification request", ex); + PKCS10CertificationRequest csr; + try { + csr = new PKCS10CertificationRequest(csrDer); + } catch (Exception ex) { + throw new PkiException("Invalid PKCS#10 certification request: code=CSR_MALFORMED"); + } + + X500Name subject = csr.getSubject(); + SubjectRef subjectRef = new SubjectRef(subject.toString()); + + try { + spki = csr.getSubjectPublicKeyInfo().getEncoded(); + } catch (Exception ex) { + throw new PkiException("Failed to extract CSR SPKI: code=CSR_SPKI_INVALID"); + } + EncodedObject publicKeyInfo = new EncodedObject(Encoding.DER, spki); + + PkiId requestId = new PkiId("csr:" + sha256Hex(csrDer)); + + SimpleAttributeSet attrs = SimpleAttributeSet.builder() + .put(BcX509Attributes.CSR_DER, new AttributeValue.BytesValue(csrDer.clone())).build(); + + return new ParsedCertificationRequest(requestId, request.formatId(), subjectRef, publicKeyInfo, + Optional.empty(), Optional.empty(), attrs); + } finally { + java.util.Arrays.fill(csrDer, (byte) 0); + if (spki != null) { + java.util.Arrays.fill(spki, (byte) 0); + } } - - X500Name subject = csr.getSubject(); - SubjectRef subjectRef = new SubjectRef(subject.toString()); - - byte[] spki; - try { - spki = csr.getSubjectPublicKeyInfo().getEncoded(); - } catch (Exception ex) { - throw new PkiException("Failed to extract CSR SPKI", ex); - } - EncodedObject publicKeyInfo = new EncodedObject(Encoding.DER, spki); - - PkiId requestId = new PkiId("csr:" + sha256Hex(csrDer)); - - SimpleAttributeSet attrs = SimpleAttributeSet.builder() - .put(BcX509Attributes.CSR_DER, new AttributeValue.BytesValue(csrDer)).build(); - - return new ParsedCertificationRequest(requestId, request.formatId(), subjectRef, publicKeyInfo, - Optional.empty(), Optional.empty(), attrs); } /** @@ -208,11 +217,20 @@ public final class BcX509CertificationRequestParser implements CertificationRequ return switch (obj.encoding()) { case DER -> obj.bytes(); - case PEM -> readPemContentOrThrow(obj.bytes()); + case PEM -> pemToDer(obj); default -> throw new IllegalArgumentException("Unsupported CSR encoding: " + obj.encoding()); }; } + private static byte[] pemToDer(EncodedObject obj) { + byte[] pemBytes = obj.bytes(); + try { + return readPemContentOrThrow(pemBytes); + } finally { + java.util.Arrays.fill(pemBytes, (byte) 0); + } + } + /** * Reads the binary content of a PEM object. * @@ -245,7 +263,7 @@ public final class BcX509CertificationRequestParser implements CertificationRequ } return pemObject.getContent(); } catch (java.io.IOException ex) { - throw new IllegalArgumentException("Invalid PEM", ex); + throw new IllegalArgumentException("Invalid PEM: code=CSR_PEM_INVALID"); } } @@ -262,9 +280,13 @@ public final class BcX509CertificationRequestParser implements CertificationRequ try { MessageDigest md = MessageDigest.getInstance("SHA-256"); byte[] d = md.digest(in); - return HexFormat.of().formatHex(d); + try { + return HexFormat.of().formatHex(d); + } finally { + java.util.Arrays.fill(d, (byte) 0); + } } catch (Exception ex) { - throw new IllegalStateException("SHA-256 not available", ex); + throw new IllegalStateException("SHA-256 unavailable: code=DIGEST_UNAVAILABLE"); } } } diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialFramework.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialFramework.java index bd4f33b..12ee1f8 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialFramework.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialFramework.java @@ -38,7 +38,6 @@ import java.util.Objects; import zeroecho.pki.api.FormatId; import zeroecho.pki.spi.framework.CertificationRequestParser; import zeroecho.pki.spi.framework.CredentialFramework; -import zeroecho.pki.spi.framework.CredentialIssuerBackend; import zeroecho.pki.spi.framework.FrameworkAttributeMapper; import zeroecho.pki.spi.framework.ProofOfPossessionVerifier; import zeroecho.pki.spi.framework.StatusObjectGenerator; @@ -50,28 +49,18 @@ import zeroecho.pki.spi.framework.StatusObjectGenerator; * This class groups the X.509-specific framework components used by the PKI * runtime under the common {@link CredentialFramework} contract. It provides * the X.509 format identifier, CSR parsing, proof-of-possession verification, - * issuance backend access, status-object generation, and framework-specific - * attribute mapping. + * status-object generation, and framework-specific attribute mapping. It does + * not expose credential-minting backends. *

    * *

    - * The framework supports two wiring modes: - *

    - *
      - *
    • a default partially wired mode created by - * {@link #BcX509CredentialFramework()}, which provides CSR parsing, - * proof-of-possession verification, and attribute mapping but deliberately - * exposes unsupported placeholders for issuance and status-object - * generation,
    • - *
    • a fully wired mode created by one of the {@code wired(...)} methods, in - * which the caller supplies the concrete issuance and status-object generation - * components.
    • - *
    + * The default constructor provides parsing, proof verification, and attribute + * mapping with an unsupported status generator. A {@code wired(...)} method can + * supply status generation without exposing the privileged issuer backend. * *

    * This split allows the runtime to use X.509 request parsing and proof-of- - * possession verification independently from certificate issuance and status- - * object publication wiring. + * possession verification independently from certificate issuance. *

    * *

    Immutability and lifecycle

    @@ -79,16 +68,15 @@ import zeroecho.pki.spi.framework.StatusObjectGenerator; * Instances of this class are immutable. The {@code wired(...)} methods do not * mutate the current instance; instead they create and return a new framework * instance sharing the existing parser and attribute-mapper components while - * replacing the requested backend components. + * replacing the status generator. *

    * *

    Security considerations

    *
      *
    • This class is an orchestration and component-aggregation object; it does * not itself process private key material.
    • - *
    • The security properties of issuance and status-object generation depend - * on the concrete backends supplied through the {@code wired(...)} - * methods.
    • + *
    • Credential issuance remains owned by proof-gated core services rather than + * this framework facade.
    • *
    • The default instance intentionally fails fast for issuance and * status-object generation so that partially wired deployments do not silently * degrade into incomplete behavior.
    • @@ -114,7 +102,6 @@ public final class BcX509CredentialFramework implements CredentialFramework { private final CertificationRequestParser requestParser; private final ProofOfPossessionVerifier popVerifier; - private final CredentialIssuerBackend issuer; private final StatusObjectGenerator status; private final FrameworkAttributeMapper attributeMapper; @@ -131,17 +118,15 @@ public final class BcX509CredentialFramework implements CredentialFramework { *
    * *

    - * Certificate issuance and status-object generation are intentionally left - * unsupported in this default configuration and must be supplied explicitly by - * calling one of the - * {@link #wired(BcX509CredentialIssuerBackend, BcX509StatusObjectGenerator)} - * methods. + * Status-object generation is intentionally left unsupported in this default + * configuration and may be supplied explicitly through + * {@link #wired(BcX509StatusObjectGenerator)}. Credential issuance is not exposed by the + * framework facade. *

    */ public BcX509CredentialFramework() { this(new BcX509CertificationRequestParser(), new BcX509ProofOfPossessionVerifier(), - new UnsupportedIssuerBackend(), new UnsupportedStatusObjectGenerator(), - new BcX509FrameworkAttributeMapper()); + new UnsupportedStatusObjectGenerator(), new BcX509FrameworkAttributeMapper()); } /** @@ -149,53 +134,43 @@ public final class BcX509CredentialFramework implements CredentialFramework { * *

    * This constructor is private because external callers are expected to use the - * public default constructor and then derive a fully wired instance through the - * provided {@code wired(...)} methods. This preserves a simple public - * construction model while keeping the full component graph explicit inside the - * implementation. + * public default constructor and then derive a status-wired instance through + * the provided {@code wired(...)} methods. *

    * * @param requestParser certification request parser; must not be {@code null} * @param popVerifier proof-of-possession verifier; must not be {@code null} - * @param issuer credential issuance backend; must not be {@code null} * @param status status-object generator; must not be {@code null} * @param attributeMapper framework-specific attribute mapper; must not be * {@code null} * @throws NullPointerException if any component argument is {@code null} */ private BcX509CredentialFramework(CertificationRequestParser requestParser, ProofOfPossessionVerifier popVerifier, - CredentialIssuerBackend issuer, StatusObjectGenerator status, FrameworkAttributeMapper attributeMapper) { + StatusObjectGenerator status, FrameworkAttributeMapper attributeMapper) { this.requestParser = Objects.requireNonNull(requestParser, "requestParser"); this.popVerifier = Objects.requireNonNull(popVerifier, "popVerifier"); - this.issuer = Objects.requireNonNull(issuer, "issuer"); this.status = Objects.requireNonNull(status, "status"); this.attributeMapper = Objects.requireNonNull(attributeMapper, "attributeMapper"); } /** * Creates a fully wired X.509 framework instance using the current parser, - * proof-of-possession verifier, and attribute mapper together with the supplied - * issuance and status-object generation backends. + * proof-of-possession verifier and attribute mapper together with the supplied + * status-object generator. * *

    * The current instance is not modified. A new immutable framework instance is * returned. *

    * - * @param issuerBackend concrete X.509 issuance backend; must not be - * {@code null} * @param statusObjectGenerator concrete X.509 status-object generator; must not * be {@code null} * @return new fully wired framework instance - * @throws NullPointerException if {@code issuerBackend} or - * {@code statusObjectGenerator} is {@code null} + * @throws NullPointerException if {@code statusObjectGenerator} is {@code null} */ - public BcX509CredentialFramework wired(BcX509CredentialIssuerBackend issuerBackend, - BcX509StatusObjectGenerator statusObjectGenerator) { - Objects.requireNonNull(issuerBackend, "issuerBackend"); + public BcX509CredentialFramework wired(BcX509StatusObjectGenerator statusObjectGenerator) { Objects.requireNonNull(statusObjectGenerator, "statusObjectGenerator"); - return new BcX509CredentialFramework(requestParser, popVerifier, issuerBackend, statusObjectGenerator, - attributeMapper); + return new BcX509CredentialFramework(requestParser, popVerifier, statusObjectGenerator, attributeMapper); } /** @@ -213,8 +188,6 @@ public final class BcX509CredentialFramework implements CredentialFramework { * returned. *

    * - * @param issuerBackend concrete X.509 issuance backend; must not be - * {@code null} * @param statusObjectGenerator concrete X.509 status-object generator; must * not be {@code null} * @param proofOfPossessionVerifier explicit proof-of-possession verifier to use @@ -224,13 +197,12 @@ public final class BcX509CredentialFramework implements CredentialFramework { * possession verifier * @throws NullPointerException if any argument is {@code null} */ - public BcX509CredentialFramework wired(BcX509CredentialIssuerBackend issuerBackend, - BcX509StatusObjectGenerator statusObjectGenerator, ProofOfPossessionVerifier proofOfPossessionVerifier) { - Objects.requireNonNull(issuerBackend, "issuerBackend"); + public BcX509CredentialFramework wired(BcX509StatusObjectGenerator statusObjectGenerator, + ProofOfPossessionVerifier proofOfPossessionVerifier) { Objects.requireNonNull(statusObjectGenerator, "statusObjectGenerator"); Objects.requireNonNull(proofOfPossessionVerifier, "proofOfPossessionVerifier"); - return new BcX509CredentialFramework(requestParser, proofOfPossessionVerifier, issuerBackend, - statusObjectGenerator, attributeMapper); + return new BcX509CredentialFramework(requestParser, proofOfPossessionVerifier, statusObjectGenerator, + attributeMapper); } /** @@ -263,21 +235,6 @@ public final class BcX509CredentialFramework implements CredentialFramework { return popVerifier; } - /** - * Returns the credential issuance backend used by this framework instance. - * - *

    - * For a default partially wired instance, this may be an internal placeholder - * backend that fails explicitly with {@link UnsupportedOperationException}. - *

    - * - * @return credential issuance backend, never {@code null} - */ - @Override - public CredentialIssuerBackend issuerBackend() { - return issuer; - } - /** * Returns the status-object generator used by this framework instance. * @@ -304,46 +261,6 @@ public final class BcX509CredentialFramework implements CredentialFramework { return attributeMapper; } - /** - * Placeholder issuance backend used by partially wired framework instances. - * - *

    - * This backend exists to make unsupported wiring explicit. Any attempt to use - * issuance operations before a real X.509 issuance backend has been supplied is - * rejected immediately. - *

    - */ - private static final class UnsupportedIssuerBackend implements CredentialIssuerBackend { - - /** - * Always rejects end-entity issuance because no concrete X.509 issuance backend - * has been wired. - * - * @param command ignored command parameter - * @return never returns normally - * @throws UnsupportedOperationException always - */ - @Override - public zeroecho.pki.api.credential.CredentialBundle issueEndEntity( - zeroecho.pki.api.issuance.IssueEndEntityCommand command) { - throw new UnsupportedOperationException("X.509 issuance backend not wired"); - } - - /** - * Always rejects intermediate certificate issuance because no concrete X.509 - * issuance backend has been wired. - * - * @param command ignored command parameter - * @return never returns normally - * @throws UnsupportedOperationException always - */ - @Override - public zeroecho.pki.api.credential.Credential issueIntermediateCertificate( - zeroecho.pki.api.ca.IntermediateCertIssueCommand command) { - throw new UnsupportedOperationException("X.509 issuance backend not wired"); - } - } - /** * Placeholder status-object generator used by partially wired framework * instances. diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialIssuerBackend.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialIssuerBackend.java index e199330..f8f78ba 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialIssuerBackend.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialIssuerBackend.java @@ -61,11 +61,11 @@ import zeroecho.pki.api.Validity; import zeroecho.pki.api.attr.AttributeId; import zeroecho.pki.api.attr.AttributeSet; import zeroecho.pki.api.attr.AttributeValue; -import zeroecho.pki.api.ca.IntermediateCertIssueCommand; import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.CredentialBundle; import zeroecho.pki.api.credential.CredentialStatus; -import zeroecho.pki.api.issuance.IssueEndEntityCommand; +import zeroecho.pki.impl.core.ManagedCaIssuance; +import zeroecho.pki.impl.core.VerifiedIssuanceCandidate; import zeroecho.pki.impl.core.async.PkiSigningBus; import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.spi.framework.CredentialIssuerBackend; @@ -88,17 +88,15 @@ import zeroecho.pki.spi.framework.CredentialIssuerBackend; *
      *
    • {@link BcX509Attributes#ISSUER_CERT_DER},
    • *
    • {@link BcX509Attributes#ISSUER_KEYREF},
    • - *
    • optionally {@link BcX509Attributes#SERIAL},
    • - *
    • optionally {@link BcX509Attributes#SUBJECT_SPKI_DER} for intermediate - * issuance.
    • + *
    • optionally {@link BcX509Attributes#SERIAL}.
    • *
    * *

    * End-entity issuance primarily derives the subject distinguished name and - * subject public key information from the parsed certification request - * contained in {@link IssueEndEntityCommand}. Intermediate CA issuance relies - * more heavily on framework attributes for subject wiring because it operates - * on an existing CA subject entity rather than a CSR-centric flow. + * subject public key information from the proof-gated + * {@link VerifiedIssuanceCandidate}. Intermediate CA issuance relies on its + * proof-gated managed-CA input and framework attributes because it operates on + * an existing CA subject entity rather than a CSR-centric flow. *

    * *

    Signing model

    @@ -124,10 +122,10 @@ import zeroecho.pki.spi.framework.CredentialIssuerBackend; * supplied {@link PkiSigningBus} is safe for the intended runtime model. *

    */ +// PMD cannot infer that retaining backend causes would violate the redaction contract. +@SuppressWarnings("PMD.PreserveStackTrace") public final class BcX509CredentialIssuerBackend implements CredentialIssuerBackend { - private static final AttributeId SUBJECT_DN = new AttributeId("urn:zeroecho:pki:x509:subject:dn"); - private final PkiSigningBus signingBus; private final String signatureAlgorithmId; private final Duration signingTtl; @@ -165,13 +163,15 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack * *

    * The method derives issuer wiring from the supplied overrides, uses the parsed - * request to obtain the subject distinguished name and public key, constructs a + * verified candidate to obtain the subject distinguished name and public key, + * constructs a * leaf certificate with basic end-entity extensions, delegates signing through * {@link PkiSigningBus}, and returns the resulting leaf credential bundled with * the issuer certificate. *

    * - * @param command end-entity issuance command; must not be {@code null} + * @param candidate gate-produced verified issuance candidate; must not be + * {@code null} * @return issued X.509 credential bundle containing the leaf certificate and * the issuer certificate as accompanying bundle material * @throws IllegalArgumentException if {@code command} is {@code null} @@ -180,24 +180,25 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack * fails, or certificate encoding fails */ @Override - public CredentialBundle issueEndEntity(IssueEndEntityCommand command) { - if (command == null) { - throw new IllegalArgumentException("command must not be null"); + public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { + if (candidate == null) { + throw new IllegalArgumentException("candidate must not be null"); } - IssuanceContext ctx = IssuanceContext.from(command.overrides()); + IssuanceContext ctx = IssuanceContext.from(candidate.overrides()); X509CertificateHolder issuer = ctx.issuerCertHolder; + zeroecho.pki.api.request.ParsedCertificationRequest request = candidate.request(); Instant now = Instant.now(); - Validity validity = command.validityOverride().orElseGet( - () -> command.request().requestedValidity().orElse(new Validity(now, now.plus(Duration.ofDays(365))))); + Validity validity = candidate.validityOverride().orElseGet( + () -> request.requestedValidity().orElse(new Validity(now, now.plus(Duration.ofDays(365))))); BigInteger serial = ctx.serial - .orElse(BigInteger.valueOf(Math.abs(command.request().requestId().value().hashCode()) + 1L)); + .orElse(BigInteger.valueOf(Math.abs(request.requestId().value().hashCode()) + 1L)); X500Name issuerDn = issuer.getSubject(); - X500Name subjectDn = new X500Name(command.request().subjectRef().value()); - SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(command.request().publicKeyInfo().bytes()); + X500Name subjectDn = new X500Name(request.subjectRef().value()); + SubjectPublicKeyInfo spki = parseSubjectPublicKeyInfo(candidate.exactPublicKey()); X509v3CertificateBuilder builder = new X509v3CertificateBuilder(issuerDn, serial, Date.from(validity.notBefore()), Date.from(validity.notAfter()), subjectDn, spki); @@ -206,7 +207,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack builder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment)); } catch (Exception ex) { - throw new PkiException("Failed to build X.509 extensions", ex); + throw new PkiException("X.509 extension construction failed: code=EXTENSION_BUILD_FAILED"); } ContentSigner signer = new PkiBusContentSigner(signingBus, ctx.issuerKeyRef, signatureAlgorithmId, signingTtl); @@ -214,27 +215,31 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack try { leaf = builder.build(signer); } catch (Exception ex) { - throw new PkiException("Certificate signing failed", 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", ex); + throw new PkiException("Certificate encoding failed: code=CERTIFICATE_ENCODE_FAILED"); } PkiId credId = new PkiId("x509:" + sha256Hex(certDer)); - PkiId publicKeyId = new PkiId("spki:" + sha256Hex(command.request().publicKeyInfo().bytes())); + PkiId publicKeyId = new PkiId("spki:" + fingerprintEncoded(candidate.exactPublicKey())); - AttributeSet attributes = mergeAttributes(command.request().attributes(), command.overrides()); + AttributeSet attributes = mergeAttributes(request.attributes(), candidate.overrides()); - Credential credential = new Credential(credId, BcX509CredentialFramework.FORMAT_ID, - new IssuerRef(command.issuerCaId()), command.request().subjectRef(), validity, serial.toString(), - publicKeyId, command.profileId(), CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, certDer), - attributes); - - return new CredentialBundle(credential, java.util.List.of(ctx.issuerCertEncoded)); + try { + Credential credential = new Credential(credId, BcX509CredentialFramework.FORMAT_ID, + new IssuerRef(candidate.issuerCaId()), request.subjectRef(), validity, serial.toString(), + publicKeyId, candidate.profileId(), CredentialStatus.ISSUED, + new EncodedObject(Encoding.DER, certDer), + attributes); + return new CredentialBundle(credential, java.util.List.of(ctx.issuerCertEncoded)); + } finally { + java.util.Arrays.fill(certDer, (byte) 0); + } } /** @@ -247,8 +252,8 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack * credential. *

    * - * @param command intermediate CA certificate issuance command; must not be - * {@code null} + * @param issuance gate-produced managed CA issuance authority; must not be + * {@code null} * @return issued intermediate CA credential * @throws IllegalArgumentException if {@code command} is {@code null} or uses * an unsupported format identifier @@ -258,34 +263,40 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack * encoding fails */ @Override - public Credential issueIntermediateCertificate(IntermediateCertIssueCommand command) { - if (command == null) { - throw new IllegalArgumentException("command must not be null"); + public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { + if (issuance == null) { + throw new IllegalArgumentException("issuance must not be null"); } - if (!BcX509CredentialFramework.FORMAT_ID.equals(command.formatId())) { + if (!BcX509CredentialFramework.FORMAT_ID.equals(issuance.formatId())) { throw new IllegalArgumentException("Unsupported formatId"); } - IssuanceContext ctx = IssuanceContext.from(command.attributes()); + IssuanceContext ctx = IssuanceContext.from(issuance.attributes()); X509CertificateHolder issuer = ctx.issuerCertHolder; - byte[] subjectSpki = ctx.subjectSpkiDer.orElseThrow(() -> new PkiException("Missing subject SPKI")); - SubjectRef subjectRef = ctx.subjectRef.orElseThrow(() -> new PkiException("Missing subjectRef")); + byte[] subjectSpki = issuance.exactPublicKey().bytes(); + SubjectPublicKeyInfo spki; + PkiId publicKeyId; + try { + spki = SubjectPublicKeyInfo.getInstance(subjectSpki); + publicKeyId = new PkiId("spki:" + sha256Hex(subjectSpki)); + } finally { + java.util.Arrays.fill(subjectSpki, (byte) 0); + } + SubjectRef subjectRef = issuance.subjectRef(); Instant now = Instant.now(); - Validity validity = command.requestedValidity().orElse(new Validity(now, now.plus(Duration.ofDays(3650)))); + Validity validity = issuance.requestedValidity().orElse(new Validity(now, now.plus(Duration.ofDays(3650)))); BigInteger serial = ctx.serial.orElse(BigInteger.valueOf(Math.abs(System.nanoTime()))); X500Name issuerDn = issuer.getSubject(); X500Name subjectDn = new X500Name(subjectRef.value()); - SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(subjectSpki); - X509v3CertificateBuilder builder = new X509v3CertificateBuilder(issuerDn, serial, Date.from(validity.notBefore()), Date.from(validity.notAfter()), subjectDn, spki); try { builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(0)); builder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign)); } catch (Exception ex) { - throw new PkiException("Failed to build X.509 extensions", ex); + throw new PkiException("X.509 extension construction failed: code=EXTENSION_BUILD_FAILED"); } ContentSigner signer = new PkiBusContentSigner(signingBus, ctx.issuerKeyRef, signatureAlgorithmId, signingTtl); @@ -293,22 +304,25 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack try { certificate = builder.build(signer); } catch (Exception ex) { - throw new PkiException("Certificate signing failed", 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", ex); + throw new PkiException("Certificate encoding failed: code=CERTIFICATE_ENCODE_FAILED"); } PkiId credId = new PkiId("x509:" + sha256Hex(certDer)); - PkiId publicKeyId = new PkiId("spki:" + sha256Hex(subjectSpki)); - return new Credential(credId, command.formatId(), new IssuerRef(command.issuerCaId()), subjectRef, validity, - serial.toString(), publicKeyId, command.profileId(), CredentialStatus.ISSUED, - new EncodedObject(Encoding.DER, certDer), command.attributes()); + try { + return new Credential(credId, issuance.formatId(), new IssuerRef(issuance.issuerCaId()), subjectRef, + validity, serial.toString(), publicKeyId, issuance.profileId(), CredentialStatus.ISSUED, + new EncodedObject(Encoding.DER, certDer), issuance.attributes()); + } finally { + java.util.Arrays.fill(certDer, (byte) 0); + } } /** @@ -328,8 +342,6 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack private final EncodedObject issuerCertEncoded; private final KeyRef issuerKeyRef; private final Optional serial; - private final Optional subjectSpkiDer; - private final Optional subjectRef; /** * Creates the issuance context. @@ -341,20 +353,13 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack * @param issuerKeyRef issuer signing key reference; must not be * {@code null} * @param serial optional serial override; must not be {@code null} - * @param subjectSpkiDer optional subject SPKI override; must not be - * {@code null} - * @param subjectRef optional subject distinguished name override; must - * not be {@code null} */ private IssuanceContext(X509CertificateHolder issuerCertHolder, EncodedObject issuerCertEncoded, - KeyRef issuerKeyRef, Optional serial, Optional subjectSpkiDer, - Optional subjectRef) { + KeyRef issuerKeyRef, Optional serial) { this.issuerCertHolder = issuerCertHolder; this.issuerCertEncoded = issuerCertEncoded; this.issuerKeyRef = issuerKeyRef; this.serial = serial; - this.subjectSpkiDer = subjectSpkiDer; - this.subjectRef = subjectRef; } /** @@ -376,10 +381,6 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack *
      *
    • {@link BcX509Attributes#SERIAL} as * {@link AttributeValue.IntegerValue}
    • - *
    • {@link BcX509Attributes#SUBJECT_SPKI_DER} as - * {@link AttributeValue.BytesValue}
    • - *
    • {@code urn:zeroecho:pki:x509:subject:dn} as - * {@link AttributeValue.StringValue}
    • *
    * * @param attrs source attribute set; must not be {@code null} @@ -401,15 +402,11 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack Optional serial = optionalInteger(attrs, BcX509Attributes.SERIAL, "Serial must be IntegerValue") .map(BigInteger::valueOf); - Optional subjectSpkiDer = optionalBytes(attrs, BcX509Attributes.SUBJECT_SPKI_DER, - "Subject SPKI must be BytesValue"); - Optional subjectRef = optionalString(attrs, SUBJECT_DN, "Subject DN must be StringValue") - .map(SubjectRef::new); X509CertificateHolder issuerHolder = parseIssuerCertificateOrThrow(issuerCertDer); EncodedObject issuerEncoded = new EncodedObject(Encoding.DER, issuerCertDer); - return new IssuanceContext(issuerHolder, issuerEncoded, issuerKeyRef, serial, subjectSpkiDer, subjectRef); + return new IssuanceContext(issuerHolder, issuerEncoded, issuerKeyRef, serial); } /** @@ -476,50 +473,6 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack return Optional.of(((AttributeValue.IntegerValue) value.get()).value()); } - /** - * Resolves an optional bytes-valued attribute. - * - * @param attrs source attributes; must not be {@code null} - * @param id attribute identifier; must not be {@code null} - * @param typeMessage exception message used when the attribute has an - * unexpected type - * @return optional bytes value - * @throws PkiException if the attribute is present but has an unexpected value - * type - */ - private static Optional optionalBytes(AttributeSet attrs, AttributeId id, String typeMessage) { - Optional value = attrs.get(id); - if (value.isEmpty()) { - return Optional.empty(); - } - if (!(value.get() instanceof AttributeValue.BytesValue)) { - throw new PkiException(typeMessage); - } - return Optional.of(((AttributeValue.BytesValue) value.get()).value()); - } - - /** - * Resolves an optional string-valued attribute. - * - * @param attrs source attributes; must not be {@code null} - * @param id attribute identifier; must not be {@code null} - * @param typeMessage exception message used when the attribute has an - * unexpected type - * @return optional string value - * @throws PkiException if the attribute is present but has an unexpected value - * type - */ - private static Optional optionalString(AttributeSet attrs, AttributeId id, String typeMessage) { - Optional value = attrs.get(id); - if (value.isEmpty()) { - return Optional.empty(); - } - if (!(value.get() instanceof AttributeValue.StringValue)) { - throw new PkiException(typeMessage); - } - return Optional.of(((AttributeValue.StringValue) value.get()).value()); - } - /** * Parses the issuer certificate DER into an X.509 certificate holder. * @@ -531,7 +484,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack try { return new X509CertificateHolder(issuerCertDer); } catch (Exception ex) { - throw new PkiException("Invalid issuer certificate DER", ex); + throw new PkiException("Invalid issuer certificate: code=ISSUER_CERTIFICATE_INVALID"); } } } @@ -568,9 +521,32 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack private static String sha256Hex(byte[] in) { try { MessageDigest md = MessageDigest.getInstance("SHA-256"); - return HexFormat.of().formatHex(md.digest(in)); + byte[] digest = md.digest(in); + try { + return HexFormat.of().formatHex(digest); + } finally { + java.util.Arrays.fill(digest, (byte) 0); + } } catch (Exception ex) { - throw new IllegalStateException("SHA-256 not available", ex); + throw new IllegalStateException("SHA-256 unavailable: code=DIGEST_UNAVAILABLE"); + } + } + + private static SubjectPublicKeyInfo parseSubjectPublicKeyInfo(EncodedObject encoded) { + byte[] bytes = encoded.bytes(); + try { + return SubjectPublicKeyInfo.getInstance(bytes); + } finally { + java.util.Arrays.fill(bytes, (byte) 0); + } + } + + private static String fingerprintEncoded(EncodedObject encoded) { + byte[] bytes = encoded.bytes(); + try { + return sha256Hex(bytes); + } finally { + java.util.Arrays.fill(bytes, (byte) 0); } } } diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509ProofOfPossessionVerifier.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509ProofOfPossessionVerifier.java index 624439c..efa839c 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509ProofOfPossessionVerifier.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509ProofOfPossessionVerifier.java @@ -38,6 +38,7 @@ import java.util.Optional; import org.bouncycastle.operator.ContentVerifierProvider; import org.bouncycastle.operator.OperatorCreationException; import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.pkcs.PKCS10CertificationRequest; import zeroecho.pki.api.attr.AttributeValue; @@ -93,6 +94,9 @@ import zeroecho.pki.spi.framework.ProofOfPossessionVerifier; * authorization checks, or profile compliance checks. *
  • The presence of a valid PKCS#10 self-signature does not by itself imply * that the requester is entitled to receive the requested certificate.
  • + *
  • This verifier accepts signature algorithms supported by its Bouncy Castle + * provider, including RSASSA-PSS. Deployments requiring a narrower or stronger + * algorithm policy must enforce it in the issuance policy layer.
  • *
  • The CSR payload carried under {@link BcX509Attributes#CSR_DER} may be * operationally sensitive and must not be logged unsafely.
  • * @@ -104,6 +108,8 @@ import zeroecho.pki.spi.framework.ProofOfPossessionVerifier; */ public final class BcX509ProofOfPossessionVerifier implements ProofOfPossessionVerifier { + private static final BouncyCastleProvider BC_PROVIDER = new BouncyCastleProvider(); + /** * Verifies proof of possession for a parsed PKCS#10 certification request. * @@ -177,7 +183,8 @@ public final class BcX509ProofOfPossessionVerifier implements ProofOfPossessionV } catch (Exception ex) { return new ProofOfPossessionResult(ProofOfPossessionStatus.FAILED, Optional.of("Invalid CSR")); } - ContentVerifierProvider cvp = new JcaContentVerifierProviderBuilder().build(csr.getSubjectPublicKeyInfo()); + ContentVerifierProvider cvp = new JcaContentVerifierProviderBuilder().setProvider(BC_PROVIDER) + .build(csr.getSubjectPublicKeyInfo()); boolean ok = csr.isSignatureValid(cvp); if (ok) { return new ProofOfPossessionResult(ProofOfPossessionStatus.VERIFIED, Optional.empty()); diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSigner.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSigner.java index 89da479..c2f22b7 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSigner.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSigner.java @@ -37,6 +37,7 @@ import java.io.ByteArrayOutputStream; import java.io.OutputStream; import java.time.Duration; import java.time.Instant; +import java.util.Arrays; import java.util.Optional; import org.bouncycastle.asn1.x509.AlgorithmIdentifier; @@ -86,9 +87,9 @@ import zeroecho.pki.util.async.AsyncState; *
  • The signer polls the bus by repeatedly calling * {@link PkiSigningBus#sweep(Instant)} and {@link PkiSigningBus#status(PkiId)} * until the workflow succeeds, fails, or times out.
  • - *
  • On successful completion or explicit failure, persisted workflow state is - * deleted through {@link PkiSigningBus#deleteWorkflowState(PkiId)} before the - * method returns or throws.
  • + *
  • Every submitted operation is retired through + * {@link PkiSigningBus#retireSignOperation(PkiId, String)} before the method + * returns or throws.
  • * * *

    Security considerations

    @@ -108,6 +109,8 @@ import zeroecho.pki.util.async.AsyncState; * intended for one certificate or CRL signing flow. *

    */ +// PMD cannot infer that retaining provider causes would violate the redaction contract. +@SuppressWarnings("PMD.PreserveStackTrace") public final class PkiBusContentSigner implements ContentSigner { private final PkiSigningBus bus; @@ -115,7 +118,7 @@ public final class PkiBusContentSigner implements ContentSigner { private final String algorithmId; private final Duration ttl; - private final ByteArrayOutputStream baos; + private final WipeableByteArrayOutputStream baos; /** * Creates a signer that routes signature generation through the PKI signing @@ -149,7 +152,7 @@ public final class PkiBusContentSigner implements ContentSigner { this.keyRef = keyRef; this.algorithmId = algorithmId; this.ttl = ttl; - this.baos = new ByteArrayOutputStream(); + this.baos = new WipeableByteArrayOutputStream(); } /** @@ -203,8 +206,10 @@ public final class PkiBusContentSigner implements ContentSigner { *

    * On successful completion, the signature bytes contained in the workflow * result are returned. If the workflow reports success but no result is - * available, the method fails explicitly. On terminal success or failure, the - * persisted workflow state is deleted before the method returns or throws. + * available, the method fails explicitly. Every operation for which submission + * is attempted is retired before the method returns or throws. Cleanup failures + * are suppressed on the primary signing failure and otherwise become the + * returned failure. *

    * *

    @@ -219,38 +224,105 @@ public final class PkiBusContentSigner implements ContentSigner { * complete within the configured TTL */ @Override + @SuppressWarnings("PMD.AvoidCatchingGenericException") public byte[] getSignature() { byte[] tbs = baos.toByteArray(); - EncodedObject payload = new EncodedObject(Encoding.BINARY, tbs); + byte[] consumedResult = null; + PkiId opId = null; + boolean retirementRequired = false; + Throwable primaryFailure = null; + try { + EncodedObject payload = new EncodedObject(Encoding.BINARY, tbs); + Principal owner = new Principal("SYSTEM", "pki"); + opId = bus.newSubmissionId(); - Principal owner = new Principal("SYSTEM", "pki"); - PkiId clientOpId = new PkiId("sign:" + Integer.toUnsignedString(System.identityHashCode(this)) + ":" - + Long.toUnsignedString(System.nanoTime())); - PkiId opId = bus.canonicalizeOperationId(clientOpId, owner); + AccessContext ac = new AccessContext(owner, new Purpose("X509_SIGN"), Optional.empty(), + Optional.empty()); + SignContinuation cont = new SignContinuation(ac, algorithmId, payload, keyRef, Encoding.BINARY, + Optional.empty()); - AccessContext ac = new AccessContext(owner, new Purpose("X509_SIGN"), Optional.empty(), Optional.empty()); - SignContinuation cont = new SignContinuation(ac, algorithmId, payload, keyRef, Encoding.BINARY, - Optional.empty()); - - bus.submitSign(opId, owner, keyRef, algorithmId, payload, ttl, Optional.of(cont.encode())); + retirementRequired = true; + bus.submitSign(opId, owner, keyRef, algorithmId, payload, ttl, Optional.of(cont.encode())); + consumedResult = awaitSignature(opId); + return consumedResult.clone(); + } catch (RuntimeException failure) { + PkiException sanitized = new PkiException("Signing workflow failed: code=SIGNING_WORKFLOW_FAILED"); + primaryFailure = sanitized; + throw sanitized; + } catch (Error failure) { + primaryFailure = failure; + throw failure; + } finally { + try { + if (retirementRequired) { + retirePreservingFailure(opId, primaryFailure); + } + } finally { + Arrays.fill(tbs, (byte) 0); + baos.wipe(); + if (consumedResult != null) { + Arrays.fill(consumedResult, (byte) 0); + } + } + } + } + private byte[] awaitSignature(PkiId opId) { Instant deadline = Instant.now().plus(ttl); while (Instant.now().isBefore(deadline)) { bus.sweep(Instant.now()); - Optional st = bus.status(opId); - if (st.isPresent() && st.get().state() == AsyncState.SUCCEEDED) { - Optional res = bus.consumeResult(opId); - bus.deleteWorkflowState(opId); - if (res.isEmpty()) { + Optional status = bus.status(opId); + if (status.isPresent() && status.get().state() == AsyncState.SUCCEEDED) { + Optional result = bus.consumeResult(opId); + if (result.isEmpty()) { throw new PkiException("Missing signature result"); } - return res.get().bytes(); + return result.get().bytes(); } - if (st.isPresent() && st.get().state() == AsyncState.FAILED) { - bus.deleteWorkflowState(opId); - throw new PkiException("Signing failed"); + if (status.isPresent() && isTerminalFailure(status.get().state())) { + throw new PkiException("Signing failed: " + status.get().state()); } + boundedWait(deadline); } throw new PkiException("Signing did not complete before TTL"); } + + private static boolean isTerminalFailure(AsyncState state) { + return state == AsyncState.FAILED || state == AsyncState.CANCELLED || state == AsyncState.EXPIRED; + } + + @SuppressWarnings("PMD.DoNotUseThreads") + private static void boundedWait(Instant deadline) { + long remainingMillis = Duration.between(Instant.now(), deadline).toMillis(); + long waitMillis = Math.min(10L, Math.max(1L, remainingMillis)); + try { + Thread.sleep(waitMillis); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new PkiException("Interrupted while awaiting signing", ex); + } + } + + @SuppressWarnings("PMD.AvoidCatchingGenericException") + private void retirePreservingFailure(PkiId opId, Throwable primaryFailure) { + try { + bus.retireSignOperation(opId, primaryFailure == null ? "completed" : "failed-or-expired"); + } catch (RuntimeException cleanupFailure) { + if (primaryFailure == null) { + throw new PkiException("Signing cleanup failed: code=SIGNING_CLEANUP_FAILED"); + } + } + } + + /** + * Byte-array output stream whose retained backing storage can be overwritten + * after one signing attempt. + */ + private static final class WipeableByteArrayOutputStream extends ByteArrayOutputStream { + + private void wipe() { + Arrays.fill(buf, (byte) 0); + reset(); + } + } } diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java index 877bb19..2798bde 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java @@ -35,25 +35,42 @@ package zeroecho.pki.impl.fs; import java.io.Closeable; import java.io.IOException; +import java.nio.ByteBuffer; import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.channels.OverlappingFileLockException; +import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; +import java.util.Arrays; import java.util.Comparator; +import java.util.HexFormat; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.ReentrantLock; import java.util.logging.Level; import java.util.logging.Logger; +import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.PkiId; import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.orch.SigningSubmissionId; import zeroecho.pki.api.orch.WorkflowStateRecord; import zeroecho.pki.api.policy.PolicyTrace; import zeroecho.pki.api.profile.CertificateProfile; @@ -61,7 +78,9 @@ import zeroecho.pki.api.publication.PublicationRecord; import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.revocation.RevokedRecord; import zeroecho.pki.api.status.StatusObject; +import zeroecho.pki.impl.core.async.PkiSigningBus; import zeroecho.pki.spi.store.PkiStore; +import zeroecho.pki.spi.store.SignWorkflowStore; /** * Filesystem-based reference implementation of {@link PkiStore}. @@ -111,17 +130,31 @@ import zeroecho.pki.spi.store.PkiStore; * are limited to object type, safe IDs, and file operation outcomes. *

    */ +@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity", "PMD.TooManyMethods" }) public final class FilesystemPkiStore implements PkiStore, Closeable { private static final Logger LOG = Logger.getLogger(FilesystemPkiStore.class.getName()); private static final String VERSION_V1 = "v1"; + private static final int SIGN_RECORD_MAGIC = 0x5A455352; + private static final int SIGN_RECORD_VERSION = 1; + private static final int SIGN_RECORD_HEADER_BYTES = Integer.BYTES * 2; + private static final String SIGN_FINGERPRINT_PREFIX = "signfp:v1:"; + private static final int SIGN_FINGERPRINT_HEX_LENGTH = 64; + private static final long INITIAL_FENCE = 0L; + private static final String STORE_OWNERSHIP_UNAVAILABLE = + "Filesystem store ownership unavailable: code=STORE_ALREADY_OPEN"; private final FsPkiStoreOptions options; private final FsPaths paths; private final AtomicLong historySeq; + private final Clock clock; + private final String signingNamespace; + private final AtomicLong signingTimeWatermark; + private final ReentrantLock signingTimeLock; + private final ConcurrentMap signLocks; - private final FileChannel lockChannel; + private final StoreOwnership ownership; /** * Opens or creates a filesystem PKI store rooted at {@code root}. @@ -132,25 +165,55 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { * @throws IllegalStateException if the store cannot be opened or locked */ public FilesystemPkiStore(final Path root, final FsPkiStoreOptions options) { + this(root, options, Clock.systemUTC()); + } + + /** + * Opens or creates a filesystem store with an explicit authoritative clock. + * + * @param root store root directory + * @param options store options + * @param clock authoritative signing workflow clock + * @throws IllegalArgumentException if an argument is {@code null} + * @throws IllegalStateException if the store cannot be opened or is already + * owned by another store instance or process + */ + // StoreOwnership transfers to this store on success and remains open until close(); + // try-with-resources here would release process ownership at constructor return. + @SuppressWarnings("PMD.CloseResource") + public FilesystemPkiStore(final Path root, final FsPkiStoreOptions options, final Clock clock) { this.options = Objects.requireNonNull(options, "options"); Objects.requireNonNull(root, "root"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.signLocks = new ConcurrentHashMap<>(); + this.signingTimeLock = new ReentrantLock(); + this.paths = new FsPaths(root); + StoreOwnership acquiredOwnership; try { FsOperations.ensureDir(root); - this.paths = new FsPaths(root); FsOperations.ensureDir(this.paths.lockFile().getParent()); + acquiredOwnership = StoreOwnership.acquire(this.paths.lockFile()); + } catch (IOException e) { + throw new IllegalStateException("failed to open filesystem store at " + root, e); + } - this.lockChannel = FileChannel.open(this.paths.lockFile(), StandardOpenOption.CREATE, - StandardOpenOption.WRITE); - lockChannel.lock(); // exclusive lock - + boolean ownershipTransferred = false; + try { ensureVersionFile(); + this.signingNamespace = ensureSigningNamespace(); + this.signingTimeWatermark = new AtomicLong(loadSigningTimeWatermark()); this.historySeq = new AtomicLong(0L); LOG.log(Level.INFO, "running in {0}", root); - + this.ownership = acquiredOwnership; + ownershipTransferred = true; } catch (IOException e) { throw new IllegalStateException("failed to open filesystem store at " + root, e); + } finally { + if (!ownershipTransferred) { + acquiredOwnership.closeSilently(); + } } } @@ -161,7 +224,11 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { *

    * This method is an implementation-only feature. It does not modify the current * store; it clones a new store layout and reconstructs {@code current.bin} for - * history-tracked entities. + * history-tracked entities. The signing namespace, monotonic signing-time + * watermark, and authoritative signing workflow records are safety metadata: + * their current export-time values are copied regardless of {@code at}, because + * historical reconstruction could permit identifier reuse or lose a terminal + * result. *

    * * @param targetRoot new store root to create/populate @@ -369,11 +436,709 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { } @Override - public void close() throws IOException { - synchronized (this.lockChannel) { // NOPMD - if (this.lockChannel.isOpen()) { - this.lockChannel.close(); + public Instant signingNow() { + signingTimeLock.lock(); + try { + long monotonic = Math.max(signingTimeWatermark.get(), clock.instant().toEpochMilli()); + persistSigningTimeWatermark(monotonic); + signingTimeWatermark.set(monotonic); + return Instant.ofEpochMilli(monotonic); + } finally { + signingTimeLock.unlock(); + } + } + + @Override + public String signingNamespace() { + return signingNamespace; + } + + @Override + public Duration signingHorizon() { + return options.signingOperationHorizon(); + } + + @Override + public Duration signingPermittedSkew() { + return options.signingIdPermittedSkew(); + } + + @Override + public SignWorkflowStore.CreateResult createSignIntent(SignWorkflowStore.Record intent) { + Objects.requireNonNull(intent, "intent"); + SignLockEntry lock = acquireSignLock(intent.submissionId()); + try { + SigningSubmissionId parsed = SigningSubmissionId.parse(intent.submissionId()); + if (!intent.namespace().startsWith(signingNamespace + ".")) { + throw new IllegalArgumentException("Signing intent belongs to a different store namespace"); } + parsed.validate(intent.namespace(), signingNow(), options.signingOperationHorizon(), + options.signingIdPermittedSkew()); + if (!parsed.createdAt().equals(intent.createdAt()) || intent.deadline().isBefore(intent.createdAt()) + || intent.deadline().isAfter(parsed.createdAt().plus(options.signingOperationHorizon()))) { + throw new IllegalArgumentException("Signing intent timestamps do not match its stable identifier"); + } + if (intent.state() != SignWorkflowStore.State.INTENT || intent.revision() != 0L || intent.fence() != 0L + || intent.leaseUntil().isPresent() || intent.result().isPresent()) { + throw new IllegalArgumentException("New signing intent has invalid initial state"); + } + Optional existing = readSignRecord(intent.submissionId()); + if (existing.isPresent()) { + return existing.get().fingerprint().equals(intent.fingerprint()) + ? SignWorkflowStore.CreateResult.ATTACHED + : SignWorkflowStore.CreateResult.CONFLICT; + } + writeSignRecord(intent); + return SignWorkflowStore.CreateResult.CREATED; + } finally { + releaseSignLock(intent.submissionId(), lock); + } + } + + @Override + public Optional getSignRecord(PkiId submissionId) { + Objects.requireNonNull(submissionId, "submissionId"); + SignLockEntry lock = acquireSignLock(submissionId); + try { + return readSignRecord(submissionId); + } finally { + releaseSignLock(submissionId, lock); + } + } + + @Override + public List listSignRecords() { + Path root = paths.signWorkflowRoot(); + if (!Files.isDirectory(root)) { + return List.of(); + } + try (java.util.stream.Stream directories = Files.list(root)) { + return directories.filter(Files::isDirectory) + .map(directory -> directory.resolve(FsPaths.CURRENT_FILE)) + .filter(Files::isRegularFile) + .sorted(Comparator.comparing(Path::toString)) + .map(this::readSignRecordFile) + .peek(record -> validateSignRecord(record.submissionId(), record)) + .toList(); + } catch (IOException ex) { + throw new IllegalStateException("Failed to list authoritative signing records", ex); + } + } + + @Override + public Optional tryClaimSign(PkiId submissionId, long expectedRevision, + Duration lease) { + requirePositive(lease, "lease"); + SignLockEntry lock = acquireSignLock(submissionId); + try { + Optional optional = readSignRecord(submissionId); + if (optional.isEmpty()) { + return Optional.empty(); + } + SignWorkflowStore.Record current = optional.get(); + Instant now = signingNow(); + boolean claimable = current.state() == SignWorkflowStore.State.INTENT; + boolean leaseAvailable = current.leaseUntil().isEmpty() || !current.leaseUntil().get().isAfter(now); + if (current.revision() != expectedRevision || !claimable || !leaseAvailable) { + return Optional.empty(); + } + SignWorkflowStore.Record claimed = copySignRecord(current, current.state(), current.revision() + 1L, + current.fence() + 1L, Optional.of(now.plus(lease)), current.detailCode(), current.result(), + current.providerUpdatedAt()); + writeSignRecord(claimed); + return Optional.of(claimed); + } finally { + releaseSignLock(submissionId, lock); + } + } + + @Override + public Optional renewSignClaim(PkiId submissionId, long expectedRevision, long fence, + Duration lease) { + requirePositive(lease, "lease"); + SignLockEntry lock = acquireSignLock(submissionId); + try { + Optional optional = readSignRecord(submissionId); + if (optional.isEmpty()) { + return Optional.empty(); + } + SignWorkflowStore.Record current = optional.get(); + if (current.revision() != expectedRevision || current.fence() != fence || current.leaseUntil().isEmpty()) { + return Optional.empty(); + } + SignWorkflowStore.Record renewed = copySignRecord(current, current.state(), current.revision() + 1L, + fence, Optional.of(signingNow().plus(lease)), current.detailCode(), current.result(), + current.providerUpdatedAt()); + writeSignRecord(renewed); + return Optional.of(renewed); + } finally { + releaseSignLock(submissionId, lock); + } + } + + @Override + public Optional transitionSign(PkiId submissionId, long expectedRevision, long fence, + SignWorkflowStore.State target, Optional detailCode, Optional result, + Optional providerUpdatedAt) { + Objects.requireNonNull(target, "target"); + Objects.requireNonNull(detailCode, "detailCode"); + Objects.requireNonNull(result, "result"); + Objects.requireNonNull(providerUpdatedAt, "providerUpdatedAt"); + SignLockEntry lock = acquireSignLock(submissionId); + try { + Optional optional = readSignRecord(submissionId); + if (optional.isEmpty()) { + return Optional.empty(); + } + SignWorkflowStore.Record current = optional.get(); + if (current.revision() != expectedRevision || current.fence() != fence + || !validSignTransition(current.state(), target)) { + return Optional.empty(); + } + if (target == SignWorkflowStore.State.SUCCEEDED && result.isEmpty() + || target != SignWorkflowStore.State.SUCCEEDED && result.isPresent()) { + throw new IllegalArgumentException("Signing result does not match target state"); + } + if (target == SignWorkflowStore.State.SUCCEEDED + && (providerUpdatedAt.isEmpty() + || !providerUpdatedAt.get().isBefore(current.deadline()))) { + throw new IllegalArgumentException("Successful signing completion timestamp is not trustworthy"); + } + SignWorkflowStore.Record transitioned = copySignRecord(current, target, current.revision() + 1L, fence, + Optional.empty(), detailCode, result, providerUpdatedAt); + writeSignRecord(transitioned); + return Optional.of(transitioned); + } finally { + releaseSignLock(submissionId, lock); + } + } + + @Override + public Optional retireSign(PkiId submissionId, long expectedRevision, long fence) { + SignLockEntry lock = acquireSignLock(submissionId); + try { + Optional optional = readSignRecord(submissionId); + if (optional.isEmpty()) { + return Optional.empty(); + } + SignWorkflowStore.Record current = optional.get(); + if (current.revision() != expectedRevision || current.fence() != fence + || !isRetirableSignState(current.state())) { + return Optional.empty(); + } + SignWorkflowStore.Record retired = copySignRecord(current, SignWorkflowStore.State.RETIRED, + current.revision() + 1L, fence, Optional.empty(), Optional.of("RETIRED"), current.result(), + current.providerUpdatedAt()); + writeSignRecord(retired); + return Optional.of(retired); + } finally { + releaseSignLock(submissionId, lock); + } + } + + @Override + public int purgeExpiredSignRecords() { + Path root = paths.signWorkflowRoot(); + if (!Files.isDirectory(root)) { + return 0; + } + int purged = 0; + for (SignWorkflowStore.Record record : listSignRecords()) { + if (record.state() == SignWorkflowStore.State.RETIRED + && !record.createdAt().plus(options.signingOperationHorizon()).isAfter(signingNow())) { + SignLockEntry lock = acquireSignLock(record.submissionId()); + try { + Optional current = readSignRecord(record.submissionId()); + if (current.isPresent() && current.get().state() == SignWorkflowStore.State.RETIRED + && !current.get().createdAt().plus(options.signingOperationHorizon()) + .isAfter(signingNow()) + && Files.deleteIfExists(paths.signWorkflowPath(record.submissionId()))) { + purged++; + } + } catch (IOException ex) { + throw new IllegalStateException("failed to purge signing workflow", ex); + } finally { + releaseSignLock(record.submissionId(), lock); + } + } + } + return purged; + } + + @Override + public void close() throws IOException { + this.ownership.close(); + } + + private SignLockEntry acquireSignLock(PkiId submissionId) { + SignLockEntry entry = signLocks.compute(submissionId, (ignored, current) -> { + SignLockEntry selected = current == null ? new SignLockEntry() : current; + selected.references.incrementAndGet(); + return selected; + }); + entry.lock.lock(); + return entry; + } + + private void releaseSignLock(PkiId submissionId, SignLockEntry entry) { + entry.lock.unlock(); + signLocks.computeIfPresent(submissionId, (ignored, current) -> { + if (current != entry) { // NOPMD - identity protects a replacement lock entry + return current; + } + return current.references.decrementAndGet() == 0 ? null : current; + }); + } + + private Optional readSignRecord(PkiId submissionId) { + Path path = paths.signWorkflowPath(submissionId); + if (!Files.exists(path)) { + return Optional.empty(); + } + SignWorkflowStore.Record record = readSignRecordFile(path); + validateSignRecord(submissionId, record); + return Optional.of(record); + } + + private SignWorkflowStore.Record readSignRecordFile(Path path) { + try { + byte[] envelope = FsOperations.readAll(path); + if (envelope.length < SIGN_RECORD_HEADER_BYTES) { + throw new IllegalStateException("Invalid signing record envelope"); + } + ByteBuffer input = ByteBuffer.wrap(envelope); + if (input.getInt() != SIGN_RECORD_MAGIC) { + throw new IllegalStateException("Invalid signing record envelope"); + } + if (input.getInt() != SIGN_RECORD_VERSION) { + throw new IllegalStateException("Unsupported signing record version"); + } + byte[] payload = new byte[input.remaining()]; + input.get(payload); + return FsCodec.decode(payload, SignWorkflowStore.Record.class); + } catch (IOException ex) { + throw new IllegalStateException("Failed to read authoritative signing record", ex); + } + } + + // Parsing and canonicalization failures can carry persisted request material; + // validation deliberately replaces every such cause with a safe corruption code. + @SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" }) + private void validateSignRecord(PkiId requestedId, SignWorkflowStore.Record record) { + Objects.requireNonNull(requestedId, "requestedId"); + Objects.requireNonNull(record, "record"); + + SigningSubmissionId parsed; + PkiSigningBus.SignContinuation continuation; + Instant horizonEnd; + Instant futureLimit; + try { + parsed = SigningSubmissionId.parse(record.submissionId()); + continuation = PkiSigningBus.SignContinuation.decode(record.request()); + horizonEnd = record.createdAt().plus(options.signingOperationHorizon()); + futureLimit = signingNow().plus(options.signingIdPermittedSkew()); + } catch (RuntimeException ex) { + throw invalidSignRecord(record, "IDENTITY_OR_REQUEST_INVALID"); + } + + requireValidSignRecord(requestedId.equals(record.submissionId()), record, "SUBMISSION_ID_MISMATCH"); + requireValidSignRecord(record.namespace().startsWith(signingNamespace + "."), record, + "STORE_NAMESPACE_MISMATCH"); + requireValidSignRecord(parsed.namespace().equals(record.namespace()), record, "ID_NAMESPACE_MISMATCH"); + requireValidSignRecord(parsed.createdAt().equals(record.createdAt()), record, "ID_TIMESTAMP_MISMATCH"); + requireValidSignRecord(!record.createdAt().isAfter(futureLimit), record, "CREATION_TIME_FUTURE"); + requireValidSignRecord(record.deadline().isAfter(record.createdAt()) + && !record.deadline().isAfter(horizonEnd), record, "DEADLINE_INVALID"); + requireValidSignRecord(continuation.isBoundTo(record.submissionId(), record.owner()), record, + "CONTINUATION_IDENTITY_MISMATCH"); + + byte[] storedRequest = record.request().bytes(); + byte[] canonicalRequest = null; + String expectedFingerprint; + try { + canonicalRequest = continuation.encode().bytes(); + expectedFingerprint = continuation.semanticFingerprint(record.namespace(), record.deadline()); + } catch (RuntimeException ex) { + Arrays.fill(storedRequest, (byte) 0); + if (canonicalRequest != null) { + Arrays.fill(canonicalRequest, (byte) 0); + } + throw invalidSignRecord(record, "FINGERPRINT_RECOMPUTE_FAILED"); + } + try { + requireValidSignRecord(MessageDigest.isEqual(storedRequest, canonicalRequest), record, + "CONTINUATION_NONCANONICAL"); + requireValidSignRecord(hasCanonicalFingerprintShape(record.fingerprint()), record, + "FINGERPRINT_FORMAT_INVALID"); + requireValidSignRecord(constantTimeAsciiEquals(record.fingerprint(), expectedFingerprint), record, + "FINGERPRINT_MISMATCH"); + } finally { + Arrays.fill(storedRequest, (byte) 0); + Arrays.fill(canonicalRequest, (byte) 0); + } + + requireValidSignRecord(record.revision() >= record.fence(), record, "REVISION_FENCE_ORDER_INVALID"); + requireValidSignRecord(record.detailCode().isEmpty() || !record.detailCode().get().isBlank(), record, + "DETAIL_CODE_INVALID"); + if (record.leaseUntil().isPresent()) { + Instant leaseUntil = record.leaseUntil().get(); + requireValidSignRecord(leaseUntil.isAfter(record.createdAt()) && !leaseUntil.isAfter(horizonEnd), record, + "LEASE_TIME_INVALID"); + } + if (record.result().isPresent()) { + requireValidSignRecord(record.result().get().bytes().length <= FsCodec.MAX_COMPONENT_BYTES, record, + "RESULT_SIZE_INVALID"); + } + + validateSignRecordState(record); + } + + private void writeSignRecord(SignWorkflowStore.Record record) { + validateSignRecord(record.submissionId(), record); + try { + byte[] payload = FsCodec.encode(record); + ByteBuffer envelope = ByteBuffer.allocate(SIGN_RECORD_HEADER_BYTES + payload.length); + envelope.putInt(SIGN_RECORD_MAGIC); + envelope.putInt(SIGN_RECORD_VERSION); + envelope.put(payload); + FsOperations.writeAtomic(paths.signWorkflowPath(record.submissionId()), envelope.array()); + } catch (IOException ex) { + throw new IllegalStateException("failed to persist signing workflow", ex); + } + } + + private static SignWorkflowStore.Record copySignRecord(SignWorkflowStore.Record current, + SignWorkflowStore.State state, long revision, long fence, Optional leaseUntil, + Optional detailCode, Optional result, Optional providerUpdatedAt) { + return new SignWorkflowStore.Record(current.submissionId(), current.namespace(), current.fingerprint(), + current.owner(), current.createdAt(), current.deadline(), current.request(), state, revision, fence, + leaseUntil, detailCode, result, providerUpdatedAt); + } + + private static boolean validSignTransition(SignWorkflowStore.State source, SignWorkflowStore.State target) { + if (source == SignWorkflowStore.State.INTENT) { + return target == SignWorkflowStore.State.DISPATCHED || target == SignWorkflowStore.State.FAILED + || target == SignWorkflowStore.State.CANCELLING || target == SignWorkflowStore.State.EXPIRED; + } + if (source == SignWorkflowStore.State.DISPATCHED) { + return target == SignWorkflowStore.State.SUCCEEDED || target == SignWorkflowStore.State.FAILED + || target == SignWorkflowStore.State.CANCELLING || target == SignWorkflowStore.State.EXPIRED; + } + if (source == SignWorkflowStore.State.CANCELLING) { + return target == SignWorkflowStore.State.CANCELLING + || target == SignWorkflowStore.State.SUCCEEDED || target == SignWorkflowStore.State.FAILED + || target == SignWorkflowStore.State.CANCELLED || target == SignWorkflowStore.State.EXPIRED; + } + return false; + } + + private static boolean isRetirableSignState(SignWorkflowStore.State state) { + return state == SignWorkflowStore.State.SUCCEEDED || state == SignWorkflowStore.State.FAILED + || state == SignWorkflowStore.State.CANCELLED || state == SignWorkflowStore.State.EXPIRED; + } + + private static void validateSignRecordState(SignWorkflowStore.Record record) { + switch (record.state()) { + case INTENT -> { + requireValidSignRecord(record.result().isEmpty() && record.providerUpdatedAt().isEmpty(), record, + "INTENT_TERMINAL_DATA_PRESENT"); + requireDetailCode(record, "INTENT"); + if (record.fence() == INITIAL_FENCE) { + requireValidSignRecord(record.revision() == 0L && record.leaseUntil().isEmpty(), record, + "INTENT_INITIAL_CLAIM_INVALID"); + } else { + requireValidSignRecord(record.revision() >= record.fence() + && record.leaseUntil().isPresent(), record, "INTENT_CLAIM_INVALID"); + } + } + case DISPATCHED -> { + requireValidSignRecord(record.fence() > 0L && record.revision() > record.fence(), record, + "DISPATCH_REVISION_INVALID"); + requireValidSignRecord(record.leaseUntil().isEmpty() && record.result().isEmpty() + && record.providerUpdatedAt().isEmpty(), record, "DISPATCH_TERMINAL_DATA_PRESENT"); + requireDetailCode(record, "DISPATCHED"); + } + case CANCELLING -> { + requireValidSignRecord(record.revision() > record.fence(), record, "CANCELLING_REVISION_INVALID"); + requireValidSignRecord(record.leaseUntil().isEmpty() && record.result().isEmpty() + && record.providerUpdatedAt().isEmpty(), record, "CANCELLING_TERMINAL_DATA_PRESENT"); + requireValidSignRecord(record.detailCode().filter(code -> "CANCEL_REQUESTED".equals(code) + || "CANCEL_SUBMITTED".equals(code)).isPresent(), record, "DETAIL_CODE_INVALID"); + } + case SUCCEEDED -> { + requireValidSignRecord(record.fence() > 0L && record.revision() - record.fence() >= 2L, record, + "SUCCESS_REVISION_INVALID"); + requireValidSignRecord(record.leaseUntil().isEmpty() && record.result().isPresent() + && record.providerUpdatedAt().isPresent(), record, "SUCCESS_EVIDENCE_MISSING"); + requireValidSignRecord(record.providerUpdatedAt().get().isBefore(record.deadline()), record, + "PROVIDER_TIME_INVALID"); + requireValidSignRecord(!hasContradictorySuccessCode(record.detailCode()), record, + "SUCCESS_DETAIL_CONTRADICTORY"); + } + case FAILED -> { + requireValidSignRecord(record.fence() > 0L && record.revision() > record.fence(), record, + "FAILURE_REVISION_INVALID"); + requireValidSignRecord(record.leaseUntil().isEmpty() && record.result().isEmpty(), record, + "FAILURE_RESULT_OR_LEASE_PRESENT"); + requireValidSignRecord(!hasLifecycleCode(record.detailCode(), "SIGNED"), record, + "FAILURE_DETAIL_CONTRADICTORY"); + } + case CANCELLED -> { + requireValidSignRecord(record.revision() - record.fence() >= 2L, record, + "CANCELLED_REVISION_INVALID"); + requireValidSignRecord(record.leaseUntil().isEmpty() && record.result().isEmpty(), record, + "CANCELLED_RESULT_OR_LEASE_PRESENT"); + requireValidSignRecord(!hasLifecycleCode(record.detailCode(), "SIGNED"), record, + "CANCELLED_DETAIL_CONTRADICTORY"); + } + case EXPIRED -> { + requireValidSignRecord(record.revision() > record.fence(), record, "EXPIRED_REVISION_INVALID"); + requireValidSignRecord(record.leaseUntil().isEmpty() && record.result().isEmpty(), record, + "EXPIRED_RESULT_OR_LEASE_PRESENT"); + requireValidSignRecord(!hasLifecycleCode(record.detailCode(), "SIGNED"), record, + "EXPIRED_DETAIL_CONTRADICTORY"); + } + case RETIRED -> { + requireValidSignRecord(record.leaseUntil().isEmpty(), record, "RETIRED_LEASE_PRESENT"); + requireDetailCode(record, "RETIRED"); + if (record.result().isPresent()) { + requireValidSignRecord(record.fence() > 0L && record.revision() - record.fence() >= 3L + && record.providerUpdatedAt().isPresent() + && record.providerUpdatedAt().get().isBefore(record.deadline()), record, + "PROVIDER_TIME_INVALID"); + } else { + requireValidSignRecord(record.revision() - record.fence() >= 2L, record, + "RETIRED_TERMINAL_REVISION_INVALID"); + } + } + } + } + + private static boolean hasCanonicalFingerprintShape(String fingerprint) { + if (fingerprint.length() != SIGN_FINGERPRINT_PREFIX.length() + SIGN_FINGERPRINT_HEX_LENGTH + || !fingerprint.startsWith(SIGN_FINGERPRINT_PREFIX)) { + return false; + } + for (int index = SIGN_FINGERPRINT_PREFIX.length(); index < fingerprint.length(); index++) { + char value = fingerprint.charAt(index); + if (!(value >= '0' && value <= '9' || value >= 'a' && value <= 'f')) { + return false; + } + } + return true; + } + + private static boolean constantTimeAsciiEquals(String left, String right) { + byte[] leftBytes = left.getBytes(StandardCharsets.US_ASCII); + byte[] rightBytes = right.getBytes(StandardCharsets.US_ASCII); + try { + return MessageDigest.isEqual(leftBytes, rightBytes); + } finally { + Arrays.fill(leftBytes, (byte) 0); + Arrays.fill(rightBytes, (byte) 0); + } + } + + private static boolean hasContradictorySuccessCode(Optional detailCode) { + return hasLifecycleCode(detailCode, "INTENT") + || hasLifecycleCode(detailCode, "DISPATCHED") + || hasLifecycleCode(detailCode, "CANCEL_REQUESTED") + || hasLifecycleCode(detailCode, "CANCELLED") + || hasLifecycleCode(detailCode, "EXPIRED") + || hasLifecycleCode(detailCode, "RETIRED") + || hasLifecycleCode(detailCode, "REQUEST_INTEGRITY_FAILURE") + || hasLifecycleCode(detailCode, "PROVIDER_ID_MISMATCH") + || hasLifecycleCode(detailCode, "PROVIDER_SUBMISSION_FAILED") + || hasLifecycleCode(detailCode, "PROVIDER_RESULT_MISSING") + || hasLifecycleCode(detailCode, "LATE_PROVIDER_SUCCESS"); + } + + private static boolean hasLifecycleCode(Optional detailCode, String code) { + return detailCode.filter(code::equals).isPresent(); + } + + private static void requireDetailCode(SignWorkflowStore.Record record, String required) { + requireValidSignRecord(record.detailCode().filter(required::equals).isPresent(), record, + "STATE_DETAIL_CODE_INVALID"); + } + + private static void requireValidSignRecord(boolean condition, SignWorkflowStore.Record record, String code) { + if (!condition) { + throw invalidSignRecord(record, code); + } + } + + private static IllegalStateException invalidSignRecord(SignWorkflowStore.Record record, String code) { + return new IllegalStateException("Signing record corruption: type=sign-workflow version=" + + SIGN_RECORD_VERSION + " state=" + record.state() + " code=" + code); + } + + private static void requirePositive(Duration value, String name) { + Objects.requireNonNull(value, name); + if (value.isZero() || value.isNegative()) { + throw new IllegalArgumentException(name + " must be positive"); + } + } + + /** + * Reference-counted lock entry removed when no mutation uses it. + */ + private static final class SignLockEntry { + private final ReentrantLock lock = new ReentrantLock(); + private final AtomicInteger references = new AtomicInteger(); + } + + /** + * Owns the operating-system resources that exclude a second store process. + * + *

    + * Ownership begins when {@link #acquire(Path)} returns and ends when + * {@link #close()} releases the lock and closes its channel. The resources + * intentionally remain open for the complete lifetime of their enclosing + * {@link FilesystemPkiStore}. + *

    + */ + private static final class StoreOwnership implements AutoCloseable { + private final FileChannel channel; + private final FileLock lock; + private final ReentrantLock closeLock = new ReentrantLock(); + private boolean closed; + + private StoreOwnership(FileChannel channel, FileLock lock) { + this.channel = channel; + this.lock = lock; + } + + // Raw lock exceptions are deliberately replaced by the stable redacted contention code. + @SuppressWarnings("PMD.PreserveStackTrace") + private static StoreOwnership acquire(Path lockFile) throws IOException { + FileChannel channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE); + FileLock lock = null; + try { + try { + lock = channel.tryLock(); + } catch (OverlappingFileLockException contention) { + throw new IllegalStateException(STORE_OWNERSHIP_UNAVAILABLE); + } + if (lock == null) { + throw new IllegalStateException(STORE_OWNERSHIP_UNAVAILABLE); + } + return new StoreOwnership(channel, lock); + } finally { + if (lock == null) { + closePartial(channel); + } + } + } + + private static void closePartial(FileChannel channel) { + try { + channel.close(); + } catch (IOException ignored) { + // Contention remains authoritative and contains no operating-system detail. + } + } + + private void closeSilently() { + try { + close(); + } catch (IOException ignored) { + // Constructor failure remains authoritative and safely redacted. + } + } + + @Override + public void close() throws IOException { + closeLock.lock(); + try { + if (closed) { + return; + } + boolean releaseFailed = false; + boolean closeFailed = false; + try { + if (lock.isValid()) { + lock.release(); + } + } catch (IOException failure) { + releaseFailed = true; + } + try { + channel.close(); + } catch (IOException failure) { + closeFailed = true; + } + closed = true; + if (releaseFailed || closeFailed) { + throw new IOException("Filesystem store ownership release failed: " + + "code=STORE_OWNERSHIP_RELEASE_FAILED"); + } + } finally { + closeLock.unlock(); + } + } + } + + private String ensureSigningNamespace() throws IOException { + Path file = paths.signingNamespaceFile(); + if (Files.exists(file)) { + String value = Files.readString(file, StandardCharsets.US_ASCII).trim(); + if (!value.matches("[a-f0-9]{32}")) { + throw new IllegalStateException("Invalid persisted signing namespace"); + } + return value; + } + byte[] random = new byte[16]; + new SecureRandom().nextBytes(random); + String value = HexFormat.of().formatHex(random); + FsOperations.writeAtomic(file, value.getBytes(StandardCharsets.US_ASCII)); + return value; + } + + private long loadSigningTimeWatermark() throws IOException { + Path file = paths.signingTimeWatermarkFile(); + long observed = clock.instant().toEpochMilli(); + if (!Files.exists(file)) { + writeDurableSigningTimeWatermark(file, observed); + return observed; + } + try { + return Math.max(observed, Long.parseLong(Files.readString(file, StandardCharsets.US_ASCII).trim())); + } catch (NumberFormatException ex) { + throw new IllegalStateException("Invalid persisted signing time watermark", ex); + } + } + + private void persistSigningTimeWatermark(long epochMilli) { + try { + writeDurableSigningTimeWatermark(paths.signingTimeWatermarkFile(), epochMilli); + } catch (IOException ex) { + throw new IllegalStateException("Failed to persist signing time watermark", ex); + } + } + + private static void writeDurableSigningTimeWatermark(Path target, long epochMilli) throws IOException { + Path parent = target.getParent(); + FsOperations.ensureDir(parent); + Path temporary = Files.createTempFile(parent, ".signing-time-watermark-", ".tmp"); + try { + ByteBuffer bytes = ByteBuffer.wrap(Long.toString(epochMilli).getBytes(StandardCharsets.US_ASCII)); + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) { + while (bytes.hasRemaining()) { + channel.write(bytes); + } + channel.force(true); + } + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException ex) { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); + } + try (FileChannel directory = FileChannel.open(parent, StandardOpenOption.READ)) { + directory.force(true); + } + } finally { + Files.deleteIfExists(temporary); } } diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java index 5b69055..c7d918b 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java @@ -46,8 +46,10 @@ import java.time.Duration; import java.time.Instant; import java.util.Map; import java.util.Objects; +import java.util.Optional; import zeroecho.core.io.Util; +import zeroecho.pki.spi.store.SignWorkflowStore; /** * Compact binary codec for filesystem persistence. @@ -82,7 +84,7 @@ import zeroecho.core.io.Util; @SuppressWarnings("PMD.CyclomaticComplexity") final class FsCodec { - private static final int MAX_STRING_BYTES = 256 * 1024; + /* package */ static final int MAX_COMPONENT_BYTES = 256 * 1024; private static final Map, Class> PRIMITIVE_TO_WRAPPER = Map.of(boolean.class, Boolean.class, byte.class, Byte.class, short.class, Short.class, int.class, Integer.class, long.class, Long.class, char.class, @@ -135,6 +137,9 @@ final class FsCodec { try { ByteArrayInputStream bis = new ByteArrayInputStream(data); Object decoded = readAny(bis, expectedType); + if (bis.available() != 0) { + throw new IllegalStateException("trailing data after " + expectedType.getName()); + } return expectedType.cast(decoded); } catch (IOException e) { throw new IllegalStateException("decoding failed: " + expectedType.getName(), e); @@ -157,7 +162,7 @@ final class FsCodec { case Duration duration -> writeDurationCompact(out, duration); case java.util.List list -> writeListCompact(out, list); case java.util.Set set -> writeSetCompact(out, set); - case java.util.Optional optional -> writeOptionalCompact(out, optional); + case Optional optional -> writeOptionalCompact(out, optional); default -> writeComplexCompact(out, value); } } @@ -242,7 +247,7 @@ final class FsCodec { }); } - private static void writeOptionalCompact(final OutputStream out, final java.util.Optional optional) + private static void writeOptionalCompact(final OutputStream out, final Optional optional) throws IOException { out.write(TAG_OPTIONAL); if (optional.isPresent()) { @@ -273,7 +278,10 @@ final class FsCodec { out.write(TAG_ENUM); Util.writeUTF8(out, type.getName()); Enum enumValue = (Enum) value; - Util.writePack7I(out, enumValue.ordinal()); + int persistentCode = value instanceof SignWorkflowStore.State state + ? state.persistentCode() + : enumValue.ordinal(); + Util.writePack7I(out, persistentCode); } private static void writeRecordCompact(final OutputStream out, final Class type, final Object value) @@ -375,7 +383,7 @@ final class FsCodec { } private static String readStringCompact(final InputStream in) throws IOException { - return Util.readUTF8(in, MAX_STRING_BYTES); + return Util.readUTF8(in, MAX_COMPONENT_BYTES); } private static int readIntegerCompact(final InputStream in) throws IOException { @@ -395,7 +403,7 @@ final class FsCodec { } private static byte[] readBytesCompact(final InputStream in) throws IOException { - return Util.read(in, MAX_STRING_BYTES); + return Util.read(in, MAX_COMPONENT_BYTES); } private static Instant readInstantCompact(final InputStream in) throws IOException { @@ -411,13 +419,20 @@ final class FsCodec { } private static Object readEnumCompact(final InputStream in) throws IOException { - String enumTypeName = Util.readUTF8(in, MAX_STRING_BYTES); + String enumTypeName = Util.readUTF8(in, MAX_COMPONENT_BYTES); Class enumType = loadClass(enumTypeName); if (!enumType.isEnum()) { throw new IllegalStateException("encoded enum type is not an enum: " + enumTypeName); } int ordinal = Util.readPack7I(in); + if (SignWorkflowStore.State.class.equals(enumType)) { + try { + return SignWorkflowStore.State.fromPersistentCode(ordinal); + } catch (IllegalArgumentException ex) { + throw new IllegalStateException("invalid signing workflow state code " + ordinal, ex); + } + } Object[] constants = enumType.getEnumConstants(); if (constants == null || ordinal < 0 || ordinal >= constants.length) { throw new IllegalStateException("invalid enum ordinal " + ordinal + " for " + enumTypeName); @@ -426,7 +441,7 @@ final class FsCodec { } private static Object readRecordCompact(final InputStream in) throws IOException { - String recordTypeName = Util.readUTF8(in, MAX_STRING_BYTES); + String recordTypeName = Util.readUTF8(in, MAX_COMPONENT_BYTES); Class recordType = loadClass(recordTypeName); if (!recordType.isRecord()) { throw new IllegalStateException("encoded record type is not a record: " + recordTypeName); @@ -455,8 +470,8 @@ final class FsCodec { } private static Object readFallbackStringCompact(final InputStream in) throws IOException { - String typeName = Util.readUTF8(in, MAX_STRING_BYTES); - String value = Util.readUTF8(in, MAX_STRING_BYTES); + String typeName = Util.readUTF8(in, MAX_COMPONENT_BYTES); + String value = Util.readUTF8(in, MAX_COMPONENT_BYTES); Class type = loadClass(typeName); Method stringFactory = findStringFactory(type); @@ -502,10 +517,10 @@ final class FsCodec { throw new IOException("unexpected EOF"); } if (present == 0) { - return java.util.Optional.empty(); + return Optional.empty(); } Object element = readAny(in, Object.class); - return java.util.Optional.of(element); + return Optional.of(element); } private static boolean isTypeCompatible(final Class expectedType, final Class actualType) { @@ -559,4 +574,4 @@ final class FsCodec { return null; } -} \ No newline at end of file +} diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java index 7bf65c3..a62dff1 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java @@ -79,6 +79,14 @@ final class FsPaths { return this.root.resolve(VERSION_FILE); } + /* default */ Path signingNamespaceFile() { + return this.root.resolve("SIGNING_NAMESPACE"); + } + + /* default */ Path signingTimeWatermarkFile() { + return this.root.resolve("SIGNING_TIME_WATERMARK"); + } + /* default */ Path lockFile() { return this.root.resolve(LOCK_DIR).resolve(STORE_LOCK); } @@ -135,6 +143,16 @@ final class FsPaths { return this.root.resolve("requests").resolve(BY_ID).resolve(FsUtil.safeId(requestId) + ".bin"); } + /* default */ Path signWorkflowPath(final PkiId submissionId) { + Objects.requireNonNull(submissionId, "submissionId"); + return this.root.resolve("sign-workflows").resolve(BY_ID) + .resolve(FsUtil.safeId(submissionId)).resolve(CURRENT_FILE); + } + + /* default */ Path signWorkflowRoot() { + return this.root.resolve("sign-workflows").resolve(BY_ID); + } + // ------------------------------------------------------------------------- // Revocations (mutable with history) // ------------------------------------------------------------------------- diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsPkiStoreOptions.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsPkiStoreOptions.java index 8e35098..40c5f6f 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsPkiStoreOptions.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsPkiStoreOptions.java @@ -60,9 +60,13 @@ import java.util.Optional; * @param workflowHistoryPolicy history policy for workflow continuation state * @param strictSnapshotExport whether snapshot export is strict * (fail-closed) + * @param signingOperationHorizon maximum stable signing identifier and record + * retention horizon + * @param signingIdPermittedSkew permitted future signing identifier clock skew */ public record FsPkiStoreOptions(FsHistoryPolicy caHistoryPolicy, FsHistoryPolicy profileHistoryPolicy, - FsHistoryPolicy revocationHistoryPolicy, FsHistoryPolicy workflowHistoryPolicy, boolean strictSnapshotExport) { + FsHistoryPolicy revocationHistoryPolicy, FsHistoryPolicy workflowHistoryPolicy, boolean strictSnapshotExport, + Duration signingOperationHorizon, Duration signingIdPermittedSkew) { /** * Canonical constructor with validation. @@ -74,6 +78,14 @@ public record FsPkiStoreOptions(FsHistoryPolicy caHistoryPolicy, FsHistoryPolicy Objects.requireNonNull(profileHistoryPolicy, "profileHistoryPolicy"); Objects.requireNonNull(revocationHistoryPolicy, "revocationHistoryPolicy"); Objects.requireNonNull(workflowHistoryPolicy, "workflowHistoryPolicy"); + Objects.requireNonNull(signingOperationHorizon, "signingOperationHorizon"); + Objects.requireNonNull(signingIdPermittedSkew, "signingIdPermittedSkew"); + if (signingOperationHorizon.isZero() || signingOperationHorizon.isNegative()) { + throw new IllegalArgumentException("signingOperationHorizon must be positive"); + } + if (signingIdPermittedSkew.isNegative()) { + throw new IllegalArgumentException("signingIdPermittedSkew must not be negative"); + } } /** @@ -89,6 +101,7 @@ public record FsPkiStoreOptions(FsHistoryPolicy caHistoryPolicy, FsHistoryPolicy */ public static FsPkiStoreOptions defaults() { FsHistoryPolicy ninetyDays = FsHistoryPolicy.onWrite(Optional.of(Duration.ofDays(90))); - return new FsPkiStoreOptions(ninetyDays, ninetyDays, ninetyDays, ninetyDays, true); + return new FsPkiStoreOptions(ninetyDays, ninetyDays, ninetyDays, ninetyDays, true, Duration.ofDays(90), + Duration.ZERO); } } diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java index cdd6379..009bdf1 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java @@ -60,6 +60,11 @@ import java.util.logging.Logger; * to {@code current.bin} only when strict mode is disabled. *
  • Write-once objects are copied as-is (they are immutable). This exporter * does not attempt to prune them by time unless an upstream index exists.
  • + *
  • Signing namespace, signing-time watermark, and authoritative + * {@code sign-workflows} records are current safety metadata copied at export + * time. They are intentionally not reconstructed at {@code at}: rolling them + * back could make a stable identifier reusable or discard a completed signing + * result.
  • * */ final class FsSnapshotExporter { @@ -82,6 +87,8 @@ final class FsSnapshotExporter { FsPaths dst = new FsPaths(targetRoot); Files.writeString(dst.versionFile(), "v1"); + copyFile(sourceRoot.resolve("SIGNING_NAMESPACE"), targetRoot.resolve("SIGNING_NAMESPACE")); + copyFile(sourceRoot.resolve("SIGNING_TIME_WATERMARK"), targetRoot.resolve("SIGNING_TIME_WATERMARK")); // copy write-once trees as-is (best-effort, deterministic order) copyTreeIfExists(sourceRoot.resolve("credentials"), targetRoot.resolve("credentials")); @@ -89,6 +96,7 @@ final class FsSnapshotExporter { copyTreeIfExists(sourceRoot.resolve("status"), targetRoot.resolve("status")); copyTreeIfExists(sourceRoot.resolve("policy"), targetRoot.resolve("policy")); copyTreeIfExists(sourceRoot.resolve("publications"), targetRoot.resolve("publications")); + copyTreeIfExists(sourceRoot.resolve("sign-workflows"), targetRoot.resolve("sign-workflows")); // reconstruct mutable entities from history (CAS, PROFILES, REVOCATIONS) reconstructMutableTree(sourceRoot.resolve("cas"), targetRoot.resolve("cas"), at, @@ -192,4 +200,11 @@ final class FsSnapshotExporter { } }); } + + private static void copyFile(final Path source, final Path target) throws IOException { + if (!Files.isRegularFile(source)) { + throw new IllegalStateException("Required snapshot metadata is missing: " + source.getFileName()); + } + FsOperations.writeAtomic(target, Files.readAllBytes(source)); + } } diff --git a/pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflow.java b/pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflow.java index d3f578c..c708170 100644 --- a/pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflow.java +++ b/pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflow.java @@ -34,10 +34,18 @@ package zeroecho.pki.spi.crypto; import java.io.Closeable; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.security.DigestOutputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.time.Instant; +import java.util.HexFormat; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.function.Consumer; import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.Encoding; @@ -83,6 +91,8 @@ import zeroecho.pki.api.audit.AccessContext; * audit logs and API responses. * */ +// PMD cannot infer that retaining digest/encoding causes would violate the redaction contract. +@SuppressWarnings("PMD.PreserveStackTrace") public interface SignatureWorkflow extends Closeable { /** @@ -92,6 +102,30 @@ public interface SignatureWorkflow extends Closeable { */ String id(); + /** + * Validates the authoritative signing domain selected by orchestration. + * + *

    + * Providers must fail fast when their configured horizon or skew differs. The + * default contract accepts the standard {@code P90D}/{@code PT0S} domain and a + * namespace whose provider component is {@link #id()}. + *

    + * + * @param namespace combined stable store and provider namespace + * @param horizon authoritative operation horizon + * @param permittedSkew authoritative future timestamp skew + */ + default void validateSigningDomain(String namespace, java.time.Duration horizon, + java.time.Duration permittedSkew) { + Objects.requireNonNull(namespace, "namespace"); + Objects.requireNonNull(horizon, "horizon"); + Objects.requireNonNull(permittedSkew, "permittedSkew"); + if (!namespace.endsWith("." + id()) || !java.time.Duration.ofDays(90).equals(horizon) + || !java.time.Duration.ZERO.equals(permittedSkew)) { + throw new IllegalArgumentException("Signature workflow signing domain mismatch"); + } + } + /** * Submits a signing request. * @@ -100,13 +134,37 @@ public interface SignatureWorkflow extends Closeable { * (encoded) on success. *

    * + *

    + * The provider must durably bind {@link SignRequest#submissionId()} to the + * constant-time verified {@link SignRequest#semanticFingerprint()} before + * invoking cryptography. The first accepted request may execute once. Any + * concurrent or later request with the same non-expired identifier and + * fingerprint attaches to the existing non-terminal or terminal outcome and + * must not invoke cryptography again. A different fingerprint is a conflict. + * The returned identifier must equal the submitted identifier. + *

    + * + *

    + * {@link SignRequest#fencingToken()} is monotonic. A stale token must not mutate + * state; a completion may commit only while its fingerprint, token, and running + * state remain current. Providers must check an optional request deadline + * before starting cryptography or approval execution and again before committing + * success. A completion at the deadline is late; it must become + * {@link State#EXPIRED} without exposing a result. Terminal states are immutable. At and after the + * configured horizon, submission is rejected and {@link #status(PkiId)} reports + * {@link State#EXPIRED}, including after payload/result purge and restart. + * Provider callbacks must run after operation state locks are released. + *

    + * *

    Failure model (normative)

    *
      *
    • For validation and policy failures: do not throw; return operation id and * expose failure via {@link #status(PkiId)} with {@code FAILED} and stable * {@code detailCode}.
    • - *
    • May throw {@link IllegalArgumentException} only for programmer errors - * (e.g., {@code request == null}).
    • + *
    • Throws {@link IllegalStateException} for identifier/fingerprint conflicts + * or stale fencing tokens.
    • + *
    • May throw {@link IllegalArgumentException} for malformed, foreign, or + * expired identifiers and programmer errors.
    • *
    * * @param request request (never {@code null}) @@ -152,11 +210,18 @@ public interface SignatureWorkflow extends Closeable { /** * Best-effort cancellation. * + *

    + * A {@code true} return value means only that the provider accepted the + * cancellation request. It does not prove that the operation is terminal. + * Callers must re-read {@link #status(PkiId)} and may retire state only after an + * immutable terminal status is observed. + *

    + * * @param operationId operation id (never {@code null}) * @param reason non-sensitive reason (never blank) * @return true if cancellation was accepted; false if already terminal/unknown */ - boolean cancel(PkiId operationId, String reason); + boolean cancel(PkiId operationId, long fencingToken, String reason); /** * Registers a notification sink for status changes. @@ -192,6 +257,10 @@ public interface SignatureWorkflow extends Closeable { * {@link #submitSign(SignRequest)}. *

    * + * @param submissionId stable caller-assigned submission identifier + * @param namespace provider/store namespace + * @param semanticFingerprint versioned request fingerprint excluding fence + * @param fencingToken current positive fencing token * @param accessContext audit/governance context (never * {@code null}) * @param keyRef opaque reference to the private key (never @@ -202,10 +271,16 @@ public interface SignatureWorkflow extends Closeable { * @param preferredSignatureEncoding preferred signature encoding (optional) * @param deadline optional absolute deadline */ - record SignRequest(AccessContext accessContext, KeyRef keyRef, String algorithmId, EncodedObject payload, + record SignRequest(PkiId submissionId, String namespace, String semanticFingerprint, long fencingToken, + AccessContext accessContext, KeyRef keyRef, String algorithmId, EncodedObject payload, Optional preferredSignatureEncoding, Optional deadline) { + private static final long MIN_FENCING_TOKEN = 1L; + public SignRequest { + Objects.requireNonNull(submissionId, "submissionId"); + Objects.requireNonNull(namespace, "namespace"); + Objects.requireNonNull(semanticFingerprint, "semanticFingerprint"); Objects.requireNonNull(accessContext, "accessContext"); Objects.requireNonNull(keyRef, "keyRef"); Objects.requireNonNull(algorithmId, "algorithmId"); @@ -215,6 +290,103 @@ public interface SignatureWorkflow extends Closeable { if (algorithmId.isBlank()) { throw new IllegalArgumentException("algorithmId must not be blank"); } + if (fencingToken < MIN_FENCING_TOKEN) { + throw new IllegalArgumentException("fencingToken must be positive"); + } + String expected = fingerprint(namespace, accessContext, keyRef, algorithmId, payload, + preferredSignatureEncoding, deadline); + if (!constantTimeFingerprintEquals(expected, semanticFingerprint)) { + throw new IllegalArgumentException("semanticFingerprint does not match signing request"); + } + } + + /** + * Creates a request with its canonical versioned fingerprint. + */ + public static SignRequest create(PkiId submissionId, String namespace, long fencingToken, + AccessContext accessContext, KeyRef keyRef, String algorithmId, EncodedObject payload, + Optional preferredSignatureEncoding, Optional deadline) { + String fingerprint = fingerprint(namespace, accessContext, keyRef, algorithmId, payload, + preferredSignatureEncoding, deadline); + return new SignRequest(submissionId, namespace, fingerprint, fencingToken, accessContext, keyRef, + algorithmId, payload, preferredSignatureEncoding, deadline); + } + + /** + * Computes the canonical {@code signfp:v1} semantic fingerprint in + * O(payload) time and O(payload) temporary memory from the defensive payload + * copy, with constant-size digest state. + */ + public static String fingerprint(String namespace, AccessContext accessContext, KeyRef keyRef, + String algorithmId, EncodedObject payload, Optional preferredSignatureEncoding, + Optional deadline) { + Objects.requireNonNull(namespace, "namespace"); + Objects.requireNonNull(accessContext, "accessContext"); + Objects.requireNonNull(keyRef, "keyRef"); + Objects.requireNonNull(algorithmId, "algorithmId"); + Objects.requireNonNull(payload, "payload"); + Objects.requireNonNull(preferredSignatureEncoding, "preferredSignatureEncoding"); + Objects.requireNonNull(deadline, "deadline"); + try { + return fingerprintWithDigest(namespace, accessContext, keyRef, algorithmId, payload, + preferredSignatureEncoding, deadline, MessageDigest.getInstance("SHA-256"), ignored -> { + }); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("Unable to compute signing request fingerprint"); + } + } + + /* default */ static String fingerprintWithDigest(String namespace, AccessContext accessContext, KeyRef keyRef, + String algorithmId, EncodedObject payload, Optional preferredSignatureEncoding, + Optional deadline, MessageDigest digest, Consumer cleanupObserver) { + Objects.requireNonNull(digest, "digest"); + Objects.requireNonNull(cleanupObserver, "cleanupObserver"); + byte[] payloadBytes = payload.bytes(); + byte[] digestBytes = null; + try { + try (DataOutputStream output = new DataOutputStream( + new DigestOutputStream(OutputStream.nullOutputStream(), digest))) { + output.writeUTF("sign-request-v1"); + output.writeUTF(namespace); + output.writeUTF(accessContext.principal().type()); + output.writeUTF(accessContext.principal().name()); + output.writeUTF(accessContext.purpose().value()); + output.writeUTF(accessContext.objectId().map(PkiId::value).orElse("")); + output.writeUTF(accessContext.formatId().map(zeroecho.pki.api.FormatId::value).orElse("")); + output.writeUTF(keyRef.value()); + output.writeUTF(algorithmId); + output.writeUTF(payload.encoding().name()); + output.writeInt(payloadBytes.length); + output.write(payloadBytes); + output.writeUTF(preferredSignatureEncoding.map(Enum::name).orElse("")); + output.writeUTF(deadline.map(Instant::toString).orElse("")); + } + digestBytes = digest.digest(); + return "signfp:v1:" + HexFormat.of().formatHex(digestBytes); + } catch (IOException ex) { + throw new IllegalStateException("Unable to compute signing request fingerprint"); + } finally { + clearOwned(payloadBytes, cleanupObserver); + clearOwned(digestBytes, cleanupObserver); + } + } + + private static boolean constantTimeFingerprintEquals(String left, String right) { + byte[] leftBytes = left.getBytes(java.nio.charset.StandardCharsets.US_ASCII); + byte[] rightBytes = right.getBytes(java.nio.charset.StandardCharsets.US_ASCII); + try { + return MessageDigest.isEqual(leftBytes, rightBytes); + } finally { + java.util.Arrays.fill(leftBytes, (byte) 0); + java.util.Arrays.fill(rightBytes, (byte) 0); + } + } + + private static void clearOwned(byte[] owned, Consumer cleanupObserver) { + if (owned != null) { + java.util.Arrays.fill(owned, (byte) 0); + cleanupObserver.accept(owned); + } } } @@ -287,6 +459,13 @@ public interface SignatureWorkflow extends Closeable { * {@code OperationStatus} must no longer change. *

    * + *

    + * For terminal states, {@link #updatedAt()} is the durable completion timestamp, + * not the time at which a caller happened to poll. Providers must preserve that + * value across restart. A successful status at or after its request deadline is + * invalid and must instead be exposed as {@link State#EXPIRED} without a result. + *

    + * * @param state current lifecycle state of the operation (never * {@code null}) * @param updatedAt timestamp of the last state transition (never @@ -306,6 +485,10 @@ public interface SignatureWorkflow extends Closeable { Objects.requireNonNull(updatedAt, "updatedAt"); Objects.requireNonNull(detailCode, "detailCode"); Objects.requireNonNull(result, "result"); + if (state == State.SUCCEEDED && result.isEmpty() + || state != State.SUCCEEDED && result.isPresent()) { + throw new IllegalArgumentException("Operation result does not match status state"); + } } /** @@ -400,6 +583,9 @@ public interface SignatureWorkflow extends Closeable { * The callback must be treated as a best-effort notification mechanism and must * not be relied upon as the sole source of truth; callers should always be able * to query the authoritative state via {@link SignatureWorkflow#status(PkiId)}. + * Delivery may be coalesced or dropped under load. Providers must not require + * callback processing to finish an operation, and sink implementations should + * return promptly without waiting for operation-level coordination. *

    */ @FunctionalInterface diff --git a/pki/src/main/java/zeroecho/pki/spi/framework/CredentialFramework.java b/pki/src/main/java/zeroecho/pki/spi/framework/CredentialFramework.java index 4163c89..72e7dcf 100644 --- a/pki/src/main/java/zeroecho/pki/spi/framework/CredentialFramework.java +++ b/pki/src/main/java/zeroecho/pki/spi/framework/CredentialFramework.java @@ -40,8 +40,9 @@ import zeroecho.pki.api.FormatId; * *

    * A framework implementation provides request parsing, proof-of-possession - * verification (if applicable), credential issuance backend, and status object - * generation for a particular {@link FormatId}. + * verification and status object generation for a particular {@link FormatId}. + * Credential minting is intentionally not exposed by this framework facade; core + * issuance services own that privileged implementation boundary. *

    */ public interface CredentialFramework { @@ -67,13 +68,6 @@ public interface CredentialFramework { */ ProofOfPossessionVerifier proofOfPossessionVerifier(); - /** - * Returns the issuer backend for this framework. - * - * @return issuer backend - */ - CredentialIssuerBackend issuerBackend(); - /** * Returns the status object generator for this framework. * diff --git a/pki/src/main/java/zeroecho/pki/spi/framework/CredentialIssuerBackend.java b/pki/src/main/java/zeroecho/pki/spi/framework/CredentialIssuerBackend.java index 60a213e..3999e80 100644 --- a/pki/src/main/java/zeroecho/pki/spi/framework/CredentialIssuerBackend.java +++ b/pki/src/main/java/zeroecho/pki/spi/framework/CredentialIssuerBackend.java @@ -33,10 +33,10 @@ ******************************************************************************/ package zeroecho.pki.spi.framework; -import zeroecho.pki.api.ca.IntermediateCertIssueCommand; import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.CredentialBundle; -import zeroecho.pki.api.issuance.IssueEndEntityCommand; +import zeroecho.pki.impl.core.ManagedCaIssuance; +import zeroecho.pki.impl.core.VerifiedIssuanceCandidate; /** * SPI contract for framework-specific credential issuance backends. @@ -59,17 +59,19 @@ import zeroecho.pki.api.issuance.IssueEndEntityCommand; * *

    Architectural role

    *
      - *
    • {@link CredentialFramework} selects the concrete backend appropriate for - * a credential format.
    • - *
    • PKI core services prepare validated issuance commands and call this + *
    • PKI runtime wiring injects the concrete backend into authoritative core + * services without publishing it through {@link CredentialFramework}.
    • + *
    • PKI core services prepare opaque proof-gated issuance authorities and call this * backend to obtain framework-specific credentials.
    • + *
    • The backend is a privileged post-gate component whose method signatures + * cannot accept raw issuance commands.
    • *
    • This backend performs format-specific credential assembly, not CA policy * orchestration, lifecycle control, or long-term persistence.
    • *
    * *

    Implementation expectations

    *
      - *
    • Implementations should validate that the supplied command contains the + *
    • Implementations should validate that the supplied authority contains the * framework-specific material required for issuance.
    • *
    • Implementations should fail explicitly when mandatory issuer material, * subject material, or framework-specific overrides are missing or @@ -103,21 +105,27 @@ public interface CredentialIssuerBackend { * *

      * This operation produces a credential for a non-CA subject, typically from a - * validated certification request or equivalent subject input carried by the - * {@link IssueEndEntityCommand}. The returned {@link CredentialBundle} may + * cryptographically verified certification request carried by the opaque + * {@link VerifiedIssuanceCandidate}. The returned {@link CredentialBundle} may * contain the issued leaf credential together with any additional runtime * bundle material defined by the concrete framework, such as chain elements or * accompanying metadata. *

      * *

      - * The command is expected to carry all framework-specific issuance inputs + * The parameter type has no public constructor or factory. Only the core + * issuance gate can create it after mandatory proof-of-possession verification. + *

      + * + *

      + * The candidate carries all framework-specific issuance inputs * required by the concrete implementation, including any issuer wiring * attributes, profile identifiers, validity overrides, and subject request * material. The exact interpretation of those fields is framework-specific. *

      * - * @param command end-entity issuance command; must not be {@code null} + * @param candidate gate-produced verified issuance candidate; must not be + * {@code null} * @return issued credential bundle, never {@code null} * @throws IllegalArgumentException if {@code command} is {@code null} or * structurally invalid for the concrete @@ -126,7 +134,7 @@ public interface CredentialIssuerBackend { * or other framework-specific issuance * processing fails */ - CredentialBundle issueEndEntity(IssueEndEntityCommand command); + CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate); /** * Issues a CA credential for an existing CA subject entity. @@ -139,12 +147,13 @@ public interface CredentialIssuerBackend { *

      * *

      - * The supplied {@link IntermediateCertIssueCommand} is expected to contain the - * issuer CA reference, subject CA reference, profile selection, and any - * framework-specific attributes needed to construct the CA credential. + * The supplied {@link ManagedCaIssuance} can be constructed only after the core + * CA proof gate has completed a managed-key possession challenge and bound the + * exact public key, subject, operation, and authoritative attributes. *

      * - * @param command CA credential issuance command; must not be {@code null} + * @param issuance gate-produced managed CA issuance authority; must not be + * {@code null} * @return issued CA credential, never {@code null} * @throws IllegalArgumentException if {@code command} is {@code null} or * structurally invalid for the concrete @@ -153,5 +162,5 @@ public interface CredentialIssuerBackend { * or other framework-specific issuance * processing fails */ - Credential issueIntermediateCertificate(IntermediateCertIssueCommand command); + Credential issueIntermediateCertificate(ManagedCaIssuance issuance); } diff --git a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java index da8f70f..09a1c37 100644 --- a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java +++ b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java @@ -70,7 +70,7 @@ import zeroecho.pki.api.status.StatusObject; * {@link IllegalStateException} when an operation cannot be completed safely. *

      */ -public interface PkiStore { +public interface PkiStore extends SignWorkflowStore { /** * Persists or updates a Certificate Authority (CA) record. diff --git a/pki/src/main/java/zeroecho/pki/spi/store/SignWorkflowStore.java b/pki/src/main/java/zeroecho/pki/spi/store/SignWorkflowStore.java new file mode 100644 index 0000000..8431342 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/store/SignWorkflowStore.java @@ -0,0 +1,293 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.spi.store; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.audit.Principal; + +/** + * Authoritative durable state machine for signing submissions. + * + *

      + * Implementations must provide atomic per-operation compare-and-set behavior. + * External signer, callback, and audit code must never run while an implementation + * holds its coordination lock. Different submission identifiers must not share a + * permanent global mutation lock. + *

      + * + *

      + * Retained records are the source of truth through {@link #signingHorizon()}. + * Purging a record does not make its stable identifier reusable because creation + * validates the identifier timestamp against the same horizon. + *

      + */ +public interface SignWorkflowStore { + + /** + * Signing orchestration states. + * + *

      + * {@link #CANCELLING} is authoritative and non-terminal: it records that + * cancellation was requested before the provider is called. A provider result + * observed while cancelling must still be reconciled before retirement. + * Persistent codes are stable and independent of declaration order. + *

      + */ + enum State { + INTENT(10), + DISPATCHED(20), + SUCCEEDED(40), + FAILED(50), + CANCELLED(60), + RETIRED(70), + EXPIRED(80), + CANCELLING(30); + + private final int persistentCode; + + State(int persistentCode) { + this.persistentCode = persistentCode; + } + + /** + * Returns the stable filesystem persistence code. + * + * @return stable positive code + */ + public int persistentCode() { + return persistentCode; + } + + /** + * Resolves a current stable persistence code. + * + * @param code persisted code + * @return matching state + * @throws IllegalArgumentException if the code is unknown + */ + public static State fromPersistentCode(int code) { + return switch (code) { + case 10 -> INTENT; + case 20 -> DISPATCHED; + case 30 -> CANCELLING; + case 40 -> SUCCEEDED; + case 50 -> FAILED; + case 60 -> CANCELLED; + case 70 -> RETIRED; + case 80 -> EXPIRED; + default -> throw new IllegalArgumentException("Unknown signing workflow state code: " + code); + }; + } + } + + /** + * Complete durable signing state. + * + * @param submissionId stable submission identifier + * @param namespace provider/store namespace + * @param fingerprint versioned semantic request fingerprint + * @param owner request owner + * @param createdAt store-validated creation time + * @param deadline request deadline + * @param request versioned request continuation + * @param state lifecycle state + * @param revision monotonic record revision + * @param fence monotonic claim fencing token + * @param leaseUntil optional active claim lease + * @param detailCode optional non-sensitive detail code + * @param result optional terminal signature result + * @param providerUpdatedAt optional durable provider terminal-transition time; + * the provider clock may be independent of the store + * clock, but successful completion must be strictly + * before {@code deadline} + */ + record Record(PkiId submissionId, String namespace, String fingerprint, Principal owner, Instant createdAt, + Instant deadline, EncodedObject request, State state, long revision, long fence, + Optional leaseUntil, Optional detailCode, Optional result, + Optional providerUpdatedAt) { + public Record { + Objects.requireNonNull(submissionId, "submissionId"); + Objects.requireNonNull(namespace, "namespace"); + Objects.requireNonNull(fingerprint, "fingerprint"); + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(createdAt, "createdAt"); + Objects.requireNonNull(deadline, "deadline"); + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(state, "state"); + Objects.requireNonNull(leaseUntil, "leaseUntil"); + Objects.requireNonNull(detailCode, "detailCode"); + Objects.requireNonNull(result, "result"); + Objects.requireNonNull(providerUpdatedAt, "providerUpdatedAt"); + if (namespace.isBlank() || fingerprint.isBlank()) { + throw new IllegalArgumentException("namespace and fingerprint must not be blank"); + } + if (deadline.isBefore(createdAt)) { + throw new IllegalArgumentException("deadline must not be before createdAt"); + } + if (revision < 0L || fence < 0L) { + throw new IllegalArgumentException("revision and fence must not be negative"); + } + } + } + + /** Result of idempotent intent creation. */ + enum CreateResult { + CREATED, ATTACHED, CONFLICT + } + + /** + * Returns store-authoritative time. + * + * @return current store time + */ + Instant signingNow(); + + /** + * Returns the stable trusted namespace owned by this store. + * + * @return namespace persisted for the lifetime of the store + */ + String signingNamespace(); + + /** + * Returns configured retention/identity horizon. + * + * @return positive horizon + */ + Duration signingHorizon(); + + /** + * Returns the accepted future clock skew for signing identifiers. + * + * @return non-negative permitted skew + */ + Duration signingPermittedSkew(); + + /** + * Creates an INTENT or attaches to an identical existing record. + * + * @param intent revision-zero INTENT + * @return creation result + */ + CreateResult createSignIntent(Record intent); + + /** + * Returns authoritative state for an identifier. + * + * @param submissionId stable submission identifier + * @return retained state, or empty when unknown or purged + */ + Optional getSignRecord(PkiId submissionId); + + /** + * Returns a stable snapshot of retained signing records. + * + * @return retained records + */ + List listSignRecords(); + + /** + * Atomically claims an intent and increments its revision and fencing token. + * + * @param submissionId stable submission identifier + * @param expectedRevision caller-observed revision + * @param lease positive claim duration + * @return claimed record, or empty when compare-and-set loses + */ + Optional tryClaimSign(PkiId submissionId, long expectedRevision, Duration lease); + + /** + * Atomically renews an owned lease. + * + * @param submissionId stable submission identifier + * @param expectedRevision caller-observed revision + * @param fence current fencing token + * @param lease positive renewal duration + * @return renewed record, or empty when compare-and-set loses + */ + Optional renewSignClaim(PkiId submissionId, long expectedRevision, long fence, Duration lease); + + /** + * Atomically transitions state when revision and fence are current. + * + *

      + * Cancellation follows {@code INTENT|DISPATCHED -> CANCELLING -> + * CANCELLED|SUCCEEDED|FAILED|EXPIRED}. The intermediate state must be durable + * before a provider cancellation call. A {@code CANCELLING -> CANCELLING} + * transition records that the external cancellation submission returned, + * without treating provider acceptance as terminal. + *

      + * + * @param submissionId stable submission identifier + * @param expectedRevision caller-observed revision + * @param fence current fencing token + * @param target allowed target state + * @param detailCode optional non-sensitive detail + * @param result signature result, required only for success + * @param providerUpdatedAt durable provider terminal-transition time from the + * provider clock domain; required for success and + * required to be strictly before the request deadline + * @return transitioned record, or empty when compare-and-set loses + */ + Optional transitionSign(PkiId submissionId, long expectedRevision, long fence, State target, + Optional detailCode, Optional result, Optional providerUpdatedAt); + + /** + * Atomically retires a terminal current record while retaining its identity + * and any successful result. Non-terminal records, including + * {@link State#CANCELLING}, are not retirable. + * + * @param submissionId stable submission identifier + * @param expectedRevision caller-observed revision + * @param fence current fencing token + * @return retired record, or empty when compare-and-set loses + */ + Optional retireSign(PkiId submissionId, long expectedRevision, long fence); + + /** + * Purges retired payload-bearing records after the configured horizon. + * Non-retired records, including active and cancellation-pending provider + * operations, must be retained for reconciliation. + * + * @return number of purged records + */ + int purgeExpiredSignRecords(); +} diff --git a/pki/src/main/java/zeroecho/pki/util/async/impl/DurableAsyncBus.java b/pki/src/main/java/zeroecho/pki/util/async/impl/DurableAsyncBus.java index 25e354b..143a8e8 100644 --- a/pki/src/main/java/zeroecho/pki/util/async/impl/DurableAsyncBus.java +++ b/pki/src/main/java/zeroecho/pki/util/async/impl/DurableAsyncBus.java @@ -90,8 +90,8 @@ import zeroecho.pki.util.async.codec.ResultCodec; *
    • {@link #update(Object, AsyncStatus, Optional)} persists a status * transition, optionally persists a result, dispatches an event, and applies * terminal-state cleanup rules.
    • - *
    • {@link #consumeResult(Object)} returns a successful result once and then - * deletes the operation from the in-memory state.
    • + *
    • {@link #consumeResult(Object)} returns a successful result once and + * durably tombstones the operation.
    • *
    * *

    Durability semantics

    @@ -100,6 +100,8 @@ import zeroecho.pki.util.async.codec.ResultCodec; *
  • Status transitions are persisted as internal {@code T1} records.
  • *
  • Results are persisted as internal {@code R1} records only when * {@link ResultCodec#persistsResults()} returns {@code true}.
  • + *
  • Consumption and explicit retirement are persisted as internal {@code D1} + * tombstones.
  • *
  • The log format is internal to this implementation and versioned by the * leading record token.
  • *
  • Corrupted lines encountered during replay are ignored with a warning, and @@ -146,6 +148,8 @@ public final class DurableAsyncBus // NOPMD private static final String REC_SNAPSHOT = "S1"; private static final String REC_STATUS = "T1"; private static final String REC_RESULT = "R1"; + private static final String REC_DELETE = "D1"; + private static final String ARG_OP_ID = "opId"; private final IdCodec opIdCodec; private final IdCodec ownerCodec; @@ -285,7 +289,7 @@ public final class DurableAsyncBus // NOPMD public AsyncOperationSnapshot submit(OpId opId, String type, Owner owner, EndpointId endpointId, Instant createdAt, Duration ttl) { - Objects.requireNonNull(opId, "opId"); + Objects.requireNonNull(opId, ARG_OP_ID); Objects.requireNonNull(type, "type"); Objects.requireNonNull(owner, "owner"); Objects.requireNonNull(endpointId, "endpointId"); @@ -354,7 +358,7 @@ public final class DurableAsyncBus // NOPMD */ @Override public void update(OpId opId, AsyncStatus status, Optional result) { - Objects.requireNonNull(opId, "opId"); + Objects.requireNonNull(opId, ARG_OP_ID); Objects.requireNonNull(status, "status"); Objects.requireNonNull(result, "result"); @@ -381,10 +385,11 @@ public final class DurableAsyncBus // NOPMD dispatchEvent(opId, snap, status, result); if (status.isTerminal()) { - // keep terminal status for diagnostics and deterministic polling; only - // successful operations retain their result for later consumption - if (status.state() != AsyncState.SUCCEEDED) { // NOPMD - dropActiveOperation(opId); + // All terminal operations leave the active set. Only successful + // operations retain their result for later consumption. + active.remove(opId); + if (status.state() != AsyncState.SUCCEEDED) { + results.remove(opId); } } } @@ -399,7 +404,7 @@ public final class DurableAsyncBus // NOPMD */ @Override public Optional status(OpId opId) { - Objects.requireNonNull(opId, "opId"); + Objects.requireNonNull(opId, ARG_OP_ID); return Optional.ofNullable(lastStatus.get(opId)); } @@ -419,7 +424,7 @@ public final class DurableAsyncBus // NOPMD */ @Override public Optional> snapshot(OpId opId) { - Objects.requireNonNull(opId, "opId"); + Objects.requireNonNull(opId, ARG_OP_ID); return Optional.ofNullable(active.get(opId)); } @@ -427,9 +432,9 @@ public final class DurableAsyncBus // NOPMD * Returns and removes a previously stored successful result. * *

    - * Result consumption is destructive. After a successful consume, the in-memory - * operation state is deleted completely through - * {@link #deleteOperation(Object)} to keep storage bounded. + * Result consumption is destructive. Before returning the result, this method + * appends a deletion tombstone and removes the complete in-memory operation + * state. Consequently, replay cannot make a consumed result visible again. *

    * * @param opId operation identifier; must not be {@code null} @@ -438,14 +443,14 @@ public final class DurableAsyncBus // NOPMD */ @Override public Optional consumeResult(OpId opId) { - Objects.requireNonNull(opId, "opId"); + Objects.requireNonNull(opId, ARG_OP_ID); - Result r = results.remove(opId); + Result r = results.get(opId); if (r == null) { return Optional.empty(); } - // on successful consumption, forget operation entirely (bounded storage) + store.appendLine(encodeDeleteLine(opId)); deleteOperation(opId); if (LOG.isLoggable(Level.INFO)) { @@ -455,6 +460,25 @@ public final class DurableAsyncBus // NOPMD return Optional.of(r); } + /** + * Durably retires all bus state for an operation. + * + *

    + * Retirement appends a tombstone before deleting in-memory state. Replay + * applies records in order, so snapshots, statuses, and results preceding the + * tombstone cannot resurrect after restart. A later explicit submission using + * the same identifier remains visible because its records follow the tombstone. + *

    + * + * @param opId operation identifier; must not be {@code null} + * @throws NullPointerException if {@code opId} is {@code null} + */ + public void retire(OpId opId) { + Objects.requireNonNull(opId, ARG_OP_ID); + store.appendLine(encodeDeleteLine(opId)); + deleteOperation(opId); + } + /** * Performs one maintenance and polling sweep. * @@ -502,7 +526,7 @@ public final class DurableAsyncBus // NOPMD } catch (RuntimeException ex) { // NOPMD // endpoint misbehaved; do not fail sweep if (LOG.isLoggable(Level.WARNING)) { - LOG.log(Level.WARNING, "Async endpoint status() failed; opId=" + safeOpId(opId), ex); + logSafeFailure("ENDPOINT_STATUS_FAILED", opId, ex); } continue; } @@ -523,7 +547,7 @@ public final class DurableAsyncBus // NOPMD res = endpoint.result(opId); } catch (RuntimeException ex) { // NOPMD if (LOG.isLoggable(Level.WARNING)) { - LOG.log(Level.WARNING, "Async endpoint result() failed; opId=" + safeOpId(opId), ex); + logSafeFailure("ENDPOINT_RESULT_FAILED", opId, ex); } } } @@ -616,7 +640,7 @@ public final class DurableAsyncBus // NOPMD h.onEvent(event); } catch (RuntimeException ex) { // NOPMD if (LOG.isLoggable(Level.WARNING)) { - LOG.log(Level.WARNING, "Async handler failed; opId=" + safeOpId(opId), ex); + logSafeFailure("HANDLER_FAILED", opId, ex); } } } @@ -652,10 +676,13 @@ public final class DurableAsyncBus // NOPMD } else if (line.startsWith(REC_RESULT + "|")) { applyResultLine(line); applied++; + } else if (line.startsWith(REC_DELETE + "|")) { + applyDeleteLine(line); + applied++; } } catch (RuntimeException ex) { // NOPMD // corrupted line: ignore safely without logging contents - LOG.log(Level.WARNING, "Corrupted async store line encountered; skipping.", ex); + logRecordRejected(ex); } } @@ -705,6 +732,12 @@ public final class DurableAsyncBus // NOPMD AsyncStatus st = new AsyncStatus(state, updatedAt, dc, details); lastStatus.put(opId, st); + if (st.isTerminal()) { + active.remove(opId); + if (state != AsyncState.SUCCEEDED) { + results.remove(opId); + } + } } /** @@ -722,7 +755,20 @@ public final class DurableAsyncBus // NOPMD // R1|opId|resultToken OpId opId = opIdCodec.decode(parts[1]); Result r = resultCodec.decode(parts[2]); - results.put(opId, r); + AsyncStatus status = lastStatus.get(opId); + if (active.containsKey(opId) || status != null && status.state() == AsyncState.SUCCEEDED) { + results.put(opId, r); + } + } + + /** + * Applies one persisted deletion tombstone. + * + * @param line deletion record line in internal {@code D1} format + */ + private void applyDeleteLine(String line) { + String[] parts = split(line, 2); + deleteOperation(opIdCodec.decode(parts[1])); } /** @@ -762,6 +808,10 @@ public final class DurableAsyncBus // NOPMD return REC_RESULT + "|" + opIdCodec.encode(opId) + "|" + resultCodec.encode(r); } + private String encodeDeleteLine(OpId opId) { + return REC_DELETE + "|" + opIdCodec.encode(opId); + } + /** * Encodes a details map into the internal status-details token form. * @@ -881,6 +931,20 @@ public final class DurableAsyncBus // NOPMD } } + private void logSafeFailure(String code, OpId opId, Throwable failure) { + if (LOG.isLoggable(Level.WARNING)) { + LOG.log(Level.WARNING, "Async operation failed: code={0}, opId={1}, exception={2}", + new Object[] { code, safeOpId(opId), failure.getClass().getName() }); + } + } + + private static void logRecordRejected(RuntimeException failure) { + if (LOG.isLoggable(Level.WARNING)) { + LOG.log(Level.WARNING, "Async store record rejected: code={0}, exception={1}", + new Object[] { "ASYNC_RECORD_INVALID", failure.getClass().getName() }); + } + } + /** * Encodes an endpoint identifier for safe logging and truncates long values. * diff --git a/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java b/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java new file mode 100644 index 0000000..f5a8bf1 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java @@ -0,0 +1,1114 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.e2e; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.MessageDigest; +import java.security.PublicKey; +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.pkcs.CertificationRequestInfo; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.AlgorithmIdentifier; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.pkcs.PKCS10CertificationRequest; +import org.bouncycastle.pkcs.PKCS10CertificationRequestBuilder; +import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import zeroecho.pki.api.CaService; +import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.Encoding; +import zeroecho.pki.api.FormatId; +import zeroecho.pki.api.KeyRef; +import zeroecho.pki.api.PkiException; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.SubjectRef; +import zeroecho.pki.api.Validity; +import zeroecho.pki.api.attr.AttributeSet; +import zeroecho.pki.api.attr.AttributeValue; +import zeroecho.pki.api.audit.AuditEvent; +import zeroecho.pki.api.ca.CaCreateCommand; +import zeroecho.pki.api.ca.CaImportCommand; +import zeroecho.pki.api.ca.CaKeyRotationCommand; +import zeroecho.pki.api.ca.CaRecord; +import zeroecho.pki.api.ca.CaRolloverCommand; +import zeroecho.pki.api.ca.IntermediateCertIssueCommand; +import zeroecho.pki.api.ca.IntermediateCreateCommand; +import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.credential.CredentialBundle; +import zeroecho.pki.api.credential.CredentialStatus; +import zeroecho.pki.api.issuance.IssueEndEntityCommand; +import zeroecho.pki.api.issuance.ReissueCommand; +import zeroecho.pki.api.issuance.RenewCommand; +import zeroecho.pki.api.issuance.ReplaceCommand; +import zeroecho.pki.api.request.CertificationRequest; +import zeroecho.pki.api.request.ParsedCertificationRequest; +import zeroecho.pki.api.request.ProofOfPossessionResult; +import zeroecho.pki.api.request.ProofOfPossessionStatus; +import zeroecho.pki.impl.core.DefaultIssuanceService; +import zeroecho.pki.impl.core.ManagedCaIssuance; +import zeroecho.pki.impl.core.VerifiedIssuanceCandidate; +import zeroecho.pki.impl.core.attr.SimpleAttributeSet; +import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes; +import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialIssuerBackend; +import zeroecho.pki.impl.framework.x509.bc.BcX509ProofOfPossessionVerifier; +import zeroecho.pki.spi.framework.CredentialFramework; +import zeroecho.pki.spi.framework.CredentialIssuerBackend; +import zeroecho.pki.testkit.PkiTestRuntime; + +/** + * End-to-end regression tests for PKI proof-of-possession issuance gates. + */ +final class PkiProofGateE2eTest { + + @Test + void issuerBackendApiRequiresOpaqueGateProducedInputs() throws Exception { + System.out.println("issuerBackendApiRequiresOpaqueGateProducedInputs"); + + assertTrue(Modifier.isFinal(VerifiedIssuanceCandidate.class.getModifiers())); + assertTrue(Modifier.isFinal(ManagedCaIssuance.class.getModifiers())); + assertTrue(java.util.Arrays.stream(VerifiedIssuanceCandidate.class.getDeclaredConstructors()) + .noneMatch(constructor -> Modifier.isPublic(constructor.getModifiers()))); + assertTrue(java.util.Arrays.stream(ManagedCaIssuance.class.getDeclaredConstructors()) + .noneMatch(constructor -> Modifier.isPublic(constructor.getModifiers()))); + Class managedKeyProof = Class.forName("zeroecho.pki.impl.core.CaProofGate$ManagedKeyProof"); + assertTrue(java.util.Arrays.stream(managedKeyProof.getDeclaredConstructors()) + .noneMatch(constructor -> Modifier.isPublic(constructor.getModifiers()))); + assertTrue(java.util.Arrays.stream(CredentialFramework.class.getMethods()) + .noneMatch(method -> method.getName().equals("issuerBackend"))); + + Method endEntity = java.util.Arrays.stream(CredentialIssuerBackend.class.getMethods()) + .filter(method -> method.getName().equals("issueEndEntity")) + .findFirst().orElseThrow(); + Method intermediate = java.util.Arrays.stream(CredentialIssuerBackend.class.getMethods()) + .filter(method -> method.getName().equals("issueIntermediateCertificate")) + .findFirst().orElseThrow(); + assertArrayEquals(new Class[] { VerifiedIssuanceCandidate.class }, endEntity.getParameterTypes()); + assertArrayEquals(new Class[] { ManagedCaIssuance.class }, intermediate.getParameterTypes()); + assertTrue(java.util.Arrays.stream(BcX509CredentialIssuerBackend.class.getMethods()) + .filter(method -> method.getName().startsWith("issue")) + .noneMatch(method -> java.util.Arrays.asList(method.getParameterTypes()) + .contains(IssueEndEntityCommand.class) + || java.util.Arrays.asList(method.getParameterTypes()) + .contains(IntermediateCertIssueCommand.class))); + + System.out.println("...backend methods=" + endEntity.getParameterTypes()[0].getSimpleName() + "," + + intermediate.getParameterTypes()[0].getSimpleName()); + System.out.println("issuerBackendApiRequiresOpaqueGateProducedInputs...ok"); + } + + @Test + void proofGatesAloneCanReachIssuerBackend(@TempDir Path tempDir) throws Exception { + System.out.println("proofGatesAloneCanReachIssuerBackend"); + + KeyPair rootKey = generateRsa(); + KeyPair subjectKey = generateRsa(); + KeyPair wrongKey = generateRsa(); + KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:root"); + KeyRef subjectKeyRef = new KeyRef("kref:v1:keyring:test:subject"); + Map keys = Map.of(rootKeyRef, rootKey, subjectKeyRef, subjectKey); + try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), keys)) { + CountingIssuerBackend counting = new CountingIssuerBackend(runtime.issuerBackend()); + DefaultIssuanceService issuance = new DefaultIssuanceService(runtime.store(), runtime.framework(), + counting, runtime.auditSink()); + ParsedCertificationRequest valid = parse(runtime, makeCsr(subjectKey, subjectKey, "CN=Leaf")); + + assertThrows(PkiException.class, + () -> issuance.issueEndEntity(new IssueEndEntityCommand(new PkiId("ca:absent"), + parse(runtime, makeCsr(subjectKey, wrongKey, "CN=Leaf")), "default", Optional.empty(), + new SimpleAttributeSet()))); + assertEquals(0, counting.endEntityCalls.get()); + + PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), + new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet())); + CredentialBundle issued = issuance.issueEndEntity(new IssueEndEntityCommand(rootCaId, valid, "default", + Optional.empty(), new SimpleAttributeSet())); + assertEquals(1, counting.endEntityCalls.get()); + assertArrayEquals(subjectKey.getPublic().getEncoded(), + new X509CertificateHolder(issued.credential().encoded().bytes()).getSubjectPublicKeyInfo() + .getEncoded()); + + CaService caService = runtime.caService(counting); + caService.createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId, + new SubjectRef("CN=Intermediate"), "default", Optional.of(subjectKeyRef), + new SimpleAttributeSet())); + assertEquals(1, counting.intermediateCalls.get()); + + runtime.replaceResolvedKey(subjectKeyRef, wrongKey.getPublic()); + assertThrows(PkiException.class, + () -> caService.createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), + rootCaId, new SubjectRef("CN=Rejected"), "default", Optional.of(subjectKeyRef), + new SimpleAttributeSet()))); + assertEquals(1, counting.intermediateCalls.get()); + + System.out.println("...backend calls=" + counting.endEntityCalls.get() + "/" + + counting.intermediateCalls.get()); + } + + System.out.println("proofGatesAloneCanReachIssuerBackend...ok"); + } + + @Test + void unsupportedIssuanceVariantsFailWithoutSideEffects(@TempDir Path tempDir) throws Exception { + System.out.println("unsupportedIssuanceVariantsFailWithoutSideEffects"); + + try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), Map.of())) { + CountingIssuerBackend counting = new CountingIssuerBackend(runtime.issuerBackend()); + DefaultIssuanceService issuance = new DefaultIssuanceService(runtime.store(), runtime.framework(), + counting, runtime.auditSink()); + CaService caService = runtime.caService(counting); + ParsedCertificationRequest request = new ParsedCertificationRequest(new PkiId("csr:unsupported"), + runtime.framework().formatId(), new SubjectRef("CN=Unsupported"), + new EncodedObject(Encoding.DER, new byte[] { 1 }), Optional.empty(), Optional.empty(), + new SimpleAttributeSet()); + PkiId objectId = new PkiId("object:unsupported"); + long filesBefore; + try (java.util.stream.Stream files = Files.walk(tempDir)) { + filesBefore = files.filter(Files::isRegularFile).count(); + } + + assertThrows(PkiException.class, + () -> issuance.renew(new RenewCommand(objectId, Optional.empty(), new SimpleAttributeSet()))); + assertThrows(PkiException.class, + () -> issuance.replace(new ReplaceCommand(objectId, request, "default", + new SimpleAttributeSet()))); + assertThrows(PkiException.class, + () -> issuance.reissue(new ReissueCommand(objectId, new SimpleAttributeSet()))); + assertThrows(PkiException.class, + () -> caService.rolloverCaCertificate(new CaRolloverCommand(objectId, Optional.empty(), + Optional.empty(), new SimpleAttributeSet()))); + assertThrows(PkiException.class, + () -> caService.rotateCaKey(new CaKeyRotationCommand(objectId, Optional.empty(), + Optional.empty(), new SimpleAttributeSet()))); + + long filesAfter; + try (java.util.stream.Stream files = Files.walk(tempDir)) { + filesAfter = files.filter(Files::isRegularFile).count(); + } + assertEquals(filesBefore, filesAfter); + assertEquals(0, counting.endEntityCalls.get()); + assertEquals(0, counting.intermediateCalls.get()); + assertEquals(0, runtime.submittedSignCount()); + assertTrue(runtime.store().listCas().isEmpty()); + assertTrue(runtime.store().listWorkflowStates().isEmpty()); + assertTrue(runtime.auditSink().snapshot().isEmpty()); + + System.out.println("...unsupported operations=5"); + } + + System.out.println("unsupportedIssuanceVariantsFailWithoutSideEffects...ok"); + } + + @Test + void endEntityGateRejectsMalformedTamperedAndSubstitutedRequests(@TempDir Path tempDir) throws Exception { + System.out.println("endEntityGateRejectsMalformedTamperedAndSubstitutedRequests"); + + KeyPair subjectKey = generateRsa(); + KeyPair otherKey = generateRsa(); + KeyRef subjectKeyRef = new KeyRef("kref:v1:keyring:test:subject"); + try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), + Map.of(subjectKeyRef, subjectKey))) { + PKCS10CertificationRequest validCsr = makeCsr(subjectKey, subjectKey, "CN=Subject"); + ParsedCertificationRequest valid = parse(runtime, validCsr); + ParsedCertificationRequest pss = parse(runtime, + makeCsr(subjectKey, subjectKey, "CN=PssSubject", "SHA256withRSAandMGF1")); + ProofOfPossessionResult pssProof = new BcX509ProofOfPossessionVerifier().verify(pss, + new zeroecho.pki.api.issuance.VerificationPolicy(true, Optional.empty())); + assertEquals(ProofOfPossessionStatus.VERIFIED, pssProof.status()); + org.bouncycastle.asn1.pkcs.CertificationRequest original = validCsr.toASN1Structure(); + CertificationRequestInfo requestInfo = original.getCertificationRequestInfo(); + org.bouncycastle.asn1.pkcs.CertificationRequest unknownAlgorithm = + new org.bouncycastle.asn1.pkcs.CertificationRequest(requestInfo, + new AlgorithmIdentifier(new ASN1ObjectIdentifier("1.2.3.4.5.6.7")), + original.getSignature()); + ParsedCertificationRequest unsupported = parse(runtime, + new PKCS10CertificationRequest(unknownAlgorithm)); + ProofOfPossessionResult unsupportedProof = new BcX509ProofOfPossessionVerifier().verify(unsupported, + new zeroecho.pki.api.issuance.VerificationPolicy(true, Optional.empty())); + assertEquals(ProofOfPossessionStatus.FAILED, unsupportedProof.status()); + + assertRejected(runtime, withAttributes(valid, new SimpleAttributeSet()), "CSR_MISSING"); + assertRejected(runtime, withCsr(valid, new byte[] { 0x01 }), "CSR_MALFORMED"); + + byte[] tampered = csrDer(valid).clone(); + tampered[tampered.length - 1] ^= 0x01; + ParsedCertificationRequest tamperedParsed = parse(runtime, new PKCS10CertificationRequest(tampered)); + assertRejected(runtime, tamperedParsed, "PROOF_FAILED"); + + ParsedCertificationRequest wrongSigner = parse(runtime, + makeCsr(subjectKey, otherKey, "CN=Subject")); + assertRejected(runtime, wrongSigner, "PROOF_FAILED"); + + assertRejected(runtime, + new ParsedCertificationRequest(new PkiId("csr:substituted"), valid.formatId(), valid.subjectRef(), + valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(), + valid.attributes()), + "REQUEST_ID_MISMATCH"); + assertRejected(runtime, + new ParsedCertificationRequest(valid.requestId(), valid.formatId(), new SubjectRef("CN=Other"), + valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(), + valid.attributes()), + "SUBJECT_MISMATCH"); + assertRejected(runtime, + new ParsedCertificationRequest(valid.requestId(), valid.formatId(), valid.subjectRef(), + new EncodedObject(Encoding.DER, otherKey.getPublic().getEncoded()), + valid.requestedValidity(), valid.requestedProfileId(), valid.attributes()), + "SPKI_MISMATCH"); + assertRejected(runtime, + new ParsedCertificationRequest(valid.requestId(), new FormatId("unsupported"), valid.subjectRef(), + valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(), + valid.attributes()), + "FORMAT_UNSUPPORTED"); + byte[] maximum = new byte[1024 * 1024]; + System.arraycopy(csrDer(valid), 0, maximum, 0, csrDer(valid).length); + assertRejected(runtime, withCsr(valid, maximum), "CSR_MALFORMED"); + assertRejected(runtime, withCsr(valid, new byte[1024 * 1024 + 1]), "CSR_TOO_LARGE"); + + assertTrue(runtime.store().listCas().isEmpty()); + AuditEvent last = runtime.auditSink().snapshot().get(runtime.auditSink().snapshot().size() - 1); + assertEquals("ISSUE_END_ENTITY_REJECTED", last.action()); + assertEquals("SYSTEM", last.principal().type()); + assertEquals("pki", last.principal().name()); + assertEquals(Set.of("code"), last.details().keySet()); + } + + System.out.println("endEntityGateRejectsMalformedTamperedAndSubstitutedRequests...ok"); + } + + @Test + void endEntityGateForcesRequiredPolicyAndRejectsEveryNonVerifiedStatus(@TempDir Path tempDir) throws Exception { + System.out.println("endEntityGateForcesRequiredPolicyAndRejectsEveryNonVerifiedStatus"); + + KeyPair subjectKey = generateRsa(); + KeyRef subjectKeyRef = new KeyRef("kref:v1:keyring:test:subject"); + for (ProofOfPossessionStatus status : new ProofOfPossessionStatus[] { + ProofOfPossessionStatus.NOT_PRESENT, + ProofOfPossessionStatus.NOT_SUPPORTED, + ProofOfPossessionStatus.FAILED }) { + AtomicBoolean required = new AtomicBoolean(); + Path caseDir = tempDir.resolve(status.name()); + try (PkiTestRuntime runtime = PkiTestRuntime.create(caseDir, caseDir.resolve("bus.log"), + Map.of(subjectKeyRef, subjectKey), Map.of(subjectKeyRef, subjectKey.getPublic()), + (request, policy) -> { + required.set(policy.requireProofOfPossession()); + return new ProofOfPossessionResult(status, Optional.empty()); + })) { + ParsedCertificationRequest parsed = parse(runtime, makeCsr(subjectKey, subjectKey, "CN=Subject")); + assertThrows(PkiException.class, () -> issue(runtime, parsed)); + assertTrue(required.get()); + assertEquals("PROOF_" + status.name(), + runtime.auditSink().snapshot().get(0).details().get("code")); + assertTrue(runtime.store().listCas().isEmpty()); + } + } + + System.out.println("endEntityGateForcesRequiredPolicyAndRejectsEveryNonVerifiedStatus...ok"); + } + + @Test + void verifiedSnapshotSurvivesCallerArrayMutationAndIssuerOverrides(@TempDir Path tempDir) throws Exception { + System.out.println("verifiedSnapshotSurvivesCallerArrayMutationAndIssuerOverrides"); + + KeyPair rootKey = generateRsa(); + KeyPair subjectKey = generateRsa(); + KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:root"); + KeyRef subjectKeyRef = new KeyRef("kref:v1:keyring:test:subject"); + AtomicReference mutation = new AtomicReference<>(() -> { + }); + BcX509ProofOfPossessionVerifier delegate = new BcX509ProofOfPossessionVerifier(); + + Map signingKeys = Map.of(rootKeyRef, rootKey, subjectKeyRef, subjectKey); + Map resolvedKeys = Map.of(rootKeyRef, rootKey.getPublic(), subjectKeyRef, + subjectKey.getPublic()); + try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), signingKeys, + resolvedKeys, (request, policy) -> { + ProofOfPossessionResult result = delegate.verify(request, policy); + mutation.get().run(); + return result; + })) { + PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), + new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet())); + ParsedCertificationRequest parsed = parse(runtime, makeCsr(subjectKey, subjectKey, "CN=Subject")); + byte[] callerCsr = csrDer(parsed); + byte[] callerSpki = parsed.publicKeyInfo().bytes(); + mutation.set(() -> { + callerCsr[0] ^= 0x7f; + callerSpki[0] ^= 0x7f; + }); + + AttributeSet hostileOverrides = SimpleAttributeSet.builder() + .put(BcX509Attributes.ISSUER_CERT_DER, new AttributeValue.BytesValue(new byte[] { 0x01 })) + .put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue("attacker-key")) + .build(); + CredentialBundle bundle = runtime.issuanceService() + .issueEndEntity(new IssueEndEntityCommand(rootCaId, parsed, "default", Optional.empty(), + hostileOverrides)); + + X509CertificateHolder issued = new X509CertificateHolder(bundle.credential().encoded().bytes()); + assertEquals("CN=Root", issued.getIssuer().toString()); + assertEquals("CN=Subject", issued.getSubject().toString()); + assertArrayEquals(subjectKey.getPublic().getEncoded(), issued.getSubjectPublicKeyInfo().getEncoded()); + } + + System.out.println("verifiedSnapshotSurvivesCallerArrayMutationAndIssuerOverrides...ok"); + } + + @Test + void caProofGatesVerifyRootAndManagedIntermediateKeys(@TempDir Path tempDir) throws Exception { + System.out.println("caProofGatesVerifyRootAndManagedIntermediateKeys"); + + KeyPair expectedRoot = generateRsa(); + KeyPair wrongRootSigner = generateRsa(); + KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:root"); + Path wrongDir = tempDir.resolve("wrong-root"); + try (PkiTestRuntime runtime = PkiTestRuntime.create(wrongDir, wrongDir.resolve("bus.log"), + Map.of(rootKeyRef, wrongRootSigner), Map.of(rootKeyRef, expectedRoot.getPublic()), + new BcX509ProofOfPossessionVerifier())) { + assertThrows(PkiException.class, + () -> runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), + new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), + new SimpleAttributeSet()))); + assertTrue(runtime.store().listCas().isEmpty()); + assertTrue(runtime.store().listWorkflowStates().isEmpty()); + assertEquals(1, runtime.submittedSignCount()); + assertEquals("MANAGED_KEY_PROOF_FAILED", + runtime.auditSink().snapshot().get(0).details().get("code")); + } + Path failedDir = tempDir.resolve("failed-workflow"); + try (PkiTestRuntime runtime = PkiTestRuntime.create(failedDir, failedDir.resolve("bus.log"), Map.of(), + Map.of(rootKeyRef, expectedRoot.getPublic()), new BcX509ProofOfPossessionVerifier())) { + assertThrows(PkiException.class, + () -> runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), + new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), + new SimpleAttributeSet()))); + assertTrue(runtime.store().listWorkflowStates().isEmpty()); + assertEquals(1, runtime.submittedSignCount()); + } + + KeyPair rootKey = generateRsa(); + KeyPair intermediateKey = generateRsa(); + KeyRef intermediateKeyRef = new KeyRef("kref:v1:keyring:test:intermediate"); + Path validDir = tempDir.resolve("valid-intermediate"); + Map keys = new HashMap<>(); + keys.put(rootKeyRef, rootKey); + keys.put(intermediateKeyRef, intermediateKey); + try (PkiTestRuntime runtime = PkiTestRuntime.create(validDir, validDir.resolve("bus.log"), keys)) { + PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), + new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet())); + AttributeSet hostile = hostileIntermediateAttributes(expectedRoot.getPublic()); + PkiId intermediateCaId = runtime.caService() + .createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId, + new SubjectRef("CN=Intermediate"), "default", Optional.of(intermediateKeyRef), hostile)); + + Credential first = runtime.caService().getCa(intermediateCaId).caCredentials().get(0); + X509CertificateHolder firstHolder = new X509CertificateHolder(first.encoded().bytes()); + assertEquals("CN=Root", firstHolder.getIssuer().toString()); + assertEquals("CN=Intermediate", firstHolder.getSubject().toString()); + assertArrayEquals(intermediateKey.getPublic().getEncoded(), + firstHolder.getSubjectPublicKeyInfo().getEncoded()); + + Credential additional = runtime.caService() + .issueIntermediateCertificate(new IntermediateCertIssueCommand(runtime.framework().formatId(), + rootCaId, intermediateCaId, "default", Optional.empty(), hostile)); + X509CertificateHolder additionalHolder = new X509CertificateHolder(additional.encoded().bytes()); + assertEquals("CN=Intermediate", additionalHolder.getSubject().toString()); + assertArrayEquals(intermediateKey.getPublic().getEncoded(), + additionalHolder.getSubjectPublicKeyInfo().getEncoded()); + } + + System.out.println("caProofGatesVerifyRootAndManagedIntermediateKeys...ok"); + } + + @Test + void caIssuerImportAndWorkflowFailuresLeaveNoDurableSideEffects(@TempDir Path tempDir) throws Exception { + System.out.println("caIssuerImportAndWorkflowFailuresLeaveNoDurableSideEffects"); + + KeyPair rootKey = generateRsa(); + KeyPair replacementRootKey = generateRsa(); + KeyPair intermediateKey = generateRsa(); + KeyPair subjectKey = generateRsa(); + KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:root"); + KeyRef intermediateKeyRef = new KeyRef("kref:v1:keyring:test:intermediate"); + + Path issuerDir = tempDir.resolve("issuer-mismatch"); + try (PkiTestRuntime runtime = PkiTestRuntime.create(issuerDir, issuerDir.resolve("bus.log"), + Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey))) { + PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), + new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet())); + assertEquals(2, runtime.submittedSignCount()); + runtime.replaceManagedKey(rootKeyRef, replacementRootKey); + assertThrows(PkiException.class, + () -> runtime.caService() + .createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), + rootCaId, new SubjectRef("CN=Intermediate"), "default", + Optional.of(intermediateKeyRef), new SimpleAttributeSet()))); + assertEquals(4, runtime.submittedSignCount()); + assertEquals(1, runtime.store().listCas().size()); + assertTrue(runtime.store().listWorkflowStates().isEmpty()); + assertEquals("ISSUER_MANAGED_KEY_MISMATCH", + runtime.auditSink().snapshot().get(runtime.auditSink().snapshot().size() - 1).details() + .get("code")); + } + + Path additionalDir = tempDir.resolve("additional-issuer-mismatch"); + try (PkiTestRuntime runtime = PkiTestRuntime.create(additionalDir, additionalDir.resolve("bus.log"), + Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey))) { + PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), + new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet())); + PkiId intermediateCaId = runtime.caService() + .createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId, + new SubjectRef("CN=Intermediate"), "default", Optional.of(intermediateKeyRef), + new SimpleAttributeSet())); + assertEquals(5, runtime.submittedSignCount()); + runtime.replaceManagedKey(rootKeyRef, replacementRootKey); + assertThrows(PkiException.class, + () -> runtime.caService().issueIntermediateCertificate( + new IntermediateCertIssueCommand(runtime.framework().formatId(), rootCaId, + intermediateCaId, "default", Optional.empty(), new SimpleAttributeSet()))); + assertEquals(7, runtime.submittedSignCount()); + assertEquals(1, runtime.caService().getCa(intermediateCaId).caCredentials().size()); + assertTrue(runtime.store().listWorkflowStates().isEmpty()); + } + + byte[] rootCertificate; + byte[] intermediateCertificate; + byte[] leafCertificate; + Path sourceDir = tempDir.resolve("import-source"); + try (PkiTestRuntime source = PkiTestRuntime.create(sourceDir, sourceDir.resolve("bus.log"), + Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey))) { + PkiId rootCaId = source.caService().createRoot(new CaCreateCommand(source.framework().formatId(), + new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet())); + rootCertificate = source.caService().getCa(rootCaId).caCredentials().get(0).encoded().bytes().clone(); + PkiId intermediateCaId = source.caService() + .createIntermediate(new IntermediateCreateCommand(source.framework().formatId(), rootCaId, + new SubjectRef("CN=Intermediate"), "default", Optional.of(intermediateKeyRef), + new SimpleAttributeSet())); + intermediateCertificate = source.caService().getCa(intermediateCaId).caCredentials().get(0).encoded() + .bytes().clone(); + ParsedCertificationRequest leaf = parse(source, makeCsr(subjectKey, subjectKey, "CN=Leaf")); + leafCertificate = source.issuanceService() + .issueEndEntity(new IssueEndEntityCommand(rootCaId, leaf, "default", Optional.empty(), + new SimpleAttributeSet())) + .credential().encoded().bytes().clone(); + } + Path importDir = tempDir.resolve("import-mismatch"); + try (PkiTestRuntime target = PkiTestRuntime.create(importDir, importDir.resolve("bus.log"), + Map.of(rootKeyRef, replacementRootKey))) { + CaImportCommand command = new CaImportCommand(target.framework().formatId(), new SubjectRef("CN=Root"), + "default", rootKeyRef, new EncodedObject(Encoding.DER, rootCertificate), + new SimpleAttributeSet()); + assertThrows(PkiException.class, () -> target.caService().importRoot(command)); + assertTrue(target.store().listCas().isEmpty()); + assertTrue(target.store().listWorkflowStates().isEmpty()); + assertEquals("ROOT_MANAGED_KEY_MISMATCH", + target.auditSink().snapshot().get(target.auditSink().snapshot().size() - 1).details().get("code")); + } + + Path mutationDir = tempDir.resolve("import-mutation"); + byte[] callerOwnedCertificate = rootCertificate.clone(); + byte[] expectedImportedCertificate = rootCertificate.clone(); + try (PkiTestRuntime target = PkiTestRuntime.create(mutationDir, mutationDir.resolve("bus.log"), + Map.of(rootKeyRef, rootKey))) { + target.onPublicKeyResolve(() -> callerOwnedCertificate[callerOwnedCertificate.length - 1] ^= 0x01); + PkiId importedCaId = target.caService().importRoot(new CaImportCommand(target.framework().formatId(), + new SubjectRef("CN=Root"), "default", rootKeyRef, + new EncodedObject(Encoding.DER, callerOwnedCertificate), new SimpleAttributeSet())); + assertArrayEquals(expectedImportedCertificate, + target.caService().getCa(importedCaId).caCredentials().get(0).encoded().bytes()); + } + + assertInvalidRootImport(tempDir.resolve("import-leaf"), rootKeyRef, rootKey, leafCertificate, "CN=Leaf"); + assertInvalidRootImport(tempDir.resolve("import-non-self"), intermediateKeyRef, intermediateKey, + intermediateCertificate, "CN=Intermediate"); + assertInvalidRootImport(tempDir.resolve("import-subject"), rootKeyRef, rootKey, rootCertificate, "CN=Wrong"); + byte[] invalidSelfSignature = rootCertificate.clone(); + invalidSelfSignature[invalidSelfSignature.length - 1] ^= 0x01; + assertInvalidRootImport(tempDir.resolve("import-signature"), rootKeyRef, rootKey, invalidSelfSignature, + "CN=Root"); + + Path signingFailureDir = tempDir.resolve("signing-failure"); + try (PkiTestRuntime signingFailure = PkiTestRuntime.create(signingFailureDir, + signingFailureDir.resolve("bus.log"), Map.of())) { + assertThrows(PkiException.class, + () -> signingFailure.caService().createRoot(new CaCreateCommand( + signingFailure.framework().formatId(), + new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), + new SimpleAttributeSet()))); + assertTrue(signingFailure.store().listCas().isEmpty()); + assertTrue(signingFailure.store().listWorkflowStates().isEmpty()); + assertTrue(signingFailure.store().listSignRecords().isEmpty()); + assertTrue(signingFailure.store().listPublicationRecords().isEmpty()); + assertFalse(signingFailure.hasRunningSignatureOperations()); + assertEquals(0, signingFailure.submittedSignCount()); + assertEquals("MANAGED_KEY_UNAVAILABLE", + signingFailure.auditSink().snapshot() + .get(signingFailure.auditSink().snapshot().size() - 1).details().get("code")); + } + + System.out.println("caIssuerImportAndWorkflowFailuresLeaveNoDurableSideEffects...ok"); + } + + private static void assertInvalidRootImport(Path rootDir, KeyRef keyRef, KeyPair keyPair, byte[] certificate, + String subject) throws Exception { + try (PkiTestRuntime runtime = PkiTestRuntime.create(rootDir, rootDir.resolve("bus.log"), + Map.of(keyRef, keyPair))) { + CaImportCommand command = new CaImportCommand(runtime.framework().formatId(), new SubjectRef(subject), + "default", keyRef, new EncodedObject(Encoding.DER, certificate), new SimpleAttributeSet()); + assertThrows(PkiException.class, () -> runtime.caService().importRoot(command)); + assertTrue(runtime.store().listCas().isEmpty()); + assertEquals(0, runtime.submittedSignCount()); + assertEquals("ROOT_CREDENTIAL_INVALID", + runtime.auditSink().snapshot().get(runtime.auditSink().snapshot().size() - 1).details().get("code")); + } + } + + @Test + void endEntityPostconditionsAndAuditFailureRejectBeforePersistence(@TempDir Path tempDir) throws Exception { + System.out.println("endEntityPostconditionsAndAuditFailureRejectBeforePersistence"); + + KeyPair rootKey = generateRsa(); + KeyPair subjectKey = generateRsa(); + KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:root"); + Path runtimeDir = tempDir.resolve("runtime"); + try (PkiTestRuntime runtime = PkiTestRuntime.create(runtimeDir, runtimeDir.resolve("bus.log"), + Map.of(rootKeyRef, rootKey))) { + PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), + new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet())); + ParsedCertificationRequest subject = parse(runtime, makeCsr(subjectKey, subjectKey, "CN=Subject")); + ParsedCertificationRequest substitute = parse(runtime, + makeCsr(subjectKey, subjectKey, "CN=Substitute")); + AtomicReference substitutedBundle = new AtomicReference<>(); + CredentialIssuerBackend delegateBackend = runtime.issuerBackend(); + CredentialIssuerBackend throwingBackend = new CredentialIssuerBackend() { + @Override + public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { + throw new IllegalStateException("DO_NOT_LOG_SIGNATURE_SENTINEL"); + } + + @Override + public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { + throw new IllegalStateException("DO_NOT_LOG_SIGNATURE_SENTINEL"); + } + }; + DefaultIssuanceService throwingBackendService = new DefaultIssuanceService(runtime.store(), + runtime.framework(), throwingBackend, runtime.auditSink()); + PkiException backendRejection = assertThrows(PkiException.class, + () -> throwingBackendService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, + "default", Optional.empty(), new SimpleAttributeSet()))); + assertThrowableRedacted(backendRejection, "DO_NOT_LOG_SIGNATURE_SENTINEL"); + + CredentialIssuerBackend maliciousBackend = new CredentialIssuerBackend() { + @Override + public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { + CredentialBundle bundle = delegateBackend.issueEndEntity(candidate); + Credential raw = bundle.credential(); + Credential forgedMetadata = new Credential(raw.credentialId(), raw.formatId(), raw.issuerRef(), + substitute.subjectRef(), raw.validity(), raw.serialOrUniqueId(), raw.publicKeyId(), + raw.profileId(), raw.status(), raw.encoded(), raw.attributes()); + bundle = new CredentialBundle(forgedMetadata, bundle.supportingObjects()); + substitutedBundle.set(bundle); + return bundle; + } + + @Override + public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { + return delegateBackend.issueIntermediateCertificate(issuance); + } + }; + DefaultIssuanceService service = new DefaultIssuanceService(runtime.store(), runtime.framework(), + maliciousBackend, runtime.auditSink()); + assertThrows(PkiException.class, + () -> service.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, "default", + Optional.empty(), new SimpleAttributeSet()))); + Credential substitutedCredential = substitutedBundle.get().credential(); + assertTrue(runtime.store().getCredential(substitutedCredential.credentialId()).isEmpty()); + assertEquals("BACKEND_CREDENTIAL_MISMATCH", + runtime.auditSink().snapshot().get(runtime.auditSink().snapshot().size() - 1).details().get("code")); + + CredentialIssuerBackend invalidSignatureBackend = new CredentialIssuerBackend() { + @Override + public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { + CredentialBundle rawBundle = delegateBackend.issueEndEntity(candidate); + Credential raw = rawBundle.credential(); + byte[] invalid = raw.encoded().bytes().clone(); + invalid[invalid.length - 1] ^= 0x01; + Credential invalidCredential = new Credential(raw.credentialId(), raw.formatId(), + raw.issuerRef(), raw.subjectRef(), raw.validity(), raw.serialOrUniqueId(), + raw.publicKeyId(), raw.profileId(), raw.status(), + new EncodedObject(Encoding.DER, invalid), raw.attributes()); + return new CredentialBundle(invalidCredential, rawBundle.supportingObjects()); + } + + @Override + public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { + return delegateBackend.issueIntermediateCertificate(issuance); + } + }; + DefaultIssuanceService invalidSignatureService = new DefaultIssuanceService(runtime.store(), + runtime.framework(), invalidSignatureBackend, runtime.auditSink()); + assertThrows(PkiException.class, + () -> invalidSignatureService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, + "default", Optional.empty(), new SimpleAttributeSet()))); + + AtomicReference rawBundle = new AtomicReference<>(); + CredentialIssuerBackend mutableBackend = new CredentialIssuerBackend() { + @Override + public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { + CredentialBundle raw = delegateBackend.issueEndEntity(candidate); + rawBundle.set(raw); + return raw; + } + + @Override + public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { + return delegateBackend.issueIntermediateCertificate(issuance); + } + }; + DefaultIssuanceService snapshotService = new DefaultIssuanceService(runtime.store(), + runtime.framework(), mutableBackend, runtime.auditSink()); + CredentialBundle returned = snapshotService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, + "default", Optional.empty(), new SimpleAttributeSet())); + byte[] expectedLeaf = returned.credential().encoded().bytes().clone(); + rawBundle.get().credential().encoded().bytes()[0] ^= 0x01; + rawBundle.get().supportingObjects().get(0).bytes()[0] ^= 0x01; + assertArrayEquals(expectedLeaf, returned.credential().encoded().bytes()); + assertArrayEquals(expectedLeaf, + runtime.store().getCredential(returned.credential().credentialId()).orElseThrow().encoded() + .bytes()); + + CaRecord root = runtime.caService().getCa(rootCaId); + Credential original = root.caCredentials().get(0); + Credential revoked = new Credential(original.credentialId(), original.formatId(), original.issuerRef(), + original.subjectRef(), original.validity(), original.serialOrUniqueId(), original.publicKeyId(), + original.profileId(), CredentialStatus.REVOKED, original.encoded(), original.attributes()); + runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(), + root.subjectRef(), List.of(revoked))); + int before = runtime.submittedSignCount(); + assertThrows(PkiException.class, + () -> runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, + "default", Optional.empty(), new SimpleAttributeSet()))); + assertEquals(before, runtime.submittedSignCount()); + + Validity expiredValidity = new Validity(Instant.now().minus(Duration.ofDays(2)), + Instant.now().minus(Duration.ofDays(1))); + Credential expired = new Credential(original.credentialId(), original.formatId(), original.issuerRef(), + original.subjectRef(), expiredValidity, original.serialOrUniqueId(), original.publicKeyId(), + original.profileId(), CredentialStatus.ISSUED, original.encoded(), original.attributes()); + runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(), + root.subjectRef(), List.of(expired))); + assertThrows(PkiException.class, + () -> runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, + "default", Optional.empty(), new SimpleAttributeSet()))); + assertEquals(before, runtime.submittedSignCount()); + + ParsedCertificationRequest missing = withAttributes(subject, new SimpleAttributeSet()); + AttributeSet hostileAttributes = new AttributeSet() { + @Override + public Set ids() { + return Set.of(); + } + + @Override + public Optional get(zeroecho.pki.api.attr.AttributeId id) { + throw new IllegalStateException("DO_NOT_LOG_CSR_SENTINEL"); + } + + @Override + public List getAll(zeroecho.pki.api.attr.AttributeId id) { + return List.of(); + } + }; + PkiException parserRejection = assertThrows(PkiException.class, + () -> runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(rootCaId, + withAttributes(subject, hostileAttributes), "default", Optional.empty(), + new SimpleAttributeSet()))); + assertThrowableRedacted(parserRejection, "DO_NOT_LOG_CSR_SENTINEL"); + + DefaultIssuanceService failingAudit = new DefaultIssuanceService(runtime.store(), runtime.framework(), + runtime.issuerBackend(), event -> { + throw new IllegalStateException("DO_NOT_LOG_PAYLOAD_SENTINEL"); + }); + PkiException rejection = assertThrows(PkiException.class, + () -> failingAudit.issueEndEntity(new IssueEndEntityCommand(rootCaId, missing, "default", + Optional.empty(), new SimpleAttributeSet()))); + assertTrue(rejection.getMessage().contains("CSR_MISSING")); + assertThrowableRedacted(rejection, "DO_NOT_LOG_PAYLOAD_SENTINEL"); + } + + System.out.println("endEntityPostconditionsAndAuditFailureRejectBeforePersistence...ok"); + } + + @Test + void intermediateBackendOutputIsValidatedAndSnapshotted(@TempDir Path tempDir) throws Exception { + KeyPair rootKey = generateRsa(); + KeyPair intermediateKey = generateRsa(); + KeyPair wrongKey = generateRsa(); + KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:root"); + KeyRef intermediateKeyRef = new KeyRef("kref:v1:keyring:test:intermediate"); + try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), + Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey))) { + PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), + new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet())); + CredentialIssuerBackend delegate = runtime.issuerBackend(); + CredentialIssuerBackend wrongKeyBackend = new CredentialIssuerBackend() { + @Override + public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { + return delegate.issueEndEntity(candidate); + } + + @Override + public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { + Credential raw = delegate.issueIntermediateCertificate(issuance); + return rebuildIntermediateIdentity(raw, rootKey, Optional.of(wrongKey.getPublic()), + Optional.empty()); + } + }; + CaService wrongKeyService = runtime.caService(wrongKeyBackend); + assertThrows(PkiException.class, + () -> wrongKeyService.createIntermediate(new IntermediateCreateCommand(runtime.framework() + .formatId(), rootCaId, new SubjectRef("CN=Intermediate"), "default", + Optional.of(intermediateKeyRef), new SimpleAttributeSet()))); + assertEquals(1, runtime.store().listCas().size()); + + PkiId intermediateCaId = runtime.caService() + .createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId, + new SubjectRef("CN=Intermediate"), "default", Optional.of(intermediateKeyRef), + new SimpleAttributeSet())); + CredentialIssuerBackend wrongSubjectBackend = new CredentialIssuerBackend() { + @Override + public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { + return delegate.issueEndEntity(candidate); + } + + @Override + public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { + Credential raw = delegate.issueIntermediateCertificate(issuance); + return rebuildIntermediateIdentity(raw, rootKey, Optional.empty(), + Optional.of("CN=WrongIntermediate")); + } + }; + CaService wrongSubjectService = runtime.caService(wrongSubjectBackend); + assertThrows(PkiException.class, + () -> wrongSubjectService.issueIntermediateCertificate(new IntermediateCertIssueCommand( + runtime.framework().formatId(), rootCaId, intermediateCaId, "default", Optional.empty(), + new SimpleAttributeSet()))); + assertEquals(1, runtime.caService().getCa(intermediateCaId).caCredentials().size()); + + CredentialIssuerBackend invalidSignatureBackend = new CredentialIssuerBackend() { + @Override + public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { + return delegate.issueEndEntity(candidate); + } + + @Override + public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { + Credential raw = delegate.issueIntermediateCertificate(issuance); + byte[] invalid = raw.encoded().bytes().clone(); + invalid[invalid.length - 1] ^= 0x01; + return new Credential(raw.credentialId(), raw.formatId(), raw.issuerRef(), + raw.subjectRef(), raw.validity(), raw.serialOrUniqueId(), raw.publicKeyId(), + raw.profileId(), raw.status(), new EncodedObject(Encoding.DER, invalid), + raw.attributes()); + } + }; + CaService invalidSignatureService = runtime.caService(invalidSignatureBackend); + assertThrows(PkiException.class, + () -> invalidSignatureService.issueIntermediateCertificate(new IntermediateCertIssueCommand( + runtime.framework().formatId(), rootCaId, intermediateCaId, "default", Optional.empty(), + new SimpleAttributeSet()))); + assertEquals(1, runtime.caService().getCa(intermediateCaId).caCredentials().size()); + + for (IntermediateExtensionVariant variant : IntermediateExtensionVariant.values()) { + CaService maliciousExtensionService = runtime.caService( + extensionVariantBackend(delegate, rootKey, variant)); + assertThrows(PkiException.class, + () -> maliciousExtensionService.issueIntermediateCertificate( + new IntermediateCertIssueCommand(runtime.framework().formatId(), rootCaId, + intermediateCaId, "default", Optional.empty(), new SimpleAttributeSet())), + variant.name()); + assertEquals(1, runtime.caService().getCa(intermediateCaId).caCredentials().size(), variant.name()); + } + + AtomicReference rawCredential = new AtomicReference<>(); + CredentialIssuerBackend mutableBackend = new CredentialIssuerBackend() { + @Override + public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { + return delegate.issueEndEntity(candidate); + } + + @Override + public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { + Credential raw = delegate.issueIntermediateCertificate(issuance); + rawCredential.set(raw); + return raw; + } + }; + CaService snapshotService = runtime.caService(mutableBackend); + Credential returned = snapshotService.issueIntermediateCertificate(new IntermediateCertIssueCommand( + runtime.framework().formatId(), rootCaId, intermediateCaId, "default", Optional.empty(), + new SimpleAttributeSet())); + byte[] expected = returned.encoded().bytes().clone(); + rawCredential.get().encoded().bytes()[0] ^= 0x01; + assertArrayEquals(expected, returned.encoded().bytes()); + assertArrayEquals(expected, runtime.store().getCredential(returned.credentialId()).orElseThrow() + .encoded().bytes()); + } + } + + private enum IntermediateExtensionVariant { + ABSENT_BASIC_CONSTRAINTS, + NONCRITICAL_BASIC_CONSTRAINTS, + UNLIMITED_BASIC_CONSTRAINTS, + ABSENT_KEY_USAGE, + NONCRITICAL_KEY_USAGE, + INCOMPATIBLE_KEY_USAGE + } + + private static final class CountingIssuerBackend implements CredentialIssuerBackend { + + private final CredentialIssuerBackend delegate; + private final AtomicInteger endEntityCalls; + private final AtomicInteger intermediateCalls; + + private CountingIssuerBackend(CredentialIssuerBackend delegate) { + this.delegate = delegate; + this.endEntityCalls = new AtomicInteger(); + this.intermediateCalls = new AtomicInteger(); + } + + @Override + public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { + endEntityCalls.incrementAndGet(); + return delegate.issueEndEntity(candidate); + } + + @Override + public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { + intermediateCalls.incrementAndGet(); + return delegate.issueIntermediateCertificate(issuance); + } + } + + private static CredentialIssuerBackend extensionVariantBackend( + CredentialIssuerBackend delegate, KeyPair issuerKey, + IntermediateExtensionVariant variant) { + return new CredentialIssuerBackend() { + @Override + public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { + return delegate.issueEndEntity(candidate); + } + + @Override + public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { + Credential credential = delegate.issueIntermediateCertificate(issuance); + return rebuildIntermediateExtensions(credential, issuerKey, variant); + } + }; + } + + private static Credential rebuildIntermediateIdentity(Credential credential, KeyPair issuerKey, + Optional subjectPublicKey, Optional subjectName) { + try { + X509CertificateHolder original = new X509CertificateHolder(credential.encoded().bytes()); + X500Name subject = subjectName.map(X500Name::new).orElse(original.getSubject()); + org.bouncycastle.asn1.x509.SubjectPublicKeyInfo publicKeyInfo = subjectPublicKey + .map(key -> org.bouncycastle.asn1.x509.SubjectPublicKeyInfo.getInstance(key.getEncoded())) + .orElse(original.getSubjectPublicKeyInfo()); + X509v3CertificateBuilder builder = new X509v3CertificateBuilder(original.getIssuer(), + original.getSerialNumber(), original.getNotBefore(), original.getNotAfter(), subject, + publicKeyInfo); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(0)); + builder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign)); + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(issuerKey.getPrivate()); + byte[] encoded = builder.build(signer).getEncoded(); + return new Credential(new PkiId("x509:" + sha256Hex(encoded)), credential.formatId(), + credential.issuerRef(), new SubjectRef(subject.toString()), credential.validity(), + credential.serialOrUniqueId(), new PkiId("spki:" + sha256Hex(publicKeyInfo.getEncoded())), + credential.profileId(), credential.status(), new EncodedObject(Encoding.DER, encoded), + credential.attributes()); + } catch (Exception ex) { + throw new PkiException("Failed to create adversarial intermediate identity", ex); + } + } + + private static Credential rebuildIntermediateExtensions(Credential credential, KeyPair issuerKey, + IntermediateExtensionVariant variant) { + try { + X509CertificateHolder original = new X509CertificateHolder(credential.encoded().bytes()); + X509v3CertificateBuilder builder = new X509v3CertificateBuilder(original.getIssuer(), + original.getSerialNumber(), original.getNotBefore(), original.getNotAfter(), original.getSubject(), + original.getSubjectPublicKeyInfo()); + if (variant != IntermediateExtensionVariant.ABSENT_BASIC_CONSTRAINTS) { + boolean critical = variant != IntermediateExtensionVariant.NONCRITICAL_BASIC_CONSTRAINTS; + BasicConstraints constraints = variant == IntermediateExtensionVariant.UNLIMITED_BASIC_CONSTRAINTS + ? new BasicConstraints(true) + : new BasicConstraints(0); + builder.addExtension(Extension.basicConstraints, critical, constraints); + } + if (variant != IntermediateExtensionVariant.ABSENT_KEY_USAGE) { + boolean critical = variant != IntermediateExtensionVariant.NONCRITICAL_KEY_USAGE; + int usages = KeyUsage.keyCertSign | KeyUsage.cRLSign; + if (variant == IntermediateExtensionVariant.INCOMPATIBLE_KEY_USAGE) { + usages |= KeyUsage.digitalSignature; + } + builder.addExtension(Extension.keyUsage, critical, new KeyUsage(usages)); + } + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(issuerKey.getPrivate()); + byte[] encoded = builder.build(signer).getEncoded(); + return new Credential(new PkiId("x509:" + sha256Hex(encoded)), credential.formatId(), + credential.issuerRef(), credential.subjectRef(), credential.validity(), + credential.serialOrUniqueId(), credential.publicKeyId(), credential.profileId(), + credential.status(), new EncodedObject(Encoding.DER, encoded), credential.attributes()); + } catch (Exception ex) { + throw new PkiException("Failed to create adversarial intermediate certificate", ex); + } + } + + private static ParsedCertificationRequest parse(PkiTestRuntime runtime, PKCS10CertificationRequest csr) + throws Exception { + return runtime.certificationRequestService() + .parse(new CertificationRequest(runtime.framework().formatId(), + new EncodedObject(Encoding.DER, csr.getEncoded()))); + } + + private static PKCS10CertificationRequest makeCsr(KeyPair publicKeyPair, KeyPair signingKeyPair, String dn) + throws Exception { + return makeCsr(publicKeyPair, signingKeyPair, dn, "SHA256withRSA"); + } + + private static PKCS10CertificationRequest makeCsr(KeyPair publicKeyPair, KeyPair signingKeyPair, String dn, + String algorithm) throws Exception { + PKCS10CertificationRequestBuilder builder = new JcaPKCS10CertificationRequestBuilder(new X500Name(dn), + publicKeyPair.getPublic()); + JcaContentSignerBuilder signerBuilder = new JcaContentSignerBuilder(algorithm); + if ("SHA256withRSAandMGF1".equals(algorithm)) { + signerBuilder.setProvider(new BouncyCastleProvider()); + } + ContentSigner signer = signerBuilder.build(signingKeyPair.getPrivate()); + return builder.build(signer); + } + + private static KeyPair generateRsa() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + return generator.generateKeyPair(); + } + + private static String sha256Hex(byte[] value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); + } catch (Exception ex) { + throw new IllegalStateException("SHA-256 unavailable", ex); + } + } + + private static byte[] csrDer(ParsedCertificationRequest request) { + return ((AttributeValue.BytesValue) request.attributes().get(BcX509Attributes.CSR_DER).orElseThrow()).value(); + } + + private static ParsedCertificationRequest withCsr(ParsedCertificationRequest source, byte[] csrDer) { + AttributeSet attributes = SimpleAttributeSet.builder() + .put(BcX509Attributes.CSR_DER, new AttributeValue.BytesValue(csrDer)).build(); + return withAttributes(source, attributes); + } + + private static ParsedCertificationRequest withAttributes(ParsedCertificationRequest source, + AttributeSet attributes) { + return new ParsedCertificationRequest(source.requestId(), source.formatId(), source.subjectRef(), + source.publicKeyInfo(), source.requestedValidity(), source.requestedProfileId(), attributes); + } + + private static void assertRejected(PkiTestRuntime runtime, ParsedCertificationRequest request, String code) { + int before = runtime.auditSink().snapshot().size(); + assertThrows(PkiException.class, () -> issue(runtime, request)); + assertEquals(before + 1, runtime.auditSink().snapshot().size()); + assertEquals(code, runtime.auditSink().snapshot().get(before).details().get("code")); + } + + private static void issue(PkiTestRuntime runtime, ParsedCertificationRequest request) { + runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(new PkiId("ca:absent"), request, "default", + Optional.empty(), new SimpleAttributeSet())); + } + + private static AttributeSet hostileIntermediateAttributes(PublicKey wrongPublicKey) { + return SimpleAttributeSet.builder() + .put(BcX509Attributes.ISSUER_CERT_DER, new AttributeValue.BytesValue(new byte[] { 0x01 })) + .put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue("attacker-key")) + .put(BcX509Attributes.SUBJECT_SPKI_DER, + new AttributeValue.BytesValue(wrongPublicKey.getEncoded())) + .put(BcX509Attributes.SUBJECT_DN, new AttributeValue.StringValue("CN=Attacker")).build(); + } + + private static void assertThrowableRedacted(Throwable failure, String sentinel) { + Throwable current = failure; + while (current != null) { + assertFalse(String.valueOf(current.getMessage()).contains(sentinel)); + for (Throwable suppressed : current.getSuppressed()) { + assertThrowableRedacted(suppressed, sentinel); + } + current = current.getCause(); + } + } +} diff --git a/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusFailureTest.java b/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusFailureTest.java new file mode 100644 index 0000000..dcb8815 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusFailureTest.java @@ -0,0 +1,1136 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.impl.core.async; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.time.Duration; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.Encoding; +import zeroecho.pki.api.KeyRef; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.audit.AccessContext; +import zeroecho.pki.api.audit.Principal; +import zeroecho.pki.api.audit.Purpose; +import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy; +import zeroecho.pki.api.orch.WorkflowStateRecord; +import zeroecho.pki.impl.fs.FilesystemPkiStore; +import zeroecho.pki.impl.fs.FsPkiStoreOptions; +import zeroecho.pki.api.PkiException; +import zeroecho.pki.spi.crypto.SignatureWorkflow; +import zeroecho.pki.spi.store.PkiStore; +import zeroecho.pki.spi.store.SignWorkflowStore; +import zeroecho.pki.testkit.InMemorySignatureWorkflow; +import zeroecho.pki.util.async.AsyncState; + +final class PkiSigningBusFailureTest { + + @Test + void acceptedCancellationWaitsForObservedTerminalProviderState(@TempDir Path tempDir) throws Exception { + AcceptedDelayedCancellationWorkflow signer = new AcceptedDelayedCancellationWorkflow(); + try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"), + FsPkiStoreOptions.defaults()); + PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"))) { + PkiId id = submit(bus); + assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state()); + + bus.retireSignOperation(id, "accepted cancellation"); + + assertEquals(SignWorkflowStore.State.CANCELLING, store.getSignRecord(id).orElseThrow().state()); + assertTrue(store.getSignRecord(id).orElseThrow().result().isEmpty()); + assertEquals(1, signer.cancellations.get()); + + signer.completeSuccessfully(id); + bus.retireSignOperation(id, "provider won cancellation race"); + + SignWorkflowStore.Record retired = store.getSignRecord(id).orElseThrow(); + assertEquals(SignWorkflowStore.State.RETIRED, retired.state()); + assertArrayEquals(new byte[] { 21 }, retired.result().orElseThrow().bytes()); + assertEquals(1, signer.cancellations.get()); + } + } + + @Test + void deadlineAdvisoryStillRequiresProviderCancellationBeforeRetirement(@TempDir Path tempDir) + throws Exception { + Instant createdAt = Instant.parse("2026-06-07T08:09:10Z"); + MutableClock clock = new MutableClock(createdAt); + ControlledWorkflow signer = new ControlledWorkflow(clock); + try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"), + FsPkiStoreOptions.defaults(), clock); + PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"))) { + PkiId id = submit(bus, Duration.ofSeconds(10)); + assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state()); + clock.set(createdAt.plusSeconds(10)); + + assertEquals(AsyncState.EXPIRED, bus.status(id).orElseThrow().state()); + assertEquals(SignWorkflowStore.State.DISPATCHED, store.getSignRecord(id).orElseThrow().state()); + + bus.retireSignOperation(id, "deadline elapsed"); + + assertEquals(1, signer.cancellations.get()); + assertEquals(SignWorkflowStore.State.RETIRED, store.getSignRecord(id).orElseThrow().state()); + } + } + + @Test + void providerCompletionTimestampControlsDeadlineAcrossRestart(@TempDir Path tempDir) throws Exception { + Instant createdAt = Instant.parse("2026-06-07T08:09:10Z"); + Instant deadline = createdAt.plusSeconds(10); + MutableClock clock = new MutableClock(createdAt); + ControlledWorkflow signer = new ControlledWorkflow(clock); + Path storeRoot = tempDir.resolve("store"); + Path busLog = tempDir.resolve("bus.log"); + PkiId exact; + PkiId late; + try (FilesystemPkiStore store = new FilesystemPkiStore(storeRoot, FsPkiStoreOptions.defaults(), clock); + PkiSigningBus bus = new PkiSigningBus(store, signer, busLog)) { + PkiId onTime = submit(bus, Duration.ofSeconds(10)); + assertEquals(AsyncState.RUNNING, bus.status(onTime).orElseThrow().state()); + signer.succeedAt(onTime, deadline.minusNanos(1), (byte) 31); + assertEquals(AsyncState.SUCCEEDED, bus.status(onTime).orElseThrow().state()); + assertEquals(31, bus.consumeResult(onTime).orElseThrow().bytes()[0]); + + exact = submit(bus, Duration.ofSeconds(10)); + assertEquals(AsyncState.RUNNING, bus.status(exact).orElseThrow().state()); + signer.succeedAt(exact, deadline, (byte) 32); + assertEquals(AsyncState.EXPIRED, bus.status(exact).orElseThrow().state()); + assertTrue(bus.consumeResult(exact).isEmpty()); + assertEquals(deadline, store.getSignRecord(exact).orElseThrow().providerUpdatedAt().orElseThrow()); + + late = submit(bus, Duration.ofSeconds(10)); + assertEquals(AsyncState.RUNNING, bus.status(late).orElseThrow().state()); + signer.succeedAt(late, deadline.plusNanos(1), (byte) 33); + assertEquals(AsyncState.EXPIRED, bus.status(late).orElseThrow().state()); + assertTrue(bus.consumeResult(late).isEmpty()); + } + + try (FilesystemPkiStore reopened = new FilesystemPkiStore(storeRoot, FsPkiStoreOptions.defaults(), clock); + PkiSigningBus replayed = new PkiSigningBus(reopened, signer, busLog)) { + assertEquals(AsyncState.EXPIRED, replayed.status(exact).orElseThrow().state()); + assertTrue(replayed.consumeResult(exact).isEmpty()); + assertEquals(AsyncState.EXPIRED, replayed.status(late).orElseThrow().state()); + assertTrue(replayed.consumeResult(late).isEmpty()); + replayed.retireSignOperation(late, "late completion cleanup"); + assertEquals(SignWorkflowStore.State.RETIRED, reopened.getSignRecord(late).orElseThrow().state()); + } + } + + @Test + void rejectedCancellationReconcilesProviderSuccessBeforeRetirement(@TempDir Path tempDir) throws Exception { + CancelRejectedAfterCompletionWorkflow signer = new CancelRejectedAfterCompletionWorkflow(); + Path storeRoot = tempDir.resolve("store"); + Path busLog = tempDir.resolve("bus.log"); + PkiId id; + try (FilesystemPkiStore store = new FilesystemPkiStore(storeRoot, FsPkiStoreOptions.defaults()); + PkiSigningBus bus = new PkiSigningBus(store, signer, busLog)) { + id = submit(bus); + assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state()); + bus.retireSignOperation(id, "provider completed"); + SignWorkflowStore.Record retired = store.getSignRecord(id).orElseThrow(); + assertEquals(SignWorkflowStore.State.RETIRED, retired.state()); + assertArrayEquals(new byte[] { 11, 12 }, retired.result().orElseThrow().bytes()); + assertEquals(1, signer.cancellations.get()); + } + try (FilesystemPkiStore reopened = new FilesystemPkiStore(storeRoot, FsPkiStoreOptions.defaults()); + PkiSigningBus replayed = new PkiSigningBus(reopened, signer, busLog)) { + assertEquals(AsyncState.SUCCEEDED, replayed.status(id).orElseThrow().state()); + assertArrayEquals(new byte[] { 11, 12 }, replayed.consumeResult(id).orElseThrow().bytes()); + } + } + + @Test + void providerCallbackReturnsWhileSameOperationSubmissionIsBlocked(@TempDir Path tempDir) throws Exception { + BlockingWorkflow signer = new BlockingWorkflow(); + try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"), + FsPkiStoreOptions.defaults()); + PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log")); + ExecutorService executor = Executors.newFixedThreadPool(2)) { + PkiId id = submit(bus); + Future submission = executor.submit(() -> bus.status(id)); + assertTrue(signer.entered.await(5, TimeUnit.SECONDS)); + Future callback = executor.submit(() -> signer.completeAndNotify(id)); + callback.get(5, TimeUnit.SECONDS); + signer.release.countDown(); + submission.get(5, TimeUnit.SECONDS); + assertEquals(AsyncState.SUCCEEDED, bus.status(id).orElseThrow().state()); + assertArrayEquals(new byte[] { 13 }, bus.consumeResult(id).orElseThrow().bytes()); + } + } + + @Test + void cancellingOperationReplaysAfterRestart(@TempDir Path tempDir) throws Exception { + RetirementRaceWorkflow signer = new RetirementRaceWorkflow(); + Path storeRoot = tempDir.resolve("store"); + Path busLog = tempDir.resolve("bus.log"); + PkiId id; + try (FilesystemPkiStore store = new FilesystemPkiStore(storeRoot, FsPkiStoreOptions.defaults()); + PkiSigningBus bus = new PkiSigningBus(store, signer, busLog)) { + id = submit(bus); + assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state()); + SignWorkflowStore.Record dispatched = store.getSignRecord(id).orElseThrow(); + store.transitionSign(id, dispatched.revision(), dispatched.fence(), + SignWorkflowStore.State.CANCELLING, Optional.of("CANCEL_REQUESTED"), Optional.empty(), + Optional.empty()) + .orElseThrow(); + } + try (FilesystemPkiStore reopened = new FilesystemPkiStore(storeRoot, FsPkiStoreOptions.defaults()); + PkiSigningBus replayed = new PkiSigningBus(reopened, signer, busLog)) { + assertEquals(AsyncState.RUNNING, replayed.status(id).orElseThrow().state()); + replayed.retireSignOperation(id, "restart cancellation"); + assertEquals(SignWorkflowStore.State.RETIRED, reopened.getSignRecord(id).orElseThrow().state()); + assertEquals(1, signer.cancellations.get()); + } + } + + @Test + void retirementReconcilesCompletionAndAdvisoryCallbackWithoutResultLoss(@TempDir Path tempDir) throws Exception { + RetirementRaceWorkflow signer = new RetirementRaceWorkflow(); + try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"), + FsPkiStoreOptions.defaults()); + PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log")); + ExecutorService executor = Executors.newFixedThreadPool(2)) { + PkiId id = submit(bus); + assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state()); + signer.armStatusBlock(); + + Future retirement = executor.submit(() -> bus.retireSignOperation(id, "completed concurrently")); + assertTrue(signer.statusEntered.await(5, TimeUnit.SECONDS)); + Future callback = executor.submit(signer::completeAndNotify); + retirement.get(5, TimeUnit.SECONDS); + callback.get(5, TimeUnit.SECONDS); + + assertEquals(SignWorkflowStore.State.RETIRED, store.getSignRecord(id).orElseThrow().state()); + assertEquals(7, store.getSignRecord(id).orElseThrow().result().orElseThrow().bytes()[0]); + assertEquals(AsyncState.SUCCEEDED, bus.status(id).orElseThrow().state()); + assertEquals(7, bus.consumeResult(id).orElseThrow().bytes()[0]); + assertEquals(1, signer.submissions.get()); + assertEquals(0, signer.cancellations.get()); + } + } + + @Test + void pollAndRetirementSerializeAndPreserveProviderCompletion(@TempDir Path tempDir) throws Exception { + RetirementRaceWorkflow signer = new RetirementRaceWorkflow(); + try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"), + FsPkiStoreOptions.defaults()); + PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log")); + ExecutorService executor = Executors.newFixedThreadPool(2)) { + PkiId id = submit(bus); + assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state()); + signer.armStatusBlock(); + + Future poll = executor.submit(() -> bus.status(id)); + assertTrue(signer.statusEntered.await(5, TimeUnit.SECONDS)); + Future retirement = executor.submit(() -> bus.retireSignOperation(id, "poll race")); + signer.completeWithoutNotification(); + poll.get(5, TimeUnit.SECONDS); + retirement.get(5, TimeUnit.SECONDS); + + assertEquals(7, bus.consumeResult(id).orElseThrow().bytes()[0]); + assertEquals(1, signer.submissions.get()); + assertEquals(0, signer.cancellations.get()); + } + } + + @Test + void consumeAndRetirementShareCoordinatorWithoutResultLoss(@TempDir Path tempDir) throws Exception { + RetirementRaceWorkflow signer = new RetirementRaceWorkflow(); + try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"), + FsPkiStoreOptions.defaults()); + PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log")); + ExecutorService executor = Executors.newFixedThreadPool(2)) { + PkiId id = submit(bus); + assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state()); + signer.completeWithoutNotification(); + CountDownLatch start = new CountDownLatch(1); + Future> consumed = executor.submit(() -> { + start.await(); + return bus.consumeResult(id); + }); + Future retirement = executor.submit(() -> { + start.await(); + bus.retireSignOperation(id, "consume race"); + return null; + }); + start.countDown(); + retirement.get(5, TimeUnit.SECONDS); + Optional concurrentObservation = consumed.get(5, TimeUnit.SECONDS); + concurrentObservation.ifPresent(result -> assertEquals(7, result.bytes()[0])); + assertEquals(7, bus.consumeResult(id).orElseThrow().bytes()[0]); + assertEquals(1, signer.submissions.get()); + } + } + + @Test + void preDispatchExpiryMakesNoProviderCallAndPurgesOnlyAfterRetirement(@TempDir Path tempDir) + throws Exception { + Instant createdAt = Instant.parse("2026-05-06T07:08:09Z"); + MutableClock clock = new MutableClock(createdAt); + ControlledWorkflow signer = new ControlledWorkflow(clock); + try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"), + FsPkiStoreOptions.defaults(), clock); + PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"))) { + PkiId id = submit(bus, Duration.ofSeconds(10)); + assertEquals(1, store.listWorkflowStates().size()); + clock.set(createdAt.plusSeconds(10)); + bus.sweep(clock.instant()); + assertEquals(0, signer.submissions.get()); + assertEquals(SignWorkflowStore.State.RETIRED, store.getSignRecord(id).orElseThrow().state()); + assertTrue(bus.consumeResult(id).isEmpty()); + assertTrue(store.listWorkflowStates().isEmpty()); + + clock.set(createdAt.plus(Duration.ofDays(90))); + bus.sweep(clock.instant()); + assertTrue(store.listSignRecords().isEmpty()); + assertEquals(AsyncState.EXPIRED, bus.status(id).orElseThrow().state()); + } + } + + @Test + void postDispatchExpiryRetainsAuthorityUntilProviderTerminalReconciliation(@TempDir Path tempDir) + throws Exception { + Instant createdAt = Instant.parse("2026-05-06T07:08:09Z"); + MutableClock clock = new MutableClock(createdAt); + AcceptedDelayedCancellationWorkflow signer = new AcceptedDelayedCancellationWorkflow(clock); + try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"), + FsPkiStoreOptions.defaults(), clock); + PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"))) { + PkiId id = submit(bus, Duration.ofSeconds(10)); + assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state()); + assertEquals(1, signer.submissions.get()); + + clock.set(createdAt.plusSeconds(10)); + bus.sweep(clock.instant()); + assertEquals(SignWorkflowStore.State.CANCELLING, store.getSignRecord(id).orElseThrow().state()); + assertEquals("CANCEL_SUBMITTED", + store.getSignRecord(id).orElseThrow().detailCode().orElseThrow()); + assertEquals(1, signer.cancellations.get()); + assertEquals(1, store.listWorkflowStates().size()); + assertTrue(bus.consumeResult(id).isEmpty()); + + bus.sweep(clock.instant()); + assertEquals(1, signer.cancellations.get()); + assertEquals(SignWorkflowStore.State.CANCELLING, store.getSignRecord(id).orElseThrow().state()); + + signer.completeCancelled(id, clock.instant()); + bus.sweep(clock.instant()); + assertEquals(SignWorkflowStore.State.RETIRED, store.getSignRecord(id).orElseThrow().state()); + assertTrue(store.listWorkflowStates().isEmpty()); + assertTrue(bus.consumeResult(id).isEmpty()); + + clock.set(createdAt.plus(Duration.ofDays(90))); + bus.sweep(clock.instant()); + assertTrue(store.listSignRecords().isEmpty()); + } + } + + @Test + void blockedProviderCannotRedispatchAfterLeaseExpiry(@TempDir Path tempDir) throws Exception { + MutableClock clock = new MutableClock(Instant.parse("2026-05-06T07:08:09Z")); + BlockingWorkflow signer = new BlockingWorkflow(); + try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"), + FsPkiStoreOptions.defaults(), clock); + PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log")); + ExecutorService executor = Executors.newFixedThreadPool(2)) { + Principal owner = new Principal("TEST", "owner"); + PkiId id = bus.newSubmissionId(); + EncodedObject payload = new EncodedObject(Encoding.BINARY, new byte[] { 1 }); + AccessContext access = new AccessContext(owner, new Purpose("TEST"), Optional.empty(), Optional.empty()); + PkiSigningBus.SignContinuation continuation = new PkiSigningBus.SignContinuation(access, + "SHA256withRSA", payload, new KeyRef("test"), Encoding.BINARY, Optional.empty()); + bus.submitSign(id, owner, new KeyRef("test"), "SHA256withRSA", payload, Duration.ofMinutes(5), + Optional.of(continuation.encode())); + Future first = executor.submit(() -> bus.status(id)); + assertTrue(signer.entered.await(5, TimeUnit.SECONDS)); + clock.set(clock.instant().plusSeconds(60)); + CountDownLatch secondStarted = new CountDownLatch(1); + Future second = executor.submit(() -> { + secondStarted.countDown(); + return bus.status(id); + }); + assertTrue(secondStarted.await(5, TimeUnit.SECONDS)); + assertEquals(1, signer.submissions.get()); + second.get(5, TimeUnit.SECONDS); + signer.release.countDown(); + first.get(5, TimeUnit.SECONDS); + assertEquals(1, signer.submissions.get()); + } + } + + @Test + void providerCallsPermitCrossThreadReentryOutsideOperationLock(@TempDir Path tempDir) throws Exception { + CrossThreadReentrantWorkflow signer = new CrossThreadReentrantWorkflow(); + try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"), + FsPkiStoreOptions.defaults()); + PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"))) { + signer.attach(bus); + PkiId id = submit(bus); + assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state()); + bus.retireSignOperation(id, "reentrant cancellation"); + assertEquals(SignWorkflowStore.State.RETIRED, store.getSignRecord(id).orElseThrow().state()); + assertEquals(1, signer.submissions.get()); + assertTrue(signer.statusReads.get() >= 2); + assertEquals(1, signer.cancellations.get()); + } finally { + signer.close(); + } + } + + @Test + void blockedSubmissionDoesNotBlockAnotherOperation(@TempDir Path tempDir) throws Exception { + SelectiveBlockingWorkflow signer = new SelectiveBlockingWorkflow(); + try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"), + FsPkiStoreOptions.defaults()); + PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log")); + ExecutorService executor = Executors.newFixedThreadPool(2)) { + PkiId blocked = submit(bus); + Future first = executor.submit(() -> bus.status(blocked)); + assertTrue(signer.blockedEntered.await(5, TimeUnit.SECONDS)); + + PkiId independent = submit(bus); + Future> second = + executor.submit(() -> bus.status(independent)); + assertEquals(AsyncState.RUNNING, second.get(5, TimeUnit.SECONDS).orElseThrow().state()); + assertEquals(2, signer.submissions.get()); + + signer.blockedRelease.countDown(); + first.get(5, TimeUnit.SECONDS); + } + } + + @Test + void providerStatusExceptionReleasesSingleFlightReservation(@TempDir Path tempDir) throws Exception { + ThrowOnceStatusWorkflow signer = new ThrowOnceStatusWorkflow(); + try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"), + FsPkiStoreOptions.defaults()); + PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"))) { + PkiId id = submit(bus); + PkiException sanitized = assertThrows(PkiException.class, () -> bus.status(id)); + assertTrue(sanitized.getMessage().contains("PROVIDER_STATUS_FAILED")); + assertFalse(sanitized.getMessage().contains("injected status failure")); + assertNull(sanitized.getCause()); + assertEquals(0, sanitized.getSuppressed().length); + assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state()); + assertEquals(2, signer.statusReads.get()); + } + } + + @Test + void unsupportedContinuationVersionCannotActivate(@TempDir Path tempDir) throws Exception { + try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"), + FsPkiStoreOptions.defaults())) { + Principal owner = new Principal("SYSTEM", "pki"); + Instant now = Instant.now(); + EncodedObject unsupported = new EncodedObject(Encoding.BINARY, new byte[] { 99 }); + store.putWorkflowState(new WorkflowStateRecord(new PkiId("unsupported-sign"), + PkiSigningBus.TYPE_SIGN, owner, + OrchestrationDurabilityPolicy.DURABLE_MIN_STATE, now, now, now.plusSeconds(60), Encoding.BINARY, + Optional.of(unsupported))); + InMemorySignatureWorkflow signer = new InMemorySignatureWorkflow(Map.of(), false); + try { + assertThrows(PkiException.class, + () -> new PkiSigningBus(store, signer, tempDir.resolve("unsupported-bus.log"))); + } finally { + signer.close(); + } + } + } + + @Test + void advisoryProjectionFailureDoesNotOverrideAuthoritativeWorkflow(@TempDir Path tempDir) throws Exception { + FilesystemPkiStore delegate = new FilesystemPkiStore(tempDir.resolve("store"), FsPkiStoreOptions.defaults()); + AtomicInteger writes = new AtomicInteger(); + PkiStore failingStore = (PkiStore) Proxy.newProxyInstance(PkiStore.class.getClassLoader(), + new Class[] { PkiStore.class }, (proxy, method, arguments) -> { + if ("putWorkflowState".equals(method.getName()) && writes.incrementAndGet() == 1) { + throw new IllegalStateException("injected continuation failure"); + } + try { + return method.invoke(delegate, arguments); + } catch (InvocationTargetException ex) { + throw ex.getCause(); + } + }); + + KeyPair keyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair(); + KeyRef keyRef = new KeyRef("kref:v1:keyring:test:failure"); + InMemorySignatureWorkflow signer = new InMemorySignatureWorkflow(Map.of(keyRef.value(), keyPair), false); + Path busLog = tempDir.resolve("bus.log"); + Principal owner = new Principal("SYSTEM", "pki"); + PkiId operationId; + try (PkiSigningBus bus = new PkiSigningBus(failingStore, signer, busLog)) { + operationId = bus.canonicalizeOperationId(new PkiId("sign:failure"), owner); + EncodedObject payload = new EncodedObject(Encoding.BINARY, new byte[] { 1, 2, 3 }); + AccessContext access = new AccessContext(owner, new Purpose("TEST"), Optional.empty(), Optional.empty()); + PkiSigningBus.SignContinuation continuation = new PkiSigningBus.SignContinuation(access, + "SHA256withRSA", payload, keyRef, Encoding.BINARY, Optional.empty()); + bus.submitSign(operationId, owner, keyRef, "SHA256withRSA", payload, Duration.ofSeconds(5), + Optional.of(continuation.encode())); + bus.sweep(java.time.Instant.now()); + + assertEquals(AsyncState.RUNNING, bus.status(operationId).orElseThrow().state()); + assertTrue(delegate.listWorkflowStates().isEmpty()); + assertTrue(signer.hasRunningOperations()); + bus.retireSignOperation(operationId, "test complete"); + assertFalse(signer.hasRunningOperations()); + } + + try (PkiSigningBus replayed = new PkiSigningBus(delegate, signer, busLog)) { + assertEquals(AsyncState.CANCELLED, replayed.status(operationId).orElseThrow().state()); + } finally { + signer.close(); + delegate.close(); + } + } + + private static PkiId submit(PkiSigningBus bus) { + return submit(bus, Duration.ofMinutes(5)); + } + + private static PkiId submit(PkiSigningBus bus, Duration ttl) { + Principal owner = new Principal("TEST", "owner"); + PkiId id = bus.newSubmissionId(); + EncodedObject payload = new EncodedObject(Encoding.BINARY, new byte[] { 1 }); + AccessContext access = new AccessContext(owner, new Purpose("TEST"), Optional.empty(), Optional.empty()); + PkiSigningBus.SignContinuation continuation = new PkiSigningBus.SignContinuation(access, + "SHA256withRSA", payload, new KeyRef("test"), Encoding.BINARY, Optional.empty()); + bus.submitSign(id, owner, new KeyRef("test"), "SHA256withRSA", payload, ttl, + Optional.of(continuation.encode())); + return id; + } + + private static final class AcceptedDelayedCancellationWorkflow implements SignatureWorkflow { + private final Clock clock; + private final Map statuses = new java.util.concurrent.ConcurrentHashMap<>(); + private final AtomicInteger submissions = new AtomicInteger(); + private final AtomicInteger cancellations = new AtomicInteger(); + + private AcceptedDelayedCancellationWorkflow() { + this(Clock.systemUTC()); + } + + private AcceptedDelayedCancellationWorkflow(Clock clock) { + this.clock = clock; + } + + @Override + public String id() { + return "accepted-delayed-cancellation"; + } + + @Override + public PkiId submitSign(SignRequest request) { + submissions.incrementAndGet(); + statuses.put(request.submissionId(), new OperationStatus(State.RUNNING, clock.instant(), + Optional.of("RUNNING"), Optional.empty())); + return request.submissionId(); + } + + @Override + public PkiId submitVerify(VerifyRequest request) { + throw new UnsupportedOperationException(); + } + + @Override + public OperationStatus status(PkiId operationId) { + return statuses.get(operationId); + } + + @Override + public boolean cancel(PkiId operationId, long fencingToken, String reason) { + cancellations.incrementAndGet(); + return true; + } + + @Override + public Registration register(NotificationSink sink) { + return () -> { + // Polling is authoritative. + }; + } + + @Override + public Set supportedAlgorithms() { + return Set.of("SHA256withRSA"); + } + + @Override + public void close() { + // No owned resources. + } + + private void completeSuccessfully(PkiId operationId) { + OperationResult result = new OperationResult( + Optional.of(new EncodedObject(Encoding.BINARY, new byte[] { 21 })), Optional.empty()); + statuses.put(operationId, new OperationStatus(State.SUCCEEDED, Instant.now(), Optional.of("SIGNED"), + Optional.of(result))); + } + + private void completeCancelled(PkiId operationId, Instant completedAt) { + statuses.put(operationId, new OperationStatus(State.CANCELLED, completedAt, + Optional.of("CANCELLED"), Optional.empty())); + } + } + + private static final class ControlledWorkflow implements SignatureWorkflow { + private final Clock clock; + private final Map statuses = new java.util.concurrent.ConcurrentHashMap<>(); + private final AtomicInteger submissions = new AtomicInteger(); + private final AtomicInteger cancellations = new AtomicInteger(); + + private ControlledWorkflow(Clock clock) { + this.clock = clock; + } + + @Override + public String id() { + return "controlled"; + } + + @Override + public PkiId submitSign(SignRequest request) { + submissions.incrementAndGet(); + statuses.put(request.submissionId(), new OperationStatus(State.RUNNING, clock.instant(), + Optional.of("RUNNING"), Optional.empty())); + return request.submissionId(); + } + + @Override + public PkiId submitVerify(VerifyRequest request) { + throw new UnsupportedOperationException(); + } + + @Override + public OperationStatus status(PkiId operationId) { + return statuses.get(operationId); + } + + @Override + public boolean cancel(PkiId operationId, long fencingToken, String reason) { + cancellations.incrementAndGet(); + statuses.put(operationId, new OperationStatus(State.CANCELLED, clock.instant(), + Optional.of("CANCELLED"), Optional.empty())); + return true; + } + + @Override + public Registration register(NotificationSink sink) { + return () -> { + // Polling is authoritative. + }; + } + + @Override + public Set supportedAlgorithms() { + return Set.of("SHA256withRSA"); + } + + @Override + public void close() { + // No owned resources. + } + + private void succeedAt(PkiId operationId, Instant completedAt, byte value) { + OperationResult result = new OperationResult( + Optional.of(new EncodedObject(Encoding.BINARY, new byte[] { value })), Optional.empty()); + statuses.put(operationId, new OperationStatus(State.SUCCEEDED, completedAt, Optional.of("SIGNED"), + Optional.of(result))); + } + } + + private static final class RetirementRaceWorkflow implements SignatureWorkflow { + private final AtomicReference status = new AtomicReference<>( + new OperationStatus(State.RUNNING, Instant.EPOCH, Optional.of("RUNNING"), Optional.empty())); + private final AtomicReference operationId = new AtomicReference<>(); + private final AtomicReference sink = new AtomicReference<>(); + private final AtomicBoolean blockStatus = new AtomicBoolean(); + private final CountDownLatch statusEntered = new CountDownLatch(1); + private final CountDownLatch statusRelease = new CountDownLatch(1); + private final AtomicInteger submissions = new AtomicInteger(); + private final AtomicInteger cancellations = new AtomicInteger(); + + @Override + public String id() { + return "retirement-race"; + } + + @Override + public PkiId submitSign(SignRequest request) { + submissions.incrementAndGet(); + operationId.set(request.submissionId()); + status.set(new OperationStatus(State.RUNNING, Instant.now(), Optional.of("RUNNING"), Optional.empty())); + return request.submissionId(); + } + + @Override + public PkiId submitVerify(VerifyRequest request) { + throw new UnsupportedOperationException(); + } + + @Override + public OperationStatus status(PkiId operationId) { + if (blockStatus.compareAndSet(true, false)) { + statusEntered.countDown(); + try { + statusRelease.await(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(ex); + } + } + return status.get(); + } + + @Override + public boolean cancel(PkiId operationId, long fencingToken, String reason) { + OperationStatus current = status.get(); + if (current.isTerminal()) { + return false; + } + cancellations.incrementAndGet(); + status.set(new OperationStatus(State.CANCELLED, Instant.now(), Optional.of("CANCELLED"), + Optional.empty())); + return true; + } + + @Override + public Registration register(NotificationSink notificationSink) { + sink.set(notificationSink); + return () -> sink.compareAndSet(notificationSink, null); + } + + @Override + public Set supportedAlgorithms() { + return Set.of("SHA256withRSA"); + } + + @Override + public void close() { + statusRelease.countDown(); + } + + private void armStatusBlock() { + blockStatus.set(true); + } + + private void completeWithoutNotification() { + OperationResult result = new OperationResult( + Optional.of(new EncodedObject(Encoding.BINARY, new byte[] { 7 })), Optional.empty()); + status.set(new OperationStatus(State.SUCCEEDED, Instant.now(), Optional.of("SIGNED"), + Optional.of(result))); + statusRelease.countDown(); + } + + private void completeAndNotify() { + completeWithoutNotification(); + NotificationSink registered = sink.get(); + if (registered != null) { + registered.onStatusChanged(operationId.get(), + new OperationStatus(State.FAILED, Instant.now(), Optional.of("ADVISORY"), Optional.empty())); + } + } + } + + private static final class CancelRejectedAfterCompletionWorkflow implements SignatureWorkflow { + private final AtomicReference status = new AtomicReference<>( + new OperationStatus(State.RUNNING, Instant.EPOCH, Optional.of("RUNNING"), Optional.empty())); + private final AtomicInteger cancellations = new AtomicInteger(); + + @Override + public String id() { + return "cancel-rejected-after-completion"; + } + + @Override + public PkiId submitSign(SignRequest request) { + status.set(new OperationStatus(State.RUNNING, Instant.now(), Optional.of("RUNNING"), Optional.empty())); + return request.submissionId(); + } + + @Override + public PkiId submitVerify(VerifyRequest request) { + throw new UnsupportedOperationException(); + } + + @Override + public OperationStatus status(PkiId operationId) { + return status.get(); + } + + @Override + public boolean cancel(PkiId operationId, long fencingToken, String reason) { + cancellations.incrementAndGet(); + OperationResult result = new OperationResult( + Optional.of(new EncodedObject(Encoding.BINARY, new byte[] { 11, 12 })), Optional.empty()); + status.set(new OperationStatus(State.SUCCEEDED, Instant.now(), Optional.of("SIGNED"), + Optional.of(result))); + return false; + } + + @Override + public Registration register(NotificationSink sink) { + return () -> { + // No callback is required for authoritative reconciliation. + }; + } + + @Override + public Set supportedAlgorithms() { + return Set.of("SHA256withRSA"); + } + + @Override + public void close() { + // No owned resources. + } + } + + private static final class BlockingWorkflow implements SignatureWorkflow { + private final CountDownLatch entered = new CountDownLatch(1); + private final CountDownLatch release = new CountDownLatch(1); + private final AtomicInteger submissions = new AtomicInteger(); + private final Map statuses = new java.util.concurrent.ConcurrentHashMap<>(); + private final AtomicReference sink = new AtomicReference<>(); + + @Override + public String id() { + return "blocking-test"; + } + + @Override + public PkiId submitSign(SignRequest request) { + submissions.incrementAndGet(); + statuses.put(request.submissionId(), new OperationStatus(State.RUNNING, Instant.now(), + Optional.of("RUNNING"), Optional.empty())); + entered.countDown(); + try { + release.await(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(ex); + } + return request.submissionId(); + } + + @Override + public PkiId submitVerify(VerifyRequest request) { + throw new UnsupportedOperationException(); + } + + @Override + public OperationStatus status(PkiId operationId) { + return statuses.get(operationId); + } + + @Override + public boolean cancel(PkiId operationId, long fencingToken, String reason) { + return false; + } + + @Override + public Registration register(NotificationSink sink) { + this.sink.set(sink); + return () -> this.sink.compareAndSet(sink, null); + } + + @Override + public Set supportedAlgorithms() { + return Set.of("SHA256withRSA"); + } + + @Override + public void close() { + release.countDown(); + } + + private void completeAndNotify(PkiId operationId) { + OperationResult result = new OperationResult( + Optional.of(new EncodedObject(Encoding.BINARY, new byte[] { 13 })), Optional.empty()); + OperationStatus succeeded = new OperationStatus(State.SUCCEEDED, Instant.now(), Optional.of("SIGNED"), + Optional.of(result)); + statuses.put(operationId, succeeded); + NotificationSink registered = sink.get(); + if (registered != null) { + registered.onStatusChanged(operationId, succeeded); + } + } + } + + private static final class CrossThreadReentrantWorkflow implements SignatureWorkflow { + private final AtomicReference bus = new AtomicReference<>(); + private final AtomicReference status = new AtomicReference<>(); + private final ExecutorService reentrantExecutor = Executors.newSingleThreadExecutor(); + private final AtomicInteger submissions = new AtomicInteger(); + private final AtomicInteger statusReads = new AtomicInteger(); + private final AtomicInteger cancellations = new AtomicInteger(); + + private void attach(PkiSigningBus attached) { + bus.set(attached); + } + + @Override + public String id() { + return "cross-thread-reentrant"; + } + + @Override + public PkiId submitSign(SignRequest request) { + submissions.incrementAndGet(); + status.set(new OperationStatus(State.RUNNING, Instant.now(), Optional.of("RUNNING"), + Optional.empty())); + reenterStatus(request.submissionId()); + return request.submissionId(); + } + + @Override + public PkiId submitVerify(VerifyRequest request) { + throw new UnsupportedOperationException(); + } + + @Override + public OperationStatus status(PkiId requested) { + statusReads.incrementAndGet(); + reenterStatus(requested); + return status.get(); + } + + @Override + public boolean cancel(PkiId requested, long fencingToken, String reason) { + cancellations.incrementAndGet(); + reenterStatus(requested); + status.set(new OperationStatus(State.CANCELLED, Instant.now(), Optional.of("CANCELLED"), + Optional.empty())); + return true; + } + + private void reenterStatus(PkiId requested) { + try { + reentrantExecutor.submit(() -> bus.get().status(requested)).get(5, TimeUnit.SECONDS); + } catch (Exception failure) { + throw new IllegalStateException("Cross-thread provider reentry failed", failure); + } + } + + @Override + public Registration register(NotificationSink sink) { + return () -> { + // Polling is authoritative. + }; + } + + @Override + public Set supportedAlgorithms() { + return Set.of("SHA256withRSA"); + } + + @Override + public void close() { + reentrantExecutor.shutdownNow(); + } + } + + private static final class SelectiveBlockingWorkflow implements SignatureWorkflow { + private final AtomicReference blockedOperation = new AtomicReference<>(); + private final Map statuses = new java.util.concurrent.ConcurrentHashMap<>(); + private final CountDownLatch blockedEntered = new CountDownLatch(1); + private final CountDownLatch blockedRelease = new CountDownLatch(1); + private final AtomicInteger submissions = new AtomicInteger(); + + @Override + public String id() { + return "selective-blocking"; + } + + @Override + public PkiId submitSign(SignRequest request) { + submissions.incrementAndGet(); + statuses.put(request.submissionId(), new OperationStatus(State.RUNNING, Instant.now(), + Optional.of("RUNNING"), Optional.empty())); + if (blockedOperation.compareAndSet(null, request.submissionId()) + || blockedOperation.get().equals(request.submissionId())) { + blockedEntered.countDown(); + try { + blockedRelease.await(); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(failure); + } + } + return request.submissionId(); + } + + @Override + public PkiId submitVerify(VerifyRequest request) { + throw new UnsupportedOperationException(); + } + + @Override + public OperationStatus status(PkiId operationId) { + return statuses.get(operationId); + } + + @Override + public boolean cancel(PkiId operationId, long fencingToken, String reason) { + statuses.put(operationId, new OperationStatus(State.CANCELLED, Instant.now(), + Optional.of("CANCELLED"), Optional.empty())); + return true; + } + + @Override + public Registration register(NotificationSink sink) { + return () -> { + // Polling is authoritative. + }; + } + + @Override + public Set supportedAlgorithms() { + return Set.of("SHA256withRSA"); + } + + @Override + public void close() { + blockedRelease.countDown(); + } + } + + private static final class ThrowOnceStatusWorkflow implements SignatureWorkflow { + private final AtomicReference status = new AtomicReference<>(); + private final AtomicInteger statusReads = new AtomicInteger(); + + @Override + public String id() { + return "throw-once-status"; + } + + @Override + public PkiId submitSign(SignRequest request) { + status.set(new OperationStatus(State.RUNNING, Instant.now(), Optional.of("RUNNING"), Optional.empty())); + return request.submissionId(); + } + + @Override + public PkiId submitVerify(VerifyRequest request) { + throw new UnsupportedOperationException(); + } + + @Override + public OperationStatus status(PkiId operationId) { + if (statusReads.incrementAndGet() == 1) { + throw new IllegalStateException("injected status failure"); + } + return status.get(); + } + + @Override + public boolean cancel(PkiId operationId, long fencingToken, String reason) { + status.set(new OperationStatus(State.CANCELLED, Instant.now(), Optional.of("CANCELLED"), + Optional.empty())); + return true; + } + + @Override + public Registration register(NotificationSink sink) { + return () -> { + // Polling is authoritative. + }; + } + + @Override + public Set supportedAlgorithms() { + return Set.of("SHA256withRSA"); + } + + @Override + public void close() { + // No owned resources. + } + } + + private static final class MutableClock extends Clock { + private volatile Instant value; + + private MutableClock(Instant value) { + this.value = value; + } + + private void set(Instant next) { + value = next; + } + + @Override + public ZoneId getZone() { + return java.time.ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return value; + } + } +} diff --git a/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusOperatorApprovalTest.java b/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusOperatorApprovalTest.java index f2547f4..c8c400f 100644 --- a/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusOperatorApprovalTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusOperatorApprovalTest.java @@ -33,6 +33,7 @@ ******************************************************************************/ package zeroecho.pki.impl.core.async; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import java.nio.file.Path; @@ -57,6 +58,7 @@ import zeroecho.pki.api.audit.Principal; import zeroecho.pki.api.audit.Purpose; import zeroecho.pki.impl.fs.FilesystemPkiStore; import zeroecho.pki.impl.fs.FsPkiStoreOptions; +import zeroecho.pki.spi.crypto.SignatureWorkflow; import zeroecho.pki.testkit.DurableOperatorApprovalSignatureWorkflow; import zeroecho.pki.util.async.AsyncState; import zeroecho.pki.util.async.AsyncStatus; @@ -66,6 +68,29 @@ public final class PkiSigningBusOperatorApprovalTest { @TempDir Path tempDir; + @Test + public void operatorApprovalAtExpiredRequestDeadlineCannotStartSigning() throws Exception { + KeyPair pair = KeyPairGenerator.getInstance("RSA").generateKeyPair(); + KeyRef keyRef = new KeyRef("kref:test:expired-approval"); + try (DurableOperatorApprovalSignatureWorkflow signer = new DurableOperatorApprovalSignatureWorkflow( + tempDir.resolve("expired-approval"), Duration.ofSeconds(10), Duration.ZERO, + Map.of(keyRef.value(), pair.getPrivate()))) { + AccessContext access = new AccessContext(new Principal("USER", "late"), new Purpose("ISSUANCE"), + Optional.empty(), Optional.empty()); + SignatureWorkflow.SignRequest request = SignatureWorkflow.SignRequest.create( + new PkiId("late-approval"), "test", 1L, access, keyRef, "SHA256withRSA", + new EncodedObject(Encoding.BINARY, new byte[] { 1 }), Optional.of(Encoding.BINARY), + Optional.of(Instant.EPOCH)); + + PkiId operationId = signer.submitSign(request); + signer.approve(operationId); + + SignatureWorkflow.OperationStatus status = signer.status(operationId); + assertEquals(SignatureWorkflow.State.EXPIRED, status.state()); + assertTrue(status.result().isEmpty()); + } + } + @Test public void operatorApprove_completesSignature() throws Exception { System.out.println("operatorApprove_completesSignature"); diff --git a/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusResilienceTest.java b/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusResilienceTest.java index 26d702e..26799e8 100644 --- a/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusResilienceTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusResilienceTest.java @@ -131,14 +131,13 @@ public final class PkiSigningBusResilienceTest { // requester "goes away" here (close) } - // "Requester restart": new instance attaches by knowing (owner, clientOpId) -> - // same canonical opId. + // "Requester restart": the caller retains the stable submission identifier. try (FilesystemPkiStore store2 = new FilesystemPkiStore(storeRoot, options); DurableOperatorApprovalSignatureWorkflow signer2 = new DurableOperatorApprovalSignatureWorkflow(wfRoot, Duration.ofSeconds(10), Duration.ofMillis(0), Map.of(keyRef.value(), kp.getPrivate())); PkiSigningBus bus2 = new PkiSigningBus(store2, signer2, busFile)) { - PkiId opId2 = bus2.canonicalizeOperationId(clientOpId, owner); + PkiId opId2 = opId; System.out.println("...opIdReattach=" + opId2.value()); assertTrue(opId2.value().equals(opId.value())); diff --git a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibKeyRefParsingTest.java b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibKeyRefParsingTest.java index 14faaab..4fa8742 100644 --- a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibKeyRefParsingTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibKeyRefParsingTest.java @@ -36,10 +36,14 @@ package zeroecho.pki.impl.crypto.zeroecholib; import static org.junit.jupiter.api.Assertions.assertEquals; import java.nio.file.Path; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; import java.time.Instant; import java.util.Optional; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.Encoding; @@ -48,23 +52,30 @@ import zeroecho.pki.api.PkiId; import zeroecho.pki.api.audit.AccessContext; import zeroecho.pki.api.audit.Principal; import zeroecho.pki.api.audit.Purpose; +import zeroecho.pki.api.orch.SigningSubmissionId; import zeroecho.pki.spi.crypto.SignatureWorkflow; public final class ZeroEchoLibKeyRefParsingTest { + private static final String NAMESPACE = "0123456789abcdef0123456789abcdef.zeroecho-lib"; + + @TempDir + Path tempDir; + @Test void signing_requires_prv_suffix_in_strict_mode_ok() { System.out.println("signing_requires_prv_suffix_in_strict_mode_ok"); try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("zeroecho-lib", Path.of("nonexistent"), - "zeroecho-lib:", true)) { + tempDir.resolve("operations-1"), Clock.systemUTC(), Duration.ofDays(90), "zeroecho-lib:", true)) { AccessContext ctx = new AccessContext(new Principal("TEST", "unit"), new Purpose("UNIT_TEST"), Optional.empty(), Optional.empty()); - SignatureWorkflow.SignRequest req = new SignatureWorkflow.SignRequest(ctx, new KeyRef("zeroecho-lib:abc"), // missing - // .prv - "ECDSA", new EncodedObject(Encoding.BINARY, new byte[] { 0x01 }), Optional.of(Encoding.BINARY), + PkiId submissionId = SigningSubmissionId.create(NAMESPACE, Instant.now(), new SecureRandom()).id(); + SignatureWorkflow.SignRequest req = SignatureWorkflow.SignRequest.create(submissionId, NAMESPACE, 1L, + ctx, new KeyRef("zeroecho-lib:abc"), "ECDSA", + new EncodedObject(Encoding.BINARY, new byte[] { 0x01 }), Optional.of(Encoding.BINARY), Optional.of(Instant.now())); PkiId opId = wf.submitSign(req); @@ -84,7 +95,7 @@ public final class ZeroEchoLibKeyRefParsingTest { System.out.println("verify_with_publicKeyEncoded_invalid_spki_fails_with_crypto_failure_ok"); try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("zeroecho-lib", Path.of("nonexistent"), - "zeroecho-lib:", true)) { + tempDir.resolve("operations-2"), Clock.systemUTC(), Duration.ofDays(90), "zeroecho-lib:", true)) { AccessContext ctx = new AccessContext(new Principal("TEST", "unit"), new Purpose("UNIT_TEST"), Optional.empty(), Optional.empty()); @@ -112,7 +123,7 @@ public final class ZeroEchoLibKeyRefParsingTest { System.out.println("status_unknown_operation_is_deterministic_ok"); try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("zeroecho-lib", Path.of("nonexistent"), - "zeroecho-lib:", true)) { + tempDir.resolve("operations-3"), Clock.systemUTC(), Duration.ofDays(90), "zeroecho-lib:", true)) { PkiId unknown = new PkiId("00000000-0000-0000-0000-000000000000"); SignatureWorkflow.OperationStatus st = wf.status(unknown); diff --git a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowPersistenceTest.java b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowPersistenceTest.java new file mode 100644 index 0000000..21a4b92 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowPersistenceTest.java @@ -0,0 +1,490 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.impl.crypto.zeroecholib; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.ByteBuffer; +import java.nio.file.Path; +import java.nio.file.Files; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.Encoding; +import zeroecho.pki.api.KeyRef; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.audit.AccessContext; +import zeroecho.pki.api.audit.Principal; +import zeroecho.pki.api.audit.Purpose; +import zeroecho.pki.api.orch.SigningSubmissionId; +import zeroecho.pki.spi.crypto.SignatureWorkflow; +import zeroecho.core.alg.rsa.RsaPrivateKeySpec; +import zeroecho.core.alg.rsa.RsaPublicKeySpec; +import zeroecho.core.storage.KeyringStore; + +final class ZeroEchoLibSignatureWorkflowPersistenceTest { + + private static final String NAMESPACE = "0123456789abcdef0123456789abcdef.zeroecho-lib"; + + @Test + void signingVerificationPersistenceAndCallbackCopiesAreCleared(@TempDir Path root) throws Exception { + System.out.println("signingVerificationPersistenceAndCallbackCopiesAreCleared"); + Instant now = Instant.parse("2026-02-03T04:05:06.789Z"); + Path keyring = root.resolve("keyring.txt"); + KeyPair pair = KeyPairGenerator.getInstance("RSA").generateKeyPair(); + KeyringStore keyringStore = new KeyringStore(new zeroecho.sdk.ZeroEchoSession()); + keyringStore.putPrivate("test.prv", "RSA", new RsaPrivateKeySpec(pair.getPrivate().getEncoded())); + keyringStore.putPublic("test.pub", "RSA", new RsaPublicKeySpec(pair.getPublic().getEncoded())); + keyringStore.save(keyring); + + List cleared = new ArrayList<>(); + List records = new ArrayList<>(); + Logger logger = Logger.getLogger(ZeroEchoLibSignatureWorkflow.class.getName()); + Level previousLevel = logger.getLevel(); + Handler handler = new Handler() { + @Override + public void publish(LogRecord record) { + records.add(record); + } + + @Override + public void flush() { + // No buffered output. + } + + @Override + public void close() { + // No resources. + } + }; + logger.setLevel(Level.FINE); + handler.setLevel(Level.FINE); + logger.addHandler(handler); + try (ZeroEchoLibSignatureWorkflow workflow = new ZeroEchoLibSignatureWorkflow("zeroecho-lib", keyring, + root.resolve("cleanup-operations"), Clock.fixed(now, ZoneOffset.UTC), Duration.ofDays(90), + "zeroecho-lib:", true, (category, bytes) -> cleared.add(new ObservedBuffer(category, bytes))); + SignatureWorkflow.Registration registration = workflow.register((operationId, status) -> { + throw new IllegalStateException("DO_NOT_LOG_SIGNATURE_SENTINEL"); + })) { + byte[] message = new byte[] { 7, 8, 9, 10 }; + PkiId signId = SigningSubmissionId.create(NAMESPACE, now, new SecureRandom()).id(); + SignatureWorkflow.SignRequest signRequest = request(signId, 1L, message, + new KeyRef("zeroecho-lib:test.prv"), Optional.empty()); + workflow.submitSign(signRequest); + EncodedObject signature = workflow.status(signId).result().orElseThrow().signature().orElseThrow(); + assertTrue(signature.bytes().length > 0); + + AccessContext access = signRequest.accessContext(); + SignatureWorkflow.VerifyRequest verifyRequest = new SignatureWorkflow.VerifyRequest(access, + "SHA256withRSA", new EncodedObject(Encoding.BINARY, message), signature, + Optional.of(new KeyRef("zeroecho-lib:test.pub")), Optional.empty(), Optional.empty()); + PkiId verifyId = workflow.submitVerify(verifyRequest); + assertEquals(Optional.of(true), + workflow.status(verifyId).result().orElseThrow().verified()); + + byte[] invalidBytes = signature.bytes(); + invalidBytes[0] ^= 0x01; + SignatureWorkflow.VerifyRequest invalidRequest = new SignatureWorkflow.VerifyRequest(access, + "SHA256withRSA", new EncodedObject(Encoding.BINARY, message), + new EncodedObject(Encoding.BINARY, invalidBytes), + Optional.of(new KeyRef("zeroecho-lib:test.pub")), Optional.empty(), Optional.empty()); + PkiId invalidId = workflow.submitVerify(invalidRequest); + assertEquals(Optional.of(false), + workflow.status(invalidId).result().orElseThrow().verified()); + + assertTrue(cleared.stream().anyMatch(value -> "sign-payload".equals(value.category()))); + assertTrue(cleared.stream().anyMatch(value -> "sign-result-copy".equals(value.category()))); + assertTrue(cleared.stream().anyMatch(value -> "verify-payload".equals(value.category()))); + assertTrue(cleared.stream().anyMatch(value -> "verify-signature".equals(value.category()))); + assertTrue(cleared.stream().anyMatch(value -> "persisted-operation-buffer".equals(value.category()))); + assertTrue(cleared.stream().allMatch(value -> isCleared(value.bytes()))); + assertTrue(records.stream().allMatch(record -> record.getThrown() == null)); + assertTrue(records.stream().noneMatch(record -> String.valueOf(record.getMessage()) + .contains("DO_NOT_LOG_SIGNATURE_SENTINEL"))); + System.out.println("...clearedBuffers=" + cleared.size()); + } finally { + logger.removeHandler(handler); + logger.setLevel(previousLevel); + } + System.out.println("signingVerificationPersistenceAndCallbackCopiesAreCleared...ok"); + } + + @Test + void unsupportedProviderRecordVersionFailsClosedWithRedactedDiagnostic(@TempDir Path root) throws Exception { + Instant now = Instant.parse("2026-02-03T04:05:06.789Z"); + Clock clock = Clock.fixed(now, ZoneOffset.UTC); + Path operations = root.resolve("unsupported-version"); + PkiId id = SigningSubmissionId.create(NAMESPACE, now, new SecureRandom()).id(); + byte[] payload = new byte[] { 91, 92, 93 }; + try (ZeroEchoLibSignatureWorkflow workflow = workflow(root, operations, clock)) { + workflow.submitSign(request(id, 1L, payload)); + } + + Path record = onlyOperationRecord(operations); + byte[] encoded = Files.readAllBytes(record); + ByteBuffer.wrap(encoded).putInt(99); + Files.write(record, encoded); + + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> workflow(root, operations, clock)); + assertFalse(failure.toString().contains(id.value())); + assertFalse(failure.toString().contains(java.util.Base64.getEncoder().encodeToString(payload))); + } + + @Test + void signingDeadlineIsStrictAndSlowCryptoCannotCommitLate(@TempDir Path root) throws Exception { + Instant base = Instant.parse("2026-02-03T04:05:06Z"); + Path keyring = root.resolve("keyring.txt"); + KeyPair pair = KeyPairGenerator.getInstance("RSA").generateKeyPair(); + KeyringStore keyringStore = new KeyringStore(new zeroecho.sdk.ZeroEchoSession()); + keyringStore.putPrivate("test.prv", "RSA", new RsaPrivateKeySpec(pair.getPrivate().getEncoded())); + keyringStore.putPublic("test.pub", "RSA", new RsaPublicKeySpec(pair.getPublic().getEncoded())); + keyringStore.save(keyring); + + PkiId onTimeId = SigningSubmissionId.create(NAMESPACE, base, new SecureRandom()).id(); + Clock onTimeClock = Clock.fixed(base, ZoneOffset.UTC); + try (ZeroEchoLibSignatureWorkflow workflow = workflow(root, root.resolve("on-time"), keyring, onTimeClock)) { + SignatureWorkflow.SignRequest onTime = request(onTimeId, 1L, new byte[] { 1 }, + new KeyRef("zeroecho-lib:test.prv"), Optional.of(base.plusNanos(1))); + workflow.submitSign(onTime); + assertEquals(SignatureWorkflow.State.SUCCEEDED, workflow.status(onTimeId).state()); + } + + PkiId exactId = SigningSubmissionId.create(NAMESPACE, base, new SecureRandom()).id(); + Clock exactClock = Clock.fixed(base, ZoneOffset.UTC); + try (ZeroEchoLibSignatureWorkflow workflow = workflow(root, root.resolve("exact"), keyring, exactClock)) { + SignatureWorkflow.SignRequest exact = request(exactId, 1L, new byte[] { 2 }, + new KeyRef("zeroecho-lib:test.prv"), Optional.of(base)); + workflow.submitSign(exact); + assertEquals(SignatureWorkflow.State.EXPIRED, workflow.status(exactId).state()); + assertEquals(Optional.empty(), workflow.status(exactId).result()); + } + + PkiId crossingId = SigningSubmissionId.create(NAMESPACE, base, new SecureRandom()).id(); + Instant deadline = base.plusSeconds(1); + StepClock crossingClock = new StepClock(base, deadline, 6); + Path crossingOperations = root.resolve("crossing"); + try (ZeroEchoLibSignatureWorkflow workflow = workflow(root, crossingOperations, keyring, crossingClock)) { + SignatureWorkflow.SignRequest crossing = request(crossingId, 1L, new byte[] { 3 }, + new KeyRef("zeroecho-lib:test.prv"), Optional.of(deadline)); + workflow.submitSign(crossing); + assertEquals(SignatureWorkflow.State.EXPIRED, workflow.status(crossingId).state()); + assertEquals(Optional.empty(), workflow.status(crossingId).result()); + } + try (ZeroEchoLibSignatureWorkflow restarted = workflow(root, crossingOperations, keyring, + Clock.fixed(deadline, ZoneOffset.UTC))) { + assertEquals(SignatureWorkflow.State.EXPIRED, restarted.status(crossingId).state()); + assertEquals(Optional.empty(), restarted.status(crossingId).result()); + } + } + + @Test + void encodedObjectAndSigningRequestDefensivelySnapshotBytes() { + byte[] source = new byte[] { 1, 2, 3 }; + EncodedObject encoded = new EncodedObject(Encoding.BINARY, source); + source[0] = 9; + byte[] exposed = encoded.bytes(); + exposed[1] = 9; + assertEquals(1, encoded.bytes()[0]); + assertEquals(2, encoded.bytes()[1]); + + PkiId id = SigningSubmissionId.create(NAMESPACE, Instant.parse("2026-02-03T04:05:06Z"), + new SecureRandom()).id(); + SignatureWorkflow.SignRequest request = request(id, 1L, encoded.bytes()); + byte[] requestBytes = request.payload().bytes(); + requestBytes[2] = 9; + assertEquals(3, request.payload().bytes()[2]); + assertEquals(request.semanticFingerprint(), SignatureWorkflow.SignRequest.fingerprint(NAMESPACE, + request.accessContext(), request.keyRef(), request.algorithmId(), request.payload(), + request.preferredSignatureEncoding(), request.deadline())); + } + + @Test + void restartAttachConflictExpiryAndFence(@TempDir Path root) { + Instant now = Instant.parse("2026-02-03T04:05:06.789Z"); + Clock clock = Clock.fixed(now, ZoneOffset.UTC); + Path operations = root.resolve("operations"); + PkiId id = SigningSubmissionId.create(NAMESPACE, now, new SecureRandom()).id(); + SignatureWorkflow.SignRequest request = request(id, 2L, new byte[] { 1 }); + + try (ZeroEchoLibSignatureWorkflow workflow = workflow(root, operations, clock)) { + assertEquals(id, workflow.submitSign(request)); + assertEquals(SignatureWorkflow.State.FAILED, workflow.status(id).state()); + } + + try (ZeroEchoLibSignatureWorkflow restarted = workflow(root, operations, clock)) { + assertEquals(SignatureWorkflow.State.FAILED, restarted.status(id).state()); + assertEquals(id, restarted.submitSign(request)); + assertThrows(IllegalStateException.class, () -> restarted.submitSign(request(id, 1L, new byte[] { 1 }))); + assertThrows(IllegalStateException.class, () -> restarted.submitSign(request(id, 3L, new byte[] { 2 }))); + } + forcePersistedStateCode(operations, 50, 30); + try (ZeroEchoLibSignatureWorkflow recovered = workflow(root, operations, clock)) { + assertEquals(SignatureWorkflow.State.FAILED, recovered.status(id).state()); + assertEquals("RECOVERY_INCOMPLETE", recovered.status(id).detailCode().orElseThrow()); + } + + Clock expired = Clock.fixed(now.plus(Duration.ofDays(90)), ZoneOffset.UTC); + try (ZeroEchoLibSignatureWorkflow restarted = workflow(root, operations, expired)) { + assertEquals(SignatureWorkflow.State.EXPIRED, restarted.status(id).state()); + PkiId expiredId = SigningSubmissionId.create(NAMESPACE, now, new SecureRandom()).id(); + assertThrows(IllegalArgumentException.class, + () -> restarted.submitSign(request(expiredId, 1L, new byte[] { 3 }))); + } + } + + @Test + void concurrentAttachCancelAndReentrantCallbackAreOperationLocal(@TempDir Path root) throws Exception { + Instant now = Instant.parse("2026-02-03T04:05:06.789Z"); + Clock clock = Clock.fixed(now, ZoneOffset.UTC); + PkiId firstId = SigningSubmissionId.create(NAMESPACE, now, new SecureRandom()).id(); + PkiId secondId = SigningSubmissionId.create(NAMESPACE, now, new SecureRandom()).id(); + SignatureWorkflow.SignRequest first = request(firstId, 1L, new byte[] { 1 }); + SignatureWorkflow.SignRequest second = request(secondId, 1L, new byte[] { 2 }); + CountDownLatch callbackEntered = new CountDownLatch(1); + CountDownLatch callbackRelease = new CountDownLatch(1); + try (ZeroEchoLibSignatureWorkflow workflow = workflow(root, root.resolve("operations"), clock); + ExecutorService executor = Executors.newFixedThreadPool(3); + SignatureWorkflow.Registration registration = workflow.register((operationId, status) -> { + if (firstId.equals(operationId) && status.state() == SignatureWorkflow.State.RUNNING) { + assertEquals(SignatureWorkflow.State.RUNNING, workflow.status(operationId).state()); + callbackEntered.countDown(); + try { + callbackRelease.await(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(ex); + } + } + })) { + Future firstFuture = executor.submit(() -> workflow.submitSign(first)); + assertEquals(true, callbackEntered.await(5, TimeUnit.SECONDS)); + assertEquals(firstId, executor.submit(() -> workflow.submitSign(first)).get(5, TimeUnit.SECONDS)); + assertEquals(secondId, executor.submit(() -> workflow.submitSign(second)).get(5, TimeUnit.SECONDS)); + assertEquals(true, workflow.cancel(firstId, 2L, "test cancellation")); + callbackRelease.countDown(); + assertEquals(firstId, firstFuture.get(5, TimeUnit.SECONDS)); + assertEquals(SignatureWorkflow.State.CANCELLED, workflow.status(firstId).state()); + assertEquals(false, workflow.cancel(firstId, 1L, "stale")); + } + } + + @Test + void concurrentWatermarkWritesNeverRegressAcrossRestart(@TempDir Path root) throws Exception { + Instant base = Instant.parse("2026-02-03T04:05:06.789Z"); + Instant high = base.plusSeconds(60); + LatchClock clock = new LatchClock(base); + Path operations = root.resolve("operations"); + try (ZeroEchoLibSignatureWorkflow workflow = workflow(root, operations, clock); + ExecutorService executor = Executors.newFixedThreadPool(2)) { + clock.arm(high); + Future highRead = executor.submit(() -> workflow.status(new PkiId("unknown-high"))); + assertEquals(true, clock.observed.await(5, TimeUnit.SECONDS)); + clock.set(base.minusSeconds(60)); + Future rollbackRead = executor.submit(() -> workflow.status(new PkiId("unknown-low"))); + clock.release.countDown(); + highRead.get(5, TimeUnit.SECONDS); + rollbackRead.get(5, TimeUnit.SECONDS); + assertEquals(high.toEpochMilli(), Long.parseLong(Files.readString( + operations.resolve("TIME_WATERMARK")).trim())); + } + clock.set(base.minusSeconds(120)); + try (ZeroEchoLibSignatureWorkflow restarted = workflow(root, operations, clock)) { + restarted.status(new PkiId("unknown-restart")); + assertEquals(high.toEpochMilli(), Long.parseLong(Files.readString( + operations.resolve("TIME_WATERMARK")).trim())); + } + } + + private static ZeroEchoLibSignatureWorkflow workflow(Path root, Path operations, Clock clock) { + return workflow(root, operations, root.resolve("missing-keyring"), clock); + } + + private static ZeroEchoLibSignatureWorkflow workflow(Path root, Path operations, Path keyring, Clock clock) { + return new ZeroEchoLibSignatureWorkflow("zeroecho-lib", keyring, operations, clock, + Duration.ofDays(90), "zeroecho-lib:", true); + } + + private static SignatureWorkflow.SignRequest request(PkiId id, long fence, byte[] payload) { + return request(id, fence, payload, new KeyRef("zeroecho-lib:missing.prv"), Optional.empty()); + } + + private static SignatureWorkflow.SignRequest request(PkiId id, long fence, byte[] payload, KeyRef keyRef, + Optional deadline) { + AccessContext access = new AccessContext(new Principal("TEST", "owner"), new Purpose("TEST"), + Optional.empty(), Optional.empty()); + return SignatureWorkflow.SignRequest.create(id, NAMESPACE, fence, access, + keyRef, "SHA256withRSA", new EncodedObject(Encoding.BINARY, payload), + Optional.of(Encoding.BINARY), deadline); + } + + private static void forcePersistedStateCode(Path operations, int from, int to) { + try { + Path record; + try (java.util.stream.Stream paths = Files.list(operations.resolve("records"))) { + record = paths.filter(Files::isRegularFile).findFirst().orElseThrow(); + } + byte[] bytes = Files.readAllBytes(record); + for (int index = bytes.length - Integer.BYTES; index >= 0; index--) { + if (bytes[index] == 0 && bytes[index + 1] == 0 && bytes[index + 2] == 0 + && Byte.toUnsignedInt(bytes[index + 3]) == from) { + bytes[index + 3] = (byte) to; + Files.write(record, bytes); + return; + } + } + throw new IllegalStateException("Persisted state code not found"); + } catch (java.io.IOException ex) { + throw new IllegalStateException(ex); + } + } + + private static Path onlyOperationRecord(Path operations) throws java.io.IOException { + try (java.util.stream.Stream paths = Files.list(operations.resolve("records"))) { + return paths.filter(Files::isRegularFile).findFirst().orElseThrow(); + } + } + + private static boolean isCleared(byte[] bytes) { + for (byte value : bytes) { + if (value != 0) { + return false; + } + } + return true; + } + + private record ObservedBuffer(String category, byte[] bytes) { + } + + private static final class LatchClock extends Clock { + private final AtomicBoolean block = new AtomicBoolean(); + private final CountDownLatch observed = new CountDownLatch(1); + private final CountDownLatch release = new CountDownLatch(1); + private volatile Instant value; + + private LatchClock(Instant value) { + this.value = value; + } + + private void arm(Instant next) { + value = next; + block.set(true); + } + + private void set(Instant next) { + value = next; + } + + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + Instant observedValue = value; + if (block.compareAndSet(true, false)) { + observed.countDown(); + try { + release.await(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(ex); + } + } + return observedValue; + } + } + + private static final class StepClock extends Clock { + private final Instant initial; + private final Instant advanced; + private final int initialReads; + private final AtomicInteger reads = new AtomicInteger(); + + private StepClock(Instant initial, Instant advanced, int initialReads) { + this.initial = initial; + this.advanced = advanced; + this.initialReads = initialReads; + } + + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return reads.incrementAndGet() <= initialReads ? initial : advanced; + } + } +} diff --git a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedEcdsaTest.java b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedEcdsaTest.java index 06562e1..d84e5bc 100644 --- a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedEcdsaTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedEcdsaTest.java @@ -40,6 +40,8 @@ import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.Signature; import java.security.spec.ECGenParameterSpec; +import java.time.Clock; +import java.time.Duration; import java.time.Instant; import java.util.Optional; @@ -72,7 +74,8 @@ public final class ZeroEchoLibSignatureWorkflowVerifyEncodedEcdsaTest { KeyringStore ks = new KeyringStore(new zeroecho.sdk.ZeroEchoSession()); ks.save(keyring); - try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("wf", keyring, "zeroecho-lib:", true)) { + try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("wf", keyring, + tempDir.resolve("operations"), Clock.systemUTC(), Duration.ofDays(90), "zeroecho-lib:", true)) { KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC"); kpg.initialize(new ECGenParameterSpec("secp256r1")); KeyPair kp = kpg.generateKeyPair(); diff --git a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedTest.java b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedTest.java index 5b90e2e..ddb9bff 100644 --- a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedTest.java @@ -39,6 +39,8 @@ import java.nio.file.Path; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.Signature; +import java.time.Clock; +import java.time.Duration; import java.time.Instant; import java.util.Optional; @@ -67,7 +69,8 @@ public final class ZeroEchoLibSignatureWorkflowVerifyEncodedTest { KeyringStore ks = new KeyringStore(new zeroecho.sdk.ZeroEchoSession()); ks.save(keyring); - try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("wf", keyring, "zeroecho-lib:", true)) { + try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("wf", keyring, + tempDir.resolve("operations"), Clock.systemUTC(), Duration.ofDays(90), "zeroecho-lib:", true)) { KeyPair kp = KeyPairGenerator.getInstance("RSA").generateKeyPair(); byte[] payload = "pqc-ready".getBytes(java.nio.charset.StandardCharsets.UTF_8); diff --git a/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSignerCleanupTest.java b/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSignerCleanupTest.java new file mode 100644 index 0000000..0319724 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSignerCleanupTest.java @@ -0,0 +1,119 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.impl.framework.x509.bc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import zeroecho.pki.api.KeyRef; +import zeroecho.pki.api.PkiException; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.impl.core.async.PkiSigningBus; +import zeroecho.pki.impl.fs.FilesystemPkiStore; +import zeroecho.pki.impl.fs.FsPkiStoreOptions; +import zeroecho.pki.testkit.InMemorySignatureWorkflow; + +final class PkiBusContentSignerCleanupTest { + + @Test + void endEntityFailureRetiresEverySigningArtifact(@TempDir Path tempDir) throws Exception { + assertCleanup(tempDir, false); + } + + @Test + void intermediateFailureRetiresEverySigningArtifact(@TempDir Path tempDir) throws Exception { + assertCleanup(tempDir, true); + } + + private static void assertCleanup(Path tempDir, boolean intermediate) throws Exception { + KeyPair subjectKey = generateRsa(); + KeyRef issuerKeyRef = new KeyRef("kref:v1:keyring:test:issuer"); + InMemorySignatureWorkflow signer = new InMemorySignatureWorkflow(Map.of(), true); + Path storeRoot = tempDir.resolve("store"); + Path busLog = tempDir.resolve("bus.log"); + + try (FilesystemPkiStore store = new FilesystemPkiStore(storeRoot, FsPkiStoreOptions.defaults()); + PkiSigningBus bus = new PkiSigningBus(store, signer, busLog)) { + PkiBusContentSigner contentSigner = new PkiBusContentSigner(bus, issuerKeyRef, "SHA256withRSA", + Duration.ofSeconds(5)); + contentSigner.getOutputStream().write(intermediate + ? subjectKey.getPublic().getEncoded() + : new byte[] { 1, 2, 3 }); + assertThrows(PkiException.class, contentSigner::getSignature); + + assertEquals(1, signer.submittedSignCount()); + assertTrue(store.listWorkflowStates().isEmpty()); + assertFalse(signer.hasRunningOperations()); + assertTrue(store.listCas().isEmpty()); + assertFalse(Files.exists(storeRoot.resolve("credentials"))); + + PkiId operationId = submittedOperation(busLog); + try (PkiSigningBus replayed = new PkiSigningBus(store, signer, busLog)) { + assertEquals(zeroecho.pki.util.async.AsyncState.CANCELLED, + replayed.status(operationId).orElseThrow().state()); + } + } finally { + signer.close(); + } + } + + private static PkiId submittedOperation(Path busLog) throws Exception { + List lines = Files.readAllLines(busLog); + for (String line : lines) { + if (line.startsWith("S1|")) { + return new PkiId(line.split("\\|", -1)[1]); + } + } + throw new AssertionError("No durable signing operation snapshot found"); + } + + private static KeyPair generateRsa() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + return generator.generateKeyPair(); + } +} diff --git a/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifierTest.java b/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifierTest.java index 927619d..5b870c6 100644 --- a/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifierTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifierTest.java @@ -87,7 +87,8 @@ public final class WorkflowProofOfPossessionVerifierTest { ParsedCertificationRequest parsed = new BcX509CertificationRequestParser().parse(req); ZeroEchoLibSignatureWorkflowProvider provider = new ZeroEchoLibSignatureWorkflowProvider(); - ProviderConfig cfg = new ProviderConfig(provider.id(), Map.of("keyringPath", keyringPath.toString())); + ProviderConfig cfg = new ProviderConfig(provider.id(), Map.of("keyringPath", keyringPath.toString(), + "operationRoot", tempDir.resolve("signing-operations").toString())); SignatureWorkflow wf = provider.allocate(cfg); WorkflowProofOfPossessionVerifier verifier = new WorkflowProofOfPossessionVerifier(wf); diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreOwnershipTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreOwnershipTest.java new file mode 100644 index 0000000..d212957 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreOwnershipTest.java @@ -0,0 +1,312 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.impl.fs; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.logging.LogManager; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Verifies exclusive process-lifetime ownership of a filesystem PKI store. + */ +final class FilesystemPkiStoreOwnershipTest { + + private static final String OWNERSHIP_CODE = "STORE_ALREADY_OPEN"; + private static final long PROCESS_TIMEOUT_SECONDS = 10L; + + @Test + void sameJvmOwnerExcludesSecondStoreAndReleasesOnClose(@TempDir Path tempDir) throws Exception { + System.out.println("sameJvmOwnerExcludesSecondStoreAndReleasesOnClose"); + Path root = tempDir.resolve("shared"); + FsPkiStoreOptions options = FsPkiStoreOptions.defaults(); + + FilesystemPkiStore first = new FilesystemPkiStore(root, options); + try { + first.putProfile(FilesystemPkiStoreTest.TestObjects.minimalProfile("ownership-profile", true)); + assertOwnershipRejected(root); + assertTrue(first.getProfile("ownership-profile").isPresent()); + } finally { + first.close(); + } + first.close(); + + try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, options)) { + assertTrue(reopened.getProfile("ownership-profile").isPresent()); + } + System.out.println("sameJvmOwnerExcludesSecondStoreAndReleasesOnClose...ok"); + } + + @Test + void separateStoreRootsRemainIndependent(@TempDir Path tempDir) throws Exception { + System.out.println("separateStoreRootsRemainIndependent"); + try (FilesystemPkiStore first = new FilesystemPkiStore(tempDir.resolve("first"), + FsPkiStoreOptions.defaults()); + FilesystemPkiStore second = new FilesystemPkiStore(tempDir.resolve("second"), + FsPkiStoreOptions.defaults())) { + first.putProfile(FilesystemPkiStoreTest.TestObjects.minimalProfile("first-profile", true)); + second.putProfile(FilesystemPkiStoreTest.TestObjects.minimalProfile("second-profile", true)); + assertTrue(first.getProfile("first-profile").isPresent()); + assertFalse(first.getProfile("second-profile").isPresent()); + assertTrue(second.getProfile("second-profile").isPresent()); + assertFalse(second.getProfile("first-profile").isPresent()); + } + System.out.println("separateStoreRootsRemainIndependent...ok"); + } + + @Test + void constructorFailureAfterAcquisitionReleasesOwnership(@TempDir Path tempDir) throws Exception { + System.out.println("constructorFailureAfterAcquisitionReleasesOwnership"); + Path root = tempDir.resolve("invalid-version"); + Files.createDirectories(root); + Files.writeString(root.resolve(FsPaths.VERSION_FILE), "unsupported", StandardCharsets.US_ASCII); + + assertThrows(IllegalStateException.class, + () -> new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())); + Files.writeString(root.resolve(FsPaths.VERSION_FILE), "v1", StandardCharsets.US_ASCII); + + try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) { + assertTrue(reopened.listCas().isEmpty()); + } + System.out.println("constructorFailureAfterAcquisitionReleasesOwnership...ok"); + } + + @Test + void forkedJvmOwnerExcludesParentAndReleasesNormally(@TempDir Path tempDir) throws Exception { + System.out.println("forkedJvmOwnerExcludesParentAndReleasesNormally"); + Path root = tempDir.resolve("forked-graceful"); + try (ChildOwner child = ChildOwner.start(root)) { + child.expect("LOCK_ACQUIRED"); + assertOwnershipRejected(root); + child.send("PING"); + child.expect("STORE_USABLE"); + child.send("CLOSE"); + child.expect("STORE_CLOSED"); + child.awaitExit(0); + } + + try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) { + assertTrue(reopened.listCas().isEmpty()); + } + System.out.println("forkedJvmOwnerExcludesParentAndReleasesNormally...ok"); + } + + @Test + void forkedJvmTerminationReleasesOperatingSystemLock(@TempDir Path tempDir) throws Exception { + System.out.println("forkedJvmTerminationReleasesOperatingSystemLock"); + Path root = tempDir.resolve("forked-abrupt"); + try (ChildOwner child = ChildOwner.start(root)) { + child.expect("LOCK_ACQUIRED"); + assertOwnershipRejected(root); + child.destroyForcibly(); + } + + try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) { + assertTrue(reopened.listCas().isEmpty()); + } + System.out.println("forkedJvmTerminationReleasesOperatingSystemLock...ok"); + } + + private static void assertOwnershipRejected(Path root) { + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())); + assertTrue(failure.getMessage().contains(OWNERSHIP_CODE)); + assertNull(failure.getCause()); + } + + private static String childClasspath() throws Exception { + Set entries = new LinkedHashSet<>(); + String configured = System.getProperty("java.class.path", ""); + if (!configured.isBlank()) { + for (String entry : configured.split(java.io.File.pathSeparator)) { + entries.add(entry); + } + } + ClassLoader loader = FilesystemPkiStoreOwnershipTest.class.getClassLoader(); + while (loader != null) { + if (loader instanceof URLClassLoader urls) { + for (URL url : urls.getURLs()) { + if ("file".equals(url.getProtocol())) { + entries.add(Path.of(url.toURI()).toString()); + } + } + } + loader = loader.getParent(); + } + entries.add(codeSource(FilesystemPkiStoreOwnershipTest.class)); + entries.add(codeSource(FilesystemPkiStore.class)); + return String.join(java.io.File.pathSeparator, entries); + } + + private static String codeSource(Class type) throws Exception { + return Path.of(type.getProtectionDomain().getCodeSource().getLocation().toURI()).toString(); + } + + private static final class ChildOwner implements AutoCloseable { + private final Process process; + private final BufferedReader output; + private final BufferedWriter input; + private final ExecutorService reader; + + private ChildOwner(Process process) { + this.process = process; + this.output = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)); + this.input = new BufferedWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8)); + this.reader = Executors.newSingleThreadExecutor(); + } + + private static ChildOwner start(Path root) throws Exception { + String executable = System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT) + .contains("win") ? "java.exe" : "java"; + Path java = Path.of(System.getProperty("java.home"), "bin", executable); + Process process = new ProcessBuilder(java.toString(), "-cp", childClasspath(), + FilesystemPkiStoreLockProcess.class.getName(), root.toString()).redirectErrorStream(true).start(); + return new ChildOwner(process); + } + + private void send(String command) throws IOException { + input.write(command); + input.newLine(); + input.flush(); + } + + private void expect(String expected) throws Exception { + Future line = reader.submit(output::readLine); + try { + assertEquals(expected, line.get(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + } catch (ExecutionException ex) { + throw new IOException("Child protocol read failed", ex.getCause()); + } catch (TimeoutException ex) { + line.cancel(true); + throw new IOException("Child protocol timed out"); + } + } + + private void awaitExit(int expectedCode) throws Exception { + assertTrue(process.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + assertEquals(expectedCode, process.exitValue()); + } + + private void destroyForcibly() throws Exception { + process.destroyForcibly(); + assertTrue(process.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + } + + @Override + public void close() throws Exception { + if (process.isAlive()) { + process.destroyForcibly(); + process.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + input.close(); + output.close(); + reader.shutdownNow(); + assertTrue(reader.awaitTermination(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + } + } +} + +/** + * Test-process protocol endpoint for filesystem-store ownership checks. + */ +final class FilesystemPkiStoreLockProcess { + + private FilesystemPkiStoreLockProcess() { + } + + /** + * Opens one store and serves the fixed ownership-test control protocol. + * + * @param args one filesystem store root + */ + public static void main(String[] args) { + if (args.length != 1) { + System.out.println("SETUP_FAILED"); + System.exit(2); + } + LogManager.getLogManager().reset(); + try (FilesystemPkiStore store = new FilesystemPkiStore(Path.of(args[0]), + FsPkiStoreOptions.defaults()); + BufferedReader control = new BufferedReader(new InputStreamReader(System.in, + StandardCharsets.UTF_8))) { + System.out.println("LOCK_ACQUIRED"); + System.out.flush(); + String command; + while ((command = control.readLine()) != null) { + if ("PING".equals(command)) { + store.listCas(); + System.out.println("STORE_USABLE"); + System.out.flush(); + } else if ("CLOSE".equals(command)) { + store.close(); + System.out.println("STORE_CLOSED"); + System.out.flush(); + return; + } else { + System.out.println("PROTOCOL_FAILED"); + System.out.flush(); + System.exit(3); + } + } + } catch (Exception failure) { + System.out.println("SETUP_FAILED:" + failure.getClass().getName()); + System.out.flush(); + System.exit(2); + } + } +} diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java index 084b520..0da5d2c 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java @@ -342,13 +342,15 @@ public final class FilesystemPkiStoreTest { private static FsPkiStoreOptions nonStrictSnapshotOptions() { FsPkiStoreOptions defaults = FsPkiStoreOptions.defaults(); return new FsPkiStoreOptions(defaults.caHistoryPolicy(), defaults.profileHistoryPolicy(), - defaults.revocationHistoryPolicy(), defaults.workflowHistoryPolicy(), false); + defaults.revocationHistoryPolicy(), defaults.workflowHistoryPolicy(), false, + defaults.signingOperationHorizon(), defaults.signingIdPermittedSkew()); } private static FsPkiStoreOptions strictSnapshotOptions() { FsPkiStoreOptions defaults = FsPkiStoreOptions.defaults(); return new FsPkiStoreOptions(defaults.caHistoryPolicy(), defaults.profileHistoryPolicy(), - defaults.revocationHistoryPolicy(), defaults.workflowHistoryPolicy(), true); + defaults.revocationHistoryPolicy(), defaults.workflowHistoryPolicy(), true, + defaults.signingOperationHorizon(), defaults.signingIdPermittedSkew()); } private static void sleepMillis(long ms) throws InterruptedException { diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemSignWorkflowStoreTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemSignWorkflowStoreTest.java new file mode 100644 index 0000000..60cfe52 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemSignWorkflowStoreTest.java @@ -0,0 +1,677 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.impl.fs; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import zeroecho.core.io.Util; +import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.Encoding; +import zeroecho.pki.api.KeyRef; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.audit.AccessContext; +import zeroecho.pki.api.audit.Principal; +import zeroecho.pki.api.audit.Purpose; +import zeroecho.pki.api.orch.SigningSubmissionId; +import zeroecho.pki.impl.core.async.PkiSigningBus; +import zeroecho.pki.spi.store.SignWorkflowStore; +import zeroecho.pki.testkit.InMemorySignatureWorkflow; + +final class FilesystemSignWorkflowStoreTest { + + private static final Principal TEST_OWNER = new Principal("TEST", "owner"); + private static final AccessContext TEST_ACCESS = new AccessContext(TEST_OWNER, new Purpose("SIGN"), + Optional.empty(), Optional.empty()); + private static final KeyRef TEST_KEY = new KeyRef("test-key"); + private static final String TEST_ALGORITHM = "SHA256withRSA"; + + @Test + void currentRecordCodecRequiresExactShapeAndRedactsStructuralFailures() throws Exception { + System.out.println("currentRecordCodecRequiresExactShapeAndRedactsStructuralFailures"); + Instant createdAt = Instant.parse("2026-01-02T03:04:05.123Z"); + PkiId id = SigningSubmissionId.create("0123456789abcdef0123456789abcdef.test-signer", createdAt, + new SecureRandom()).id(); + SignWorkflowStore.Record current = intent(id, createdAt, + new EncodedObject(Encoding.BINARY, new byte[] { 7 }), TEST_ALGORITHM); + String fingerprint = current.fingerprint(); + byte[] encoded = FsCodec.encode(current); + + SignWorkflowStore.Record decoded = FsCodec.decode(encoded, SignWorkflowStore.Record.class); + assertEquals(current.submissionId(), decoded.submissionId()); + assertEquals(current.providerUpdatedAt(), decoded.providerUpdatedAt()); + + int componentCountOffset = recordComponentCountOffset(encoded); + byte[] missingComponent = encoded.clone(); + missingComponent[componentCountOffset] = (byte) (SignWorkflowStore.Record.class.getRecordComponents().length + - 1); + assertRedactedStructuralFailure(missingComponent, fingerprint); + + byte[] extraComponent = encoded.clone(); + extraComponent[componentCountOffset] = (byte) (SignWorkflowStore.Record.class.getRecordComponents().length + + 1); + assertRedactedStructuralFailure(extraComponent, fingerprint); + + byte[] missingProviderUpdatedAt = Arrays.copyOf(encoded, encoded.length - 3); + assertRedactedStructuralFailure(missingProviderUpdatedAt, fingerprint); + System.out.println("...ok"); + } + + @Test + void malformedCurrentRecordCannotActivateAttachOrExposeResult(@TempDir Path root) throws Exception { + System.out.println("malformedCurrentRecordCannotActivateAttachOrExposeResult"); + Instant createdAt = Instant.parse("2026-01-02T03:04:05.123Z"); + MutableClock clock = new MutableClock(createdAt); + SignWorkflowStore.Record intent; + PkiId id; + try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock)) { + String namespace = store.signingNamespace() + ".test-signer"; + id = SigningSubmissionId.create(namespace, createdAt, new SecureRandom()).id(); + intent = intent(id, createdAt, new EncodedObject(Encoding.BINARY, new byte[] { 9 }), TEST_ALGORITHM); + store.createSignIntent(intent); + } + String fingerprint = intent.fingerprint(); + + Path recordPath = new FsPaths(root).signWorkflowPath(id); + byte[] malformed = Files.readAllBytes(recordPath); + int envelopeBytes = Integer.BYTES * 2; + int componentCountOffset = envelopeBytes + + recordComponentCountOffset(Arrays.copyOfRange(malformed, envelopeBytes, malformed.length)); + malformed[componentCountOffset] = (byte) (SignWorkflowStore.Record.class.getRecordComponents().length - 1); + Files.write(recordPath, malformed); + + try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock); + InMemorySignatureWorkflow signer = new InMemorySignatureWorkflow(Map.of(), false)) { + RuntimeException activationFailure = assertThrows(RuntimeException.class, + () -> new PkiSigningBus(reopened, signer, root.resolve("bus.log"))); + assertFalse(activationFailure.toString().contains(fingerprint)); + RuntimeException attachFailure = assertThrows(RuntimeException.class, + () -> reopened.createSignIntent(intent)); + assertFalse(attachFailure.toString().contains(fingerprint)); + assertThrows(RuntimeException.class, () -> reopened.getSignRecord(id)); + } + System.out.println("...ok"); + } + + @Test + void unsupportedFilesystemSigningRecordVersionCannotActivate(@TempDir Path root) throws Exception { + System.out.println("unsupportedFilesystemSigningRecordVersionCannotActivate"); + Instant createdAt = Instant.parse("2026-01-02T03:04:05.123Z"); + MutableClock clock = new MutableClock(createdAt); + PkiId id; + String fingerprint; + try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock)) { + String namespace = store.signingNamespace() + ".test-signer"; + id = SigningSubmissionId.create(namespace, createdAt, new SecureRandom()).id(); + SignWorkflowStore.Record current = intent(id, createdAt, + new EncodedObject(Encoding.BINARY, new byte[] { 10 }), TEST_ALGORITHM); + fingerprint = current.fingerprint(); + store.createSignIntent(current); + } + + Path recordPath = new FsPaths(root).signWorkflowPath(id); + byte[] unsupported = Files.readAllBytes(recordPath); + java.nio.ByteBuffer.wrap(unsupported).putInt(Integer.BYTES, 99); + Files.write(recordPath, unsupported); + + try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock); + InMemorySignatureWorkflow signer = new InMemorySignatureWorkflow(Map.of(), false)) { + RuntimeException failure = assertThrows(RuntimeException.class, + () -> new PkiSigningBus(reopened, signer, root.resolve("unsupported-version-bus.log"))); + assertTrue(failure.toString().contains("Unsupported signing record version")); + assertFalse(failure.toString().contains(fingerprint)); + assertFalse(failure.toString().contains(id.value())); + } + System.out.println("...ok"); + } + + @Test + void stableIdentityCasRestartConflictAndExpiry(@TempDir java.nio.file.Path root) throws Exception { + Instant createdAt = Instant.parse("2026-01-02T03:04:05.123Z"); + MutableClock clock = new MutableClock(createdAt); + FsPkiStoreOptions defaults = FsPkiStoreOptions.defaults(); + FsPkiStoreOptions options = new FsPkiStoreOptions(defaults.caHistoryPolicy(), + defaults.profileHistoryPolicy(), defaults.revocationHistoryPolicy(), defaults.workflowHistoryPolicy(), + true, Duration.ofDays(90), Duration.ZERO); + EncodedObject request = new EncodedObject(Encoding.BINARY, new byte[] { 1, 2, 3 }); + PkiId id; + SignWorkflowStore.Record intent; + try (FilesystemPkiStore store = new FilesystemPkiStore(root, options, clock)) { + String namespace = store.signingNamespace() + ".test-signer"; + id = SigningSubmissionId.create(namespace, createdAt, new SecureRandom()).id(); + intent = intent(id, createdAt, request, TEST_ALGORITHM); + assertEquals(SignWorkflowStore.CreateResult.CREATED, store.createSignIntent(intent)); + assertEquals(SignWorkflowStore.CreateResult.ATTACHED, store.createSignIntent(intent)); + assertEquals(SignWorkflowStore.CreateResult.CONFLICT, + store.createSignIntent(intent(id, createdAt, request, "SHA512withRSA"))); + + SignWorkflowStore.Record claimed = store.tryClaimSign(id, 0L, Duration.ofSeconds(30)).orElseThrow(); + assertEquals(1L, claimed.revision()); + assertEquals(1L, claimed.fence()); + assertTrue(store.tryClaimSign(id, 0L, Duration.ofSeconds(30)).isEmpty()); + assertTrue(store.transitionSign(id, claimed.revision(), claimed.fence(), + SignWorkflowStore.State.DISPATCHED, Optional.of("DISPATCHED"), Optional.empty(), + Optional.empty()).isPresent()); + } + + try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, options, clock)) { + assertEquals(SignWorkflowStore.State.DISPATCHED, reopened.getSignRecord(id).orElseThrow().state()); + clock.set(createdAt.plus(Duration.ofDays(90))); + assertEquals(0, reopened.purgeExpiredSignRecords()); + SignWorkflowStore.Record active = reopened.getSignRecord(id).orElseThrow(); + SignWorkflowStore.Record failed = reopened.transitionSign(id, active.revision(), active.fence(), + SignWorkflowStore.State.FAILED, Optional.of("PROVIDER_FAILED"), Optional.empty(), + Optional.of(clock.instant())).orElseThrow(); + reopened.retireSign(id, failed.revision(), failed.fence()).orElseThrow(); + assertEquals(1, reopened.purgeExpiredSignRecords()); + assertFalse(reopened.getSignRecord(id).isPresent()); + assertThrows(IllegalArgumentException.class, () -> reopened.createSignIntent(intent)); + } + try (FilesystemPkiStore foreign = new FilesystemPkiStore(root.resolveSibling("foreign"), options, clock)) { + assertThrows(IllegalArgumentException.class, () -> foreign.createSignIntent(intent)); + } + } + + @Test + void snapshotRetainsNamespaceResultRevisionFenceAndRollbackWatermark(@TempDir java.nio.file.Path root) + throws Exception { + Instant createdAt = Instant.parse("2026-04-05T06:07:08.900Z"); + MutableClock clock = new MutableClock(createdAt); + Path snapshot = root.resolveSibling(root.getFileName() + "-snapshot"); + String namespace; + PkiId id; + try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock)) { + namespace = store.signingNamespace() + ".test-signer"; + id = SigningSubmissionId.create(namespace, createdAt, new SecureRandom()).id(); + SignWorkflowStore.Record created = intent(id, createdAt, + new EncodedObject(Encoding.BINARY, new byte[] { 4 }), TEST_ALGORITHM); + store.createSignIntent(created); + SignWorkflowStore.Record claimed = store.tryClaimSign(id, 0L, Duration.ofSeconds(10)).orElseThrow(); + SignWorkflowStore.Record dispatched = store.transitionSign(id, claimed.revision(), claimed.fence(), + SignWorkflowStore.State.DISPATCHED, Optional.of("DISPATCHED"), Optional.empty(), + Optional.empty()).orElseThrow(); + clock.set(createdAt.plusSeconds(1)); + store.transitionSign(id, dispatched.revision(), dispatched.fence(), SignWorkflowStore.State.SUCCEEDED, + Optional.of("SIGNED"), Optional.of(new EncodedObject(Encoding.BINARY, new byte[] { 9 })), + Optional.of(createdAt.plusSeconds(1))) + .orElseThrow(); + clock.set(createdAt.plusSeconds(30)); + assertEquals(createdAt.plusSeconds(30), store.signingNow()); + clock.set(createdAt.minusSeconds(30)); + assertEquals(createdAt.plusSeconds(30), store.signingNow()); + // Signing safety metadata deliberately reflects export time, even when the + // requested historical instant predates the operation. + store.exportSnapshot(snapshot, createdAt.minusSeconds(60)); + } + try (FilesystemPkiStore restored = new FilesystemPkiStore(snapshot, FsPkiStoreOptions.defaults(), clock)) { + SignWorkflowStore.Record record = restored.getSignRecord(id).orElseThrow(); + assertEquals(namespace.substring(0, 32), restored.signingNamespace()); + assertEquals(SignWorkflowStore.State.SUCCEEDED, record.state()); + assertEquals(3L, record.revision()); + assertEquals(1L, record.fence()); + assertEquals(9, record.result().orElseThrow().bytes()[0]); + assertEquals(createdAt.plusSeconds(30), restored.signingNow()); + } + } + + @Test + void concurrentClaimsHaveOneWinnerAndDifferentIdsRemainIndependent(@TempDir java.nio.file.Path root) + throws Exception { + Instant createdAt = Instant.parse("2026-01-02T03:04:05.123Z"); + MutableClock clock = new MutableClock(createdAt); + EncodedObject request = new EncodedObject(Encoding.BINARY, new byte[] { 1 }); + try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock); + ExecutorService executor = Executors.newFixedThreadPool(8)) { + String namespace = store.signingNamespace() + ".test-signer"; + PkiId first = SigningSubmissionId.create(namespace, createdAt, new SecureRandom()).id(); + PkiId second = SigningSubmissionId.create(namespace, createdAt, new SecureRandom()).id(); + store.createSignIntent(intent(first, createdAt, request, TEST_ALGORITHM)); + store.createSignIntent(intent(second, createdAt, request, TEST_ALGORITHM)); + Callable claim = () -> store.tryClaimSign(first, 0L, Duration.ofSeconds(30)).isPresent(); + long winners = executor.invokeAll(java.util.Collections.nCopies(32, claim)).stream().filter(future -> { + try { + return future.get(); + } catch (Exception ex) { + throw new IllegalStateException(ex); + } + }).count(); + assertEquals(1L, winners); + assertTrue(store.tryClaimSign(second, 0L, Duration.ofSeconds(30)).isPresent()); + } + } + + @Test + void concurrentWatermarkWritesNeverRegressAcrossRestart(@TempDir Path root) throws Exception { + Instant base = Instant.parse("2026-01-02T03:04:05.123Z"); + Instant high = base.plusSeconds(60); + MutableClock clock = new MutableClock(base); + try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock); + ExecutorService executor = Executors.newFixedThreadPool(2)) { + clock.arm(high); + Future highRead = executor.submit(store::signingNow); + assertTrue(clock.observed.await(5, TimeUnit.SECONDS)); + clock.set(base.minusSeconds(60)); + Future rollbackRead = executor.submit(store::signingNow); + clock.release.countDown(); + assertEquals(high, highRead.get(5, TimeUnit.SECONDS)); + assertEquals(high, rollbackRead.get(5, TimeUnit.SECONDS)); + } + clock.set(base.minusSeconds(120)); + try (FilesystemPkiStore restarted = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock)) { + assertEquals(high, restarted.signingNow()); + } + } + + @Test + void cancellingStateUsesStableCodeAndSurvivesRestart(@TempDir Path root) throws Exception { + Instant createdAt = Instant.parse("2026-01-02T03:04:05.123Z"); + MutableClock clock = new MutableClock(createdAt); + PkiId id; + try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock)) { + String namespace = store.signingNamespace() + ".test-signer"; + id = SigningSubmissionId.create(namespace, createdAt, new SecureRandom()).id(); + store.createSignIntent(intent(id, createdAt, + new EncodedObject(Encoding.BINARY, new byte[] { 8 }), TEST_ALGORITHM)); + SignWorkflowStore.Record claimed = store.tryClaimSign(id, 0L, Duration.ofSeconds(30)).orElseThrow(); + SignWorkflowStore.Record dispatched = store.transitionSign(id, claimed.revision(), claimed.fence(), + SignWorkflowStore.State.DISPATCHED, Optional.of("DISPATCHED"), Optional.empty(), + Optional.empty()).orElseThrow(); + store.transitionSign(id, dispatched.revision(), dispatched.fence(), + SignWorkflowStore.State.CANCELLING, Optional.of("CANCEL_REQUESTED"), Optional.empty(), + Optional.empty()) + .orElseThrow(); + assertEquals(30, SignWorkflowStore.State.CANCELLING.persistentCode()); + assertEquals(SignWorkflowStore.State.CANCELLING, + FsCodec.decode(FsCodec.encode(SignWorkflowStore.State.CANCELLING), + SignWorkflowStore.State.class)); + assertThrows(IllegalArgumentException.class, + () -> SignWorkflowStore.State.fromPersistentCode(999)); + } + try (FilesystemPkiStore restarted = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock)) { + assertEquals(SignWorkflowStore.State.CANCELLING, restarted.getSignRecord(id).orElseThrow().state()); + } + } + + @Test + void everyCurrentSigningStateSurvivesSemanticValidationOnRestart(@TempDir Path root) throws Exception { + System.out.println("everyCurrentSigningStateSurvivesSemanticValidationOnRestart"); + Instant createdAt = Instant.parse("2026-01-02T03:04:05.123Z"); + MutableClock clock = new MutableClock(createdAt); + java.util.LinkedHashMap expected = new java.util.LinkedHashMap<>(); + try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock)) { + expected.put(persistIntent(store, createdAt, 1), SignWorkflowStore.State.INTENT); + + PkiId claimedId = persistIntent(store, createdAt, 2); + store.tryClaimSign(claimedId, 0L, Duration.ofSeconds(30)).orElseThrow(); + expected.put(claimedId, SignWorkflowStore.State.INTENT); + + PkiId dispatchedId = persistDispatched(store, createdAt, 3); + expected.put(dispatchedId, SignWorkflowStore.State.DISPATCHED); + + PkiId cancellingId = persistDispatched(store, createdAt, 4); + SignWorkflowStore.Record cancellingSource = store.getSignRecord(cancellingId).orElseThrow(); + store.transitionSign(cancellingId, cancellingSource.revision(), cancellingSource.fence(), + SignWorkflowStore.State.CANCELLING, Optional.of("CANCEL_REQUESTED"), Optional.empty(), + Optional.empty()).orElseThrow(); + expected.put(cancellingId, SignWorkflowStore.State.CANCELLING); + + PkiId succeededId = persistDispatched(store, createdAt, 5); + SignWorkflowStore.Record successSource = store.getSignRecord(succeededId).orElseThrow(); + store.transitionSign(succeededId, successSource.revision(), successSource.fence(), + SignWorkflowStore.State.SUCCEEDED, Optional.of("SIGNED"), + Optional.of(new EncodedObject(Encoding.BINARY, new byte[] { 5 })), + Optional.of(createdAt.plusSeconds(1))).orElseThrow(); + expected.put(succeededId, SignWorkflowStore.State.SUCCEEDED); + + PkiId failedId = persistClaimed(store, createdAt, 6); + SignWorkflowStore.Record failureSource = store.getSignRecord(failedId).orElseThrow(); + store.transitionSign(failedId, failureSource.revision(), failureSource.fence(), + SignWorkflowStore.State.FAILED, Optional.of("PROVIDER_SUBMISSION_FAILED"), Optional.empty(), + Optional.empty()).orElseThrow(); + expected.put(failedId, SignWorkflowStore.State.FAILED); + + PkiId cancelledId = persistIntent(store, createdAt, 7); + SignWorkflowStore.Record cancelSource = store.getSignRecord(cancelledId).orElseThrow(); + SignWorkflowStore.Record cancelling = store.transitionSign(cancelledId, cancelSource.revision(), + cancelSource.fence(), SignWorkflowStore.State.CANCELLING, Optional.of("CANCEL_REQUESTED"), + Optional.empty(), Optional.empty()).orElseThrow(); + store.transitionSign(cancelledId, cancelling.revision(), cancelling.fence(), + SignWorkflowStore.State.CANCELLED, Optional.of("CANCELLED"), Optional.empty(), Optional.empty()) + .orElseThrow(); + expected.put(cancelledId, SignWorkflowStore.State.CANCELLED); + + PkiId expiredId = persistIntent(store, createdAt, 8); + SignWorkflowStore.Record expirySource = store.getSignRecord(expiredId).orElseThrow(); + store.transitionSign(expiredId, expirySource.revision(), expirySource.fence(), + SignWorkflowStore.State.EXPIRED, Optional.of("EXPIRED"), Optional.empty(), Optional.empty()) + .orElseThrow(); + expected.put(expiredId, SignWorkflowStore.State.EXPIRED); + + PkiId retiredId = persistDispatched(store, createdAt, 9); + SignWorkflowStore.Record retiredSuccessSource = store.getSignRecord(retiredId).orElseThrow(); + SignWorkflowStore.Record retiredSuccess = store.transitionSign(retiredId, + retiredSuccessSource.revision(), retiredSuccessSource.fence(), SignWorkflowStore.State.SUCCEEDED, + Optional.of("SIGNED"), Optional.of(new EncodedObject(Encoding.BINARY, new byte[] { 9 })), + Optional.of(createdAt.plusSeconds(1))).orElseThrow(); + store.retireSign(retiredId, retiredSuccess.revision(), retiredSuccess.fence()).orElseThrow(); + expected.put(retiredId, SignWorkflowStore.State.RETIRED); + } + + try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock)) { + for (Map.Entry entry : expected.entrySet()) { + SignWorkflowStore.Record restored = reopened.getSignRecord(entry.getKey()).orElseThrow(); + System.out.println("...state=" + restored.state()); + assertEquals(entry.getValue(), restored.state()); + if (restored.state() == SignWorkflowStore.State.SUCCEEDED + || restored.state() == SignWorkflowStore.State.RETIRED) { + assertTrue(restored.result().isPresent()); + } + } + } + System.out.println("...ok"); + } + + @Test + void semanticCorruptionCannotActivateRecoverOrExposeInjectedResult(@TempDir Path root) throws Exception { + System.out.println("semanticCorruptionCannotActivateRecoverOrExposeInjectedResult"); + List cases = List.of( + new CorruptionCase("request-changed", record -> copy(record, + request(record.submissionId(), new byte[] { 99 }, TEST_ALGORITHM), + record.fingerprint(), record.state(), record.revision(), record.fence(), record.leaseUntil(), + record.detailCode(), record.result(), record.providerUpdatedAt()), "FINGERPRINT_MISMATCH"), + new CorruptionCase("fingerprint-changed", record -> copy(record, record.request(), + flipFingerprint(record.fingerprint()), record.state(), record.revision(), record.fence(), + record.leaseUntil(), record.detailCode(), record.result(), record.providerUpdatedAt()), + "FINGERPRINT_MISMATCH"), + new CorruptionCase("fingerprint-length", record -> copy(record, record.request(), "signfp:v1:00", + record.state(), record.revision(), record.fence(), record.leaseUntil(), record.detailCode(), + record.result(), record.providerUpdatedAt()), "FINGERPRINT_FORMAT_INVALID"), + new CorruptionCase("success-no-result", record -> copy(record, record.request(), + record.fingerprint(), record.state(), record.revision(), record.fence(), record.leaseUntil(), + record.detailCode(), Optional.empty(), record.providerUpdatedAt()), + "SUCCESS_EVIDENCE_MISSING"), + new CorruptionCase("success-no-provider-time", record -> copy(record, record.request(), + record.fingerprint(), record.state(), record.revision(), record.fence(), record.leaseUntil(), + record.detailCode(), record.result(), Optional.empty()), "SUCCESS_EVIDENCE_MISSING"), + new CorruptionCase("success-at-deadline", record -> copy(record, record.request(), + record.fingerprint(), record.state(), record.revision(), record.fence(), record.leaseUntil(), + record.detailCode(), record.result(), Optional.of(record.deadline())), + "PROVIDER_TIME_INVALID"), + new CorruptionCase("success-after-deadline", record -> copy(record, record.request(), + record.fingerprint(), record.state(), record.revision(), record.fence(), record.leaseUntil(), + record.detailCode(), record.result(), Optional.of(record.deadline().plusNanos(1))), + "PROVIDER_TIME_INVALID"), + new CorruptionCase("success-active-lease", record -> copy(record, record.request(), + record.fingerprint(), record.state(), record.revision(), record.fence(), + Optional.of(record.createdAt().plusSeconds(30)), record.detailCode(), record.result(), + record.providerUpdatedAt()), "SUCCESS_EVIDENCE_MISSING"), + new CorruptionCase("success-expiry-code", record -> copy(record, record.request(), + record.fingerprint(), record.state(), record.revision(), record.fence(), record.leaseUntil(), + Optional.of("EXPIRED"), record.result(), record.providerUpdatedAt()), + "SUCCESS_DETAIL_CONTRADICTORY"), + new CorruptionCase("success-stale-fence", record -> copy(record, record.request(), + record.fingerprint(), record.state(), record.revision(), record.revision(), + record.leaseUntil(), record.detailCode(), record.result(), record.providerUpdatedAt()), + "SUCCESS_REVISION_INVALID"), + new CorruptionCase("injected-result-fingerprint-mismatch", record -> copy(record, record.request(), + flipFingerprint(record.fingerprint()), record.state(), record.revision(), record.fence(), + record.leaseUntil(), record.detailCode(), + Optional.of(new EncodedObject(Encoding.BINARY, "injected-signature".getBytes( + java.nio.charset.StandardCharsets.UTF_8))), + record.providerUpdatedAt()), "FINGERPRINT_MISMATCH"), + new CorruptionCase("failed-with-result", record -> copy(record, record.request(), + record.fingerprint(), SignWorkflowStore.State.FAILED, record.revision(), record.fence(), + record.leaseUntil(), Optional.of("FAILED"), record.result(), record.providerUpdatedAt()), + "FAILURE_RESULT_OR_LEASE_PRESENT"), + new CorruptionCase("claim-without-lease", record -> copy(record, record.request(), + record.fingerprint(), SignWorkflowStore.State.INTENT, 1L, 1L, Optional.empty(), + Optional.of("INTENT"), Optional.empty(), Optional.empty()), "INTENT_CLAIM_INVALID"), + new CorruptionCase("terminal-with-lease", record -> copy(record, record.request(), + record.fingerprint(), record.state(), record.revision(), record.fence(), + Optional.of(record.createdAt().plusSeconds(30)), record.detailCode(), record.result(), + record.providerUpdatedAt()), "SUCCESS_EVIDENCE_MISSING"), + new CorruptionCase("lease-before-creation", record -> copy(record, record.request(), + record.fingerprint(), SignWorkflowStore.State.INTENT, 1L, 1L, + Optional.of(record.createdAt().minusNanos(1)), Optional.of("INTENT"), Optional.empty(), + Optional.empty()), "LEASE_TIME_INVALID"), + new CorruptionCase("dispatched-zero-fence", record -> copy(record, record.request(), + record.fingerprint(), SignWorkflowStore.State.DISPATCHED, 1L, 0L, Optional.empty(), + Optional.of("DISPATCHED"), Optional.empty(), Optional.empty()), + "DISPATCH_REVISION_INVALID")); + + int index = 0; + for (CorruptionCase corruption : cases) { + Path caseRoot = root.resolve("case-" + index++); + assertSemanticCorruptionRejected(caseRoot, corruption); + System.out.println("...case=" + corruption.name()); + } + System.out.println("...ok"); + } + + private static SignWorkflowStore.Record intent(PkiId id, Instant createdAt, EncodedObject request, + String algorithmId) { + Instant deadline = createdAt.plusSeconds(60); + PkiSigningBus.SignContinuation continuation = continuation(id, request, algorithmId); + String namespace = SigningSubmissionId.parse(id).namespace(); + String fingerprint = continuation.semanticFingerprint(namespace, deadline); + return new SignWorkflowStore.Record(id, namespace, fingerprint, TEST_OWNER, + createdAt, deadline, continuation.encode(), SignWorkflowStore.State.INTENT, 0L, 0L, + Optional.empty(), Optional.of("INTENT"), Optional.empty(), Optional.empty()); + } + + private static PkiId persistIntent(FilesystemPkiStore store, Instant createdAt, int marker) { + String namespace = store.signingNamespace() + ".test-signer"; + PkiId id = SigningSubmissionId.create(namespace, createdAt, new SecureRandom()).id(); + store.createSignIntent(intent(id, createdAt, + new EncodedObject(Encoding.BINARY, new byte[] { (byte) marker }), TEST_ALGORITHM)); + return id; + } + + private static PkiId persistClaimed(FilesystemPkiStore store, Instant createdAt, int marker) { + PkiId id = persistIntent(store, createdAt, marker); + store.tryClaimSign(id, 0L, Duration.ofSeconds(30)).orElseThrow(); + return id; + } + + private static PkiId persistDispatched(FilesystemPkiStore store, Instant createdAt, int marker) { + PkiId id = persistClaimed(store, createdAt, marker); + SignWorkflowStore.Record claimed = store.getSignRecord(id).orElseThrow(); + store.transitionSign(id, claimed.revision(), claimed.fence(), SignWorkflowStore.State.DISPATCHED, + Optional.of("DISPATCHED"), Optional.empty(), Optional.empty()).orElseThrow(); + return id; + } + + private static PkiSigningBus.SignContinuation continuation(PkiId id, EncodedObject payload, + String algorithmId) { + return new PkiSigningBus.SignContinuation(TEST_ACCESS, algorithmId, payload, TEST_KEY, Encoding.BINARY, + Optional.of(id)); + } + + private static EncodedObject request(PkiId id, byte[] payload, String algorithmId) { + return continuation(id, new EncodedObject(Encoding.BINARY, payload), algorithmId).encode(); + } + + private static SignWorkflowStore.Record copy(SignWorkflowStore.Record source, EncodedObject request, + String fingerprint, SignWorkflowStore.State state, long revision, long fence, + Optional leaseUntil, Optional detailCode, Optional result, + Optional providerUpdatedAt) { + return new SignWorkflowStore.Record(source.submissionId(), source.namespace(), fingerprint, source.owner(), + source.createdAt(), source.deadline(), request, state, revision, fence, leaseUntil, detailCode, result, + providerUpdatedAt); + } + + private static String flipFingerprint(String fingerprint) { + int index = "signfp:v1:".length(); + char replacement = fingerprint.charAt(index) == '0' ? '1' : '0'; + return fingerprint.substring(0, index) + replacement + fingerprint.substring(index + 1); + } + + private static void assertSemanticCorruptionRejected(Path root, CorruptionCase corruption) throws Exception { + Instant createdAt = Instant.parse("2026-01-02T03:04:05.123Z"); + MutableClock clock = new MutableClock(createdAt); + PkiId id; + SignWorkflowStore.Record succeeded; + try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock)) { + id = persistDispatched(store, createdAt, 42); + SignWorkflowStore.Record dispatched = store.getSignRecord(id).orElseThrow(); + clock.set(createdAt.plusSeconds(1)); + succeeded = store.transitionSign(id, dispatched.revision(), dispatched.fence(), + SignWorkflowStore.State.SUCCEEDED, Optional.of("SIGNED"), + Optional.of(new EncodedObject(Encoding.BINARY, "valid-signature".getBytes( + java.nio.charset.StandardCharsets.UTF_8))), + Optional.of(createdAt.plusSeconds(1))).orElseThrow(); + } + + SignWorkflowStore.Record corrupted = corruption.mutation().apply(succeeded); + writeRawCurrentRecord(root, id, corrupted); + String sensitiveRequest = "injected-signature"; + String sensitiveFingerprint = corrupted.fingerprint(); + Path busLog = root.resolve("corrupt-bus.log"); + try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock); + InMemorySignatureWorkflow signer = new InMemorySignatureWorkflow(Map.of(), false)) { + IllegalStateException readFailure = assertThrows(IllegalStateException.class, + () -> reopened.getSignRecord(id)); + assertTrue(readFailure.getMessage().contains("code=" + corruption.expectedCode())); + assertFalse(readFailure.toString().contains(sensitiveRequest)); + assertFalse(readFailure.toString().contains(sensitiveFingerprint)); + + IllegalStateException activationFailure = assertThrows(IllegalStateException.class, + () -> new PkiSigningBus(reopened, signer, busLog)); + assertTrue(activationFailure.getMessage().contains("code=" + corruption.expectedCode())); + assertEquals(0, signer.submittedSignCount()); + assertThrows(IllegalStateException.class, () -> reopened.listSignRecords()); + } + assertFalse(Files.exists(busLog) && Files.size(busLog) > 0L); + } + + private static void writeRawCurrentRecord(Path root, PkiId id, SignWorkflowStore.Record record) + throws Exception { + Path path = new FsPaths(root).signWorkflowPath(id); + byte[] existing = Files.readAllBytes(path); + byte[] payload = FsCodec.encode(record); + ByteBuffer replacement = ByteBuffer.allocate(Integer.BYTES * 2 + payload.length); + replacement.put(existing, 0, Integer.BYTES * 2); + replacement.put(payload); + Files.write(path, replacement.array()); + } + + private static int recordComponentCountOffset(byte[] encoded) throws Exception { + ByteArrayInputStream input = new ByteArrayInputStream(encoded); + input.read(); + input.read(); + Util.readUTF8(input, 4096); + return encoded.length - input.available(); + } + + private static void assertRedactedStructuralFailure(byte[] encoded, String sensitiveFingerprint) { + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> FsCodec.decode(encoded, SignWorkflowStore.Record.class)); + assertFalse(failure.toString().contains(sensitiveFingerprint)); + } + + private record CorruptionCase(String name, + java.util.function.UnaryOperator mutation, String expectedCode) { + } + + private static final class MutableClock extends Clock { + private final AtomicBoolean block = new AtomicBoolean(); + private final CountDownLatch observed = new CountDownLatch(1); + private final CountDownLatch release = new CountDownLatch(1); + private volatile Instant instant; + + private MutableClock(Instant instant) { + this.instant = instant; + } + + private void set(Instant value) { + this.instant = value; + } + + private void arm(Instant value) { + this.instant = value; + block.set(true); + } + + @Override + public ZoneId getZone() { + return ZoneId.of("UTC"); + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + Instant observedValue = instant; + if (block.compareAndSet(true, false)) { + observed.countDown(); + try { + release.await(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(ex); + } + } + return observedValue; + } + } +} diff --git a/pki/src/test/java/zeroecho/pki/spi/bootstrap/PkiBootstrapTest.java b/pki/src/test/java/zeroecho/pki/spi/bootstrap/PkiBootstrapTest.java index 5cd249b..2cbc2c2 100644 --- a/pki/src/test/java/zeroecho/pki/spi/bootstrap/PkiBootstrapTest.java +++ b/pki/src/test/java/zeroecho/pki/spi/bootstrap/PkiBootstrapTest.java @@ -263,6 +263,8 @@ public final class PkiBootstrapTest { System.setProperty("zeroecho.pki.crypto.workflow", "zeroecho-lib"); System.setProperty("zeroecho.pki.crypto.workflow.keyringPath", this.tempDir.resolve("workflow").resolve("keyring.zek").toString()); + System.setProperty("zeroecho.pki.crypto.workflow.operationRoot", + this.tempDir.resolve("workflow").resolve("operations").toString()); System.setProperty("zeroecho.pki.crypto.workflow.keyRefPrefix", "test-prefix:"); System.setProperty("zeroecho.pki.crypto.workflow.requireComponentSuffix", "false"); diff --git a/pki/src/test/java/zeroecho/pki/spi/crypto/SignRequestCleanupTest.java b/pki/src/test/java/zeroecho/pki/spi/crypto/SignRequestCleanupTest.java new file mode 100644 index 0000000..730f950 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/spi/crypto/SignRequestCleanupTest.java @@ -0,0 +1,96 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.spi.crypto; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.security.MessageDigest; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.Encoding; +import zeroecho.pki.api.KeyRef; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.audit.AccessContext; +import zeroecho.pki.api.audit.Principal; +import zeroecho.pki.api.audit.Purpose; + +final class SignRequestCleanupTest { + + @Test + void fingerprintClearsPayloadAndDigestCopiesAfterSuccessAndRepeatedUse() throws Exception { + System.out.println("fingerprintClearsPayloadAndDigestCopiesAfterSuccessAndRepeatedUse"); + List cleared = new ArrayList<>(); + String first = fingerprint(MessageDigest.getInstance("SHA-256"), cleared); + String second = fingerprint(MessageDigest.getInstance("SHA-256"), cleared); + System.out.println("...fingerprint=" + first.substring(0, 20) + "..."); + assertEquals(first, second); + assertEquals(4, cleared.size()); + assertTrue(cleared.stream().allMatch(SignRequestCleanupTest::isCleared)); + System.out.println("fingerprintClearsPayloadAndDigestCopiesAfterSuccessAndRepeatedUse...ok"); + } + + @Test + void fingerprintClearsPayloadCopyAfterDigestFailure() { + System.out.println("fingerprintClearsPayloadCopyAfterDigestFailure"); + List cleared = new ArrayList<>(); + assertThrows(IllegalStateException.class, () -> fingerprint(new FailingDigest(), cleared)); + assertEquals(1, cleared.size()); + assertTrue(isCleared(cleared.get(0))); + System.out.println("...clearedBuffers=" + cleared.size()); + System.out.println("fingerprintClearsPayloadCopyAfterDigestFailure...ok"); + } + + private static String fingerprint(MessageDigest digest, List cleared) { + AccessContext context = new AccessContext(new Principal("SYSTEM", "cleanup"), new Purpose("SIGN"), + Optional.of(new PkiId("cleanup-operation")), Optional.empty()); + return SignatureWorkflow.SignRequest.fingerprintWithDigest("cleanup.namespace", context, + new KeyRef("provider:key"), "SHA256withRSA", + new EncodedObject(Encoding.BINARY, new byte[] { 1, 2, 3, 4 }), Optional.of(Encoding.BINARY), + Optional.of(Instant.parse("2026-08-01T00:00:00Z")), digest, cleared::add); + } + + private static boolean isCleared(byte[] bytes) { + for (byte value : bytes) { + if (value != 0) { + return false; + } + } + return true; + } + + private static final class FailingDigest extends MessageDigest { + private FailingDigest() { + super("FAILING"); + } + + @Override + protected void engineUpdate(byte input) { + throw new IllegalStateException("controlled digest failure"); + } + + @Override + protected void engineUpdate(byte[] input, int offset, int length) { + throw new IllegalStateException("controlled digest failure"); + } + + @Override + protected byte[] engineDigest() { + return new byte[32]; + } + + @Override + protected void engineReset() { + // No state. + } + } +} diff --git a/pki/src/test/java/zeroecho/pki/testkit/DurableDelayedSignatureWorkflow.java b/pki/src/test/java/zeroecho/pki/testkit/DurableDelayedSignatureWorkflow.java index 5c7d3bb..ec467f2 100644 --- a/pki/src/test/java/zeroecho/pki/testkit/DurableDelayedSignatureWorkflow.java +++ b/pki/src/test/java/zeroecho/pki/testkit/DurableDelayedSignatureWorkflow.java @@ -73,12 +73,14 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow, private static final String FILE_SIGNATURE = "signature"; private final Path root; + private final TestSignIdentityRegistry identities; private final Duration signingDelay; private final Map keysByRef; private final Map sinks; public DurableDelayedSignatureWorkflow(Path root, Duration signingDelay, Map keysByRef) { this.root = Objects.requireNonNull(root, "root"); + this.identities = new TestSignIdentityRegistry(root); this.signingDelay = Objects.requireNonNull(signingDelay, "signingDelay"); this.keysByRef = Objects.requireNonNull(keysByRef, "keysByRef"); this.sinks = new ConcurrentHashMap(); @@ -102,7 +104,10 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow, public PkiId submitSign(SignRequest request) { Objects.requireNonNull(request, "request"); - PkiId opId = new PkiId("sig-" + UUID.randomUUID()); + PkiId opId = request.submissionId(); + if (!identities.begin(request)) { + return opId; + } Instant now = Instant.now(); persistRequest(opId, request); @@ -133,7 +138,15 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow, } if (st.state() == State.PENDING) { - OperationStatus running = new OperationStatus(State.RUNNING, Instant.now(), Optional.of("SIGNING"), + PersistedSignRequest request = loadRequest(operationId); + Instant startedAt = Instant.now(); + if (deadlineReached(request.deadline, startedAt)) { + OperationStatus expired = expired(startedAt); + persistStatus(operationId, expired); + notifySink(operationId, expired); + return expired; + } + OperationStatus running = new OperationStatus(State.RUNNING, startedAt, Optional.of("SIGNING"), Optional.empty()); persistStatus(operationId, running); notifySink(operationId, running); @@ -148,12 +161,10 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow, // Best-effort: complete if signature file exists. Optional sig = loadSignature(operationId); if (sig.isPresent()) { - OperationResult res = new OperationResult(Optional.of(sig.get()), Optional.empty()); - OperationStatus done = new OperationStatus(State.SUCCEEDED, Instant.now(), Optional.of("SIGNED"), - Optional.of(res)); - persistStatus(operationId, done); - notifySink(operationId, done); - return done; + OperationStatus expired = expired(Instant.now()); + persistStatus(operationId, expired); + notifySink(operationId, expired); + return expired; } } @@ -161,12 +172,18 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow, } @Override - public boolean cancel(PkiId operationId, String reason) { + public boolean cancel(PkiId operationId, long fencingToken, String reason) { Objects.requireNonNull(operationId, "operationId"); Objects.requireNonNull(reason, "reason"); + if (fencingToken <= 0L) { + throw new IllegalArgumentException("fencingToken must be positive"); + } if (reason.isBlank()) { throw new IllegalArgumentException("reason must not be blank"); } + if (!identities.acceptFence(operationId, fencingToken)) { + return false; + } OperationStatus st = status(operationId); if (st.isTerminal()) { return false; @@ -203,6 +220,10 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow, private OperationStatus performSign(PkiId opId) { PersistedSignRequest req = loadRequest(opId); + Instant startedAt = Instant.now(); + if (deadlineReached(req.deadline, startedAt)) { + return expired(startedAt); + } PrivateKey key = keysByRef.get(req.keyRef); if (key == null) { return new OperationStatus(State.FAILED, Instant.now(), Optional.of("UNKNOWN_KEY"), Optional.empty()); @@ -226,11 +247,15 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow, sig.update(req.payload); byte[] signature = sig.sign(); + Instant completedAt = Instant.now(); + if (deadlineReached(req.deadline, completedAt)) { + return expired(completedAt); + } persistSignature(opId, signature); EncodedObject enc = new EncodedObject(Encoding.BINARY, signature); OperationResult res = new OperationResult(Optional.of(enc), Optional.empty()); - return new OperationStatus(State.SUCCEEDED, Instant.now(), Optional.of("SIGNED"), Optional.of(res)); + return new OperationStatus(State.SUCCEEDED, completedAt, Optional.of("SIGNED"), Optional.of(res)); } catch (Exception ex) { return new OperationStatus(State.FAILED, Instant.now(), Optional.of("SIGN_ERROR"), Optional.empty()); } @@ -255,7 +280,8 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow, try { Files.createDirectories(dir); String line = req.keyRef().value() + "\n" + req.algorithmId() + "\n" - + Base64.getEncoder().encodeToString(req.payload().bytes()); + + Base64.getEncoder().encodeToString(req.payload().bytes()) + "\n" + + req.deadline().map(Instant::toString).orElse(""); Files.writeString(dir.resolve(FILE_REQUEST), line, StandardCharsets.UTF_8); } catch (IOException ex) { throw new IllegalStateException("Cannot persist request", ex); @@ -266,13 +292,15 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow, Path f = opDir(opId).resolve(FILE_REQUEST); try { String s = Files.readString(f, StandardCharsets.UTF_8); - String[] parts = s.split("\n", 3); + String[] parts = s.split("\n", 4); String keyRef = parts[0]; String alg = parts[1]; byte[] payload = Base64.getDecoder().decode(parts[2]); - return new PersistedSignRequest(keyRef, alg, payload); + Optional deadline = parts.length == 4 && !parts[3].isBlank() + ? Optional.of(Instant.parse(parts[3])) : Optional.empty(); + return new PersistedSignRequest(keyRef, alg, payload, deadline); } catch (IOException ex) { - return new PersistedSignRequest("", "", new byte[0]); + return new PersistedSignRequest("", "", new byte[0], Optional.empty()); } } @@ -339,11 +367,21 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow, private final String keyRef; private final String algorithmId; private final byte[] payload; + private final Optional deadline; - private PersistedSignRequest(String keyRef, String algorithmId, byte[] payload) { + private PersistedSignRequest(String keyRef, String algorithmId, byte[] payload, Optional deadline) { this.keyRef = keyRef; this.algorithmId = algorithmId; this.payload = payload; + this.deadline = deadline; } } + + private static boolean deadlineReached(Optional deadline, Instant observedAt) { + return deadline.isPresent() && !observedAt.isBefore(deadline.get()); + } + + private static OperationStatus expired(Instant observedAt) { + return new OperationStatus(State.EXPIRED, observedAt, Optional.of("EXPIRED"), Optional.empty()); + } } diff --git a/pki/src/test/java/zeroecho/pki/testkit/DurableOperatorApprovalSignatureWorkflow.java b/pki/src/test/java/zeroecho/pki/testkit/DurableOperatorApprovalSignatureWorkflow.java index e9a25c6..f54e3b1 100644 --- a/pki/src/test/java/zeroecho/pki/testkit/DurableOperatorApprovalSignatureWorkflow.java +++ b/pki/src/test/java/zeroecho/pki/testkit/DurableOperatorApprovalSignatureWorkflow.java @@ -89,6 +89,7 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature private static final String DECISION_DENY = "DENY"; private final Path root; + private final TestSignIdentityRegistry identities; private final Duration approvalWindow; private final Duration signingDelay; private final Map keysByRef; @@ -105,6 +106,7 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature public DurableOperatorApprovalSignatureWorkflow(Path root, Duration approvalWindow, Duration signingDelay, Map keysByRef) { this.root = Objects.requireNonNull(root, "root"); + this.identities = new TestSignIdentityRegistry(root); this.approvalWindow = Objects.requireNonNull(approvalWindow, "approvalWindow"); this.signingDelay = Objects.requireNonNull(signingDelay, "signingDelay"); this.keysByRef = Objects.requireNonNull(keysByRef, "keysByRef"); @@ -137,9 +139,15 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature public PkiId submitSign(SignRequest request) { Objects.requireNonNull(request, "request"); - PkiId opId = new PkiId("sig-" + UUID.randomUUID()); + PkiId opId = request.submissionId(); + if (!identities.begin(request)) { + return opId; + } Instant now = Instant.now(); Instant deadline = now.plus(approvalWindow); + if (request.deadline().isPresent() && request.deadline().get().isBefore(deadline)) { + deadline = request.deadline().get(); + } persistRequest(opId, request); persistDeadline(opId, deadline); @@ -165,8 +173,9 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature public void approve(PkiId operationId) { Objects.requireNonNull(operationId, "operationId"); persistDecision(operationId, DECISION_APPROVE); - OperationStatus st = new OperationStatus(State.PENDING, Instant.now(), Optional.of("APPROVED"), - Optional.empty()); + Instant now = Instant.now(); + OperationStatus st = deadlineReached(loadDeadline(operationId), now) + ? expired(now) : new OperationStatus(State.PENDING, now, Optional.of("APPROVED"), Optional.empty()); persistStatus(operationId, st); notifySink(operationId, st); } @@ -196,9 +205,9 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature if (st.state() == State.WAITING_APPROVAL) { // expire if past deadline Instant deadline = loadDeadline(operationId).orElse(Instant.EPOCH); - if (Instant.now().isAfter(deadline)) { - OperationStatus expired = new OperationStatus(State.EXPIRED, Instant.now(), - Optional.of("APPROVAL_EXPIRED"), Optional.empty()); + Instant now = Instant.now(); + if (deadlineReached(Optional.of(deadline), now)) { + OperationStatus expired = expired(now); persistStatus(operationId, expired); notifySink(operationId, expired); return expired; @@ -225,7 +234,14 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature } if (st.state() == State.PENDING) { - OperationStatus running = new OperationStatus(State.RUNNING, Instant.now(), Optional.of("SIGNING"), + Instant startedAt = Instant.now(); + if (deadlineReached(loadDeadline(operationId), startedAt)) { + OperationStatus expired = expired(startedAt); + persistStatus(operationId, expired); + notifySink(operationId, expired); + return expired; + } + OperationStatus running = new OperationStatus(State.RUNNING, startedAt, Optional.of("SIGNING"), Optional.empty()); persistStatus(operationId, running); notifySink(operationId, running); @@ -240,12 +256,18 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature } @Override - public boolean cancel(PkiId operationId, String reason) { + public boolean cancel(PkiId operationId, long fencingToken, String reason) { Objects.requireNonNull(operationId, "operationId"); Objects.requireNonNull(reason, "reason"); + if (fencingToken <= 0L) { + throw new IllegalArgumentException("fencingToken must be positive"); + } if (reason.isBlank()) { throw new IllegalArgumentException("reason must not be blank"); } + if (!identities.acceptFence(operationId, fencingToken)) { + return false; + } OperationStatus st = loadStatus(operationId); if (st.isTerminal()) { @@ -279,6 +301,10 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature private OperationStatus performSign(PkiId opId) { PersistedSignRequest req = loadRequest(opId); + Instant startedAt = Instant.now(); + if (deadlineReached(loadDeadline(opId), startedAt)) { + return expired(startedAt); + } PrivateKey key = keysByRef.get(req.keyRef); if (key == null) { @@ -303,11 +329,15 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature sig.update(req.payload); byte[] signature = sig.sign(); + Instant completedAt = Instant.now(); + if (deadlineReached(loadDeadline(opId), completedAt)) { + return expired(completedAt); + } persistSignature(opId, signature); EncodedObject enc = new EncodedObject(Encoding.BINARY, signature); OperationResult res = new OperationResult(Optional.of(enc), Optional.empty()); - return new OperationStatus(State.SUCCEEDED, Instant.now(), Optional.of("SIGNED"), Optional.of(res)); + return new OperationStatus(State.SUCCEEDED, completedAt, Optional.of("SIGNED"), Optional.of(res)); } catch (Exception ex) { return new OperationStatus(State.FAILED, Instant.now(), Optional.of("SIGN_ERROR"), Optional.empty()); } @@ -375,6 +405,14 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature } } + private static boolean deadlineReached(Optional deadline, Instant observedAt) { + return deadline.isPresent() && !observedAt.isBefore(deadline.get()); + } + + private static OperationStatus expired(Instant observedAt) { + return new OperationStatus(State.EXPIRED, observedAt, Optional.of("EXPIRED"), Optional.empty()); + } + private void persistDecision(PkiId opId, String decision) { try { Files.writeString(opDir(opId).resolve(FILE_DECISION), decision, StandardCharsets.UTF_8); diff --git a/pki/src/test/java/zeroecho/pki/testkit/InMemorySignatureWorkflow.java b/pki/src/test/java/zeroecho/pki/testkit/InMemorySignatureWorkflow.java index abfb1b1..c03b3d0 100644 --- a/pki/src/test/java/zeroecho/pki/testkit/InMemorySignatureWorkflow.java +++ b/pki/src/test/java/zeroecho/pki/testkit/InMemorySignatureWorkflow.java @@ -40,9 +40,13 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicInteger; import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.Encoding; +import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.PkiId; import zeroecho.pki.spi.crypto.SignatureWorkflow; @@ -53,11 +57,25 @@ public final class InMemorySignatureWorkflow implements SignatureWorkflow { private final Map keys; private final Map status; + private final boolean completeImmediately; + private final ConcurrentMap fingerprints; + private final ConcurrentMap fences; + private final ConcurrentMap operationLocks; private long counter; + private final AtomicInteger submittedSignCount; public InMemorySignatureWorkflow(Map keys) { + this(keys, true); + } + + public InMemorySignatureWorkflow(Map keys, boolean completeImmediately) { this.keys = new HashMap<>(Objects.requireNonNull(keys, "keys")); this.status = new HashMap<>(); + this.completeImmediately = completeImmediately; + this.fingerprints = new ConcurrentHashMap<>(); + this.fences = new ConcurrentHashMap<>(); + this.operationLocks = new ConcurrentHashMap<>(); + this.submittedSignCount = new AtomicInteger(); this.counter = 1L; } @@ -69,14 +87,39 @@ public final class InMemorySignatureWorkflow implements SignatureWorkflow { @Override public PkiId submitSign(SignRequest request) { Objects.requireNonNull(request, "request"); - PkiId opId = new PkiId("sig:" + (counter++)); + PkiId opId = request.submissionId(); + Object lock = operationLocks.computeIfAbsent(opId, ignored -> new Object()); + synchronized (lock) { // test provider operation-local state only; no external call inside + String fingerprint = fingerprints.get(opId); + if (fingerprint != null) { + if (!fingerprint.equals(request.semanticFingerprint())) { + throw new IllegalStateException("conflicting signing request"); + } + if (request.fencingToken() < fences.get(opId)) { + throw new IllegalStateException("stale signing fence"); + } + return opId; + } + fingerprints.put(opId, request.semanticFingerprint()); + fences.put(opId, request.fencingToken()); + status.put(opId, new OperationStatus(State.RUNNING, Instant.now(), Optional.of("PENDING"), + Optional.empty())); + submittedSignCount.incrementAndGet(); + } + if (!completeImmediately) { + return opId; + } try { + if (deadlineReached(request, Instant.now())) { + complete(request, expired(Instant.now())); + return opId; + } KeyPair kp = keys.get(request.keyRef().value()); if (kp == null) { OperationStatus st = new OperationStatus(State.FAILED, Instant.now(), Optional.of("UNKNOWN_KEY"), Optional.empty()); - status.put(opId, st); + complete(request, st); return opId; } @@ -85,20 +128,46 @@ public final class InMemorySignatureWorkflow implements SignatureWorkflow { sig.update(request.payload().bytes()); byte[] s = sig.sign(); + Instant completedAt = Instant.now(); + if (deadlineReached(request, completedAt)) { + complete(request, expired(completedAt)); + return opId; + } OperationResult res = new OperationResult(Optional.of(new EncodedObject(Encoding.BINARY, s)), Optional.empty()); - OperationStatus st = new OperationStatus(State.SUCCEEDED, Instant.now(), Optional.of("SIGNED"), + OperationStatus st = new OperationStatus(State.SUCCEEDED, completedAt, Optional.of("SIGNED"), Optional.of(res)); - status.put(opId, st); + complete(request, st); return opId; } catch (Exception ex) { OperationStatus st = new OperationStatus(State.FAILED, Instant.now(), Optional.of("SIGN_FAILED"), Optional.empty()); - status.put(opId, st); + complete(request, st); return opId; } } + private void complete(SignRequest request, OperationStatus terminal) { + Object lock = operationLocks.get(request.submissionId()); + synchronized (lock) { + if (status.get(request.submissionId()).state() == State.RUNNING + && fences.get(request.submissionId()) == request.fencingToken()) { + if (terminal.state() == State.SUCCEEDED && deadlineReached(request, terminal.updatedAt())) { + terminal = expired(terminal.updatedAt()); + } + status.put(request.submissionId(), terminal); + } + } + } + + private static boolean deadlineReached(SignRequest request, Instant observedAt) { + return request.deadline().isPresent() && !observedAt.isBefore(request.deadline().get()); + } + + private static OperationStatus expired(Instant observedAt) { + return new OperationStatus(State.EXPIRED, observedAt, Optional.of("EXPIRED"), Optional.empty()); + } + @Override public PkiId submitVerify(VerifyRequest request) { Objects.requireNonNull(request, "request"); @@ -120,10 +189,23 @@ public final class InMemorySignatureWorkflow implements SignatureWorkflow { } @Override - public boolean cancel(PkiId operationId, String reason) { + public boolean cancel(PkiId operationId, long fencingToken, String reason) { Objects.requireNonNull(operationId, "operationId"); Objects.requireNonNull(reason, "reason"); - return false; + if (fencingToken <= 0L) { + throw new IllegalArgumentException("fencingToken must be positive"); + } + Object lock = operationLocks.computeIfAbsent(operationId, ignored -> new Object()); + synchronized (lock) { + OperationStatus existing = status.get(operationId); + if (existing == null || existing.isTerminal() || fencingToken < fences.get(operationId)) { + return false; + } + fences.put(operationId, fencingToken); + status.put(operationId, new OperationStatus(State.CANCELLED, Instant.now(), Optional.of("CANCELLED"), + Optional.empty())); + return true; + } } @Override @@ -139,6 +221,18 @@ public final class InMemorySignatureWorkflow implements SignatureWorkflow { return Set.of("SHA256withRSA"); } + public int submittedSignCount() { + return submittedSignCount.get(); + } + + public void putKeyPair(KeyRef keyRef, KeyPair keyPair) { + keys.put(Objects.requireNonNull(keyRef, "keyRef").value(), Objects.requireNonNull(keyPair, "keyPair")); + } + + public boolean hasRunningOperations() { + return status.values().stream().anyMatch(operation -> !operation.isTerminal()); + } + @Override public void close() { // no-op diff --git a/pki/src/test/java/zeroecho/pki/testkit/OperatorApprovalSignatureWorkflow.java b/pki/src/test/java/zeroecho/pki/testkit/OperatorApprovalSignatureWorkflow.java index 44b79ea..393b037 100644 --- a/pki/src/test/java/zeroecho/pki/testkit/OperatorApprovalSignatureWorkflow.java +++ b/pki/src/test/java/zeroecho/pki/testkit/OperatorApprovalSignatureWorkflow.java @@ -90,11 +90,13 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo private static final String K_DETAIL = "detailCode"; private static final String K_KEYREF = "keyRef"; private static final String K_ALG = "algorithmId"; + private static final String K_DEADLINE = "deadline"; private static final String K_APPROVED_AT = "approvedAt"; private static final String K_DENIED_AT = "deniedAt"; private final String id; private final Path root; + private final TestSignIdentityRegistry identities; private final Map keysByKeyRef; private final Duration approvalWindow; private final Duration signDelay; @@ -114,6 +116,7 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo Duration approvalWindow, Duration signDelay) { this.id = Objects.requireNonNull(id, "id"); this.root = Objects.requireNonNull(root, "root"); + this.identities = new TestSignIdentityRegistry(root); this.keysByKeyRef = Objects.requireNonNull(keysByKeyRef, "keysByKeyRef"); this.approvalWindow = Objects.requireNonNull(approvalWindow, "approvalWindow"); this.signDelay = Objects.requireNonNull(signDelay, "signDelay"); @@ -142,7 +145,10 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo long n = seq.incrementAndGet(); persistSeq(root, n); - PkiId opId = new PkiId("swf:test:" + Long.toUnsignedString(n)); + PkiId opId = request.submissionId(); + if (!identities.begin(request)) { + return opId; + } Path dir = opDir(opId); try { @@ -151,8 +157,13 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo java.util.Properties p = new java.util.Properties(); Instant now = Instant.now(); + Instant deadline = now.plus(approvalWindow); + if (request.deadline().isPresent() && request.deadline().get().isBefore(deadline)) { + deadline = request.deadline().get(); + } p.setProperty(K_CREATED_AT, now.toString()); p.setProperty(K_UPDATED_AT, now.toString()); + p.setProperty(K_DEADLINE, deadline.toString()); p.setProperty(K_STATE, State.WAITING_APPROVAL.name()); p.setProperty(K_DETAIL, "WAITING_APPROVAL"); p.setProperty(K_KEYREF, request.keyRef().value()); @@ -196,6 +207,14 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo return; } Instant now = Instant.now(); + if (deadlineReached(p, now)) { + p.setProperty(K_UPDATED_AT, now.toString()); + p.setProperty(K_STATE, State.EXPIRED.name()); + p.setProperty(K_DETAIL, "EXPIRED"); + writePropsSafe(dir.resolve(FILE_META), p); + notifySink(operationId); + return; + } p.setProperty(K_APPROVED_AT, now.toString()); p.setProperty(K_UPDATED_AT, now.toString()); p.setProperty(K_STATE, State.RUNNING.name()); @@ -236,15 +255,13 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo return new OperationStatus(State.FAILED, Instant.now(), Optional.of("UNKNOWN"), Optional.empty()); } - Instant createdAt = Instant.parse(p.getProperty(K_CREATED_AT)); Instant updatedAt = Instant.parse(p.getProperty(K_UPDATED_AT)); State state = parseState(p.getProperty(K_STATE)); String detail = p.getProperty(K_DETAIL, ""); // Expire while waiting for approval - Instant approvalDeadline = createdAt.plus(approvalWindow); if (!isTerminalState(state) && (state == State.WAITING_APPROVAL || state == State.PENDING) - && Instant.now().isAfter(approvalDeadline)) { + && deadlineReached(p, Instant.now())) { state = State.EXPIRED; detail = "APPROVAL_EXPIRED"; updatedAt = Instant.now(); @@ -261,6 +278,16 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo if (!approvedAtRaw.isBlank()) { Instant approvedAt = Instant.parse(approvedAtRaw); if (Instant.now().isAfter(approvedAt.plus(signDelay))) { + Instant startedAt = Instant.now(); + if (deadlineReached(p, startedAt)) { + p.setProperty(K_STATE, State.EXPIRED.name()); + p.setProperty(K_DETAIL, "EXPIRED"); + p.setProperty(K_UPDATED_AT, startedAt.toString()); + writePropsSafe(dir.resolve(FILE_META), p); + notifySink(operationId); + return new OperationStatus(State.EXPIRED, startedAt, Optional.of("EXPIRED"), + Optional.empty()); + } try { byte[] payload = Files.readAllBytes(dir.resolve(FILE_REQUEST)); String keyRef = p.getProperty(K_KEYREF); @@ -270,10 +297,15 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo return fail(operationId, dir, p, "UNKNOWN_KEY"); } byte[] sigBytes = sign(alg, pk, payload); - Files.write(dir.resolve(FILE_SIGNATURE), sigBytes); - state = State.SUCCEEDED; - detail = "SIGNED"; updatedAt = Instant.now(); + if (deadlineReached(p, updatedAt)) { + state = State.EXPIRED; + detail = "EXPIRED"; + } else { + Files.write(dir.resolve(FILE_SIGNATURE), sigBytes); + state = State.SUCCEEDED; + detail = "SIGNED"; + } p.setProperty(K_STATE, state.name()); p.setProperty(K_DETAIL, detail); p.setProperty(K_UPDATED_AT, updatedAt.toString()); @@ -297,12 +329,18 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo } @Override - public boolean cancel(PkiId operationId, String reason) { + public boolean cancel(PkiId operationId, long fencingToken, String reason) { Objects.requireNonNull(operationId, "operationId"); Objects.requireNonNull(reason, "reason"); + if (fencingToken <= 0L) { + throw new IllegalArgumentException("fencingToken must be positive"); + } if (reason.isBlank()) { throw new IllegalArgumentException("reason must not be blank"); } + if (!identities.acceptFence(operationId, fencingToken)) { + return false; + } Path dir = opDir(operationId); java.util.Properties p = readPropsSafe(dir.resolve(FILE_META)); if (p.isEmpty()) { @@ -414,6 +452,11 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo return s == State.SUCCEEDED || s == State.FAILED || s == State.CANCELLED || s == State.EXPIRED; } + private static boolean deadlineReached(java.util.Properties properties, Instant observedAt) { + String value = properties.getProperty(K_DEADLINE, ""); + return !value.isBlank() && !observedAt.isBefore(Instant.parse(value)); + } + private static long loadSeq(Path root) { Path f = root.resolve("seq.txt"); try { diff --git a/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java b/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java index 13b6e74..f30fbbf 100644 --- a/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java +++ b/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java @@ -36,10 +36,12 @@ package zeroecho.pki.testkit; import java.io.IOException; import java.nio.file.Path; import java.security.KeyPair; +import java.security.PublicKey; import java.time.Duration; import java.util.HashMap; import java.util.Map; import java.util.Objects; +import java.util.Optional; import zeroecho.pki.api.CaService; import zeroecho.pki.api.CertificationRequestService; @@ -56,6 +58,7 @@ import zeroecho.pki.impl.core.DefaultRevocationService; import zeroecho.pki.impl.core.DefaultStatusObjectService; import zeroecho.pki.impl.core.async.PkiSigningBus; import zeroecho.pki.impl.core.attr.SimpleAttributeSet; +import zeroecho.pki.impl.audit.InMemoryAuditSink; import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialFramework; import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialIssuerBackend; import zeroecho.pki.impl.framework.x509.bc.BcX509StatusObjectGenerator; @@ -63,6 +66,8 @@ import zeroecho.pki.impl.fs.FilesystemPkiStore; import zeroecho.pki.impl.fs.FsPkiStoreOptions; import zeroecho.pki.spi.crypto.SignatureWorkflow; import zeroecho.pki.spi.framework.CredentialFramework; +import zeroecho.pki.spi.framework.CredentialIssuerBackend; +import zeroecho.pki.spi.framework.ProofOfPossessionVerifier; import zeroecho.pki.spi.store.PkiStore; /** @@ -79,8 +84,10 @@ public final class PkiTestRuntime implements AutoCloseable { private final FilesystemPkiStore store; private final PkiSigningBus signingBus; private final SignatureWorkflow signatureWorkflow; + private final InMemoryAuditSink auditSink; private final CredentialFramework framework; + private final CredentialIssuerBackend issuerBackend; private final CaService caService; private final CertificationRequestService certificationRequestService; @@ -88,23 +95,29 @@ public final class PkiTestRuntime implements AutoCloseable { private final RevocationService revocationService; private final StatusObjectService statusObjectService; - private final Map keyPairsByKeyRef; + private final Map publicKeysByKeyRef; + private Runnable publicKeyResolveHook; private PkiTestRuntime(FilesystemPkiStore store, PkiSigningBus signingBus, SignatureWorkflow signatureWorkflow, - CredentialFramework framework, Map keyPairsByKeyRef) { + CredentialFramework framework, CredentialIssuerBackend issuerBackend, + Map publicKeysByKeyRef, Duration signingTtl) { this.store = store; this.signingBus = signingBus; this.signatureWorkflow = signatureWorkflow; + this.auditSink = new InMemoryAuditSink(); this.framework = framework; - this.keyPairsByKeyRef = keyPairsByKeyRef; + this.issuerBackend = issuerBackend; + this.publicKeysByKeyRef = publicKeysByKeyRef; + this.publicKeyResolveHook = () -> { + }; this.certificationRequestService = new DefaultCertificationRequestService(store, framework); - this.issuanceService = new DefaultIssuanceService(store, framework); + this.issuanceService = new DefaultIssuanceService(store, framework, issuerBackend, auditSink); this.revocationService = new DefaultRevocationService(store); this.statusObjectService = new DefaultStatusObjectService(store, framework); - this.caService = new DefaultCaService(store, framework, this::resolvePublicKeyInfo, signingBus, "SHA256withRSA", - Duration.ofSeconds(2)); + this.caService = new DefaultCaService(store, framework, issuerBackend, this::resolvePublicKeyInfo, signingBus, + auditSink, "SHA256withRSA", signingTtl); } /** @@ -116,9 +129,36 @@ public final class PkiTestRuntime implements AutoCloseable { * @return runtime */ public static PkiTestRuntime create(Path rootDir, Path busFile, Map keyPairs) { + Map publicKeys = new HashMap<>(); + for (Map.Entry entry : keyPairs.entrySet()) { + publicKeys.put(entry.getKey(), entry.getValue().getPublic()); + } + return create(rootDir, busFile, keyPairs, publicKeys, Optional.empty()); + } + + /** + * Creates a test runtime with independently controlled signing keys, resolved + * public keys, and proof verifier. + * + * @param rootDir working root for the filesystem store + * @param busFile durable bus line store file path + * @param signingKeys signing workflow key pairs indexed by key reference + * @param resolvedKeys public keys returned by managed-key resolution + * @param proofVerifier proof verifier used by the credential framework + * @return runtime + */ + public static PkiTestRuntime create(Path rootDir, Path busFile, Map signingKeys, + Map resolvedKeys, ProofOfPossessionVerifier proofVerifier) { + return create(rootDir, busFile, signingKeys, resolvedKeys, Optional.of(proofVerifier)); + } + + private static PkiTestRuntime create(Path rootDir, Path busFile, Map keyPairs, + Map resolvedKeys, Optional proofVerifier) { Objects.requireNonNull(rootDir, "rootDir"); Objects.requireNonNull(busFile, "busFile"); Objects.requireNonNull(keyPairs, "keyPairs"); + Objects.requireNonNull(resolvedKeys, "resolvedKeys"); + Objects.requireNonNull(proofVerifier, "proofVerifier"); FsPkiStoreOptions opts = FsPkiStoreOptions.defaults(); @@ -129,6 +169,10 @@ public final class PkiTestRuntime implements AutoCloseable { for (Map.Entry e : keyPairs.entrySet()) { byRef.put(e.getKey().value(), e.getValue()); } + Map publicByRef = new HashMap<>(); + for (Map.Entry entry : resolvedKeys.entrySet()) { + publicByRef.put(entry.getKey().value(), entry.getValue()); + } SignatureWorkflow signer = new InMemorySignatureWorkflow(byRef); PkiSigningBus signingBus = new PkiSigningBus(store, signer, busFile); @@ -137,17 +181,43 @@ public final class PkiTestRuntime implements AutoCloseable { Duration.ofSeconds(2)); BcX509StatusObjectGenerator statusGen = new BcX509StatusObjectGenerator(signingBus, "SHA256withRSA", Duration.ofSeconds(2)); - CredentialFramework framework = new BcX509CredentialFramework().wired(issuerBackend, statusGen); + BcX509CredentialFramework baseFramework = new BcX509CredentialFramework(); + CredentialFramework framework = proofVerifier + .map(verifier -> baseFramework.wired(statusGen, verifier)) + .orElseGet(() -> baseFramework.wired(statusGen)); - return new PkiTestRuntime(store, signingBus, signer, framework, byRef); + return new PkiTestRuntime(store, signingBus, signer, framework, issuerBackend, publicByRef, + Duration.ofSeconds(2)); + } + + public static PkiTestRuntime createWithPendingSigner(Path rootDir, Path busFile, Map keyPairs, + Duration signingTtl) { + Objects.requireNonNull(signingTtl, "signingTtl"); + FsPkiStoreOptions opts = FsPkiStoreOptions.defaults(); + FilesystemPkiStore store = new FilesystemPkiStore(rootDir.resolve("store"), opts); + Map byRef = new HashMap<>(); + Map publicByRef = new HashMap<>(); + for (Map.Entry entry : keyPairs.entrySet()) { + byRef.put(entry.getKey().value(), entry.getValue()); + publicByRef.put(entry.getKey().value(), entry.getValue().getPublic()); + } + SignatureWorkflow signer = new InMemorySignatureWorkflow(byRef, false); + PkiSigningBus signingBus = new PkiSigningBus(store, signer, busFile); + BcX509CredentialIssuerBackend issuerBackend = new BcX509CredentialIssuerBackend(signingBus, + "SHA256withRSA", signingTtl); + BcX509StatusObjectGenerator statusGen = new BcX509StatusObjectGenerator(signingBus, "SHA256withRSA", + signingTtl); + CredentialFramework framework = new BcX509CredentialFramework().wired(statusGen); + return new PkiTestRuntime(store, signingBus, signer, framework, issuerBackend, publicByRef, signingTtl); } private EncodedObject resolvePublicKeyInfo(KeyRef keyRef) { - KeyPair kp = keyPairsByKeyRef.get(keyRef.value()); - if (kp == null) { + publicKeyResolveHook.run(); + PublicKey publicKey = publicKeysByKeyRef.get(keyRef.value()); + if (publicKey == null) { throw new IllegalArgumentException("Unknown keyRef"); } - return new EncodedObject(Encoding.DER, kp.getPublic().getEncoded()); + return new EncodedObject(Encoding.DER, publicKey.getEncoded()); } public PkiStore store() { @@ -162,14 +232,65 @@ public final class PkiTestRuntime implements AutoCloseable { return signatureWorkflow; } + public int submittedSignCount() { + return ((InMemorySignatureWorkflow) signatureWorkflow).submittedSignCount(); + } + + public void replaceManagedKey(KeyRef keyRef, KeyPair keyPair) { + publicKeysByKeyRef.put(keyRef.value(), keyPair.getPublic()); + ((InMemorySignatureWorkflow) signatureWorkflow).putKeyPair(keyRef, keyPair); + } + + /** + * Replaces only the public key returned by the managed-key resolver. + * + * @param keyRef managed key reference + * @param publicKey replacement resolved public key + */ + public void replaceResolvedKey(KeyRef keyRef, PublicKey publicKey) { + publicKeysByKeyRef.put(keyRef.value(), Objects.requireNonNull(publicKey, "publicKey")); + } + + public void onPublicKeyResolve(Runnable hook) { + this.publicKeyResolveHook = Objects.requireNonNull(hook, "hook"); + } + + public boolean hasRunningSignatureOperations() { + return ((InMemorySignatureWorkflow) signatureWorkflow).hasRunningOperations(); + } + + /** + * Returns the deterministic audit sink shared by the test runtime services. + * + * @return in-memory audit sink + */ + public InMemoryAuditSink auditSink() { + return auditSink; + } + public CredentialFramework framework() { return framework; } + public CredentialIssuerBackend issuerBackend() { + return issuerBackend; + } + public CaService caService() { return caService; } + public CaService caService(CredentialFramework credentialFramework) { + return new DefaultCaService(store, Objects.requireNonNull(credentialFramework, "credentialFramework"), + issuerBackend, this::resolvePublicKeyInfo, signingBus, auditSink, "SHA256withRSA", + Duration.ofSeconds(2)); + } + + public CaService caService(CredentialIssuerBackend backend) { + return new DefaultCaService(store, framework, Objects.requireNonNull(backend, "backend"), + this::resolvePublicKeyInfo, signingBus, auditSink, "SHA256withRSA", Duration.ofSeconds(2)); + } + public CertificationRequestService certificationRequestService() { return certificationRequestService; } @@ -186,6 +307,11 @@ public final class PkiTestRuntime implements AutoCloseable { return statusObjectService; } + /** + * Returns a new empty attribute set suitable for test commands. + * + * @return empty attributes + */ public SimpleAttributeSet emptyAttributes() { return new SimpleAttributeSet(); } diff --git a/pki/src/test/java/zeroecho/pki/testkit/TestSignIdentityRegistry.java b/pki/src/test/java/zeroecho/pki/testkit/TestSignIdentityRegistry.java new file mode 100644 index 0000000..eee62e8 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/testkit/TestSignIdentityRegistry.java @@ -0,0 +1,135 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.testkit; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import zeroecho.pki.api.PkiId; +import zeroecho.pki.spi.crypto.SignatureWorkflow.SignRequest; + +/** + * Durable exact-idempotency helper shared by filesystem-backed test providers. + */ +final class TestSignIdentityRegistry { + private final Path root; + private final Map locks = new ConcurrentHashMap<>(); + + TestSignIdentityRegistry(Path providerRoot) { + this.root = providerRoot.resolve(".sign-identities"); + } + + boolean begin(SignRequest request) { + Object lock = locks.computeIfAbsent(request.submissionId(), ignored -> new Object()); + synchronized (lock) { + Path path = path(request.submissionId()); + if (Files.exists(path)) { + Identity existing = read(path); + if (!constantTimeEquals(existing.fingerprint, request.semanticFingerprint())) { + throw new IllegalStateException("conflicting signing request"); + } + if (request.fencingToken() < existing.fence) { + throw new IllegalStateException("stale signing fence"); + } + if (request.fencingToken() > existing.fence) { + write(path, new Identity(existing.fingerprint, request.fencingToken())); + } + return false; + } + write(path, new Identity(request.semanticFingerprint(), request.fencingToken())); + return true; + } + } + + boolean acceptFence(PkiId operationId, long fence) { + Object lock = locks.computeIfAbsent(operationId, ignored -> new Object()); + synchronized (lock) { + Path path = path(operationId); + if (!Files.exists(path)) { + return false; + } + Identity existing = read(path); + if (fence < existing.fence) { + return false; + } + write(path, new Identity(existing.fingerprint, fence)); + return true; + } + } + + private Path path(PkiId id) { + try { + byte[] hash = MessageDigest.getInstance("SHA-256").digest(id.value().getBytes(StandardCharsets.UTF_8)); + return root.resolve(HexFormat.of().formatHex(hash) + ".txt"); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException(ex); + } + } + + private static Identity read(Path path) { + try { + String[] parts = Files.readString(path, StandardCharsets.US_ASCII).split("\\n", -1); + if (parts.length != 2) { + throw new IllegalStateException("corrupt test signing identity"); + } + return new Identity(parts[0], Long.parseLong(parts[1])); + } catch (IOException | NumberFormatException ex) { + throw new IllegalStateException(ex); + } + } + + private static void write(Path path, Identity identity) { + try { + Files.createDirectories(path.getParent()); + Files.writeString(path, identity.fingerprint + "\n" + identity.fence, StandardCharsets.US_ASCII); + } catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + private static boolean constantTimeEquals(String left, String right) { + return MessageDigest.isEqual(left.getBytes(StandardCharsets.US_ASCII), + right.getBytes(StandardCharsets.US_ASCII)); + } + + private record Identity(String fingerprint, long fence) { + } +} diff --git a/pki/src/test/java/zeroecho/pki/util/async/DurableAsyncBusTest.java b/pki/src/test/java/zeroecho/pki/util/async/DurableAsyncBusTest.java index ae1a021..6ce5fa6 100644 --- a/pki/src/test/java/zeroecho/pki/util/async/DurableAsyncBusTest.java +++ b/pki/src/test/java/zeroecho/pki/util/async/DurableAsyncBusTest.java @@ -107,6 +107,23 @@ public class DurableAsyncBusTest { assertTrue(bus.snapshot(opId).isEmpty()); assertTrue(bus.status(opId).isEmpty()); + DurableAsyncBus replayed = + new DurableAsyncBus(PkiCodecs.PKI_ID, PkiCodecs.PRINCIPAL, + PkiCodecs.STRING, new ResultCodec() { + @Override + public String encode(String result) { + return result; + } + + @Override + public String decode(String token) { + return token; + } + }, new AppendOnlyLineStore(log)); + assertTrue(replayed.snapshot(opId).isEmpty()); + assertTrue(replayed.status(opId).isEmpty()); + assertTrue(replayed.consumeResult(opId).isEmpty()); + System.out.println("...ok"); } @@ -152,6 +169,12 @@ public class DurableAsyncBusTest { assertEquals(AsyncState.FAILED, st.get().state()); assertTrue(bus.snapshot(opId).isEmpty()); + DurableAsyncBus replayed = + new DurableAsyncBus(PkiCodecs.PKI_ID, PkiCodecs.PRINCIPAL, + PkiCodecs.STRING, ResultCodec.none(), new AppendOnlyLineStore(log)); + assertTrue(replayed.snapshot(opId).isEmpty()); + assertEquals(AsyncState.FAILED, replayed.status(opId).orElseThrow().state()); + System.out.println("failedStatus_remainsVisibleUntilExplicitPurge...ok"); } @@ -193,4 +216,70 @@ public class DurableAsyncBusTest { System.out.println("...ok"); } + + @Test + public void terminalAndRetiredOperations_doNotReactivateOnReplay() { + Principal owner = new Principal("SERVICE", "issuer"); + Path successLog = tempDir.resolve("async-succeeded.log"); + PkiId successId = new PkiId("op-succeeded"); + ResultCodec persistedStrings = new ResultCodec() { + @Override + public String encode(String result) { + return result; + } + + @Override + public String decode(String token) { + return token; + } + }; + DurableAsyncBus successful = + new DurableAsyncBus(PkiCodecs.PKI_ID, PkiCodecs.PRINCIPAL, + PkiCodecs.STRING, persistedStrings, new AppendOnlyLineStore(successLog)); + successful.submit(successId, "SIGN", owner, "endpoint-1", Instant.parse("2025-01-01T00:00:00Z"), + Duration.ofHours(1)); + successful.update(successId, new AsyncStatus(AsyncState.SUCCEEDED, + Instant.parse("2025-01-01T00:00:10Z"), Optional.of("DONE"), java.util.Map.of()), + Optional.of("signature")); + + DurableAsyncBus replayedSuccessful = + new DurableAsyncBus(PkiCodecs.PKI_ID, PkiCodecs.PRINCIPAL, + PkiCodecs.STRING, persistedStrings, new AppendOnlyLineStore(successLog)); + assertTrue(replayedSuccessful.snapshot(successId).isEmpty()); + assertEquals(AsyncState.SUCCEEDED, replayedSuccessful.status(successId).orElseThrow().state()); + assertEquals("signature", replayedSuccessful.consumeResult(successId).orElseThrow()); + + for (AsyncState state : new AsyncState[] { AsyncState.CANCELLED, AsyncState.EXPIRED }) { + Path log = tempDir.resolve("async-" + state.name() + ".log"); + PkiId opId = new PkiId("op-" + state.name()); + DurableAsyncBus bus = + new DurableAsyncBus(PkiCodecs.PKI_ID, PkiCodecs.PRINCIPAL, + PkiCodecs.STRING, ResultCodec.none(), new AppendOnlyLineStore(log)); + bus.submit(opId, "SIGN", owner, "endpoint-1", Instant.parse("2025-01-01T00:00:00Z"), + Duration.ofHours(1)); + bus.update(opId, new AsyncStatus(state, Instant.parse("2025-01-01T00:00:10Z"), + Optional.of(state.name()), java.util.Map.of()), Optional.empty()); + + DurableAsyncBus replayed = + new DurableAsyncBus(PkiCodecs.PKI_ID, PkiCodecs.PRINCIPAL, + PkiCodecs.STRING, ResultCodec.none(), new AppendOnlyLineStore(log)); + assertTrue(replayed.snapshot(opId).isEmpty()); + assertEquals(state, replayed.status(opId).orElseThrow().state()); + } + + Path retiredLog = tempDir.resolve("async-retired.log"); + PkiId retiredId = new PkiId("op-retired"); + DurableAsyncBus retired = + new DurableAsyncBus(PkiCodecs.PKI_ID, PkiCodecs.PRINCIPAL, + PkiCodecs.STRING, ResultCodec.none(), new AppendOnlyLineStore(retiredLog)); + retired.submit(retiredId, "SIGN", owner, "endpoint-1", Instant.parse("2025-01-01T00:00:00Z"), + Duration.ofHours(1)); + retired.retire(retiredId); + + DurableAsyncBus replayedRetired = + new DurableAsyncBus(PkiCodecs.PKI_ID, PkiCodecs.PRINCIPAL, + PkiCodecs.STRING, ResultCodec.none(), new AppendOnlyLineStore(retiredLog)); + assertTrue(replayedRetired.snapshot(retiredId).isEmpty()); + assertTrue(replayedRetired.status(retiredId).isEmpty()); + } }