security(pki): enforce proof gates and durable signing workflows

This commit is contained in:
2026-07-29 14:20:21 +02:00
parent 49dc080c65
commit 07e04e0eed
53 changed files with 9628 additions and 950 deletions

View File

@@ -81,6 +81,14 @@ public interface CertificationRequestService {
* Verifies proof-of-possession (PoP) for the private key corresponding to the * Verifies proof-of-possession (PoP) for the private key corresponding to the
* requested public key. * requested public key.
* *
* <p>
* 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.
* </p>
*
* @param parsed parsed request * @param parsed parsed request
* @param policy verification policy * @param policy verification policy
* @return PoP verification result * @return PoP verification result

View File

@@ -67,5 +67,16 @@ public record EncodedObject(Encoding encoding, byte[] bytes) {
if (bytes == null || bytes.length == 0) { if (bytes == null || bytes.length == 0) {
throw new IllegalArgumentException("bytes must not be null/empty"); 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();
} }
} }

View File

@@ -55,6 +55,14 @@ public interface IssuanceService {
/** /**
* Issues a new end-entity credential. * Issues a new end-entity credential.
* *
* <p>
* 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.
* </p>
*
* @param command issuance command * @param command issuance command
* @return credential bundle (credential plus supporting artifacts) * @return credential bundle (credential plus supporting artifacts)
* @throws IllegalArgumentException if {@code command} is invalid * @throws IllegalArgumentException if {@code command} is invalid

View File

@@ -43,6 +43,12 @@ import java.util.Optional;
* framework-specific verification modes via optional hints. * framework-specific verification modes via optional hints.
* </p> * </p>
* *
* <p>
* 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.
* </p>
*
* @param requireProofOfPossession whether proof-of-possession is required * @param requireProofOfPossession whether proof-of-possession is required
* @param compatibilityProfileId optional compatibility profile hint for * @param compatibilityProfileId optional compatibility profile hint for
* parsers/verifiers * parsers/verifiers

View File

@@ -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.
*
* <p>The format is {@code zsign:v1:<namespace>:<epoch-millisecond>:<128-bit-random>}.
* Creation time remains store-authoritative: stores must validate the embedded
* time against their own clock and persist it with the intent.</p>
*
* @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");
}
}
}

View File

@@ -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<PkiId> 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<Validity> 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<PkiId> 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<AsyncStatus> status = bus.status(opId);
if (status.isPresent() && status.get().state() == AsyncState.SUCCEEDED) {
Optional<EncodedObject> result = bus.consumeResult(opId);
if (result.isEmpty()) {
throw new PkiException("Missing signature result");
}
return result.get().bytes();
}
if (status.isPresent() && isTerminalFailure(status.get().state())) {
throw new PkiException("Signing failed: " + status.get().state());
}
boundedWait(deadline);
}
throw new PkiException("Signing did not complete before TTL");
}
private static boolean isTerminalFailure(AsyncState state) {
return state == AsyncState.FAILED || state == AsyncState.CANCELLED || state == AsyncState.EXPIRED;
}
@SuppressWarnings("PMD.DoNotUseThreads")
private static void boundedWait(Instant deadline) {
long remainingMillis = Duration.between(Instant.now(), deadline).toMillis();
long waitMillis = Math.min(10L, Math.max(1L, remainingMillis));
try {
Thread.sleep(waitMillis);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new PkiException("Interrupted while awaiting signing", ex);
}
}
private void deletePreservingFailure(PkiId opId) {
try {
bus.retireSignOperation(opId, "failed-or-expired");
} catch (RuntimeException cleanupFailure) { // NOPMD - preserve primary failure
// The primary safe failure remains authoritative; provider diagnostics are discarded.
}
}
}
}

View File

@@ -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<EncodedObject> 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<SimpleAttributeSet.Entry> entries = new ArrayList<>();
for (AttributeId id : source.ids()) {
List<AttributeValue> 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;
}
}

View File

@@ -33,6 +33,7 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.impl.core; package zeroecho.pki.impl.core;
import java.io.IOException;
import java.math.BigInteger; import java.math.BigInteger;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.time.Duration; 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.BasicConstraints;
import org.bouncycastle.asn1.x509.Extension; import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.KeyUsage; import org.bouncycastle.asn1.x509.KeyUsage;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.X509v3CertificateBuilder; import org.bouncycastle.cert.X509v3CertificateBuilder;
import org.bouncycastle.operator.ContentSigner; 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.CaService;
import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding; import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.IssuerRef; import zeroecho.pki.api.IssuerRef;
import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.SubjectRef; import zeroecho.pki.api.SubjectRef;
import zeroecho.pki.api.Validity; 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.CaCreateCommand;
import zeroecho.pki.api.ca.CaImportCommand; import zeroecho.pki.api.ca.CaImportCommand;
import zeroecho.pki.api.ca.CaKeyRotationCommand; 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.Credential;
import zeroecho.pki.api.credential.CredentialStatus; import zeroecho.pki.api.credential.CredentialStatus;
import zeroecho.pki.impl.core.async.PkiSigningBus; 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.CredentialFramework;
import zeroecho.pki.spi.framework.CredentialIssuerBackend;
import zeroecho.pki.spi.store.PkiStore; import zeroecho.pki.spi.store.PkiStore;
/** /**
@@ -133,16 +142,16 @@ import zeroecho.pki.spi.store.PkiStore;
* model. * model.
* </p> * </p>
*/ */
// PMD cannot infer that retaining boundary causes would violate the redaction contract.
@SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.PreserveStackTrace" })
public final class DefaultCaService implements CaService { public final class DefaultCaService implements CaService {
private static final Logger LOG = Logger.getLogger(DefaultCaService.class.getName()); private static final Logger LOG = Logger.getLogger(DefaultCaService.class.getName());
private final PkiStore store; private final PkiStore store;
private final CredentialFramework framework; private final CredentialFramework framework;
private final PublicKeyInfoResolver publicKeyResolver; private final CredentialIssuerBackend issuerBackend;
private final PkiSigningBus signingBus; private final CaProofGate proofGate;
private final String signatureAlgorithmId;
private final Duration signingTtl;
/** /**
* Creates a CA service bound to a specific store, credential framework, and * 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 * @param framework credential framework responsible for
* format-specific issuance and validation paths; * format-specific issuance and validation paths;
* must not be {@code null} * 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 * @param publicKeyResolver resolver used to obtain subject public key
* material in SPKI DER form for key references; * material in SPKI DER form for key references;
* must not be {@code null} * must not be {@code null}
* @param signingBus signing orchestration component used to request * @param signingBus signing orchestration component used to request
* delegated signing operations; must not be * delegated signing operations; must not be
* {@code null} * {@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 * @param signatureAlgorithmId non-blank JCA signature algorithm identifier used
* for certificate signing requests initiated by * for certificate signing requests initiated by
* this service * this service
@@ -187,21 +201,23 @@ public final class DefaultCaService implements CaService {
* {@code signingTtl} is {@code null}, zero, or * {@code signingTtl} is {@code null}, zero, or
* negative * negative
*/ */
public DefaultCaService(PkiStore store, CredentialFramework framework, PublicKeyInfoResolver publicKeyResolver, public DefaultCaService(PkiStore store, CredentialFramework framework, CredentialIssuerBackend issuerBackend,
PkiSigningBus signingBus, String signatureAlgorithmId, Duration signingTtl) { PublicKeyInfoResolver publicKeyResolver, PkiSigningBus signingBus, AuditSink auditSink,
String signatureAlgorithmId, Duration signingTtl) {
this.store = Objects.requireNonNull(store, "store"); this.store = Objects.requireNonNull(store, "store");
this.framework = Objects.requireNonNull(framework, "framework"); this.framework = Objects.requireNonNull(framework, "framework");
this.publicKeyResolver = Objects.requireNonNull(publicKeyResolver, "publicKeyResolver"); this.issuerBackend = Objects.requireNonNull(issuerBackend, "issuerBackend");
this.signingBus = Objects.requireNonNull(signingBus, "signingBus"); Objects.requireNonNull(publicKeyResolver, "publicKeyResolver");
Objects.requireNonNull(signingBus, "signingBus");
Objects.requireNonNull(auditSink, "auditSink");
if (signatureAlgorithmId == null || signatureAlgorithmId.isBlank()) { if (signatureAlgorithmId == null || signatureAlgorithmId.isBlank()) {
throw new IllegalArgumentException("signatureAlgorithmId must not be null/blank"); throw new IllegalArgumentException("signatureAlgorithmId must not be null/blank");
} }
if (signingTtl == null || signingTtl.isZero() || signingTtl.isNegative()) { if (signingTtl == null || signingTtl.isZero() || signingTtl.isNegative()) {
throw new IllegalArgumentException("signingTtl must be positive"); throw new IllegalArgumentException("signingTtl must be positive");
} }
this.signatureAlgorithmId = signatureAlgorithmId; this.proofGate = new CaProofGate(publicKeyResolver, signingBus, auditSink, signatureAlgorithmId, signingTtl);
this.signingTtl = signingTtl;
} }
/** /**
@@ -244,7 +260,10 @@ public final class DefaultCaService implements CaService {
KeyRef keyRef = command.keyRef().get(); KeyRef keyRef = command.keyRef().get();
SubjectRef subjectRef = command.subjectRef(); 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(); Instant now = Instant.now();
Validity validity = new Validity(now.minus(Duration.ofMinutes(1)), now.plus(Duration.ofDays(3650))); 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); BigInteger serial = BigInteger.valueOf(Math.abs(now.toEpochMilli()) + 1L);
X509v3CertificateBuilder b = new X509v3CertificateBuilder(dn, serial, Date.from(validity.notBefore()), X509v3CertificateBuilder b = new X509v3CertificateBuilder(dn, serial, Date.from(validity.notBefore()),
Date.from(validity.notAfter()), dn, Date.from(validity.notAfter()), dn, rootPublicKeyInfo);
org.bouncycastle.asn1.x509.SubjectPublicKeyInfo.getInstance(spki.bytes()));
try { try {
b.addExtension(Extension.basicConstraints, true, new BasicConstraints(true)); b.addExtension(Extension.basicConstraints, true, new BasicConstraints(true));
b.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign)); b.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign));
} catch (Exception ex) { } 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; X509CertificateHolder cert;
try { try {
cert = b.build(signer); cert = b.build(signer);
} catch (RuntimeException ex) { // NOPMD } 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; byte[] certDer;
try { try {
certDer = cert.getEncoded(); certDer = cert.getEncoded();
} catch (Exception ex) { } 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)); PkiId credId = new PkiId("x509:" + sha256Hex(certDer));
@@ -303,9 +327,10 @@ public final class DefaultCaService implements CaService {
* </p> * </p>
* *
* <p> * <p>
* The imported root may optionally carry a {@link KeyRef}. When absent, the CA * The imported certificate must be a self-issued, validly self-signed CA
* is still represented in the store, but later signing operations may be * certificate whose subject matches the command. The command's managed
* unavailable depending on higher-level policy and runtime wiring. * {@link KeyRef} must also complete a signing challenge and resolve to the exact
* certificate SPKI before either record is persisted.
* </p> * </p>
* *
* @param command root CA import command; must not be {@code null} * @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"); throw new PkiException("Only DER import supported by this runtime");
} }
byte[] certDer = command.existingCaCredential().bytes().clone();
X509CertificateHolder holder; X509CertificateHolder holder;
try { try {
holder = new X509CertificateHolder(command.existingCaCredential().bytes()); holder = new X509CertificateHolder(certDer);
} catch (Exception ex) { } 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 notBefore = holder.getNotBefore().toInstant();
Instant notAfter = holder.getNotAfter().toInstant(); Instant notAfter = holder.getNotAfter().toInstant();
Validity validity = new Validity(notBefore, notAfter); Validity validity = new Validity(notBefore, notAfter);
byte[] certDer = command.existingCaCredential().bytes();
PkiId credId = new PkiId("x509:" + sha256Hex(certDer)); PkiId credId = new PkiId("x509:" + sha256Hex(certDer));
PkiId caId = new PkiId("ca:" + sha256Hex(certDer).substring(0, 16)); PkiId caId = new PkiId("ca:" + sha256Hex(certDer).substring(0, 16));
byte[] spkiDer; byte[] spkiDer;
try { try {
spkiDer = holder.getSubjectPublicKeyInfo().getEncoded(); spkiDer = holder.getSubjectPublicKeyInfo().getEncoded();
} catch (java.io.IOException ex) { } catch (IOException ex) {
throw new PkiException("Failed to encode subject public key info", ex); throw new PkiException("Subject public key encoding failed: code=SPKI_ENCODE_FAILED");
} }
PkiId publicKeyId = new PkiId("spki:" + sha256Hex(spkiDer)); 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, validity, serial.toString(), publicKeyId, command.profileId(), CredentialStatus.ISSUED,
new EncodedObject(Encoding.DER, certDer), command.attributes()); new EncodedObject(Encoding.DER, certDer), command.attributes());
store.putCredential(credential); store.putCredential(credential);
CaRecord ca = new CaRecord(caId, CaKind.ROOT, CaState.ACTIVE, command.keyRef(), command.subjectRef(), CaRecord ca = new CaRecord(caId, CaKind.ROOT, CaState.ACTIVE, command.keyRef(), command.subjectRef(),
List.of(credential)); List.of(credential));
store.putCa(ca); store.putCa(ca);
@@ -400,14 +426,35 @@ public final class DefaultCaService implements CaService {
if (issuer.caCredentials().isEmpty()) { if (issuer.caCredentials().isEmpty()) {
throw new PkiException("Issuer CA has no credentials"); 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()) PkiId caId = new PkiId("ca:" + sha256Hex((issuer.caId().value() + "\n" + command.subjectRef().value())
.getBytes(java.nio.charset.StandardCharsets.UTF_8)).substring(0, 16)); .getBytes(java.nio.charset.StandardCharsets.UTF_8)).substring(0, 16));
IntermediateCertIssueCommand issue = new IntermediateCertIssueCommand(command.formatId(), command.issuerCaId(), CaProofGate.ManagedKeyProof subjectProof = proofGate.proveManagedKey(command.keyRef().get(),
caId, command.profileId(), Optional.empty(), command.attributes()); 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); store.putCredential(cred);
CaRecord subject = new CaRecord(caId, CaKind.INTERMEDIATE, CaState.ACTIVE, command.keyRef().get(), 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"); ensureActive(issuer, "issuer");
CaRecord subject = getCa(command.subjectCaId()); CaRecord subject = getCa(command.subjectCaId());
ensureActive(subject, "subject"); 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); store.putCredential(cred);
List<Credential> updated = new ArrayList<>(subject.caCredentials()); List<Credential> 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<PkiId> 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) { private static String sha256Hex(byte[] in) {
try { try {
MessageDigest md = MessageDigest.getInstance("SHA-256"); 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}.
*
* <p>
* 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}.
* </p>
*
* <p>
* 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.
* </p>
*
* <h2>Security considerations</h2>
* <ul>
* <li>The private key is never accessed directly by this class.</li>
* <li>The signer only transports the to-be-signed payload and consumes the
* resulting signature bytes.</li>
* <li>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.</li>
* <li>Callers should ensure that the configured {@code algId} is compatible
* with the referenced key material and with the expectations of the downstream
* signing implementation.</li>
* </ul>
*
* <h2>Thread-safety</h2>
* <p>
* 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.
* </p>
*/
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.
*
* <p>
* 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.
* </p>
*
* @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.
*
* <p>
* This value is consumed by Bouncy Castle during certificate construction to
* encode the signature algorithm metadata into the resulting certificate
* structure.
* </p>
*
* @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.
*
* <p>
* 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.
* </p>
*
* @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.
*
* <p>
* 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.
* </p>
*
* <p>
* 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.
* </p>
*
* @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<zeroecho.pki.util.async.AsyncStatus> st = bus.status(opId);
if (st.isPresent() && st.get().state() == zeroecho.pki.util.async.AsyncState.SUCCEEDED) {
Optional<EncodedObject> 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");
}
}
} }

View File

@@ -33,10 +33,20 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.impl.core; package zeroecho.pki.impl.core;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects; 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.EncodedObject;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.FormatId; import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.IssuanceService; import zeroecho.pki.api.IssuanceService;
import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.KeyRef;
@@ -44,6 +54,9 @@ import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.attr.AttributeSet; import zeroecho.pki.api.attr.AttributeSet;
import zeroecho.pki.api.attr.AttributeValue; 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.CaRecord;
import zeroecho.pki.api.ca.CaState; import zeroecho.pki.api.ca.CaState;
import zeroecho.pki.api.credential.Credential; 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.ReissueCommand;
import zeroecho.pki.api.issuance.RenewCommand; import zeroecho.pki.api.issuance.RenewCommand;
import zeroecho.pki.api.issuance.ReplaceCommand; 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.core.attr.SimpleAttributeSet;
import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes; 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.CredentialFramework;
import zeroecho.pki.spi.framework.CredentialIssuerBackend;
import zeroecho.pki.spi.store.PkiStore; import zeroecho.pki.spi.store.PkiStore;
/** /**
@@ -78,11 +98,15 @@ import zeroecho.pki.spi.store.PkiStore;
* <ul> * <ul>
* <li>the issuer CA must exist,</li> * <li>the issuer CA must exist,</li>
* <li>the issuer CA must be in {@link CaState#ACTIVE} state,</li> * <li>the issuer CA must be in {@link CaState#ACTIVE} state,</li>
* <li>the issuer CA must expose at least one credential,</li> * <li>the issuer CA must expose a currently valid
* <li>a suitable issuer credential must be resolvable for the active framework * {@link CredentialStatus#ISSUED} credential for the active framework
* {@link FormatId},</li> * {@link FormatId},</li>
* <li>issuer material required by the current X.509 runtime wiring must be * <li>issuer material required by the current X.509 runtime wiring must be
* present in issuance overrides before the backend is invoked.</li> * present in issuance overrides before the backend is invoked,</li>
* <li>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.</li>
* </ul> * </ul>
* *
* <p> * <p>
@@ -110,10 +134,22 @@ import zeroecho.pki.spi.store.PkiStore;
* concurrency model. * concurrency model.
* </p> * </p>
*/ */
// PMD cannot infer that retaining boundary causes would violate the redaction contract.
@SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.PreserveStackTrace" })
public final class DefaultIssuanceService implements IssuanceService { 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 PkiStore store;
private final CredentialFramework framework; private final CredentialFramework framework;
private final CredentialIssuerBackend issuerBackend;
private final AuditSink auditSink;
/** /**
* Creates the issuance service bound to the supplied persistence and framework * 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 * @param store PKI store used for issuer CA lookup and credential
* persistence; must not be {@code null} * persistence; must not be {@code null}
* @param framework credential framework providing format-specific issuance * @param framework credential framework providing format-specific issuance
* backends; must not be {@code null} * parsing and proof verification; must not be {@code null}
* @throws NullPointerException if {@code store} or {@code framework} is * @param issuerBackend privileged issuer implementation that accepts only
* {@code null} * 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.store = Objects.requireNonNull(store, "store");
this.framework = Objects.requireNonNull(framework, "framework"); 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 * issuance proceeds. The method then selects a suitable issuer credential for
* the active framework format, enriches the supplied issuance overrides with * the active framework format, enriches the supplied issuance overrides with
* issuer material required by the X.509 backend wiring, delegates issuance to * 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.
* </p> * </p>
* *
* <p> * <p>
@@ -149,17 +192,19 @@ public final class DefaultIssuanceService implements IssuanceService {
* </p> * </p>
* *
* @param command end-entity issuance command; must not be {@code null} * @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 NullPointerException if {@code command} is {@code null}
* @throws PkiException if the issuer CA does not exist, is not active, * @throws PkiException if the issuer CA does not exist, is not active,
* has no credentials, no compatible issuer * has no credentials, no compatible issuer
* credential can be selected, issuer material * current issued credential can be selected,
* enrichment fails, backend issuance fails, or * issuer material enrichment fails, backend
* persistence of the issued leaf credential fails * issuance or result validation fails, or
* persistence of the validated leaf fails
*/ */
@Override @Override
public CredentialBundle issueEndEntity(IssueEndEntityCommand command) { public CredentialBundle issueEndEntity(IssueEndEntityCommand command) {
Objects.requireNonNull(command, "command"); Objects.requireNonNull(command, "command");
VerifiedIssuanceCandidate candidate = verifyIssuanceCandidate(command);
CaRecord issuer = store.getCa(command.issuerCaId()).orElseThrow(() -> new PkiException("Issuer CA not found")); CaRecord issuer = store.getCa(command.issuerCaId()).orElseThrow(() -> new PkiException("Issuer CA not found"));
if (issuer.state() != CaState.ACTIVE) { if (issuer.state() != CaState.ACTIVE) {
@@ -169,7 +214,7 @@ public final class DefaultIssuanceService implements IssuanceService {
throw new PkiException("Issuer CA has no credentials"); 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(), AttributeSet enrichedOverrides = enrichOverrides(command.overrides(), issuerCred.encoded(),
issuer.issuerKeyRef()); issuer.issuerKeyRef());
@@ -180,11 +225,15 @@ public final class DefaultIssuanceService implements IssuanceService {
if (enrichedOverrides.get(BcX509Attributes.ISSUER_KEYREF).isEmpty()) { if (enrichedOverrides.get(BcX509Attributes.ISSUER_KEYREF).isEmpty()) {
throw new PkiException("Issuer material wiring failed: missing issuer keyref override"); throw new PkiException("Issuer material wiring failed: missing issuer keyref override");
} }
candidate = candidate.withAuthoritativeOverrides(command, enrichedOverrides);
IssueEndEntityCommand enriched = new IssueEndEntityCommand(command.issuerCaId(), command.request(), CredentialBundle bundle;
command.profileId(), command.validityOverride(), enrichedOverrides); try {
bundle = CredentialSnapshots.copy(issuerBackend.issueEndEntity(candidate));
CredentialBundle bundle = framework.issuerBackend().issueEndEntity(enriched); } 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()); store.putCredential(bundle.credential());
return bundle; return bundle;
} }
@@ -194,16 +243,15 @@ public final class DefaultIssuanceService implements IssuanceService {
* format. * format.
* *
* <p> * <p>
* The selection strategy prefers a credential whose {@link FormatId} matches * The selected credential must match the requested {@link FormatId}, have
* the requested format and whose status is {@link CredentialStatus#ISSUED}. If * {@link CredentialStatus#ISSUED} status, and contain the current instant
* no such credential exists, the method falls back to the first non-null * within its inclusive validity interval. No status or validity fallback is
* credential whose format matches, regardless of status. * permitted.
* </p> * </p>
* *
* <p> * <p>
* This method does not evaluate validity windows, revocation state external to * This method does not evaluate profile suitability or revocation information
* {@link CredentialStatus}, or profile suitability. It performs only the * external to {@link CredentialStatus}.
* minimal runtime selection required by the current implementation.
* </p> * </p>
* *
* @param issuer issuer CA record containing candidate credentials; must not * @param issuer issuer CA record containing candidate credentials; must not
@@ -213,27 +261,24 @@ public final class DefaultIssuanceService implements IssuanceService {
* @return selected issuer credential * @return selected issuer credential
* @throws NullPointerException if {@code issuer} or {@code formatId} is * @throws NullPointerException if {@code issuer} or {@code formatId} is
* {@code null} * {@code null}
* @throws PkiException if the issuer CA has no credential compatible * @throws PkiException if the issuer CA has no current issued
* with the requested format * credential compatible with the requested format
*/ */
private static Credential selectIssuerCredential(CaRecord issuer, FormatId formatId) { private static Credential selectIssuerCredential(CaRecord issuer, FormatId formatId) {
Objects.requireNonNull(issuer, "issuer"); Objects.requireNonNull(issuer, "issuer");
Objects.requireNonNull(formatId, "formatId"); Objects.requireNonNull(formatId, "formatId");
Instant now = Instant.now();
for (Credential c : issuer.caCredentials()) { for (Credential c : issuer.caCredentials()) {
if (c == null) { if (c == null) {
continue; 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; return c;
} }
} }
for (Credential c : issuer.caCredentials()) { throw new PkiException("Issuer CA has no current issued credential for formatId " + formatId.value());
if (c != null && formatId.equals(c.formatId())) {
return c;
}
}
throw new PkiException("Issuer CA has no credential for formatId " + formatId.value());
} }
/** /**
@@ -241,10 +286,10 @@ public final class DefaultIssuanceService implements IssuanceService {
* the current X.509 backend wiring. * the current X.509 backend wiring.
* *
* <p> * <p>
* 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_CERT_DER} and
* {@link BcX509Attributes#ISSUER_KEYREF} are populated from the supplied issuer * {@link BcX509Attributes#ISSUER_KEYREF} are overwritten from the supplied
* credential encoding and issuer key reference. * issuer credential encoding and issuer key reference.
* </p> * </p>
* *
* <p> * <p>
@@ -253,13 +298,11 @@ public final class DefaultIssuanceService implements IssuanceService {
* </p> * </p>
* *
* @param overrides original issuance overrides; must not be {@code null} * @param overrides original issuance overrides; must not be {@code null}
* @param issuerCertDer DER-encoded issuer credential payload to inject when the * @param issuerCertDer DER-encoded authoritative issuer credential payload;
* corresponding override is absent; must not be * must not be {@code null}
* @param issuerKeyRef authoritative issuer key reference; must not be
* {@code null} * {@code null}
* @param issuerKeyRef issuer key reference to inject when the corresponding * @return enriched attribute set containing authoritative issuer wiring
* override is absent; must not be {@code null}
* @return enriched attribute set containing the original overrides plus any
* missing issuer wiring attributes
* @throws NullPointerException if any argument is {@code null} * @throws NullPointerException if any argument is {@code null}
*/ */
private static AttributeSet enrichOverrides(AttributeSet overrides, EncodedObject issuerCertDer, private static AttributeSet enrichOverrides(AttributeSet overrides, EncodedObject issuerCertDer,
@@ -269,16 +312,178 @@ public final class DefaultIssuanceService implements IssuanceService {
Objects.requireNonNull(issuerKeyRef, "issuerKeyRef"); Objects.requireNonNull(issuerKeyRef, "issuerKeyRef");
SimpleAttributeSet.Builder b = SimpleAttributeSet.builder().putAll(overrides); SimpleAttributeSet.Builder b = SimpleAttributeSet.builder().putAll(overrides);
byte[] issuerBytes = issuerCertDer.bytes();
if (overrides.get(BcX509Attributes.ISSUER_CERT_DER).isEmpty()) { try {
b.put(BcX509Attributes.ISSUER_CERT_DER, new AttributeValue.BytesValue(issuerCertDer.bytes())); b.put(BcX509Attributes.ISSUER_CERT_DER, new AttributeValue.BytesValue(issuerBytes.clone()));
} } finally {
if (overrides.get(BcX509Attributes.ISSUER_KEYREF).isEmpty()) { java.util.Arrays.fill(issuerBytes, (byte) 0);
b.put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue(issuerKeyRef.value()));
} }
b.put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue(issuerKeyRef.value()));
return b.build(); 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<AttributeValue> 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. * Requests credential renewal.
* *

View File

@@ -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.
*
* <p>
* 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.
* </p>
*
* <p>
* All mutable values are defensively snapshotted. Accessors return defensive
* copies where necessary, allowing a backend to consume the object concurrently
* without observing caller mutation.
* </p>
*/
@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<Validity> 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<Validity> 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<Validity> 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());
}
}

View File

@@ -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.
*
* <p>
* 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.
* </p>
*/
@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<Validity> 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<Validity> 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<SimpleAttributeSet.Entry> entries = new ArrayList<>();
for (AttributeId id : source.ids()) {
List<AttributeValue> 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;
}
}

View File

@@ -34,6 +34,8 @@
package zeroecho.pki.impl.crypto.zeroecholib; package zeroecho.pki.impl.crypto.zeroecholib;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Clock;
import java.time.Duration;
import java.util.Set; import java.util.Set;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
@@ -52,6 +54,10 @@ import zeroecho.pki.spi.crypto.SignatureWorkflowProvider;
* <ul> * <ul>
* <li>{@code keyringPath} (required): filesystem path to the KeyringStore * <li>{@code keyringPath} (required): filesystem path to the KeyringStore
* file</li> * file</li>
* <li>{@code operationRoot} (required): exclusively owned durable signing
* operation directory</li>
* <li>{@code operationHorizon} (optional ISO-8601 duration, default
* {@code P90D})</li>
* <li>{@code keyRefPrefix} (optional, default {@code "zeroecho-lib:"})</li> * <li>{@code keyRefPrefix} (optional, default {@code "zeroecho-lib:"})</li>
* <li>{@code requireComponentSuffix} (optional, default {@code true})</li> * <li>{@code requireComponentSuffix} (optional, default {@code true})</li>
* </ul> * </ul>
@@ -66,6 +72,8 @@ public final class ZeroEchoLibSignatureWorkflowProvider implements SignatureWork
private static final Logger LOG = Logger.getLogger(ZeroEchoLibSignatureWorkflowProvider.class.getName()); private static final Logger LOG = Logger.getLogger(ZeroEchoLibSignatureWorkflowProvider.class.getName());
private static final String KEY_KEYRING_PATH = "keyringPath"; 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_KEYREF_PREFIX = "keyRefPrefix";
private static final String KEY_REQUIRE_SUFFIX = "requireComponentSuffix"; private static final String KEY_REQUIRE_SUFFIX = "requireComponentSuffix";
@@ -76,7 +84,8 @@ public final class ZeroEchoLibSignatureWorkflowProvider implements SignatureWork
@Override @Override
public Set<String> supportedKeys() { public Set<String> 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); SignatureWorkflowProvider.super.validateConfig(config);
String keyringPath = config.require(KEY_KEYRING_PATH); String keyringPath = config.require(KEY_KEYRING_PATH);
Path.of(keyringPath); 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 -> { config.get(KEY_KEYREF_PREFIX).ifPresent(value -> {
if (value.isBlank()) { if (value.isBlank()) {
throw new IllegalArgumentException("Configuration key '" + KEY_KEYREF_PREFIX + "' must not be blank."); 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) { public SignatureWorkflow allocate(final ProviderConfig config) {
validateConfig(config); validateConfig(config);
String keyringPath = config.require(KEY_KEYRING_PATH); 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:"); String prefix = config.get(KEY_KEYREF_PREFIX).orElse("zeroecho-lib:");
boolean requireSuffix = config.get(KEY_REQUIRE_SUFFIX).map(Boolean::parseBoolean).orElse(Boolean.TRUE); 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);
} }
} }

View File

@@ -148,6 +148,17 @@ public final class BcX509Attributes {
*/ */
public static final AttributeId SUBJECT_SPKI_DER = new AttributeId("urn:zeroecho:pki:x509:subject:spki"); 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.
*
* <p>
* 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.
* </p>
*/
public static final AttributeId SUBJECT_DN = new AttributeId("urn:zeroecho:pki:x509:subject:dn");
private BcX509Attributes() { private BcX509Attributes() {
// utility // utility
} }

View File

@@ -108,6 +108,8 @@ import zeroecho.pki.spi.framework.CertificationRequestParser;
* This class is stateless and thread-safe. * This class is stateless and thread-safe.
* </p> * </p>
*/ */
// PMD cannot infer that retaining parser causes would violate the redaction contract.
@SuppressWarnings("PMD.PreserveStackTrace")
public final class BcX509CertificationRequestParser implements CertificationRequestParser { public final class BcX509CertificationRequestParser implements CertificationRequestParser {
/** /**
* Parses a PKCS#10 certification request into the normalized PKI request * 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(); EncodedObject enc = request.encoded();
byte[] csrDer = toDer(enc); byte[] csrDer = toDer(enc);
PKCS10CertificationRequest csr; byte[] spki = null;
try { try {
csr = new PKCS10CertificationRequest(csrDer); PKCS10CertificationRequest csr;
} catch (Exception ex) { try {
throw new PkiException("Invalid PKCS#10 certification request", ex); 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()) { return switch (obj.encoding()) {
case DER -> obj.bytes(); case DER -> obj.bytes();
case PEM -> readPemContentOrThrow(obj.bytes()); case PEM -> pemToDer(obj);
default -> throw new IllegalArgumentException("Unsupported CSR encoding: " + obj.encoding()); 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. * Reads the binary content of a PEM object.
* *
@@ -245,7 +263,7 @@ public final class BcX509CertificationRequestParser implements CertificationRequ
} }
return pemObject.getContent(); return pemObject.getContent();
} catch (java.io.IOException ex) { } 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 { try {
MessageDigest md = MessageDigest.getInstance("SHA-256"); MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] d = md.digest(in); 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) { } catch (Exception ex) {
throw new IllegalStateException("SHA-256 not available", ex); throw new IllegalStateException("SHA-256 unavailable: code=DIGEST_UNAVAILABLE");
} }
} }
} }

View File

@@ -38,7 +38,6 @@ import java.util.Objects;
import zeroecho.pki.api.FormatId; import zeroecho.pki.api.FormatId;
import zeroecho.pki.spi.framework.CertificationRequestParser; import zeroecho.pki.spi.framework.CertificationRequestParser;
import zeroecho.pki.spi.framework.CredentialFramework; import zeroecho.pki.spi.framework.CredentialFramework;
import zeroecho.pki.spi.framework.CredentialIssuerBackend;
import zeroecho.pki.spi.framework.FrameworkAttributeMapper; import zeroecho.pki.spi.framework.FrameworkAttributeMapper;
import zeroecho.pki.spi.framework.ProofOfPossessionVerifier; import zeroecho.pki.spi.framework.ProofOfPossessionVerifier;
import zeroecho.pki.spi.framework.StatusObjectGenerator; 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 * This class groups the X.509-specific framework components used by the PKI
* runtime under the common {@link CredentialFramework} contract. It provides * runtime under the common {@link CredentialFramework} contract. It provides
* the X.509 format identifier, CSR parsing, proof-of-possession verification, * the X.509 format identifier, CSR parsing, proof-of-possession verification,
* issuance backend access, status-object generation, and framework-specific * status-object generation, and framework-specific attribute mapping. It does
* attribute mapping. * not expose credential-minting backends.
* </p> * </p>
* *
* <p> * <p>
* The framework supports two wiring modes: * The default constructor provides parsing, proof verification, and attribute
* </p> * mapping with an unsupported status generator. A {@code wired(...)} method can
* <ul> * supply status generation without exposing the privileged issuer backend.
* <li>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,</li>
* <li>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.</li>
* </ul>
* *
* <p> * <p>
* This split allows the runtime to use X.509 request parsing and proof-of- * This split allows the runtime to use X.509 request parsing and proof-of-
* possession verification independently from certificate issuance and status- * possession verification independently from certificate issuance.
* object publication wiring.
* </p> * </p>
* *
* <h2>Immutability and lifecycle</h2> * <h2>Immutability and lifecycle</h2>
@@ -79,16 +68,15 @@ import zeroecho.pki.spi.framework.StatusObjectGenerator;
* Instances of this class are immutable. The {@code wired(...)} methods do not * Instances of this class are immutable. The {@code wired(...)} methods do not
* mutate the current instance; instead they create and return a new framework * mutate the current instance; instead they create and return a new framework
* instance sharing the existing parser and attribute-mapper components while * instance sharing the existing parser and attribute-mapper components while
* replacing the requested backend components. * replacing the status generator.
* </p> * </p>
* *
* <h2>Security considerations</h2> * <h2>Security considerations</h2>
* <ul> * <ul>
* <li>This class is an orchestration and component-aggregation object; it does * <li>This class is an orchestration and component-aggregation object; it does
* not itself process private key material.</li> * not itself process private key material.</li>
* <li>The security properties of issuance and status-object generation depend * <li>Credential issuance remains owned by proof-gated core services rather than
* on the concrete backends supplied through the {@code wired(...)} * this framework facade.</li>
* methods.</li>
* <li>The default instance intentionally fails fast for issuance and * <li>The default instance intentionally fails fast for issuance and
* status-object generation so that partially wired deployments do not silently * status-object generation so that partially wired deployments do not silently
* degrade into incomplete behavior.</li> * degrade into incomplete behavior.</li>
@@ -114,7 +102,6 @@ public final class BcX509CredentialFramework implements CredentialFramework {
private final CertificationRequestParser requestParser; private final CertificationRequestParser requestParser;
private final ProofOfPossessionVerifier popVerifier; private final ProofOfPossessionVerifier popVerifier;
private final CredentialIssuerBackend issuer;
private final StatusObjectGenerator status; private final StatusObjectGenerator status;
private final FrameworkAttributeMapper attributeMapper; private final FrameworkAttributeMapper attributeMapper;
@@ -131,17 +118,15 @@ public final class BcX509CredentialFramework implements CredentialFramework {
* </ul> * </ul>
* *
* <p> * <p>
* Certificate issuance and status-object generation are intentionally left * Status-object generation is intentionally left unsupported in this default
* unsupported in this default configuration and must be supplied explicitly by * configuration and may be supplied explicitly through
* calling one of the * {@link #wired(BcX509StatusObjectGenerator)}. Credential issuance is not exposed by the
* {@link #wired(BcX509CredentialIssuerBackend, BcX509StatusObjectGenerator)} * framework facade.
* methods.
* </p> * </p>
*/ */
public BcX509CredentialFramework() { public BcX509CredentialFramework() {
this(new BcX509CertificationRequestParser(), new BcX509ProofOfPossessionVerifier(), this(new BcX509CertificationRequestParser(), new BcX509ProofOfPossessionVerifier(),
new UnsupportedIssuerBackend(), new UnsupportedStatusObjectGenerator(), new UnsupportedStatusObjectGenerator(), new BcX509FrameworkAttributeMapper());
new BcX509FrameworkAttributeMapper());
} }
/** /**
@@ -149,53 +134,43 @@ public final class BcX509CredentialFramework implements CredentialFramework {
* *
* <p> * <p>
* This constructor is private because external callers are expected to use the * This constructor is private because external callers are expected to use the
* public default constructor and then derive a fully wired instance through the * public default constructor and then derive a status-wired instance through
* provided {@code wired(...)} methods. This preserves a simple public * the provided {@code wired(...)} methods.
* construction model while keeping the full component graph explicit inside the
* implementation.
* </p> * </p>
* *
* @param requestParser certification request parser; must not be {@code null} * @param requestParser certification request parser; must not be {@code null}
* @param popVerifier proof-of-possession verifier; 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 status status-object generator; must not be {@code null}
* @param attributeMapper framework-specific attribute mapper; must not be * @param attributeMapper framework-specific attribute mapper; must not be
* {@code null} * {@code null}
* @throws NullPointerException if any component argument is {@code null} * @throws NullPointerException if any component argument is {@code null}
*/ */
private BcX509CredentialFramework(CertificationRequestParser requestParser, ProofOfPossessionVerifier popVerifier, private BcX509CredentialFramework(CertificationRequestParser requestParser, ProofOfPossessionVerifier popVerifier,
CredentialIssuerBackend issuer, StatusObjectGenerator status, FrameworkAttributeMapper attributeMapper) { StatusObjectGenerator status, FrameworkAttributeMapper attributeMapper) {
this.requestParser = Objects.requireNonNull(requestParser, "requestParser"); this.requestParser = Objects.requireNonNull(requestParser, "requestParser");
this.popVerifier = Objects.requireNonNull(popVerifier, "popVerifier"); this.popVerifier = Objects.requireNonNull(popVerifier, "popVerifier");
this.issuer = Objects.requireNonNull(issuer, "issuer");
this.status = Objects.requireNonNull(status, "status"); this.status = Objects.requireNonNull(status, "status");
this.attributeMapper = Objects.requireNonNull(attributeMapper, "attributeMapper"); this.attributeMapper = Objects.requireNonNull(attributeMapper, "attributeMapper");
} }
/** /**
* Creates a fully wired X.509 framework instance using the current parser, * Creates a fully wired X.509 framework instance using the current parser,
* proof-of-possession verifier, and attribute mapper together with the supplied * proof-of-possession verifier and attribute mapper together with the supplied
* issuance and status-object generation backends. * status-object generator.
* *
* <p> * <p>
* The current instance is not modified. A new immutable framework instance is * The current instance is not modified. A new immutable framework instance is
* returned. * returned.
* </p> * </p>
* *
* @param issuerBackend concrete X.509 issuance backend; must not be
* {@code null}
* @param statusObjectGenerator concrete X.509 status-object generator; must not * @param statusObjectGenerator concrete X.509 status-object generator; must not
* be {@code null} * be {@code null}
* @return new fully wired framework instance * @return new fully wired framework instance
* @throws NullPointerException if {@code issuerBackend} or * @throws NullPointerException if {@code statusObjectGenerator} is {@code null}
* {@code statusObjectGenerator} is {@code null}
*/ */
public BcX509CredentialFramework wired(BcX509CredentialIssuerBackend issuerBackend, public BcX509CredentialFramework wired(BcX509StatusObjectGenerator statusObjectGenerator) {
BcX509StatusObjectGenerator statusObjectGenerator) {
Objects.requireNonNull(issuerBackend, "issuerBackend");
Objects.requireNonNull(statusObjectGenerator, "statusObjectGenerator"); Objects.requireNonNull(statusObjectGenerator, "statusObjectGenerator");
return new BcX509CredentialFramework(requestParser, popVerifier, issuerBackend, statusObjectGenerator, return new BcX509CredentialFramework(requestParser, popVerifier, statusObjectGenerator, attributeMapper);
attributeMapper);
} }
/** /**
@@ -213,8 +188,6 @@ public final class BcX509CredentialFramework implements CredentialFramework {
* returned. * returned.
* </p> * </p>
* *
* @param issuerBackend concrete X.509 issuance backend; must not be
* {@code null}
* @param statusObjectGenerator concrete X.509 status-object generator; must * @param statusObjectGenerator concrete X.509 status-object generator; must
* not be {@code null} * not be {@code null}
* @param proofOfPossessionVerifier explicit proof-of-possession verifier to use * @param proofOfPossessionVerifier explicit proof-of-possession verifier to use
@@ -224,13 +197,12 @@ public final class BcX509CredentialFramework implements CredentialFramework {
* possession verifier * possession verifier
* @throws NullPointerException if any argument is {@code null} * @throws NullPointerException if any argument is {@code null}
*/ */
public BcX509CredentialFramework wired(BcX509CredentialIssuerBackend issuerBackend, public BcX509CredentialFramework wired(BcX509StatusObjectGenerator statusObjectGenerator,
BcX509StatusObjectGenerator statusObjectGenerator, ProofOfPossessionVerifier proofOfPossessionVerifier) { ProofOfPossessionVerifier proofOfPossessionVerifier) {
Objects.requireNonNull(issuerBackend, "issuerBackend");
Objects.requireNonNull(statusObjectGenerator, "statusObjectGenerator"); Objects.requireNonNull(statusObjectGenerator, "statusObjectGenerator");
Objects.requireNonNull(proofOfPossessionVerifier, "proofOfPossessionVerifier"); Objects.requireNonNull(proofOfPossessionVerifier, "proofOfPossessionVerifier");
return new BcX509CredentialFramework(requestParser, proofOfPossessionVerifier, issuerBackend, return new BcX509CredentialFramework(requestParser, proofOfPossessionVerifier, statusObjectGenerator,
statusObjectGenerator, attributeMapper); attributeMapper);
} }
/** /**
@@ -263,21 +235,6 @@ public final class BcX509CredentialFramework implements CredentialFramework {
return popVerifier; return popVerifier;
} }
/**
* Returns the credential issuance backend used by this framework instance.
*
* <p>
* For a default partially wired instance, this may be an internal placeholder
* backend that fails explicitly with {@link UnsupportedOperationException}.
* </p>
*
* @return credential issuance backend, never {@code null}
*/
@Override
public CredentialIssuerBackend issuerBackend() {
return issuer;
}
/** /**
* Returns the status-object generator used by this framework instance. * Returns the status-object generator used by this framework instance.
* *
@@ -304,46 +261,6 @@ public final class BcX509CredentialFramework implements CredentialFramework {
return attributeMapper; return attributeMapper;
} }
/**
* Placeholder issuance backend used by partially wired framework instances.
*
* <p>
* 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.
* </p>
*/
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 * Placeholder status-object generator used by partially wired framework
* instances. * instances.

View File

@@ -61,11 +61,11 @@ import zeroecho.pki.api.Validity;
import zeroecho.pki.api.attr.AttributeId; import zeroecho.pki.api.attr.AttributeId;
import zeroecho.pki.api.attr.AttributeSet; import zeroecho.pki.api.attr.AttributeSet;
import zeroecho.pki.api.attr.AttributeValue; import zeroecho.pki.api.attr.AttributeValue;
import zeroecho.pki.api.ca.IntermediateCertIssueCommand;
import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialBundle; import zeroecho.pki.api.credential.CredentialBundle;
import zeroecho.pki.api.credential.CredentialStatus; 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.async.PkiSigningBus;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
import zeroecho.pki.spi.framework.CredentialIssuerBackend; import zeroecho.pki.spi.framework.CredentialIssuerBackend;
@@ -88,17 +88,15 @@ import zeroecho.pki.spi.framework.CredentialIssuerBackend;
* <ul> * <ul>
* <li>{@link BcX509Attributes#ISSUER_CERT_DER},</li> * <li>{@link BcX509Attributes#ISSUER_CERT_DER},</li>
* <li>{@link BcX509Attributes#ISSUER_KEYREF},</li> * <li>{@link BcX509Attributes#ISSUER_KEYREF},</li>
* <li>optionally {@link BcX509Attributes#SERIAL},</li> * <li>optionally {@link BcX509Attributes#SERIAL}.</li>
* <li>optionally {@link BcX509Attributes#SUBJECT_SPKI_DER} for intermediate
* issuance.</li>
* </ul> * </ul>
* *
* <p> * <p>
* End-entity issuance primarily derives the subject distinguished name and * End-entity issuance primarily derives the subject distinguished name and
* subject public key information from the parsed certification request * subject public key information from the proof-gated
* contained in {@link IssueEndEntityCommand}. Intermediate CA issuance relies * {@link VerifiedIssuanceCandidate}. Intermediate CA issuance relies on its
* more heavily on framework attributes for subject wiring because it operates * proof-gated managed-CA input and framework attributes because it operates on
* on an existing CA subject entity rather than a CSR-centric flow. * an existing CA subject entity rather than a CSR-centric flow.
* </p> * </p>
* *
* <h2>Signing model</h2> * <h2>Signing model</h2>
@@ -124,10 +122,10 @@ import zeroecho.pki.spi.framework.CredentialIssuerBackend;
* supplied {@link PkiSigningBus} is safe for the intended runtime model. * supplied {@link PkiSigningBus} is safe for the intended runtime model.
* </p> * </p>
*/ */
// PMD cannot infer that retaining backend causes would violate the redaction contract.
@SuppressWarnings("PMD.PreserveStackTrace")
public final class BcX509CredentialIssuerBackend implements CredentialIssuerBackend { 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 PkiSigningBus signingBus;
private final String signatureAlgorithmId; private final String signatureAlgorithmId;
private final Duration signingTtl; private final Duration signingTtl;
@@ -165,13 +163,15 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
* *
* <p> * <p>
* The method derives issuer wiring from the supplied overrides, uses the parsed * 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 * leaf certificate with basic end-entity extensions, delegates signing through
* {@link PkiSigningBus}, and returns the resulting leaf credential bundled with * {@link PkiSigningBus}, and returns the resulting leaf credential bundled with
* the issuer certificate. * the issuer certificate.
* </p> * </p>
* *
* @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 * @return issued X.509 credential bundle containing the leaf certificate and
* the issuer certificate as accompanying bundle material * the issuer certificate as accompanying bundle material
* @throws IllegalArgumentException if {@code command} is {@code null} * @throws IllegalArgumentException if {@code command} is {@code null}
@@ -180,24 +180,25 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
* fails, or certificate encoding fails * fails, or certificate encoding fails
*/ */
@Override @Override
public CredentialBundle issueEndEntity(IssueEndEntityCommand command) { public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) {
if (command == null) { if (candidate == null) {
throw new IllegalArgumentException("command must not be 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; X509CertificateHolder issuer = ctx.issuerCertHolder;
zeroecho.pki.api.request.ParsedCertificationRequest request = candidate.request();
Instant now = Instant.now(); Instant now = Instant.now();
Validity validity = command.validityOverride().orElseGet( Validity validity = candidate.validityOverride().orElseGet(
() -> command.request().requestedValidity().orElse(new Validity(now, now.plus(Duration.ofDays(365))))); () -> request.requestedValidity().orElse(new Validity(now, now.plus(Duration.ofDays(365)))));
BigInteger serial = ctx.serial 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 issuerDn = issuer.getSubject();
X500Name subjectDn = new X500Name(command.request().subjectRef().value()); X500Name subjectDn = new X500Name(request.subjectRef().value());
SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(command.request().publicKeyInfo().bytes()); SubjectPublicKeyInfo spki = parseSubjectPublicKeyInfo(candidate.exactPublicKey());
X509v3CertificateBuilder builder = new X509v3CertificateBuilder(issuerDn, serial, X509v3CertificateBuilder builder = new X509v3CertificateBuilder(issuerDn, serial,
Date.from(validity.notBefore()), Date.from(validity.notAfter()), subjectDn, spki); 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, builder.addExtension(Extension.keyUsage, true,
new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment)); new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment));
} catch (Exception ex) { } 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); ContentSigner signer = new PkiBusContentSigner(signingBus, ctx.issuerKeyRef, signatureAlgorithmId, signingTtl);
@@ -214,27 +215,31 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
try { try {
leaf = builder.build(signer); leaf = builder.build(signer);
} catch (Exception ex) { } catch (Exception ex) {
throw new PkiException("Certificate signing failed", ex); throw new PkiException("Certificate signing failed: code=CERTIFICATE_SIGN_FAILED");
} }
byte[] certDer; byte[] certDer;
try { try {
certDer = leaf.getEncoded(); certDer = leaf.getEncoded();
} catch (Exception ex) { } 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 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, try {
new IssuerRef(command.issuerCaId()), command.request().subjectRef(), validity, serial.toString(), Credential credential = new Credential(credId, BcX509CredentialFramework.FORMAT_ID,
publicKeyId, command.profileId(), CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, certDer), new IssuerRef(candidate.issuerCaId()), request.subjectRef(), validity, serial.toString(),
attributes); publicKeyId, candidate.profileId(), CredentialStatus.ISSUED,
new EncodedObject(Encoding.DER, certDer),
return new CredentialBundle(credential, java.util.List.of(ctx.issuerCertEncoded)); 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. * credential.
* </p> * </p>
* *
* @param command intermediate CA certificate issuance command; must not be * @param issuance gate-produced managed CA issuance authority; must not be
* {@code null} * {@code null}
* @return issued intermediate CA credential * @return issued intermediate CA credential
* @throws IllegalArgumentException if {@code command} is {@code null} or uses * @throws IllegalArgumentException if {@code command} is {@code null} or uses
* an unsupported format identifier * an unsupported format identifier
@@ -258,34 +263,40 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
* encoding fails * encoding fails
*/ */
@Override @Override
public Credential issueIntermediateCertificate(IntermediateCertIssueCommand command) { public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) {
if (command == null) { if (issuance == null) {
throw new IllegalArgumentException("command must not be 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"); throw new IllegalArgumentException("Unsupported formatId");
} }
IssuanceContext ctx = IssuanceContext.from(command.attributes()); IssuanceContext ctx = IssuanceContext.from(issuance.attributes());
X509CertificateHolder issuer = ctx.issuerCertHolder; X509CertificateHolder issuer = ctx.issuerCertHolder;
byte[] subjectSpki = ctx.subjectSpkiDer.orElseThrow(() -> new PkiException("Missing subject SPKI")); byte[] subjectSpki = issuance.exactPublicKey().bytes();
SubjectRef subjectRef = ctx.subjectRef.orElseThrow(() -> new PkiException("Missing subjectRef")); 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(); 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()))); BigInteger serial = ctx.serial.orElse(BigInteger.valueOf(Math.abs(System.nanoTime())));
X500Name issuerDn = issuer.getSubject(); X500Name issuerDn = issuer.getSubject();
X500Name subjectDn = new X500Name(subjectRef.value()); X500Name subjectDn = new X500Name(subjectRef.value());
SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(subjectSpki);
X509v3CertificateBuilder builder = new X509v3CertificateBuilder(issuerDn, serial, X509v3CertificateBuilder builder = new X509v3CertificateBuilder(issuerDn, serial,
Date.from(validity.notBefore()), Date.from(validity.notAfter()), subjectDn, spki); Date.from(validity.notBefore()), Date.from(validity.notAfter()), subjectDn, spki);
try { try {
builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(0)); builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(0));
builder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign)); builder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign));
} catch (Exception ex) { } 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); ContentSigner signer = new PkiBusContentSigner(signingBus, ctx.issuerKeyRef, signatureAlgorithmId, signingTtl);
@@ -293,22 +304,25 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
try { try {
certificate = builder.build(signer); certificate = builder.build(signer);
} catch (Exception ex) { } catch (Exception ex) {
throw new PkiException("Certificate signing failed", ex); throw new PkiException("Certificate signing failed: code=CERTIFICATE_SIGN_FAILED");
} }
byte[] certDer; byte[] certDer;
try { try {
certDer = certificate.getEncoded(); certDer = certificate.getEncoded();
} catch (Exception ex) { } 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 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, try {
serial.toString(), publicKeyId, command.profileId(), CredentialStatus.ISSUED, return new Credential(credId, issuance.formatId(), new IssuerRef(issuance.issuerCaId()), subjectRef,
new EncodedObject(Encoding.DER, certDer), command.attributes()); 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 EncodedObject issuerCertEncoded;
private final KeyRef issuerKeyRef; private final KeyRef issuerKeyRef;
private final Optional<BigInteger> serial; private final Optional<BigInteger> serial;
private final Optional<byte[]> subjectSpkiDer;
private final Optional<SubjectRef> subjectRef;
/** /**
* Creates the issuance context. * Creates the issuance context.
@@ -341,20 +353,13 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
* @param issuerKeyRef issuer signing key reference; must not be * @param issuerKeyRef issuer signing key reference; must not be
* {@code null} * {@code null}
* @param serial optional serial override; 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, private IssuanceContext(X509CertificateHolder issuerCertHolder, EncodedObject issuerCertEncoded,
KeyRef issuerKeyRef, Optional<BigInteger> serial, Optional<byte[]> subjectSpkiDer, KeyRef issuerKeyRef, Optional<BigInteger> serial) {
Optional<SubjectRef> subjectRef) {
this.issuerCertHolder = issuerCertHolder; this.issuerCertHolder = issuerCertHolder;
this.issuerCertEncoded = issuerCertEncoded; this.issuerCertEncoded = issuerCertEncoded;
this.issuerKeyRef = issuerKeyRef; this.issuerKeyRef = issuerKeyRef;
this.serial = serial; this.serial = serial;
this.subjectSpkiDer = subjectSpkiDer;
this.subjectRef = subjectRef;
} }
/** /**
@@ -376,10 +381,6 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
* <ul> * <ul>
* <li>{@link BcX509Attributes#SERIAL} as * <li>{@link BcX509Attributes#SERIAL} as
* {@link AttributeValue.IntegerValue}</li> * {@link AttributeValue.IntegerValue}</li>
* <li>{@link BcX509Attributes#SUBJECT_SPKI_DER} as
* {@link AttributeValue.BytesValue}</li>
* <li>{@code urn:zeroecho:pki:x509:subject:dn} as
* {@link AttributeValue.StringValue}</li>
* </ul> * </ul>
* *
* @param attrs source attribute set; must not be {@code null} * @param attrs source attribute set; must not be {@code null}
@@ -401,15 +402,11 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
Optional<BigInteger> serial = optionalInteger(attrs, BcX509Attributes.SERIAL, "Serial must be IntegerValue") Optional<BigInteger> serial = optionalInteger(attrs, BcX509Attributes.SERIAL, "Serial must be IntegerValue")
.map(BigInteger::valueOf); .map(BigInteger::valueOf);
Optional<byte[]> subjectSpkiDer = optionalBytes(attrs, BcX509Attributes.SUBJECT_SPKI_DER,
"Subject SPKI must be BytesValue");
Optional<SubjectRef> subjectRef = optionalString(attrs, SUBJECT_DN, "Subject DN must be StringValue")
.map(SubjectRef::new);
X509CertificateHolder issuerHolder = parseIssuerCertificateOrThrow(issuerCertDer); X509CertificateHolder issuerHolder = parseIssuerCertificateOrThrow(issuerCertDer);
EncodedObject issuerEncoded = new EncodedObject(Encoding.DER, 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()); 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<byte[]> optionalBytes(AttributeSet attrs, AttributeId id, String typeMessage) {
Optional<AttributeValue> 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<String> optionalString(AttributeSet attrs, AttributeId id, String typeMessage) {
Optional<AttributeValue> 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. * Parses the issuer certificate DER into an X.509 certificate holder.
* *
@@ -531,7 +484,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
try { try {
return new X509CertificateHolder(issuerCertDer); return new X509CertificateHolder(issuerCertDer);
} catch (Exception ex) { } 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) { private static String sha256Hex(byte[] in) {
try { try {
MessageDigest md = MessageDigest.getInstance("SHA-256"); 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) { } 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);
} }
} }
} }

View File

@@ -38,6 +38,7 @@ import java.util.Optional;
import org.bouncycastle.operator.ContentVerifierProvider; import org.bouncycastle.operator.ContentVerifierProvider;
import org.bouncycastle.operator.OperatorCreationException; import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder; import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.pkcs.PKCS10CertificationRequest; import org.bouncycastle.pkcs.PKCS10CertificationRequest;
import zeroecho.pki.api.attr.AttributeValue; import zeroecho.pki.api.attr.AttributeValue;
@@ -93,6 +94,9 @@ import zeroecho.pki.spi.framework.ProofOfPossessionVerifier;
* authorization checks, or profile compliance checks.</li> * authorization checks, or profile compliance checks.</li>
* <li>The presence of a valid PKCS#10 self-signature does not by itself imply * <li>The presence of a valid PKCS#10 self-signature does not by itself imply
* that the requester is entitled to receive the requested certificate.</li> * that the requester is entitled to receive the requested certificate.</li>
* <li>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.</li>
* <li>The CSR payload carried under {@link BcX509Attributes#CSR_DER} may be * <li>The CSR payload carried under {@link BcX509Attributes#CSR_DER} may be
* operationally sensitive and must not be logged unsafely.</li> * operationally sensitive and must not be logged unsafely.</li>
* </ul> * </ul>
@@ -104,6 +108,8 @@ import zeroecho.pki.spi.framework.ProofOfPossessionVerifier;
*/ */
public final class BcX509ProofOfPossessionVerifier implements 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. * Verifies proof of possession for a parsed PKCS#10 certification request.
* *
@@ -177,7 +183,8 @@ public final class BcX509ProofOfPossessionVerifier implements ProofOfPossessionV
} catch (Exception ex) { } catch (Exception ex) {
return new ProofOfPossessionResult(ProofOfPossessionStatus.FAILED, Optional.of("Invalid CSR")); 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); boolean ok = csr.isSignatureValid(cvp);
if (ok) { if (ok) {
return new ProofOfPossessionResult(ProofOfPossessionStatus.VERIFIED, Optional.empty()); return new ProofOfPossessionResult(ProofOfPossessionStatus.VERIFIED, Optional.empty());

View File

@@ -37,6 +37,7 @@ import java.io.ByteArrayOutputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Arrays;
import java.util.Optional; import java.util.Optional;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier; import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
@@ -86,9 +87,9 @@ import zeroecho.pki.util.async.AsyncState;
* <li>The signer polls the bus by repeatedly calling * <li>The signer polls the bus by repeatedly calling
* {@link PkiSigningBus#sweep(Instant)} and {@link PkiSigningBus#status(PkiId)} * {@link PkiSigningBus#sweep(Instant)} and {@link PkiSigningBus#status(PkiId)}
* until the workflow succeeds, fails, or times out.</li> * until the workflow succeeds, fails, or times out.</li>
* <li>On successful completion or explicit failure, persisted workflow state is * <li>Every submitted operation is retired through
* deleted through {@link PkiSigningBus#deleteWorkflowState(PkiId)} before the * {@link PkiSigningBus#retireSignOperation(PkiId, String)} before the method
* method returns or throws.</li> * returns or throws.</li>
* </ul> * </ul>
* *
* <h2>Security considerations</h2> * <h2>Security considerations</h2>
@@ -108,6 +109,8 @@ import zeroecho.pki.util.async.AsyncState;
* intended for one certificate or CRL signing flow. * intended for one certificate or CRL signing flow.
* </p> * </p>
*/ */
// PMD cannot infer that retaining provider causes would violate the redaction contract.
@SuppressWarnings("PMD.PreserveStackTrace")
public final class PkiBusContentSigner implements ContentSigner { public final class PkiBusContentSigner implements ContentSigner {
private final PkiSigningBus bus; private final PkiSigningBus bus;
@@ -115,7 +118,7 @@ public final class PkiBusContentSigner implements ContentSigner {
private final String algorithmId; private final String algorithmId;
private final Duration ttl; private final Duration ttl;
private final ByteArrayOutputStream baos; private final WipeableByteArrayOutputStream baos;
/** /**
* Creates a signer that routes signature generation through the PKI signing * 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.keyRef = keyRef;
this.algorithmId = algorithmId; this.algorithmId = algorithmId;
this.ttl = ttl; this.ttl = ttl;
this.baos = new ByteArrayOutputStream(); this.baos = new WipeableByteArrayOutputStream();
} }
/** /**
@@ -203,8 +206,10 @@ public final class PkiBusContentSigner implements ContentSigner {
* <p> * <p>
* On successful completion, the signature bytes contained in the workflow * On successful completion, the signature bytes contained in the workflow
* result are returned. If the workflow reports success but no result is * result are returned. If the workflow reports success but no result is
* available, the method fails explicitly. On terminal success or failure, the * available, the method fails explicitly. Every operation for which submission
* persisted workflow state is deleted before the method returns or throws. * 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.
* </p> * </p>
* *
* <p> * <p>
@@ -219,38 +224,105 @@ public final class PkiBusContentSigner implements ContentSigner {
* complete within the configured TTL * complete within the configured TTL
*/ */
@Override @Override
@SuppressWarnings("PMD.AvoidCatchingGenericException")
public byte[] getSignature() { public byte[] getSignature() {
byte[] tbs = baos.toByteArray(); 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"); AccessContext ac = new AccessContext(owner, new Purpose("X509_SIGN"), Optional.empty(),
PkiId clientOpId = new PkiId("sign:" + Integer.toUnsignedString(System.identityHashCode(this)) + ":" Optional.empty());
+ Long.toUnsignedString(System.nanoTime())); SignContinuation cont = new SignContinuation(ac, algorithmId, payload, keyRef, Encoding.BINARY,
PkiId opId = bus.canonicalizeOperationId(clientOpId, owner); Optional.empty());
AccessContext ac = new AccessContext(owner, new Purpose("X509_SIGN"), Optional.empty(), Optional.empty()); retirementRequired = true;
SignContinuation cont = new SignContinuation(ac, algorithmId, payload, keyRef, Encoding.BINARY, bus.submitSign(opId, owner, keyRef, algorithmId, payload, ttl, Optional.of(cont.encode()));
Optional.empty()); consumedResult = awaitSignature(opId);
return consumedResult.clone();
bus.submitSign(opId, owner, keyRef, algorithmId, payload, ttl, Optional.of(cont.encode())); } 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); Instant deadline = Instant.now().plus(ttl);
while (Instant.now().isBefore(deadline)) { while (Instant.now().isBefore(deadline)) {
bus.sweep(Instant.now()); bus.sweep(Instant.now());
Optional<zeroecho.pki.util.async.AsyncStatus> st = bus.status(opId); Optional<zeroecho.pki.util.async.AsyncStatus> status = bus.status(opId);
if (st.isPresent() && st.get().state() == AsyncState.SUCCEEDED) { if (status.isPresent() && status.get().state() == AsyncState.SUCCEEDED) {
Optional<EncodedObject> res = bus.consumeResult(opId); Optional<EncodedObject> result = bus.consumeResult(opId);
bus.deleteWorkflowState(opId); if (result.isEmpty()) {
if (res.isEmpty()) {
throw new PkiException("Missing signature result"); throw new PkiException("Missing signature result");
} }
return res.get().bytes(); return result.get().bytes();
} }
if (st.isPresent() && st.get().state() == AsyncState.FAILED) { if (status.isPresent() && isTerminalFailure(status.get().state())) {
bus.deleteWorkflowState(opId); throw new PkiException("Signing failed: " + status.get().state());
throw new PkiException("Signing failed");
} }
boundedWait(deadline);
} }
throw new PkiException("Signing did not complete before TTL"); 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();
}
}
} }

View File

@@ -35,25 +35,42 @@ package zeroecho.pki.impl.fs;
import java.io.Closeable; import java.io.Closeable;
import java.io.IOException; import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel; 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.FileAlreadyExistsException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption; 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.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator; import java.util.Comparator;
import java.util.HexFormat;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; 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.AtomicLong;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.orch.SigningSubmissionId;
import zeroecho.pki.api.orch.WorkflowStateRecord; import zeroecho.pki.api.orch.WorkflowStateRecord;
import zeroecho.pki.api.policy.PolicyTrace; import zeroecho.pki.api.policy.PolicyTrace;
import zeroecho.pki.api.profile.CertificateProfile; 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.request.ParsedCertificationRequest;
import zeroecho.pki.api.revocation.RevokedRecord; import zeroecho.pki.api.revocation.RevokedRecord;
import zeroecho.pki.api.status.StatusObject; import zeroecho.pki.api.status.StatusObject;
import zeroecho.pki.impl.core.async.PkiSigningBus;
import zeroecho.pki.spi.store.PkiStore; import zeroecho.pki.spi.store.PkiStore;
import zeroecho.pki.spi.store.SignWorkflowStore;
/** /**
* Filesystem-based reference implementation of {@link PkiStore}. * 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. * are limited to object type, safe IDs, and file operation outcomes.
* </p> * </p>
*/ */
@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity", "PMD.TooManyMethods" })
public final class FilesystemPkiStore implements PkiStore, Closeable { public final class FilesystemPkiStore implements PkiStore, Closeable {
private static final Logger LOG = Logger.getLogger(FilesystemPkiStore.class.getName()); private static final Logger LOG = Logger.getLogger(FilesystemPkiStore.class.getName());
private static final String VERSION_V1 = "v1"; 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 FsPkiStoreOptions options;
private final FsPaths paths; private final FsPaths paths;
private final AtomicLong historySeq; private final AtomicLong historySeq;
private final Clock clock;
private final String signingNamespace;
private final AtomicLong signingTimeWatermark;
private final ReentrantLock signingTimeLock;
private final ConcurrentMap<PkiId, SignLockEntry> signLocks;
private final FileChannel lockChannel; private final StoreOwnership ownership;
/** /**
* Opens or creates a filesystem PKI store rooted at {@code root}. * 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 * @throws IllegalStateException if the store cannot be opened or locked
*/ */
public FilesystemPkiStore(final Path root, final FsPkiStoreOptions options) { 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"); this.options = Objects.requireNonNull(options, "options");
Objects.requireNonNull(root, "root"); 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 { try {
FsOperations.ensureDir(root); FsOperations.ensureDir(root);
this.paths = new FsPaths(root);
FsOperations.ensureDir(this.paths.lockFile().getParent()); 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, boolean ownershipTransferred = false;
StandardOpenOption.WRITE); try {
lockChannel.lock(); // exclusive lock
ensureVersionFile(); ensureVersionFile();
this.signingNamespace = ensureSigningNamespace();
this.signingTimeWatermark = new AtomicLong(loadSigningTimeWatermark());
this.historySeq = new AtomicLong(0L); this.historySeq = new AtomicLong(0L);
LOG.log(Level.INFO, "running in {0}", root); LOG.log(Level.INFO, "running in {0}", root);
this.ownership = acquiredOwnership;
ownershipTransferred = true;
} catch (IOException e) { } catch (IOException e) {
throw new IllegalStateException("failed to open filesystem store at " + root, 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 {
* <p> * <p>
* This method is an implementation-only feature. It does not modify the current * 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 * 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.
* </p> * </p>
* *
* @param targetRoot new store root to create/populate * @param targetRoot new store root to create/populate
@@ -369,11 +436,709 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
} }
@Override @Override
public void close() throws IOException { public Instant signingNow() {
synchronized (this.lockChannel) { // NOPMD signingTimeLock.lock();
if (this.lockChannel.isOpen()) { try {
this.lockChannel.close(); 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<SignWorkflowStore.Record> 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<SignWorkflowStore.Record> getSignRecord(PkiId submissionId) {
Objects.requireNonNull(submissionId, "submissionId");
SignLockEntry lock = acquireSignLock(submissionId);
try {
return readSignRecord(submissionId);
} finally {
releaseSignLock(submissionId, lock);
}
}
@Override
public List<SignWorkflowStore.Record> listSignRecords() {
Path root = paths.signWorkflowRoot();
if (!Files.isDirectory(root)) {
return List.of();
}
try (java.util.stream.Stream<Path> 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<SignWorkflowStore.Record> tryClaimSign(PkiId submissionId, long expectedRevision,
Duration lease) {
requirePositive(lease, "lease");
SignLockEntry lock = acquireSignLock(submissionId);
try {
Optional<SignWorkflowStore.Record> 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<SignWorkflowStore.Record> renewSignClaim(PkiId submissionId, long expectedRevision, long fence,
Duration lease) {
requirePositive(lease, "lease");
SignLockEntry lock = acquireSignLock(submissionId);
try {
Optional<SignWorkflowStore.Record> 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<SignWorkflowStore.Record> transitionSign(PkiId submissionId, long expectedRevision, long fence,
SignWorkflowStore.State target, Optional<String> detailCode, Optional<EncodedObject> result,
Optional<Instant> providerUpdatedAt) {
Objects.requireNonNull(target, "target");
Objects.requireNonNull(detailCode, "detailCode");
Objects.requireNonNull(result, "result");
Objects.requireNonNull(providerUpdatedAt, "providerUpdatedAt");
SignLockEntry lock = acquireSignLock(submissionId);
try {
Optional<SignWorkflowStore.Record> 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<SignWorkflowStore.Record> retireSign(PkiId submissionId, long expectedRevision, long fence) {
SignLockEntry lock = acquireSignLock(submissionId);
try {
Optional<SignWorkflowStore.Record> 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<SignWorkflowStore.Record> 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<SignWorkflowStore.Record> 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<Instant> leaseUntil,
Optional<String> detailCode, Optional<EncodedObject> result, Optional<Instant> 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<String> 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<String> 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.
*
* <p>
* 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}.
* </p>
*/
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);
} }
} }

View File

@@ -46,8 +46,10 @@ import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import zeroecho.core.io.Util; import zeroecho.core.io.Util;
import zeroecho.pki.spi.store.SignWorkflowStore;
/** /**
* Compact binary codec for filesystem persistence. * Compact binary codec for filesystem persistence.
@@ -82,7 +84,7 @@ import zeroecho.core.io.Util;
@SuppressWarnings("PMD.CyclomaticComplexity") @SuppressWarnings("PMD.CyclomaticComplexity")
final class FsCodec { 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<?>, Class<?>> PRIMITIVE_TO_WRAPPER = Map.of(boolean.class, Boolean.class, byte.class, private static final Map<Class<?>, 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, Byte.class, short.class, Short.class, int.class, Integer.class, long.class, Long.class, char.class,
@@ -135,6 +137,9 @@ final class FsCodec {
try { try {
ByteArrayInputStream bis = new ByteArrayInputStream(data); ByteArrayInputStream bis = new ByteArrayInputStream(data);
Object decoded = readAny(bis, expectedType); Object decoded = readAny(bis, expectedType);
if (bis.available() != 0) {
throw new IllegalStateException("trailing data after " + expectedType.getName());
}
return expectedType.cast(decoded); return expectedType.cast(decoded);
} catch (IOException e) { } catch (IOException e) {
throw new IllegalStateException("decoding failed: " + expectedType.getName(), e); throw new IllegalStateException("decoding failed: " + expectedType.getName(), e);
@@ -157,7 +162,7 @@ final class FsCodec {
case Duration duration -> writeDurationCompact(out, duration); case Duration duration -> writeDurationCompact(out, duration);
case java.util.List<?> list -> writeListCompact(out, list); case java.util.List<?> list -> writeListCompact(out, list);
case java.util.Set<?> set -> writeSetCompact(out, set); 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); 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 { throws IOException {
out.write(TAG_OPTIONAL); out.write(TAG_OPTIONAL);
if (optional.isPresent()) { if (optional.isPresent()) {
@@ -273,7 +278,10 @@ final class FsCodec {
out.write(TAG_ENUM); out.write(TAG_ENUM);
Util.writeUTF8(out, type.getName()); Util.writeUTF8(out, type.getName());
Enum<?> enumValue = (Enum<?>) value; 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) 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 { 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 { 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 { 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 { 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 { 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); Class<?> enumType = loadClass(enumTypeName);
if (!enumType.isEnum()) { if (!enumType.isEnum()) {
throw new IllegalStateException("encoded enum type is not an enum: " + enumTypeName); throw new IllegalStateException("encoded enum type is not an enum: " + enumTypeName);
} }
int ordinal = Util.readPack7I(in); 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(); Object[] constants = enumType.getEnumConstants();
if (constants == null || ordinal < 0 || ordinal >= constants.length) { if (constants == null || ordinal < 0 || ordinal >= constants.length) {
throw new IllegalStateException("invalid enum ordinal " + ordinal + " for " + enumTypeName); 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 { 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); Class<?> recordType = loadClass(recordTypeName);
if (!recordType.isRecord()) { if (!recordType.isRecord()) {
throw new IllegalStateException("encoded record type is not a record: " + recordTypeName); 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 { private static Object readFallbackStringCompact(final InputStream in) throws IOException {
String typeName = Util.readUTF8(in, MAX_STRING_BYTES); String typeName = Util.readUTF8(in, MAX_COMPONENT_BYTES);
String value = Util.readUTF8(in, MAX_STRING_BYTES); String value = Util.readUTF8(in, MAX_COMPONENT_BYTES);
Class<?> type = loadClass(typeName); Class<?> type = loadClass(typeName);
Method stringFactory = findStringFactory(type); Method stringFactory = findStringFactory(type);
@@ -502,10 +517,10 @@ final class FsCodec {
throw new IOException("unexpected EOF"); throw new IOException("unexpected EOF");
} }
if (present == 0) { if (present == 0) {
return java.util.Optional.empty(); return Optional.empty();
} }
Object element = readAny(in, Object.class); 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) { private static boolean isTypeCompatible(final Class<?> expectedType, final Class<?> actualType) {
@@ -559,4 +574,4 @@ final class FsCodec {
return null; return null;
} }
} }

View File

@@ -79,6 +79,14 @@ final class FsPaths {
return this.root.resolve(VERSION_FILE); 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() { /* default */ Path lockFile() {
return this.root.resolve(LOCK_DIR).resolve(STORE_LOCK); 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"); 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) // Revocations (mutable with history)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

@@ -60,9 +60,13 @@ import java.util.Optional;
* @param workflowHistoryPolicy history policy for workflow continuation state * @param workflowHistoryPolicy history policy for workflow continuation state
* @param strictSnapshotExport whether snapshot export is strict * @param strictSnapshotExport whether snapshot export is strict
* (fail-closed) * (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, 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. * Canonical constructor with validation.
@@ -74,6 +78,14 @@ public record FsPkiStoreOptions(FsHistoryPolicy caHistoryPolicy, FsHistoryPolicy
Objects.requireNonNull(profileHistoryPolicy, "profileHistoryPolicy"); Objects.requireNonNull(profileHistoryPolicy, "profileHistoryPolicy");
Objects.requireNonNull(revocationHistoryPolicy, "revocationHistoryPolicy"); Objects.requireNonNull(revocationHistoryPolicy, "revocationHistoryPolicy");
Objects.requireNonNull(workflowHistoryPolicy, "workflowHistoryPolicy"); 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() { public static FsPkiStoreOptions defaults() {
FsHistoryPolicy ninetyDays = FsHistoryPolicy.onWrite(Optional.of(Duration.ofDays(90))); 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);
} }
} }

View File

@@ -60,6 +60,11 @@ import java.util.logging.Logger;
* to {@code current.bin} only when strict mode is disabled.</li> * to {@code current.bin} only when strict mode is disabled.</li>
* <li>Write-once objects are copied as-is (they are immutable). This exporter * <li>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.</li> * does not attempt to prune them by time unless an upstream index exists.</li>
* <li>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.</li>
* </ul> * </ul>
*/ */
final class FsSnapshotExporter { final class FsSnapshotExporter {
@@ -82,6 +87,8 @@ final class FsSnapshotExporter {
FsPaths dst = new FsPaths(targetRoot); FsPaths dst = new FsPaths(targetRoot);
Files.writeString(dst.versionFile(), "v1"); 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) // copy write-once trees as-is (best-effort, deterministic order)
copyTreeIfExists(sourceRoot.resolve("credentials"), targetRoot.resolve("credentials")); copyTreeIfExists(sourceRoot.resolve("credentials"), targetRoot.resolve("credentials"));
@@ -89,6 +96,7 @@ final class FsSnapshotExporter {
copyTreeIfExists(sourceRoot.resolve("status"), targetRoot.resolve("status")); copyTreeIfExists(sourceRoot.resolve("status"), targetRoot.resolve("status"));
copyTreeIfExists(sourceRoot.resolve("policy"), targetRoot.resolve("policy")); copyTreeIfExists(sourceRoot.resolve("policy"), targetRoot.resolve("policy"));
copyTreeIfExists(sourceRoot.resolve("publications"), targetRoot.resolve("publications")); copyTreeIfExists(sourceRoot.resolve("publications"), targetRoot.resolve("publications"));
copyTreeIfExists(sourceRoot.resolve("sign-workflows"), targetRoot.resolve("sign-workflows"));
// reconstruct mutable entities from history (CAS, PROFILES, REVOCATIONS) // reconstruct mutable entities from history (CAS, PROFILES, REVOCATIONS)
reconstructMutableTree(sourceRoot.resolve("cas"), targetRoot.resolve("cas"), at, 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));
}
} }

View File

@@ -34,10 +34,18 @@
package zeroecho.pki.spi.crypto; package zeroecho.pki.spi.crypto;
import java.io.Closeable; 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.time.Instant;
import java.util.HexFormat;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.function.Consumer;
import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding; import zeroecho.pki.api.Encoding;
@@ -83,6 +91,8 @@ import zeroecho.pki.api.audit.AccessContext;
* audit logs and API responses.</li> * audit logs and API responses.</li>
* </ul> * </ul>
*/ */
// PMD cannot infer that retaining digest/encoding causes would violate the redaction contract.
@SuppressWarnings("PMD.PreserveStackTrace")
public interface SignatureWorkflow extends Closeable { public interface SignatureWorkflow extends Closeable {
/** /**
@@ -92,6 +102,30 @@ public interface SignatureWorkflow extends Closeable {
*/ */
String id(); String id();
/**
* Validates the authoritative signing domain selected by orchestration.
*
* <p>
* 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()}.
* </p>
*
* @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. * Submits a signing request.
* *
@@ -100,13 +134,37 @@ public interface SignatureWorkflow extends Closeable {
* (encoded) on success. * (encoded) on success.
* </p> * </p>
* *
* <p>
* 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.
* </p>
*
* <p>
* {@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.
* </p>
*
* <h4>Failure model (normative)</h4> * <h4>Failure model (normative)</h4>
* <ul> * <ul>
* <li>For validation and policy failures: do not throw; return operation id and * <li>For validation and policy failures: do not throw; return operation id and
* expose failure via {@link #status(PkiId)} with {@code FAILED} and stable * expose failure via {@link #status(PkiId)} with {@code FAILED} and stable
* {@code detailCode}.</li> * {@code detailCode}.</li>
* <li>May throw {@link IllegalArgumentException} only for programmer errors * <li>Throws {@link IllegalStateException} for identifier/fingerprint conflicts
* (e.g., {@code request == null}).</li> * or stale fencing tokens.</li>
* <li>May throw {@link IllegalArgumentException} for malformed, foreign, or
* expired identifiers and programmer errors.</li>
* </ul> * </ul>
* *
* @param request request (never {@code null}) * @param request request (never {@code null})
@@ -152,11 +210,18 @@ public interface SignatureWorkflow extends Closeable {
/** /**
* Best-effort cancellation. * Best-effort cancellation.
* *
* <p>
* 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.
* </p>
*
* @param operationId operation id (never {@code null}) * @param operationId operation id (never {@code null})
* @param reason non-sensitive reason (never blank) * @param reason non-sensitive reason (never blank)
* @return true if cancellation was accepted; false if already terminal/unknown * @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. * Registers a notification sink for status changes.
@@ -192,6 +257,10 @@ public interface SignatureWorkflow extends Closeable {
* {@link #submitSign(SignRequest)}. * {@link #submitSign(SignRequest)}.
* </p> * </p>
* *
* @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 * @param accessContext audit/governance context (never
* {@code null}) * {@code null})
* @param keyRef opaque reference to the private key (never * @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 preferredSignatureEncoding preferred signature encoding (optional)
* @param deadline optional absolute deadline * @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<Encoding> preferredSignatureEncoding, Optional<Instant> deadline) { Optional<Encoding> preferredSignatureEncoding, Optional<Instant> deadline) {
private static final long MIN_FENCING_TOKEN = 1L;
public SignRequest { public SignRequest {
Objects.requireNonNull(submissionId, "submissionId");
Objects.requireNonNull(namespace, "namespace");
Objects.requireNonNull(semanticFingerprint, "semanticFingerprint");
Objects.requireNonNull(accessContext, "accessContext"); Objects.requireNonNull(accessContext, "accessContext");
Objects.requireNonNull(keyRef, "keyRef"); Objects.requireNonNull(keyRef, "keyRef");
Objects.requireNonNull(algorithmId, "algorithmId"); Objects.requireNonNull(algorithmId, "algorithmId");
@@ -215,6 +290,103 @@ public interface SignatureWorkflow extends Closeable {
if (algorithmId.isBlank()) { if (algorithmId.isBlank()) {
throw new IllegalArgumentException("algorithmId must not be blank"); 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<Encoding> preferredSignatureEncoding, Optional<Instant> 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<Encoding> preferredSignatureEncoding,
Optional<Instant> 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<Encoding> preferredSignatureEncoding,
Optional<Instant> deadline, MessageDigest digest, Consumer<byte[]> 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<byte[]> 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. * {@code OperationStatus} must no longer change.
* </p> * </p>
* *
* <p>
* 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.
* </p>
*
* @param state current lifecycle state of the operation (never * @param state current lifecycle state of the operation (never
* {@code null}) * {@code null})
* @param updatedAt timestamp of the last state transition (never * @param updatedAt timestamp of the last state transition (never
@@ -306,6 +485,10 @@ public interface SignatureWorkflow extends Closeable {
Objects.requireNonNull(updatedAt, "updatedAt"); Objects.requireNonNull(updatedAt, "updatedAt");
Objects.requireNonNull(detailCode, "detailCode"); Objects.requireNonNull(detailCode, "detailCode");
Objects.requireNonNull(result, "result"); 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 * 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 * 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)}. * 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.
* </p> * </p>
*/ */
@FunctionalInterface @FunctionalInterface

View File

@@ -40,8 +40,9 @@ import zeroecho.pki.api.FormatId;
* *
* <p> * <p>
* A framework implementation provides request parsing, proof-of-possession * A framework implementation provides request parsing, proof-of-possession
* verification (if applicable), credential issuance backend, and status object * verification and status object generation for a particular {@link FormatId}.
* generation for a particular {@link FormatId}. * Credential minting is intentionally not exposed by this framework facade; core
* issuance services own that privileged implementation boundary.
* </p> * </p>
*/ */
public interface CredentialFramework { public interface CredentialFramework {
@@ -67,13 +68,6 @@ public interface CredentialFramework {
*/ */
ProofOfPossessionVerifier proofOfPossessionVerifier(); ProofOfPossessionVerifier proofOfPossessionVerifier();
/**
* Returns the issuer backend for this framework.
*
* @return issuer backend
*/
CredentialIssuerBackend issuerBackend();
/** /**
* Returns the status object generator for this framework. * Returns the status object generator for this framework.
* *

View File

@@ -33,10 +33,10 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.spi.framework; package zeroecho.pki.spi.framework;
import zeroecho.pki.api.ca.IntermediateCertIssueCommand;
import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialBundle; 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. * SPI contract for framework-specific credential issuance backends.
@@ -59,17 +59,19 @@ import zeroecho.pki.api.issuance.IssueEndEntityCommand;
* *
* <h2>Architectural role</h2> * <h2>Architectural role</h2>
* <ul> * <ul>
* <li>{@link CredentialFramework} selects the concrete backend appropriate for * <li>PKI runtime wiring injects the concrete backend into authoritative core
* a credential format.</li> * services without publishing it through {@link CredentialFramework}.</li>
* <li>PKI core services prepare validated issuance commands and call this * <li>PKI core services prepare opaque proof-gated issuance authorities and call this
* backend to obtain framework-specific credentials.</li> * backend to obtain framework-specific credentials.</li>
* <li>The backend is a privileged post-gate component whose method signatures
* cannot accept raw issuance commands.</li>
* <li>This backend performs format-specific credential assembly, not CA policy * <li>This backend performs format-specific credential assembly, not CA policy
* orchestration, lifecycle control, or long-term persistence.</li> * orchestration, lifecycle control, or long-term persistence.</li>
* </ul> * </ul>
* *
* <h2>Implementation expectations</h2> * <h2>Implementation expectations</h2>
* <ul> * <ul>
* <li>Implementations should validate that the supplied command contains the * <li>Implementations should validate that the supplied authority contains the
* framework-specific material required for issuance.</li> * framework-specific material required for issuance.</li>
* <li>Implementations should fail explicitly when mandatory issuer material, * <li>Implementations should fail explicitly when mandatory issuer material,
* subject material, or framework-specific overrides are missing or * subject material, or framework-specific overrides are missing or
@@ -103,21 +105,27 @@ public interface CredentialIssuerBackend {
* *
* <p> * <p>
* This operation produces a credential for a non-CA subject, typically from a * This operation produces a credential for a non-CA subject, typically from a
* validated certification request or equivalent subject input carried by the * cryptographically verified certification request carried by the opaque
* {@link IssueEndEntityCommand}. The returned {@link CredentialBundle} may * {@link VerifiedIssuanceCandidate}. The returned {@link CredentialBundle} may
* contain the issued leaf credential together with any additional runtime * contain the issued leaf credential together with any additional runtime
* bundle material defined by the concrete framework, such as chain elements or * bundle material defined by the concrete framework, such as chain elements or
* accompanying metadata. * accompanying metadata.
* </p> * </p>
* *
* <p> * <p>
* 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.
* </p>
*
* <p>
* The candidate carries all framework-specific issuance inputs
* required by the concrete implementation, including any issuer wiring * required by the concrete implementation, including any issuer wiring
* attributes, profile identifiers, validity overrides, and subject request * attributes, profile identifiers, validity overrides, and subject request
* material. The exact interpretation of those fields is framework-specific. * material. The exact interpretation of those fields is framework-specific.
* </p> * </p>
* *
* @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} * @return issued credential bundle, never {@code null}
* @throws IllegalArgumentException if {@code command} is {@code null} or * @throws IllegalArgumentException if {@code command} is {@code null} or
* structurally invalid for the concrete * structurally invalid for the concrete
@@ -126,7 +134,7 @@ public interface CredentialIssuerBackend {
* or other framework-specific issuance * or other framework-specific issuance
* processing fails * processing fails
*/ */
CredentialBundle issueEndEntity(IssueEndEntityCommand command); CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate);
/** /**
* Issues a CA credential for an existing CA subject entity. * Issues a CA credential for an existing CA subject entity.
@@ -139,12 +147,13 @@ public interface CredentialIssuerBackend {
* </p> * </p>
* *
* <p> * <p>
* The supplied {@link IntermediateCertIssueCommand} is expected to contain the * The supplied {@link ManagedCaIssuance} can be constructed only after the core
* issuer CA reference, subject CA reference, profile selection, and any * CA proof gate has completed a managed-key possession challenge and bound the
* framework-specific attributes needed to construct the CA credential. * exact public key, subject, operation, and authoritative attributes.
* </p> * </p>
* *
* @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} * @return issued CA credential, never {@code null}
* @throws IllegalArgumentException if {@code command} is {@code null} or * @throws IllegalArgumentException if {@code command} is {@code null} or
* structurally invalid for the concrete * structurally invalid for the concrete
@@ -153,5 +162,5 @@ public interface CredentialIssuerBackend {
* or other framework-specific issuance * or other framework-specific issuance
* processing fails * processing fails
*/ */
Credential issueIntermediateCertificate(IntermediateCertIssueCommand command); Credential issueIntermediateCertificate(ManagedCaIssuance issuance);
} }

View File

@@ -70,7 +70,7 @@ import zeroecho.pki.api.status.StatusObject;
* {@link IllegalStateException} when an operation cannot be completed safely. * {@link IllegalStateException} when an operation cannot be completed safely.
* </p> * </p>
*/ */
public interface PkiStore { public interface PkiStore extends SignWorkflowStore {
/** /**
* Persists or updates a Certificate Authority (CA) record. * Persists or updates a Certificate Authority (CA) record.

View File

@@ -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.
*
* <p>
* 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.
* </p>
*
* <p>
* 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.
* </p>
*/
public interface SignWorkflowStore {
/**
* Signing orchestration states.
*
* <p>
* {@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.
* </p>
*/
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<Instant> leaseUntil, Optional<String> detailCode, Optional<EncodedObject> result,
Optional<Instant> 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<Record> getSignRecord(PkiId submissionId);
/**
* Returns a stable snapshot of retained signing records.
*
* @return retained records
*/
List<Record> 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<Record> 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<Record> renewSignClaim(PkiId submissionId, long expectedRevision, long fence, Duration lease);
/**
* Atomically transitions state when revision and fence are current.
*
* <p>
* 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.
* </p>
*
* @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<Record> transitionSign(PkiId submissionId, long expectedRevision, long fence, State target,
Optional<String> detailCode, Optional<EncodedObject> result, Optional<Instant> 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<Record> 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();
}

View File

@@ -90,8 +90,8 @@ import zeroecho.pki.util.async.codec.ResultCodec;
* <li>{@link #update(Object, AsyncStatus, Optional)} persists a status * <li>{@link #update(Object, AsyncStatus, Optional)} persists a status
* transition, optionally persists a result, dispatches an event, and applies * transition, optionally persists a result, dispatches an event, and applies
* terminal-state cleanup rules.</li> * terminal-state cleanup rules.</li>
* <li>{@link #consumeResult(Object)} returns a successful result once and then * <li>{@link #consumeResult(Object)} returns a successful result once and
* deletes the operation from the in-memory state.</li> * durably tombstones the operation.</li>
* </ul> * </ul>
* *
* <h2>Durability semantics</h2> * <h2>Durability semantics</h2>
@@ -100,6 +100,8 @@ import zeroecho.pki.util.async.codec.ResultCodec;
* <li>Status transitions are persisted as internal {@code T1} records.</li> * <li>Status transitions are persisted as internal {@code T1} records.</li>
* <li>Results are persisted as internal {@code R1} records only when * <li>Results are persisted as internal {@code R1} records only when
* {@link ResultCodec#persistsResults()} returns {@code true}.</li> * {@link ResultCodec#persistsResults()} returns {@code true}.</li>
* <li>Consumption and explicit retirement are persisted as internal {@code D1}
* tombstones.</li>
* <li>The log format is internal to this implementation and versioned by the * <li>The log format is internal to this implementation and versioned by the
* leading record token.</li> * leading record token.</li>
* <li>Corrupted lines encountered during replay are ignored with a warning, and * <li>Corrupted lines encountered during replay are ignored with a warning, and
@@ -146,6 +148,8 @@ public final class DurableAsyncBus<OpId, Owner, EndpointId, Result> // NOPMD
private static final String REC_SNAPSHOT = "S1"; private static final String REC_SNAPSHOT = "S1";
private static final String REC_STATUS = "T1"; private static final String REC_STATUS = "T1";
private static final String REC_RESULT = "R1"; 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<OpId> opIdCodec; private final IdCodec<OpId> opIdCodec;
private final IdCodec<Owner> ownerCodec; private final IdCodec<Owner> ownerCodec;
@@ -285,7 +289,7 @@ public final class DurableAsyncBus<OpId, Owner, EndpointId, Result> // NOPMD
public AsyncOperationSnapshot<OpId, Owner, EndpointId> submit(OpId opId, String type, Owner owner, public AsyncOperationSnapshot<OpId, Owner, EndpointId> submit(OpId opId, String type, Owner owner,
EndpointId endpointId, Instant createdAt, Duration ttl) { EndpointId endpointId, Instant createdAt, Duration ttl) {
Objects.requireNonNull(opId, "opId"); Objects.requireNonNull(opId, ARG_OP_ID);
Objects.requireNonNull(type, "type"); Objects.requireNonNull(type, "type");
Objects.requireNonNull(owner, "owner"); Objects.requireNonNull(owner, "owner");
Objects.requireNonNull(endpointId, "endpointId"); Objects.requireNonNull(endpointId, "endpointId");
@@ -354,7 +358,7 @@ public final class DurableAsyncBus<OpId, Owner, EndpointId, Result> // NOPMD
*/ */
@Override @Override
public void update(OpId opId, AsyncStatus status, Optional<Result> result) { public void update(OpId opId, AsyncStatus status, Optional<Result> result) {
Objects.requireNonNull(opId, "opId"); Objects.requireNonNull(opId, ARG_OP_ID);
Objects.requireNonNull(status, "status"); Objects.requireNonNull(status, "status");
Objects.requireNonNull(result, "result"); Objects.requireNonNull(result, "result");
@@ -381,10 +385,11 @@ public final class DurableAsyncBus<OpId, Owner, EndpointId, Result> // NOPMD
dispatchEvent(opId, snap, status, result); dispatchEvent(opId, snap, status, result);
if (status.isTerminal()) { if (status.isTerminal()) {
// keep terminal status for diagnostics and deterministic polling; only // All terminal operations leave the active set. Only successful
// successful operations retain their result for later consumption // operations retain their result for later consumption.
if (status.state() != AsyncState.SUCCEEDED) { // NOPMD active.remove(opId);
dropActiveOperation(opId); if (status.state() != AsyncState.SUCCEEDED) {
results.remove(opId);
} }
} }
} }
@@ -399,7 +404,7 @@ public final class DurableAsyncBus<OpId, Owner, EndpointId, Result> // NOPMD
*/ */
@Override @Override
public Optional<AsyncStatus> status(OpId opId) { public Optional<AsyncStatus> status(OpId opId) {
Objects.requireNonNull(opId, "opId"); Objects.requireNonNull(opId, ARG_OP_ID);
return Optional.ofNullable(lastStatus.get(opId)); return Optional.ofNullable(lastStatus.get(opId));
} }
@@ -419,7 +424,7 @@ public final class DurableAsyncBus<OpId, Owner, EndpointId, Result> // NOPMD
*/ */
@Override @Override
public Optional<AsyncOperationSnapshot<OpId, Owner, EndpointId>> snapshot(OpId opId) { public Optional<AsyncOperationSnapshot<OpId, Owner, EndpointId>> snapshot(OpId opId) {
Objects.requireNonNull(opId, "opId"); Objects.requireNonNull(opId, ARG_OP_ID);
return Optional.ofNullable(active.get(opId)); return Optional.ofNullable(active.get(opId));
} }
@@ -427,9 +432,9 @@ public final class DurableAsyncBus<OpId, Owner, EndpointId, Result> // NOPMD
* Returns and removes a previously stored successful result. * Returns and removes a previously stored successful result.
* *
* <p> * <p>
* Result consumption is destructive. After a successful consume, the in-memory * Result consumption is destructive. Before returning the result, this method
* operation state is deleted completely through * appends a deletion tombstone and removes the complete in-memory operation
* {@link #deleteOperation(Object)} to keep storage bounded. * state. Consequently, replay cannot make a consumed result visible again.
* </p> * </p>
* *
* @param opId operation identifier; must not be {@code null} * @param opId operation identifier; must not be {@code null}
@@ -438,14 +443,14 @@ public final class DurableAsyncBus<OpId, Owner, EndpointId, Result> // NOPMD
*/ */
@Override @Override
public Optional<Result> consumeResult(OpId opId) { public Optional<Result> 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) { if (r == null) {
return Optional.empty(); return Optional.empty();
} }
// on successful consumption, forget operation entirely (bounded storage) store.appendLine(encodeDeleteLine(opId));
deleteOperation(opId); deleteOperation(opId);
if (LOG.isLoggable(Level.INFO)) { if (LOG.isLoggable(Level.INFO)) {
@@ -455,6 +460,25 @@ public final class DurableAsyncBus<OpId, Owner, EndpointId, Result> // NOPMD
return Optional.of(r); return Optional.of(r);
} }
/**
* Durably retires all bus state for an operation.
*
* <p>
* 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.
* </p>
*
* @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. * Performs one maintenance and polling sweep.
* *
@@ -502,7 +526,7 @@ public final class DurableAsyncBus<OpId, Owner, EndpointId, Result> // NOPMD
} catch (RuntimeException ex) { // NOPMD } catch (RuntimeException ex) { // NOPMD
// endpoint misbehaved; do not fail sweep // endpoint misbehaved; do not fail sweep
if (LOG.isLoggable(Level.WARNING)) { if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "Async endpoint status() failed; opId=" + safeOpId(opId), ex); logSafeFailure("ENDPOINT_STATUS_FAILED", opId, ex);
} }
continue; continue;
} }
@@ -523,7 +547,7 @@ public final class DurableAsyncBus<OpId, Owner, EndpointId, Result> // NOPMD
res = endpoint.result(opId); res = endpoint.result(opId);
} catch (RuntimeException ex) { // NOPMD } catch (RuntimeException ex) { // NOPMD
if (LOG.isLoggable(Level.WARNING)) { 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<OpId, Owner, EndpointId, Result> // NOPMD
h.onEvent(event); h.onEvent(event);
} catch (RuntimeException ex) { // NOPMD } catch (RuntimeException ex) { // NOPMD
if (LOG.isLoggable(Level.WARNING)) { 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<OpId, Owner, EndpointId, Result> // NOPMD
} else if (line.startsWith(REC_RESULT + "|")) { } else if (line.startsWith(REC_RESULT + "|")) {
applyResultLine(line); applyResultLine(line);
applied++; applied++;
} else if (line.startsWith(REC_DELETE + "|")) {
applyDeleteLine(line);
applied++;
} }
} catch (RuntimeException ex) { // NOPMD } catch (RuntimeException ex) { // NOPMD
// corrupted line: ignore safely without logging contents // 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<OpId, Owner, EndpointId, Result> // NOPMD
AsyncStatus st = new AsyncStatus(state, updatedAt, dc, details); AsyncStatus st = new AsyncStatus(state, updatedAt, dc, details);
lastStatus.put(opId, st); 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<OpId, Owner, EndpointId, Result> // NOPMD
// R1|opId|resultToken // R1|opId|resultToken
OpId opId = opIdCodec.decode(parts[1]); OpId opId = opIdCodec.decode(parts[1]);
Result r = resultCodec.decode(parts[2]); 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<OpId, Owner, EndpointId, Result> // NOPMD
return REC_RESULT + "|" + opIdCodec.encode(opId) + "|" + resultCodec.encode(r); 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. * Encodes a details map into the internal status-details token form.
* *
@@ -881,6 +931,20 @@ public final class DurableAsyncBus<OpId, Owner, EndpointId, Result> // 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. * Encodes an endpoint identifier for safe logging and truncates long values.
* *

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,7 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.impl.core.async; package zeroecho.pki.impl.core.async;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path; import java.nio.file.Path;
@@ -57,6 +58,7 @@ import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.audit.Purpose; import zeroecho.pki.api.audit.Purpose;
import zeroecho.pki.impl.fs.FilesystemPkiStore; import zeroecho.pki.impl.fs.FilesystemPkiStore;
import zeroecho.pki.impl.fs.FsPkiStoreOptions; import zeroecho.pki.impl.fs.FsPkiStoreOptions;
import zeroecho.pki.spi.crypto.SignatureWorkflow;
import zeroecho.pki.testkit.DurableOperatorApprovalSignatureWorkflow; import zeroecho.pki.testkit.DurableOperatorApprovalSignatureWorkflow;
import zeroecho.pki.util.async.AsyncState; import zeroecho.pki.util.async.AsyncState;
import zeroecho.pki.util.async.AsyncStatus; import zeroecho.pki.util.async.AsyncStatus;
@@ -66,6 +68,29 @@ public final class PkiSigningBusOperatorApprovalTest {
@TempDir @TempDir
Path 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 @Test
public void operatorApprove_completesSignature() throws Exception { public void operatorApprove_completesSignature() throws Exception {
System.out.println("operatorApprove_completesSignature"); System.out.println("operatorApprove_completesSignature");

View File

@@ -131,14 +131,13 @@ public final class PkiSigningBusResilienceTest {
// requester "goes away" here (close) // requester "goes away" here (close)
} }
// "Requester restart": new instance attaches by knowing (owner, clientOpId) -> // "Requester restart": the caller retains the stable submission identifier.
// same canonical opId.
try (FilesystemPkiStore store2 = new FilesystemPkiStore(storeRoot, options); try (FilesystemPkiStore store2 = new FilesystemPkiStore(storeRoot, options);
DurableOperatorApprovalSignatureWorkflow signer2 = new DurableOperatorApprovalSignatureWorkflow(wfRoot, DurableOperatorApprovalSignatureWorkflow signer2 = new DurableOperatorApprovalSignatureWorkflow(wfRoot,
Duration.ofSeconds(10), Duration.ofMillis(0), Map.of(keyRef.value(), kp.getPrivate())); Duration.ofSeconds(10), Duration.ofMillis(0), Map.of(keyRef.value(), kp.getPrivate()));
PkiSigningBus bus2 = new PkiSigningBus(store2, signer2, busFile)) { PkiSigningBus bus2 = new PkiSigningBus(store2, signer2, busFile)) {
PkiId opId2 = bus2.canonicalizeOperationId(clientOpId, owner); PkiId opId2 = opId;
System.out.println("...opIdReattach=" + opId2.value()); System.out.println("...opIdReattach=" + opId2.value());
assertTrue(opId2.value().equals(opId.value())); assertTrue(opId2.value().equals(opId.value()));

View File

@@ -36,10 +36,14 @@ package zeroecho.pki.impl.crypto.zeroecholib;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import java.nio.file.Path; import java.nio.file.Path;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Optional; import java.util.Optional;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding; 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.AccessContext;
import zeroecho.pki.api.audit.Principal; import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.audit.Purpose; import zeroecho.pki.api.audit.Purpose;
import zeroecho.pki.api.orch.SigningSubmissionId;
import zeroecho.pki.spi.crypto.SignatureWorkflow; import zeroecho.pki.spi.crypto.SignatureWorkflow;
public final class ZeroEchoLibKeyRefParsingTest { public final class ZeroEchoLibKeyRefParsingTest {
private static final String NAMESPACE = "0123456789abcdef0123456789abcdef.zeroecho-lib";
@TempDir
Path tempDir;
@Test @Test
void signing_requires_prv_suffix_in_strict_mode_ok() { void signing_requires_prv_suffix_in_strict_mode_ok() {
System.out.println("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"), 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"), AccessContext ctx = new AccessContext(new Principal("TEST", "unit"), new Purpose("UNIT_TEST"),
Optional.empty(), Optional.empty()); Optional.empty(), Optional.empty());
SignatureWorkflow.SignRequest req = new SignatureWorkflow.SignRequest(ctx, new KeyRef("zeroecho-lib:abc"), // missing PkiId submissionId = SigningSubmissionId.create(NAMESPACE, Instant.now(), new SecureRandom()).id();
// .prv SignatureWorkflow.SignRequest req = SignatureWorkflow.SignRequest.create(submissionId, NAMESPACE, 1L,
"ECDSA", new EncodedObject(Encoding.BINARY, new byte[] { 0x01 }), Optional.of(Encoding.BINARY), ctx, new KeyRef("zeroecho-lib:abc"), "ECDSA",
new EncodedObject(Encoding.BINARY, new byte[] { 0x01 }), Optional.of(Encoding.BINARY),
Optional.of(Instant.now())); Optional.of(Instant.now()));
PkiId opId = wf.submitSign(req); 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"); System.out.println("verify_with_publicKeyEncoded_invalid_spki_fails_with_crypto_failure_ok");
try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("zeroecho-lib", Path.of("nonexistent"), 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"), AccessContext ctx = new AccessContext(new Principal("TEST", "unit"), new Purpose("UNIT_TEST"),
Optional.empty(), Optional.empty()); Optional.empty(), Optional.empty());
@@ -112,7 +123,7 @@ public final class ZeroEchoLibKeyRefParsingTest {
System.out.println("status_unknown_operation_is_deterministic_ok"); System.out.println("status_unknown_operation_is_deterministic_ok");
try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("zeroecho-lib", Path.of("nonexistent"), 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"); PkiId unknown = new PkiId("00000000-0000-0000-0000-000000000000");
SignatureWorkflow.OperationStatus st = wf.status(unknown); SignatureWorkflow.OperationStatus st = wf.status(unknown);

View File

@@ -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<ObservedBuffer> cleared = new ArrayList<>();
List<LogRecord> 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<PkiId> 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<Instant> 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<Path> 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<Path> 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;
}
}
}

View File

@@ -40,6 +40,8 @@ import java.security.KeyPair;
import java.security.KeyPairGenerator; import java.security.KeyPairGenerator;
import java.security.Signature; import java.security.Signature;
import java.security.spec.ECGenParameterSpec; import java.security.spec.ECGenParameterSpec;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Optional; import java.util.Optional;
@@ -72,7 +74,8 @@ public final class ZeroEchoLibSignatureWorkflowVerifyEncodedEcdsaTest {
KeyringStore ks = new KeyringStore(new zeroecho.sdk.ZeroEchoSession()); KeyringStore ks = new KeyringStore(new zeroecho.sdk.ZeroEchoSession());
ks.save(keyring); 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"); KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
kpg.initialize(new ECGenParameterSpec("secp256r1")); kpg.initialize(new ECGenParameterSpec("secp256r1"));
KeyPair kp = kpg.generateKeyPair(); KeyPair kp = kpg.generateKeyPair();

View File

@@ -39,6 +39,8 @@ import java.nio.file.Path;
import java.security.KeyPair; import java.security.KeyPair;
import java.security.KeyPairGenerator; import java.security.KeyPairGenerator;
import java.security.Signature; import java.security.Signature;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Optional; import java.util.Optional;
@@ -67,7 +69,8 @@ public final class ZeroEchoLibSignatureWorkflowVerifyEncodedTest {
KeyringStore ks = new KeyringStore(new zeroecho.sdk.ZeroEchoSession()); KeyringStore ks = new KeyringStore(new zeroecho.sdk.ZeroEchoSession());
ks.save(keyring); 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(); KeyPair kp = KeyPairGenerator.getInstance("RSA").generateKeyPair();
byte[] payload = "pqc-ready".getBytes(java.nio.charset.StandardCharsets.UTF_8); byte[] payload = "pqc-ready".getBytes(java.nio.charset.StandardCharsets.UTF_8);

View File

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

View File

@@ -87,7 +87,8 @@ public final class WorkflowProofOfPossessionVerifierTest {
ParsedCertificationRequest parsed = new BcX509CertificationRequestParser().parse(req); ParsedCertificationRequest parsed = new BcX509CertificationRequestParser().parse(req);
ZeroEchoLibSignatureWorkflowProvider provider = new ZeroEchoLibSignatureWorkflowProvider(); 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); SignatureWorkflow wf = provider.allocate(cfg);
WorkflowProofOfPossessionVerifier verifier = new WorkflowProofOfPossessionVerifier(wf); WorkflowProofOfPossessionVerifier verifier = new WorkflowProofOfPossessionVerifier(wf);

View File

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

View File

@@ -342,13 +342,15 @@ public final class FilesystemPkiStoreTest {
private static FsPkiStoreOptions nonStrictSnapshotOptions() { private static FsPkiStoreOptions nonStrictSnapshotOptions() {
FsPkiStoreOptions defaults = FsPkiStoreOptions.defaults(); FsPkiStoreOptions defaults = FsPkiStoreOptions.defaults();
return new FsPkiStoreOptions(defaults.caHistoryPolicy(), defaults.profileHistoryPolicy(), 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() { private static FsPkiStoreOptions strictSnapshotOptions() {
FsPkiStoreOptions defaults = FsPkiStoreOptions.defaults(); FsPkiStoreOptions defaults = FsPkiStoreOptions.defaults();
return new FsPkiStoreOptions(defaults.caHistoryPolicy(), defaults.profileHistoryPolicy(), 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 { private static void sleepMillis(long ms) throws InterruptedException {

View File

@@ -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<Boolean> 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<Instant> highRead = executor.submit(store::signingNow);
assertTrue(clock.observed.await(5, TimeUnit.SECONDS));
clock.set(base.minusSeconds(60));
Future<Instant> 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<PkiId, SignWorkflowStore.State> 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<PkiId, SignWorkflowStore.State> 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<CorruptionCase> 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<Instant> leaseUntil, Optional<String> detailCode, Optional<EncodedObject> result,
Optional<Instant> 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<SignWorkflowStore.Record> 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;
}
}
}

View File

@@ -263,6 +263,8 @@ public final class PkiBootstrapTest {
System.setProperty("zeroecho.pki.crypto.workflow", "zeroecho-lib"); System.setProperty("zeroecho.pki.crypto.workflow", "zeroecho-lib");
System.setProperty("zeroecho.pki.crypto.workflow.keyringPath", System.setProperty("zeroecho.pki.crypto.workflow.keyringPath",
this.tempDir.resolve("workflow").resolve("keyring.zek").toString()); 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.keyRefPrefix", "test-prefix:");
System.setProperty("zeroecho.pki.crypto.workflow.requireComponentSuffix", "false"); System.setProperty("zeroecho.pki.crypto.workflow.requireComponentSuffix", "false");

View File

@@ -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<byte[]> 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<byte[]> 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<byte[]> 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.
}
}
}

View File

@@ -73,12 +73,14 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow,
private static final String FILE_SIGNATURE = "signature"; private static final String FILE_SIGNATURE = "signature";
private final Path root; private final Path root;
private final TestSignIdentityRegistry identities;
private final Duration signingDelay; private final Duration signingDelay;
private final Map<String, PrivateKey> keysByRef; private final Map<String, PrivateKey> keysByRef;
private final Map<PkiId, NotificationSink> sinks; private final Map<PkiId, NotificationSink> sinks;
public DurableDelayedSignatureWorkflow(Path root, Duration signingDelay, Map<String, PrivateKey> keysByRef) { public DurableDelayedSignatureWorkflow(Path root, Duration signingDelay, Map<String, PrivateKey> keysByRef) {
this.root = Objects.requireNonNull(root, "root"); this.root = Objects.requireNonNull(root, "root");
this.identities = new TestSignIdentityRegistry(root);
this.signingDelay = Objects.requireNonNull(signingDelay, "signingDelay"); this.signingDelay = Objects.requireNonNull(signingDelay, "signingDelay");
this.keysByRef = Objects.requireNonNull(keysByRef, "keysByRef"); this.keysByRef = Objects.requireNonNull(keysByRef, "keysByRef");
this.sinks = new ConcurrentHashMap<PkiId, NotificationSink>(); this.sinks = new ConcurrentHashMap<PkiId, NotificationSink>();
@@ -102,7 +104,10 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow,
public PkiId submitSign(SignRequest request) { public PkiId submitSign(SignRequest request) {
Objects.requireNonNull(request, "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 now = Instant.now();
persistRequest(opId, request); persistRequest(opId, request);
@@ -133,7 +138,15 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow,
} }
if (st.state() == State.PENDING) { 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()); Optional.empty());
persistStatus(operationId, running); persistStatus(operationId, running);
notifySink(operationId, running); notifySink(operationId, running);
@@ -148,12 +161,10 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow,
// Best-effort: complete if signature file exists. // Best-effort: complete if signature file exists.
Optional<EncodedObject> sig = loadSignature(operationId); Optional<EncodedObject> sig = loadSignature(operationId);
if (sig.isPresent()) { if (sig.isPresent()) {
OperationResult res = new OperationResult(Optional.of(sig.get()), Optional.empty()); OperationStatus expired = expired(Instant.now());
OperationStatus done = new OperationStatus(State.SUCCEEDED, Instant.now(), Optional.of("SIGNED"), persistStatus(operationId, expired);
Optional.of(res)); notifySink(operationId, expired);
persistStatus(operationId, done); return expired;
notifySink(operationId, done);
return done;
} }
} }
@@ -161,12 +172,18 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow,
} }
@Override @Override
public boolean cancel(PkiId operationId, String reason) { public boolean cancel(PkiId operationId, long fencingToken, String reason) {
Objects.requireNonNull(operationId, "operationId"); Objects.requireNonNull(operationId, "operationId");
Objects.requireNonNull(reason, "reason"); Objects.requireNonNull(reason, "reason");
if (fencingToken <= 0L) {
throw new IllegalArgumentException("fencingToken must be positive");
}
if (reason.isBlank()) { if (reason.isBlank()) {
throw new IllegalArgumentException("reason must not be blank"); throw new IllegalArgumentException("reason must not be blank");
} }
if (!identities.acceptFence(operationId, fencingToken)) {
return false;
}
OperationStatus st = status(operationId); OperationStatus st = status(operationId);
if (st.isTerminal()) { if (st.isTerminal()) {
return false; return false;
@@ -203,6 +220,10 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow,
private OperationStatus performSign(PkiId opId) { private OperationStatus performSign(PkiId opId) {
PersistedSignRequest req = loadRequest(opId); PersistedSignRequest req = loadRequest(opId);
Instant startedAt = Instant.now();
if (deadlineReached(req.deadline, startedAt)) {
return expired(startedAt);
}
PrivateKey key = keysByRef.get(req.keyRef); PrivateKey key = keysByRef.get(req.keyRef);
if (key == null) { if (key == null) {
return new OperationStatus(State.FAILED, Instant.now(), Optional.of("UNKNOWN_KEY"), Optional.empty()); 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); sig.update(req.payload);
byte[] signature = sig.sign(); byte[] signature = sig.sign();
Instant completedAt = Instant.now();
if (deadlineReached(req.deadline, completedAt)) {
return expired(completedAt);
}
persistSignature(opId, signature); persistSignature(opId, signature);
EncodedObject enc = new EncodedObject(Encoding.BINARY, signature); EncodedObject enc = new EncodedObject(Encoding.BINARY, signature);
OperationResult res = new OperationResult(Optional.of(enc), Optional.empty()); 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) { } catch (Exception ex) {
return new OperationStatus(State.FAILED, Instant.now(), Optional.of("SIGN_ERROR"), Optional.empty()); return new OperationStatus(State.FAILED, Instant.now(), Optional.of("SIGN_ERROR"), Optional.empty());
} }
@@ -255,7 +280,8 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow,
try { try {
Files.createDirectories(dir); Files.createDirectories(dir);
String line = req.keyRef().value() + "\n" + req.algorithmId() + "\n" 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); Files.writeString(dir.resolve(FILE_REQUEST), line, StandardCharsets.UTF_8);
} catch (IOException ex) { } catch (IOException ex) {
throw new IllegalStateException("Cannot persist request", 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); Path f = opDir(opId).resolve(FILE_REQUEST);
try { try {
String s = Files.readString(f, StandardCharsets.UTF_8); 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 keyRef = parts[0];
String alg = parts[1]; String alg = parts[1];
byte[] payload = Base64.getDecoder().decode(parts[2]); byte[] payload = Base64.getDecoder().decode(parts[2]);
return new PersistedSignRequest(keyRef, alg, payload); Optional<Instant> 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) { } 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 keyRef;
private final String algorithmId; private final String algorithmId;
private final byte[] payload; private final byte[] payload;
private final Optional<Instant> deadline;
private PersistedSignRequest(String keyRef, String algorithmId, byte[] payload) { private PersistedSignRequest(String keyRef, String algorithmId, byte[] payload, Optional<Instant> deadline) {
this.keyRef = keyRef; this.keyRef = keyRef;
this.algorithmId = algorithmId; this.algorithmId = algorithmId;
this.payload = payload; this.payload = payload;
this.deadline = deadline;
} }
} }
private static boolean deadlineReached(Optional<Instant> 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());
}
} }

View File

@@ -89,6 +89,7 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature
private static final String DECISION_DENY = "DENY"; private static final String DECISION_DENY = "DENY";
private final Path root; private final Path root;
private final TestSignIdentityRegistry identities;
private final Duration approvalWindow; private final Duration approvalWindow;
private final Duration signingDelay; private final Duration signingDelay;
private final Map<String, PrivateKey> keysByRef; private final Map<String, PrivateKey> keysByRef;
@@ -105,6 +106,7 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature
public DurableOperatorApprovalSignatureWorkflow(Path root, Duration approvalWindow, Duration signingDelay, public DurableOperatorApprovalSignatureWorkflow(Path root, Duration approvalWindow, Duration signingDelay,
Map<String, PrivateKey> keysByRef) { Map<String, PrivateKey> keysByRef) {
this.root = Objects.requireNonNull(root, "root"); this.root = Objects.requireNonNull(root, "root");
this.identities = new TestSignIdentityRegistry(root);
this.approvalWindow = Objects.requireNonNull(approvalWindow, "approvalWindow"); this.approvalWindow = Objects.requireNonNull(approvalWindow, "approvalWindow");
this.signingDelay = Objects.requireNonNull(signingDelay, "signingDelay"); this.signingDelay = Objects.requireNonNull(signingDelay, "signingDelay");
this.keysByRef = Objects.requireNonNull(keysByRef, "keysByRef"); this.keysByRef = Objects.requireNonNull(keysByRef, "keysByRef");
@@ -137,9 +139,15 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature
public PkiId submitSign(SignRequest request) { public PkiId submitSign(SignRequest request) {
Objects.requireNonNull(request, "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 now = Instant.now();
Instant deadline = now.plus(approvalWindow); Instant deadline = now.plus(approvalWindow);
if (request.deadline().isPresent() && request.deadline().get().isBefore(deadline)) {
deadline = request.deadline().get();
}
persistRequest(opId, request); persistRequest(opId, request);
persistDeadline(opId, deadline); persistDeadline(opId, deadline);
@@ -165,8 +173,9 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature
public void approve(PkiId operationId) { public void approve(PkiId operationId) {
Objects.requireNonNull(operationId, "operationId"); Objects.requireNonNull(operationId, "operationId");
persistDecision(operationId, DECISION_APPROVE); persistDecision(operationId, DECISION_APPROVE);
OperationStatus st = new OperationStatus(State.PENDING, Instant.now(), Optional.of("APPROVED"), Instant now = Instant.now();
Optional.empty()); OperationStatus st = deadlineReached(loadDeadline(operationId), now)
? expired(now) : new OperationStatus(State.PENDING, now, Optional.of("APPROVED"), Optional.empty());
persistStatus(operationId, st); persistStatus(operationId, st);
notifySink(operationId, st); notifySink(operationId, st);
} }
@@ -196,9 +205,9 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature
if (st.state() == State.WAITING_APPROVAL) { if (st.state() == State.WAITING_APPROVAL) {
// expire if past deadline // expire if past deadline
Instant deadline = loadDeadline(operationId).orElse(Instant.EPOCH); Instant deadline = loadDeadline(operationId).orElse(Instant.EPOCH);
if (Instant.now().isAfter(deadline)) { Instant now = Instant.now();
OperationStatus expired = new OperationStatus(State.EXPIRED, Instant.now(), if (deadlineReached(Optional.of(deadline), now)) {
Optional.of("APPROVAL_EXPIRED"), Optional.empty()); OperationStatus expired = expired(now);
persistStatus(operationId, expired); persistStatus(operationId, expired);
notifySink(operationId, expired); notifySink(operationId, expired);
return expired; return expired;
@@ -225,7 +234,14 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature
} }
if (st.state() == State.PENDING) { 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()); Optional.empty());
persistStatus(operationId, running); persistStatus(operationId, running);
notifySink(operationId, running); notifySink(operationId, running);
@@ -240,12 +256,18 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature
} }
@Override @Override
public boolean cancel(PkiId operationId, String reason) { public boolean cancel(PkiId operationId, long fencingToken, String reason) {
Objects.requireNonNull(operationId, "operationId"); Objects.requireNonNull(operationId, "operationId");
Objects.requireNonNull(reason, "reason"); Objects.requireNonNull(reason, "reason");
if (fencingToken <= 0L) {
throw new IllegalArgumentException("fencingToken must be positive");
}
if (reason.isBlank()) { if (reason.isBlank()) {
throw new IllegalArgumentException("reason must not be blank"); throw new IllegalArgumentException("reason must not be blank");
} }
if (!identities.acceptFence(operationId, fencingToken)) {
return false;
}
OperationStatus st = loadStatus(operationId); OperationStatus st = loadStatus(operationId);
if (st.isTerminal()) { if (st.isTerminal()) {
@@ -279,6 +301,10 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature
private OperationStatus performSign(PkiId opId) { private OperationStatus performSign(PkiId opId) {
PersistedSignRequest req = loadRequest(opId); PersistedSignRequest req = loadRequest(opId);
Instant startedAt = Instant.now();
if (deadlineReached(loadDeadline(opId), startedAt)) {
return expired(startedAt);
}
PrivateKey key = keysByRef.get(req.keyRef); PrivateKey key = keysByRef.get(req.keyRef);
if (key == null) { if (key == null) {
@@ -303,11 +329,15 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature
sig.update(req.payload); sig.update(req.payload);
byte[] signature = sig.sign(); byte[] signature = sig.sign();
Instant completedAt = Instant.now();
if (deadlineReached(loadDeadline(opId), completedAt)) {
return expired(completedAt);
}
persistSignature(opId, signature); persistSignature(opId, signature);
EncodedObject enc = new EncodedObject(Encoding.BINARY, signature); EncodedObject enc = new EncodedObject(Encoding.BINARY, signature);
OperationResult res = new OperationResult(Optional.of(enc), Optional.empty()); 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) { } catch (Exception ex) {
return new OperationStatus(State.FAILED, Instant.now(), Optional.of("SIGN_ERROR"), Optional.empty()); 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<Instant> 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) { private void persistDecision(PkiId opId, String decision) {
try { try {
Files.writeString(opDir(opId).resolve(FILE_DECISION), decision, StandardCharsets.UTF_8); Files.writeString(opDir(opId).resolve(FILE_DECISION), decision, StandardCharsets.UTF_8);

View File

@@ -40,9 +40,13 @@ import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set; 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.EncodedObject;
import zeroecho.pki.api.Encoding; import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
import zeroecho.pki.spi.crypto.SignatureWorkflow; import zeroecho.pki.spi.crypto.SignatureWorkflow;
@@ -53,11 +57,25 @@ public final class InMemorySignatureWorkflow implements SignatureWorkflow {
private final Map<String, KeyPair> keys; private final Map<String, KeyPair> keys;
private final Map<PkiId, OperationStatus> status; private final Map<PkiId, OperationStatus> status;
private final boolean completeImmediately;
private final ConcurrentMap<PkiId, String> fingerprints;
private final ConcurrentMap<PkiId, Long> fences;
private final ConcurrentMap<PkiId, Object> operationLocks;
private long counter; private long counter;
private final AtomicInteger submittedSignCount;
public InMemorySignatureWorkflow(Map<String, KeyPair> keys) { public InMemorySignatureWorkflow(Map<String, KeyPair> keys) {
this(keys, true);
}
public InMemorySignatureWorkflow(Map<String, KeyPair> keys, boolean completeImmediately) {
this.keys = new HashMap<>(Objects.requireNonNull(keys, "keys")); this.keys = new HashMap<>(Objects.requireNonNull(keys, "keys"));
this.status = new HashMap<>(); 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; this.counter = 1L;
} }
@@ -69,14 +87,39 @@ public final class InMemorySignatureWorkflow implements SignatureWorkflow {
@Override @Override
public PkiId submitSign(SignRequest request) { public PkiId submitSign(SignRequest request) {
Objects.requireNonNull(request, "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 { try {
if (deadlineReached(request, Instant.now())) {
complete(request, expired(Instant.now()));
return opId;
}
KeyPair kp = keys.get(request.keyRef().value()); KeyPair kp = keys.get(request.keyRef().value());
if (kp == null) { if (kp == null) {
OperationStatus st = new OperationStatus(State.FAILED, Instant.now(), Optional.of("UNKNOWN_KEY"), OperationStatus st = new OperationStatus(State.FAILED, Instant.now(), Optional.of("UNKNOWN_KEY"),
Optional.empty()); Optional.empty());
status.put(opId, st); complete(request, st);
return opId; return opId;
} }
@@ -85,20 +128,46 @@ public final class InMemorySignatureWorkflow implements SignatureWorkflow {
sig.update(request.payload().bytes()); sig.update(request.payload().bytes());
byte[] s = sig.sign(); 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)), OperationResult res = new OperationResult(Optional.of(new EncodedObject(Encoding.BINARY, s)),
Optional.empty()); 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)); Optional.of(res));
status.put(opId, st); complete(request, st);
return opId; return opId;
} catch (Exception ex) { } catch (Exception ex) {
OperationStatus st = new OperationStatus(State.FAILED, Instant.now(), Optional.of("SIGN_FAILED"), OperationStatus st = new OperationStatus(State.FAILED, Instant.now(), Optional.of("SIGN_FAILED"),
Optional.empty()); Optional.empty());
status.put(opId, st); complete(request, st);
return opId; 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 @Override
public PkiId submitVerify(VerifyRequest request) { public PkiId submitVerify(VerifyRequest request) {
Objects.requireNonNull(request, "request"); Objects.requireNonNull(request, "request");
@@ -120,10 +189,23 @@ public final class InMemorySignatureWorkflow implements SignatureWorkflow {
} }
@Override @Override
public boolean cancel(PkiId operationId, String reason) { public boolean cancel(PkiId operationId, long fencingToken, String reason) {
Objects.requireNonNull(operationId, "operationId"); Objects.requireNonNull(operationId, "operationId");
Objects.requireNonNull(reason, "reason"); 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 @Override
@@ -139,6 +221,18 @@ public final class InMemorySignatureWorkflow implements SignatureWorkflow {
return Set.of("SHA256withRSA"); 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 @Override
public void close() { public void close() {
// no-op // no-op

View File

@@ -90,11 +90,13 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo
private static final String K_DETAIL = "detailCode"; private static final String K_DETAIL = "detailCode";
private static final String K_KEYREF = "keyRef"; private static final String K_KEYREF = "keyRef";
private static final String K_ALG = "algorithmId"; 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_APPROVED_AT = "approvedAt";
private static final String K_DENIED_AT = "deniedAt"; private static final String K_DENIED_AT = "deniedAt";
private final String id; private final String id;
private final Path root; private final Path root;
private final TestSignIdentityRegistry identities;
private final Map<String, PrivateKey> keysByKeyRef; private final Map<String, PrivateKey> keysByKeyRef;
private final Duration approvalWindow; private final Duration approvalWindow;
private final Duration signDelay; private final Duration signDelay;
@@ -114,6 +116,7 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo
Duration approvalWindow, Duration signDelay) { Duration approvalWindow, Duration signDelay) {
this.id = Objects.requireNonNull(id, "id"); this.id = Objects.requireNonNull(id, "id");
this.root = Objects.requireNonNull(root, "root"); this.root = Objects.requireNonNull(root, "root");
this.identities = new TestSignIdentityRegistry(root);
this.keysByKeyRef = Objects.requireNonNull(keysByKeyRef, "keysByKeyRef"); this.keysByKeyRef = Objects.requireNonNull(keysByKeyRef, "keysByKeyRef");
this.approvalWindow = Objects.requireNonNull(approvalWindow, "approvalWindow"); this.approvalWindow = Objects.requireNonNull(approvalWindow, "approvalWindow");
this.signDelay = Objects.requireNonNull(signDelay, "signDelay"); this.signDelay = Objects.requireNonNull(signDelay, "signDelay");
@@ -142,7 +145,10 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo
long n = seq.incrementAndGet(); long n = seq.incrementAndGet();
persistSeq(root, n); 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); Path dir = opDir(opId);
try { try {
@@ -151,8 +157,13 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo
java.util.Properties p = new java.util.Properties(); java.util.Properties p = new java.util.Properties();
Instant now = Instant.now(); 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_CREATED_AT, now.toString());
p.setProperty(K_UPDATED_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_STATE, State.WAITING_APPROVAL.name());
p.setProperty(K_DETAIL, "WAITING_APPROVAL"); p.setProperty(K_DETAIL, "WAITING_APPROVAL");
p.setProperty(K_KEYREF, request.keyRef().value()); p.setProperty(K_KEYREF, request.keyRef().value());
@@ -196,6 +207,14 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo
return; return;
} }
Instant now = Instant.now(); 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_APPROVED_AT, now.toString());
p.setProperty(K_UPDATED_AT, now.toString()); p.setProperty(K_UPDATED_AT, now.toString());
p.setProperty(K_STATE, State.RUNNING.name()); 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()); 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)); Instant updatedAt = Instant.parse(p.getProperty(K_UPDATED_AT));
State state = parseState(p.getProperty(K_STATE)); State state = parseState(p.getProperty(K_STATE));
String detail = p.getProperty(K_DETAIL, ""); String detail = p.getProperty(K_DETAIL, "");
// Expire while waiting for approval // Expire while waiting for approval
Instant approvalDeadline = createdAt.plus(approvalWindow);
if (!isTerminalState(state) && (state == State.WAITING_APPROVAL || state == State.PENDING) if (!isTerminalState(state) && (state == State.WAITING_APPROVAL || state == State.PENDING)
&& Instant.now().isAfter(approvalDeadline)) { && deadlineReached(p, Instant.now())) {
state = State.EXPIRED; state = State.EXPIRED;
detail = "APPROVAL_EXPIRED"; detail = "APPROVAL_EXPIRED";
updatedAt = Instant.now(); updatedAt = Instant.now();
@@ -261,6 +278,16 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo
if (!approvedAtRaw.isBlank()) { if (!approvedAtRaw.isBlank()) {
Instant approvedAt = Instant.parse(approvedAtRaw); Instant approvedAt = Instant.parse(approvedAtRaw);
if (Instant.now().isAfter(approvedAt.plus(signDelay))) { 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 { try {
byte[] payload = Files.readAllBytes(dir.resolve(FILE_REQUEST)); byte[] payload = Files.readAllBytes(dir.resolve(FILE_REQUEST));
String keyRef = p.getProperty(K_KEYREF); String keyRef = p.getProperty(K_KEYREF);
@@ -270,10 +297,15 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo
return fail(operationId, dir, p, "UNKNOWN_KEY"); return fail(operationId, dir, p, "UNKNOWN_KEY");
} }
byte[] sigBytes = sign(alg, pk, payload); byte[] sigBytes = sign(alg, pk, payload);
Files.write(dir.resolve(FILE_SIGNATURE), sigBytes);
state = State.SUCCEEDED;
detail = "SIGNED";
updatedAt = Instant.now(); 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_STATE, state.name());
p.setProperty(K_DETAIL, detail); p.setProperty(K_DETAIL, detail);
p.setProperty(K_UPDATED_AT, updatedAt.toString()); p.setProperty(K_UPDATED_AT, updatedAt.toString());
@@ -297,12 +329,18 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo
} }
@Override @Override
public boolean cancel(PkiId operationId, String reason) { public boolean cancel(PkiId operationId, long fencingToken, String reason) {
Objects.requireNonNull(operationId, "operationId"); Objects.requireNonNull(operationId, "operationId");
Objects.requireNonNull(reason, "reason"); Objects.requireNonNull(reason, "reason");
if (fencingToken <= 0L) {
throw new IllegalArgumentException("fencingToken must be positive");
}
if (reason.isBlank()) { if (reason.isBlank()) {
throw new IllegalArgumentException("reason must not be blank"); throw new IllegalArgumentException("reason must not be blank");
} }
if (!identities.acceptFence(operationId, fencingToken)) {
return false;
}
Path dir = opDir(operationId); Path dir = opDir(operationId);
java.util.Properties p = readPropsSafe(dir.resolve(FILE_META)); java.util.Properties p = readPropsSafe(dir.resolve(FILE_META));
if (p.isEmpty()) { 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; 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) { private static long loadSeq(Path root) {
Path f = root.resolve("seq.txt"); Path f = root.resolve("seq.txt");
try { try {

View File

@@ -36,10 +36,12 @@ package zeroecho.pki.testkit;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Path; import java.nio.file.Path;
import java.security.KeyPair; import java.security.KeyPair;
import java.security.PublicKey;
import java.time.Duration; import java.time.Duration;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import zeroecho.pki.api.CaService; import zeroecho.pki.api.CaService;
import zeroecho.pki.api.CertificationRequestService; 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.DefaultStatusObjectService;
import zeroecho.pki.impl.core.async.PkiSigningBus; import zeroecho.pki.impl.core.async.PkiSigningBus;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet; 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.BcX509CredentialFramework;
import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialIssuerBackend; import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialIssuerBackend;
import zeroecho.pki.impl.framework.x509.bc.BcX509StatusObjectGenerator; 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.impl.fs.FsPkiStoreOptions;
import zeroecho.pki.spi.crypto.SignatureWorkflow; import zeroecho.pki.spi.crypto.SignatureWorkflow;
import zeroecho.pki.spi.framework.CredentialFramework; import zeroecho.pki.spi.framework.CredentialFramework;
import zeroecho.pki.spi.framework.CredentialIssuerBackend;
import zeroecho.pki.spi.framework.ProofOfPossessionVerifier;
import zeroecho.pki.spi.store.PkiStore; import zeroecho.pki.spi.store.PkiStore;
/** /**
@@ -79,8 +84,10 @@ public final class PkiTestRuntime implements AutoCloseable {
private final FilesystemPkiStore store; private final FilesystemPkiStore store;
private final PkiSigningBus signingBus; private final PkiSigningBus signingBus;
private final SignatureWorkflow signatureWorkflow; private final SignatureWorkflow signatureWorkflow;
private final InMemoryAuditSink auditSink;
private final CredentialFramework framework; private final CredentialFramework framework;
private final CredentialIssuerBackend issuerBackend;
private final CaService caService; private final CaService caService;
private final CertificationRequestService certificationRequestService; private final CertificationRequestService certificationRequestService;
@@ -88,23 +95,29 @@ public final class PkiTestRuntime implements AutoCloseable {
private final RevocationService revocationService; private final RevocationService revocationService;
private final StatusObjectService statusObjectService; private final StatusObjectService statusObjectService;
private final Map<String, KeyPair> keyPairsByKeyRef; private final Map<String, PublicKey> publicKeysByKeyRef;
private Runnable publicKeyResolveHook;
private PkiTestRuntime(FilesystemPkiStore store, PkiSigningBus signingBus, SignatureWorkflow signatureWorkflow, private PkiTestRuntime(FilesystemPkiStore store, PkiSigningBus signingBus, SignatureWorkflow signatureWorkflow,
CredentialFramework framework, Map<String, KeyPair> keyPairsByKeyRef) { CredentialFramework framework, CredentialIssuerBackend issuerBackend,
Map<String, PublicKey> publicKeysByKeyRef, Duration signingTtl) {
this.store = store; this.store = store;
this.signingBus = signingBus; this.signingBus = signingBus;
this.signatureWorkflow = signatureWorkflow; this.signatureWorkflow = signatureWorkflow;
this.auditSink = new InMemoryAuditSink();
this.framework = framework; this.framework = framework;
this.keyPairsByKeyRef = keyPairsByKeyRef; this.issuerBackend = issuerBackend;
this.publicKeysByKeyRef = publicKeysByKeyRef;
this.publicKeyResolveHook = () -> {
};
this.certificationRequestService = new DefaultCertificationRequestService(store, framework); 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.revocationService = new DefaultRevocationService(store);
this.statusObjectService = new DefaultStatusObjectService(store, framework); this.statusObjectService = new DefaultStatusObjectService(store, framework);
this.caService = new DefaultCaService(store, framework, this::resolvePublicKeyInfo, signingBus, "SHA256withRSA", this.caService = new DefaultCaService(store, framework, issuerBackend, this::resolvePublicKeyInfo, signingBus,
Duration.ofSeconds(2)); auditSink, "SHA256withRSA", signingTtl);
} }
/** /**
@@ -116,9 +129,36 @@ public final class PkiTestRuntime implements AutoCloseable {
* @return runtime * @return runtime
*/ */
public static PkiTestRuntime create(Path rootDir, Path busFile, Map<KeyRef, KeyPair> keyPairs) { public static PkiTestRuntime create(Path rootDir, Path busFile, Map<KeyRef, KeyPair> keyPairs) {
Map<KeyRef, PublicKey> publicKeys = new HashMap<>();
for (Map.Entry<KeyRef, KeyPair> 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<KeyRef, KeyPair> signingKeys,
Map<KeyRef, PublicKey> resolvedKeys, ProofOfPossessionVerifier proofVerifier) {
return create(rootDir, busFile, signingKeys, resolvedKeys, Optional.of(proofVerifier));
}
private static PkiTestRuntime create(Path rootDir, Path busFile, Map<KeyRef, KeyPair> keyPairs,
Map<KeyRef, PublicKey> resolvedKeys, Optional<ProofOfPossessionVerifier> proofVerifier) {
Objects.requireNonNull(rootDir, "rootDir"); Objects.requireNonNull(rootDir, "rootDir");
Objects.requireNonNull(busFile, "busFile"); Objects.requireNonNull(busFile, "busFile");
Objects.requireNonNull(keyPairs, "keyPairs"); Objects.requireNonNull(keyPairs, "keyPairs");
Objects.requireNonNull(resolvedKeys, "resolvedKeys");
Objects.requireNonNull(proofVerifier, "proofVerifier");
FsPkiStoreOptions opts = FsPkiStoreOptions.defaults(); FsPkiStoreOptions opts = FsPkiStoreOptions.defaults();
@@ -129,6 +169,10 @@ public final class PkiTestRuntime implements AutoCloseable {
for (Map.Entry<KeyRef, KeyPair> e : keyPairs.entrySet()) { for (Map.Entry<KeyRef, KeyPair> e : keyPairs.entrySet()) {
byRef.put(e.getKey().value(), e.getValue()); byRef.put(e.getKey().value(), e.getValue());
} }
Map<String, PublicKey> publicByRef = new HashMap<>();
for (Map.Entry<KeyRef, PublicKey> entry : resolvedKeys.entrySet()) {
publicByRef.put(entry.getKey().value(), entry.getValue());
}
SignatureWorkflow signer = new InMemorySignatureWorkflow(byRef); SignatureWorkflow signer = new InMemorySignatureWorkflow(byRef);
PkiSigningBus signingBus = new PkiSigningBus(store, signer, busFile); PkiSigningBus signingBus = new PkiSigningBus(store, signer, busFile);
@@ -137,17 +181,43 @@ public final class PkiTestRuntime implements AutoCloseable {
Duration.ofSeconds(2)); Duration.ofSeconds(2));
BcX509StatusObjectGenerator statusGen = new BcX509StatusObjectGenerator(signingBus, "SHA256withRSA", BcX509StatusObjectGenerator statusGen = new BcX509StatusObjectGenerator(signingBus, "SHA256withRSA",
Duration.ofSeconds(2)); 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<KeyRef, KeyPair> keyPairs,
Duration signingTtl) {
Objects.requireNonNull(signingTtl, "signingTtl");
FsPkiStoreOptions opts = FsPkiStoreOptions.defaults();
FilesystemPkiStore store = new FilesystemPkiStore(rootDir.resolve("store"), opts);
Map<String, KeyPair> byRef = new HashMap<>();
Map<String, PublicKey> publicByRef = new HashMap<>();
for (Map.Entry<KeyRef, KeyPair> 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) { private EncodedObject resolvePublicKeyInfo(KeyRef keyRef) {
KeyPair kp = keyPairsByKeyRef.get(keyRef.value()); publicKeyResolveHook.run();
if (kp == null) { PublicKey publicKey = publicKeysByKeyRef.get(keyRef.value());
if (publicKey == null) {
throw new IllegalArgumentException("Unknown keyRef"); throw new IllegalArgumentException("Unknown keyRef");
} }
return new EncodedObject(Encoding.DER, kp.getPublic().getEncoded()); return new EncodedObject(Encoding.DER, publicKey.getEncoded());
} }
public PkiStore store() { public PkiStore store() {
@@ -162,14 +232,65 @@ public final class PkiTestRuntime implements AutoCloseable {
return signatureWorkflow; 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() { public CredentialFramework framework() {
return framework; return framework;
} }
public CredentialIssuerBackend issuerBackend() {
return issuerBackend;
}
public CaService caService() { public CaService caService() {
return 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() { public CertificationRequestService certificationRequestService() {
return certificationRequestService; return certificationRequestService;
} }
@@ -186,6 +307,11 @@ public final class PkiTestRuntime implements AutoCloseable {
return statusObjectService; return statusObjectService;
} }
/**
* Returns a new empty attribute set suitable for test commands.
*
* @return empty attributes
*/
public SimpleAttributeSet emptyAttributes() { public SimpleAttributeSet emptyAttributes() {
return new SimpleAttributeSet(); return new SimpleAttributeSet();
} }

View File

@@ -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<PkiId, Object> 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) {
}
}

View File

@@ -107,6 +107,23 @@ public class DurableAsyncBusTest {
assertTrue(bus.snapshot(opId).isEmpty()); assertTrue(bus.snapshot(opId).isEmpty());
assertTrue(bus.status(opId).isEmpty()); assertTrue(bus.status(opId).isEmpty());
DurableAsyncBus<PkiId, Principal, String, String> replayed =
new DurableAsyncBus<PkiId, Principal, String, String>(PkiCodecs.PKI_ID, PkiCodecs.PRINCIPAL,
PkiCodecs.STRING, new ResultCodec<String>() {
@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"); System.out.println("...ok");
} }
@@ -152,6 +169,12 @@ public class DurableAsyncBusTest {
assertEquals(AsyncState.FAILED, st.get().state()); assertEquals(AsyncState.FAILED, st.get().state());
assertTrue(bus.snapshot(opId).isEmpty()); assertTrue(bus.snapshot(opId).isEmpty());
DurableAsyncBus<PkiId, Principal, String, String> replayed =
new DurableAsyncBus<PkiId, Principal, String, String>(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"); System.out.println("failedStatus_remainsVisibleUntilExplicitPurge...ok");
} }
@@ -193,4 +216,70 @@ public class DurableAsyncBusTest {
System.out.println("...ok"); 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<String> persistedStrings = new ResultCodec<String>() {
@Override
public String encode(String result) {
return result;
}
@Override
public String decode(String token) {
return token;
}
};
DurableAsyncBus<PkiId, Principal, String, String> successful =
new DurableAsyncBus<PkiId, Principal, String, String>(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<PkiId, Principal, String, String> replayedSuccessful =
new DurableAsyncBus<PkiId, Principal, String, String>(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<PkiId, Principal, String, String> bus =
new DurableAsyncBus<PkiId, Principal, String, String>(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<PkiId, Principal, String, String> replayed =
new DurableAsyncBus<PkiId, Principal, String, String>(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<PkiId, Principal, String, String> retired =
new DurableAsyncBus<PkiId, Principal, String, String>(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<PkiId, Principal, String, String> replayedRetired =
new DurableAsyncBus<PkiId, Principal, String, String>(PkiCodecs.PKI_ID, PkiCodecs.PRINCIPAL,
PkiCodecs.STRING, ResultCodec.none(), new AppendOnlyLineStore(retiredLog));
assertTrue(replayedRetired.snapshot(retiredId).isEmpty());
assertTrue(replayedRetired.status(retiredId).isEmpty());
}
} }