feat(pki-server): add ACME certificate management

Add directory-bound ACME accounts, orders, authorizations, challenge
evidence, strict JWS processing, issuance, rollover and revocation.

Isolate bounded ACME execution from administrative and public services
while preserving explicit authority, profile, issuer and chain-path
selection.
This commit is contained in:
2026-08-05 18:16:00 +02:00
parent c3bd3a33e9
commit b19edf17fd
58 changed files with 6295 additions and 46 deletions

View File

@@ -0,0 +1,81 @@
/*******************************************************************************
* 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.issuance;
import java.util.Objects;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.attr.AttributeId;
import zeroecho.pki.api.profile.CertificateProfileRef;
/**
* Durable idempotency and frozen-selection authority for one issuance request.
*
* <p>The identifier is supplied by a trusted orchestration layer such as ACME.
* It is not a credential, secret, bearer token, or operation permission. Reuse is
* valid only when the complete command commitment and frozen PKI selection match.</p>
*
* @param issuanceId stable caller correlation identity
* @param commandCommitment SHA-256 commitment of canonical safe issuance input
* @param expectedProfile exact activated profile reference
* @param expectedIssuerId exact issuer generation
* @param expectedChainPathId exact issuance chain path
* @param expectedChainPathCommitment frozen chain-path commitment
*/
public record IssuanceIntent(String issuanceId, String commandCommitment,
CertificateProfileRef expectedProfile, PkiId expectedIssuerId,
PkiId expectedChainPathId, String expectedChainPathCommitment) {
/** Stable internal metadata attribute carrying the non-secret issuance identity. */
public static final AttributeId ID_ATTRIBUTE = new AttributeId("urn:zeroecho:pki:issuance-id:v1");
/** Stable internal metadata attribute carrying the canonical command commitment. */
public static final AttributeId COMMITMENT_ATTRIBUTE =
new AttributeId("urn:zeroecho:pki:issuance-command-commitment:v1");
/** Validates the transport-neutral frozen issuance authority. */
public IssuanceIntent {
if (issuanceId == null || !issuanceId.matches("[a-z0-9][a-z0-9._:-]{0,127}")) {
throw new IllegalArgumentException("Issuance identity is invalid");
}
requireDigest(commandCommitment);
Objects.requireNonNull(expectedProfile, "expectedProfile");
Objects.requireNonNull(expectedIssuerId, "expectedIssuerId");
Objects.requireNonNull(expectedChainPathId, "expectedChainPathId");
requireDigest(expectedChainPathCommitment);
}
private static void requireDigest(String value) {
if (value == null || !value.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException("Issuance commitment is invalid");
}
}
}

View File

@@ -48,9 +48,16 @@ import zeroecho.pki.api.request.ParsedCertificationRequest;
* @param profileId profile id governing issuance
* @param validityOverride optional requested validity override
* (policy-validated)
* @param issuanceIntent optional durable correlation and frozen selection
*/
public record IssueEndEntityCommand(PkiId issuerCaId, ParsedCertificationRequest request, String profileId,
Optional<Validity> validityOverride) {
Optional<Validity> validityOverride, Optional<IssuanceIntent> issuanceIntent) {
/** Creates an ordinary non-correlated administrative issuance command. */
public IssueEndEntityCommand(PkiId issuerCaId, ParsedCertificationRequest request, String profileId,
Optional<Validity> validityOverride) {
this(issuerCaId, request, profileId, validityOverride, Optional.empty());
}
/**
* Creates an issuance command.
@@ -71,5 +78,8 @@ public record IssueEndEntityCommand(PkiId issuerCaId, ParsedCertificationRequest
if (validityOverride == null) {
throw new IllegalArgumentException("validityOverride must not be null");
}
if (issuanceIntent == null) {
throw new IllegalArgumentException("issuanceIntent must not be null");
}
}
}

View File

@@ -54,6 +54,7 @@ import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.ProfileService;
import zeroecho.pki.api.attr.AttributeValue;
import zeroecho.pki.api.attr.AttributeSet;
import zeroecho.pki.api.audit.AuditEvent;
import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.audit.Purpose;
@@ -67,6 +68,7 @@ import zeroecho.pki.api.credential.EffectiveCredentialStatus;
import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver;
import zeroecho.pki.api.issuance.BundleCommand;
import zeroecho.pki.api.issuance.IssueEndEntityCommand;
import zeroecho.pki.api.issuance.IssuanceIntent;
import zeroecho.pki.api.issuance.ReissueCommand;
import zeroecho.pki.api.issuance.RenewCommand;
import zeroecho.pki.api.issuance.ReplaceCommand;
@@ -86,6 +88,7 @@ import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.spi.framework.CredentialFramework;
import zeroecho.pki.spi.framework.CredentialIssuerBackend;
import zeroecho.pki.spi.store.PkiStore;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
/**
* Default implementation of {@link IssuanceService}.
@@ -142,7 +145,7 @@ import zeroecho.pki.spi.store.PkiStore;
* </p>
*/
// PMD cannot infer that retaining boundary causes would violate the redaction contract.
@SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.PreserveStackTrace" })
@SuppressWarnings({ "PMD.AvoidSynchronizedAtMethodLevel", "PMD.CyclomaticComplexity", "PMD.PreserveStackTrace" })
public final class DefaultIssuanceService implements IssuanceService {
/**
@@ -227,8 +230,13 @@ public final class DefaultIssuanceService implements IssuanceService {
* persistence of the validated leaf fails
*/
@Override
public CredentialBundle issueEndEntity(IssueEndEntityCommand command) {
public synchronized CredentialBundle issueEndEntity(IssueEndEntityCommand command) {
Objects.requireNonNull(command, "command");
Optional<Credential> committed = command.issuanceIntent()
.flatMap(store::getCredentialByIssuanceIntent);
if (committed.isPresent()) {
return committedBundle(command, committed.orElseThrow());
}
CaRecord issuer = store.getCa(command.issuerCaId()).orElseThrow(() -> new PkiException("Issuer CA not found"));
if (issuer.state() != CaState.ACTIVE) {
@@ -242,6 +250,11 @@ public final class DefaultIssuanceService implements IssuanceService {
throw rejection(candidate.request(), "PROFILE_NOT_ACTIVE");
}
CertificateProfile profile = active.profile();
command.issuanceIntent().ifPresent(intent -> {
if (!intent.expectedProfile().equals(active.reference())) {
throw new PkiException("Frozen issuance profile is unavailable");
}
});
if (!command.profileId().equals(active.reference().profileId())) {
throw rejection(candidate.request(), "PROFILE_ID_MISMATCH");
}
@@ -255,6 +268,7 @@ public final class DefaultIssuanceService implements IssuanceService {
CredentialUse.END_ENTITY_ISSUER, statusEvaluation));
zeroecho.pki.api.ca.IssuerGeneration generation = IssuerAuthorities.current(store, issuer);
zeroecho.pki.api.ca.IssuerChainPath issuancePath = IssuerAuthorities.issuancePath(store, issuer);
command.issuanceIntent().ifPresent(intent -> requireFrozenSelection(intent, generation, issuancePath));
ValidatedCertificateRequest validated;
try {
validated = CertificateProfileValidator.validate(candidate, profile, active.reference(), issuerCred,
@@ -274,10 +288,46 @@ public final class DefaultIssuanceService implements IssuanceService {
requireIssuedCredentialMatches(validated, issuerCred, serial, bundle, candidate.request());
Credential exactCredential = IssuerAuthorities.withIssuer(bundle.credential(),
new zeroecho.pki.api.IssuerRef(issuer.caId(), generation.issuerId(), issuancePath.pathId()));
if (command.issuanceIntent().isPresent()) {
IssuanceIntent intent = command.issuanceIntent().orElseThrow();
AttributeSet attributes = SimpleAttributeSet.builder().putAll(exactCredential.attributes())
.put(IssuanceIntent.ID_ATTRIBUTE, new AttributeValue.StringValue(intent.issuanceId()))
.put(IssuanceIntent.COMMITMENT_ATTRIBUTE,
new AttributeValue.StringValue(intent.commandCommitment())).build();
exactCredential = new Credential(exactCredential.credentialId(), exactCredential.formatId(),
exactCredential.issuerRef(), exactCredential.subjectRef(), exactCredential.validity(),
exactCredential.serialOrUniqueId(), exactCredential.publicKeyId(),
exactCredential.profileBinding(), exactCredential.status(), exactCredential.content(), attributes);
}
store.putCredential(exactCredential);
return new CredentialBundle(exactCredential, pathContent(issuancePath));
}
private CredentialBundle committedBundle(IssueEndEntityCommand command, Credential credential) {
IssuanceIntent intent = command.issuanceIntent().orElseThrow();
if (!credential.issuerRef().caId().equals(command.issuerCaId())
|| !credential.issuerRef().issuerId().equals(intent.expectedIssuerId())
|| !credential.issuerRef().chainPathId().equals(intent.expectedChainPathId())) {
throw new PkiException("Committed issuance selection mismatch");
}
zeroecho.pki.api.ca.IssuerChainPath path = store.getIssuerChainPath(intent.expectedChainPathId())
.orElseThrow(() -> new PkiException("Committed issuance chain path is unavailable"));
if (!path.pathCommitment().equals(intent.expectedChainPathCommitment())) {
throw new PkiException("Committed issuance chain commitment mismatch");
}
return new CredentialBundle(CredentialSnapshots.copy(credential), pathContent(path));
}
private static void requireFrozenSelection(IssuanceIntent intent,
zeroecho.pki.api.ca.IssuerGeneration generation,
zeroecho.pki.api.ca.IssuerChainPath path) {
if (!generation.issuerId().equals(intent.expectedIssuerId())
|| !path.pathId().equals(intent.expectedChainPathId())
|| !path.pathCommitment().equals(intent.expectedChainPathCommitment())) {
throw new PkiException("Frozen issuance selection is no longer active");
}
}
/**
* Selects the issuer credential that should be used for issuance in the given
* format.

View File

@@ -62,6 +62,7 @@ import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy;
import zeroecho.pki.api.orch.SigningSubmissionId;
import zeroecho.pki.api.orch.WorkflowStateRecord;
import zeroecho.pki.impl.framework.x509.X509AlgorithmResolver;
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
import zeroecho.pki.impl.framework.x509.X509ExecutionPlan;
import zeroecho.pki.spi.crypto.SignatureWorkflow;
@@ -332,10 +333,25 @@ public final class PkiSigningBus implements AutoCloseable {
if (supportedAlgorithms.isEmpty()) {
throw new IllegalArgumentException("Signature workflow must declare a signing identity");
}
for (String algorithm : supportedAlgorithms) {
X509ExecutionPlan<SignatureWorkflow> plan = authority.planSigning(algorithm,
workflowImplementationId(workflow), SignatureWorkflow.class);
authority.authorize(plan, workflow, AlgorithmExecutionCapability.Direction.SIGN);
int x509Capable = 0;
for (String algorithm : supportedAlgorithms.stream().sorted().toList()) {
try {
X509ExecutionPlan<SignatureWorkflow> plan = authority.planSigning(algorithm,
workflowImplementationId(workflow), SignatureWorkflow.class);
authority.authorize(plan, workflow, AlgorithmExecutionCapability.Direction.SIGN);
x509Capable++;
} catch (X509AlgorithmResolver.ResolutionException unavailable) {
if (unavailable.failure() != X509AlgorithmResolver.Failure.NO_BINDING) {
throw unavailable;
}
} catch (IllegalArgumentException unavailable) {
if (!"Unknown algorithm identity".equals(unavailable.getMessage())) {
throw unavailable;
}
}
}
if (x509Capable == 0) {
throw new IllegalArgumentException("Signature workflow has no X.509-capable signing identity");
}
}

View File

@@ -84,6 +84,8 @@ import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.ca.IssuerChainPath;
import zeroecho.pki.api.ca.IssuerGeneration;
import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.attr.AttributeValue;
import zeroecho.pki.api.issuance.IssuanceIntent;
import zeroecho.pki.api.orch.SigningSubmissionId;
import zeroecho.pki.api.orch.WorkflowStateRecord;
import zeroecho.pki.api.policy.PolicyTrace;
@@ -173,7 +175,7 @@ import zeroecho.pki.spi.store.RevocationHistory;
*/
@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity", "PMD.TooManyMethods",
"PMD.ExcessivePublicCount", "PMD.UseTryWithResources", "PMD.ExceptionAsFlowControl",
"PMD.PreserveStackTrace", "PMD.NcssCount" })
"PMD.PreserveStackTrace", "PMD.NcssCount", "PMD.AvoidDuplicateLiterals" })
public final class FilesystemPkiStore implements PkiStore, Closeable {
private static final Logger LOG = Logger.getLogger(FilesystemPkiStore.class.getName());
@@ -704,6 +706,43 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
}
}
@Override
public Optional<Credential> getCredentialByIssuanceIntent(IssuanceIntent intent) {
requireStoreUsable();
Objects.requireNonNull(intent, "intent");
Path root = paths.root().resolve("credentials").resolve("by-id");
if (!Files.isDirectory(root)) {
return Optional.empty();
}
Credential match = null;
try (Stream<Path> records = Files.list(root)) {
java.util.Iterator<Path> iterator = records
.filter(path -> path.getFileName().toString().endsWith(".bin"))
.sorted(Comparator.comparing(path -> path.getFileName().toString())).iterator();
while (iterator.hasNext()) {
Credential credential = FsCodec.decode(FsCodec.CREDENTIAL,
FsOperations.readAll(iterator.next()), stagedContent);
Optional<AttributeValue> issuance = credential.attributes().get(IssuanceIntent.ID_ATTRIBUTE);
if (issuance.orElse(null) instanceof AttributeValue.StringValue value
&& intent.issuanceId().equals(value.value())) {
Optional<AttributeValue> commitment = credential.attributes()
.get(IssuanceIntent.COMMITMENT_ATTRIBUTE);
if (!(commitment.orElse(null) instanceof AttributeValue.StringValue command)
|| !intent.commandCommitment().equals(command.value())) {
throw new IllegalStateException("Issuance identity was reused with different input");
}
if (match != null) {
throw new IllegalStateException("Duplicate issuance identity");
}
match = credentialContentTransactions.validateLoaded(credential.credentialId(), credential);
}
}
return Optional.ofNullable(match);
} catch (IOException failure) {
throw new IllegalStateException("Issuance correlation read failed", failure);
}
}
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
private CaRecord validateCaCredentialReferences(CaRecord record) {
for (PkiId issuerId : record.issuerIds()) {

View File

@@ -42,6 +42,7 @@ import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.ca.IssuerChainPath;
import zeroecho.pki.api.ca.IssuerGeneration;
import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.issuance.IssuanceIntent;
import zeroecho.pki.api.orch.WorkflowStateRecord;
import zeroecho.pki.api.policy.PolicyTrace;
import zeroecho.pki.api.profile.ActiveCertificateProfile;
@@ -193,6 +194,16 @@ public interface PkiStore extends SignWorkflowStore, AutoCloseable {
*/
Optional<Credential> getCredential(PkiId credentialId);
/**
* Resolves the exact credential atomically persisted with one issuance intent.
* Implementations must reject duplicate matches and mismatched command
* commitments rather than selecting by list order.
*
* @param intent exact trusted issuance identity and commitment
* @return the previously committed credential, when present
*/
Optional<Credential> getCredentialByIssuanceIntent(IssuanceIntent intent);
/**
* Persists a parsed certification request.
*

View File

@@ -278,7 +278,7 @@ final class H7EndEntityAcceptanceE2eTest {
assertTrue(serial.signum() > 0);
assertTrue(serial.toByteArray().length <= 20);
}
assertEquals(List.of("issuerCaId", "request", "profileId", "validityOverride"),
assertEquals(List.of("issuerCaId", "request", "profileId", "validityOverride", "issuanceIntent"),
Arrays.stream(IssueEndEntityCommand.class.getRecordComponents()).map(component -> component.getName())
.toList());

View File

@@ -87,6 +87,7 @@ import zeroecho.pki.api.credential.EffectiveCredentialStatus;
import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver;
import zeroecho.pki.api.issuance.BundleCommand;
import zeroecho.pki.api.issuance.IssueEndEntityCommand;
import zeroecho.pki.api.issuance.IssuanceIntent;
import zeroecho.pki.api.issuance.VerificationPolicy;
import zeroecho.pki.api.request.CertificationRequest;
import zeroecho.pki.api.request.ParsedCertificationRequest;
@@ -157,7 +158,17 @@ public final class PkiCoreE2eTest {
runtime.caService().selectIssuancePath(rootCaId, root.currentIssuanceIssuerId(),
root.issuanceChainPathId(), "restore explicit test selection");
issuance.issueEndEntity(new IssueEndEntityCommand(rootCaId, leafRequest, "default", Optional.empty()));
CaRecord selected = runtime.caService().getCa(rootCaId);
zeroecho.pki.api.ca.IssuerChainPath path = runtime.store()
.getIssuerChainPath(selected.issuanceChainPathId()).orElseThrow();
IssuanceIntent intent = new IssuanceIntent("issuance:matrix-leaf", "a".repeat(64),
runtime.profileService().requireActiveProfile("default").reference(),
selected.currentIssuanceIssuerId(), selected.issuanceChainPathId(), path.pathCommitment());
IssueEndEntityCommand command = new IssueEndEntityCommand(rootCaId, leafRequest, "default",
Optional.empty(), Optional.of(intent));
CredentialBundle first = issuance.issueEndEntity(command);
CredentialBundle repeated = issuance.issueEndEntity(command);
assertEquals(first.credential().credentialId(), repeated.credential().credentialId());
assertEquals(List.of(usable.credentialId()), List.copyOf(resolved));
assertEquals(1, backend.endEntityCalls.get());
}

View File

@@ -345,7 +345,7 @@ final class H7ProfileEnforcementTest {
.issueEndEntity(new IssueEndEntityCommand(caId, request, "requested", Optional.empty())));
assertEquals(signs, runtime.submittedSignCount());
assertEquals(List.of("issuerCaId", "request", "profileId", "validityOverride"),
assertEquals(List.of("issuerCaId", "request", "profileId", "validityOverride", "issuanceIntent"),
java.util.Arrays.stream(IssueEndEntityCommand.class.getRecordComponents())
.map(component -> component.getName()).toList());
}