feat(pki): complete PKI administration CLI

Complete the transport-neutral PKI session composition and expose all
currently implemented administration capabilities through typed direct
and batch operations.

Preserve explicit optional capabilities, KeyRef confinement, deterministic
lifecycle handling and reuse by the future persistent server.
This commit is contained in:
2026-08-04 03:11:14 +02:00
parent 3de6cd7a34
commit 5c66a2b25b
25 changed files with 2561 additions and 46 deletions

View File

@@ -44,34 +44,85 @@ import java.util.Optional;
import zeroecho.core.io.CancellationSignal;
import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.ca.CaCreateCommand;
import zeroecho.pki.api.CaService;
import zeroecho.pki.api.CertificationRequestService;
import zeroecho.pki.api.IssuanceService;
import zeroecho.pki.api.PublicationService;
import zeroecho.pki.api.ProfileService;
import zeroecho.pki.api.RevocationService;
import zeroecho.pki.api.StatusObjectService;
import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.credential.CaProfileBinding;
import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialBundle;
import zeroecho.pki.api.credential.EndEntityProfileBinding;
import zeroecho.pki.api.profile.CertificateProfileDefinition;
import zeroecho.pki.api.profile.CertificateProfileDocumentCodec;
import zeroecho.pki.api.profile.CertificateProfileRef;
import zeroecho.pki.api.profile.ImportedCertificateProfileVersion;
import zeroecho.pki.api.issuance.IssueEndEntityCommand;
import zeroecho.pki.api.issuance.VerificationPolicy;
import zeroecho.pki.api.publication.PublicationRequest;
import zeroecho.pki.api.publication.PublicationResult;
import zeroecho.pki.api.publication.PublicationStatus;
import zeroecho.pki.api.publication.PublicationCursor;
import zeroecho.pki.api.publication.PublicationRecord;
import zeroecho.pki.api.request.CertificationRequest;
import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.api.request.ProofOfPossessionResult;
import zeroecho.pki.api.request.RequestStorePolicy;
import zeroecho.pki.api.revocation.RevocationCommand;
import zeroecho.pki.api.revocation.RevocationRecord;
import zeroecho.pki.api.revocation.RevocationQuery;
import zeroecho.pki.api.revocation.RevocationTransition;
import zeroecho.pki.api.status.StatusObject;
import zeroecho.pki.api.status.StatusObjectGenerateCommand;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
import zeroecho.pki.spi.store.PkiStore;
import zeroecho.pki.spi.store.RevocationHistory;
import zeroecho.pki.spi.store.RevocationSnapshot;
/** Explicit non-reflective executor used by one backend session. */
@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity", "PMD.ExcessiveParameterList",
"PMD.TooManyMethods" })
final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
private static final String COUNT = "count";
private static final String FORMAT_ID = "formatId";
private final PkiSessionConfiguration configuration;
private final PkiStore store;
private final ProfileService profiles;
private final RevocationService revocations;
private final Optional<CaService> authorities;
private final Optional<CertificationRequestService> requests;
private final Optional<IssuanceService> issuance;
private final Optional<StatusObjectService> statusObjects;
private final Optional<PublicationService> publications;
private final Runnable openCheck;
/* default */ DefaultPkiOperationExecutor(PkiSessionConfiguration configuration, PkiStore store,
RevocationService revocations, Runnable openCheck) {
ProfileService profiles, RevocationService revocations, Runnable openCheck) {
this(configuration, store, profiles, revocations, Optional.empty(), Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(), openCheck);
}
/* default */ DefaultPkiOperationExecutor(PkiSessionConfiguration configuration, PkiStore store,
ProfileService profiles, RevocationService revocations, Optional<CaService> authorities,
Optional<CertificationRequestService> requests, Optional<IssuanceService> issuance,
Optional<StatusObjectService> statusObjects, Optional<PublicationService> publications,
Runnable openCheck) {
this.configuration = Objects.requireNonNull(configuration, "configuration");
this.store = Objects.requireNonNull(store, "store");
this.profiles = Objects.requireNonNull(profiles, "profiles");
this.revocations = Objects.requireNonNull(revocations, "revocations");
this.authorities = Objects.requireNonNull(authorities, "authorities");
this.requests = Objects.requireNonNull(requests, "requests");
this.issuance = Objects.requireNonNull(issuance, "issuance");
this.statusObjects = Objects.requireNonNull(statusObjects, "statusObjects");
this.publications = Objects.requireNonNull(publications, "publications");
this.openCheck = Objects.requireNonNull(openCheck, "openCheck");
}
@@ -85,10 +136,32 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
PkiOperationResult result = switch (exact) {
case PkiOperation.ValidateConfiguration ignored -> validateConfiguration();
case PkiOperation.ValidateProfile request -> validateProfile(request);
case PkiOperation.RegisterProfile request -> registerProfile(request);
case PkiOperation.InspectProfile request -> inspectProfile(request);
case PkiOperation.ListProfileVersions request -> listProfileVersions(request);
case PkiOperation.ActivateProfile request -> activateProfile(request);
case PkiOperation.InspectAuthority request -> inspectAuthority(request);
case PkiOperation.ListAuthorities request -> listAuthorities(request);
case PkiOperation.CreateAuthority request -> createAuthority(request);
case PkiOperation.TransitionAuthority request -> transitionAuthority(request);
case PkiOperation.InspectRequest request -> inspectRequest(request);
case PkiOperation.ImportRequest request -> importRequest(request);
case PkiOperation.VerifyRequest request -> verifyRequest(request);
case PkiOperation.InspectCredential request -> inspectCredential(request);
case PkiOperation.IssueCredential request -> issueCredential(request);
case PkiOperation.RevokeCredential request -> revokeCredential(request);
case PkiOperation.InspectRevocation request -> inspectRevocation(request);
case PkiOperation.ReadRevocationHistory request -> readHistory(request, signal);
case PkiOperation.SnapshotRevocations request -> snapshotRevocations(request, signal);
case PkiOperation.InspectStatus request -> inspectStatus(request);
case PkiOperation.ListStatus request -> listStatus(request);
case PkiOperation.GenerateStatus request -> generateStatus(request);
case PkiOperation.InspectPublication request -> inspectPublication(request);
case PkiOperation.ListPublications request -> listPublications(request, signal);
case PkiOperation.RegisterPublication request -> registerPublication(request);
case PkiOperation.ProcessPublication request -> processPublication(request);
case PkiOperation.RetryPublication request -> retryPublication(request);
case PkiOperation.ReconcilePublication request -> reconcilePublication(request);
};
return new PkiOperationOutcome.Success(result);
} catch (InterruptedIOException failure) {
@@ -114,7 +187,101 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
fields.put("profileId", text(definition.profileId()));
fields.put("profileVersion", integer(definition.profileVersion()));
fields.put("certificateType", text(definition.certificateType().name()));
fields.put("formatId", text(definition.formatId().value()));
fields.put(FORMAT_ID, text(definition.formatId().value()));
return result(request.name(), fields);
}
private PkiOperationResult registerProfile(PkiOperation.RegisterProfile request) {
CertificateProfileRef reference = profiles.importProfile(request.document());
return profileReferenceResult(request.name(), reference);
}
private PkiOperationResult inspectProfile(PkiOperation.InspectProfile request) {
ImportedCertificateProfileVersion version = profiles
.getImportedVersion(request.profileId(), request.profileVersion())
.orElseThrow(MissingObjectException::new);
return profileVersionResult(request.name(), version);
}
private PkiOperationResult listProfileVersions(PkiOperation.ListProfileVersions request) {
List<PkiOperationValue> versions = profiles.listImportedVersions(request.profileId()).stream()
.map(DefaultPkiOperationExecutor::profileVersionValue).toList();
Map<String, PkiOperationValue> fields = fields();
fields.put("profileId", text(request.profileId()));
fields.put(COUNT, integer(versions.size()));
profiles.getActiveReference(request.profileId())
.ifPresent(reference -> fields.put("activeVersion", integer(reference.profileVersion())));
fields.put("versions", new PkiOperationValue.ListValue(versions));
return result(request.name(), fields);
}
private PkiOperationResult activateProfile(PkiOperation.ActivateProfile request) {
CertificateProfileRef reference = profiles.activateProfile(request.profileId(), request.profileVersion());
return profileReferenceResult(request.name(), reference);
}
private PkiOperationResult inspectAuthority(PkiOperation.InspectAuthority request) {
return authorityResult(request.name(), store.getCa(request.caId()).orElseThrow(MissingObjectException::new));
}
private PkiOperationResult listAuthorities(PkiOperation.ListAuthorities request) {
List<CaRecord> records = store.listCas();
int count = Math.min(records.size(), request.limit());
List<PkiOperationValue> values = records.subList(0, count).stream()
.map(DefaultPkiOperationExecutor::authorityValue).toList();
Map<String, PkiOperationValue> fields = fields();
fields.put(COUNT, integer(count));
fields.put("truncated", bool(records.size() > count));
fields.put("authorities", new PkiOperationValue.ListValue(values));
return result(request.name(), fields);
}
private PkiOperationResult createAuthority(PkiOperation.CreateAuthority request) {
CaCreateCommand command = new CaCreateCommand(request.formatId(), request.subjectRef(), request.profileId(),
Optional.of(request.keyRef()), new SimpleAttributeSet());
PkiId caId = requireCapability(authorities, "authority").createRoot(command);
Map<String, PkiOperationValue> fields = fields();
fields.put("caId", text(caId.value()));
fields.put("state", text(store.getCa(caId).orElseThrow(MissingObjectException::new).state().name()));
return result(request.name(), fields);
}
private PkiOperationResult transitionAuthority(PkiOperation.TransitionAuthority request) {
requireCapability(authorities, "authority").setCaState(request.caId(), request.state(), request.reason());
return authorityResult(request.name(), store.getCa(request.caId()).orElseThrow(MissingObjectException::new));
}
private PkiOperationResult inspectRequest(PkiOperation.InspectRequest request) {
ParsedCertificationRequest parsed = store.getRequest(request.requestId())
.orElseThrow(MissingObjectException::new);
Map<String, PkiOperationValue> fields = fields();
fields.put("requestId", text(parsed.requestId().value()));
fields.put(FORMAT_ID, text(parsed.formatId().value()));
fields.put("publicKeyBytes", integer(parsed.publicKeyInfo().bytes().length));
parsed.requestedProfileId().ifPresent(value -> fields.put("requestedProfileId", text(value)));
fields.put("subjectAlternativeNameCount", integer(parsed.subjectAlternativeNames().size()));
return result(request.name(), fields);
}
private PkiOperationResult importRequest(PkiOperation.ImportRequest request) {
CertificationRequest encoded = new CertificationRequest(request.formatId(), request.encoded());
CertificationRequestService service = requireCapability(requests, "certification-request");
ParsedCertificationRequest parsed = service.parse(encoded);
PkiId requestId = service.store(parsed, RequestStorePolicy.STORE_ALWAYS);
Map<String, PkiOperationValue> fields = fields();
fields.put("requestId", text(requestId.value()));
fields.put(FORMAT_ID, text(parsed.formatId().value()));
return result(request.name(), fields);
}
private PkiOperationResult verifyRequest(PkiOperation.VerifyRequest request) {
CertificationRequestService service = requireCapability(requests, "certification-request");
ParsedCertificationRequest parsed = service.get(request.requestId()).orElseThrow(MissingObjectException::new);
ProofOfPossessionResult proof = service.verifyProofOfPossession(parsed,
new VerificationPolicy(true, Optional.empty()));
Map<String, PkiOperationValue> fields = fields();
fields.put("requestId", text(request.requestId().value()));
fields.put("proofStatus", text(proof.status().name()));
return result(request.name(), fields);
}
@@ -124,7 +291,7 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
CertificateProfileRef profile = profileReference(credential);
Map<String, PkiOperationValue> fields = fields();
fields.put("credentialId", text(credential.credentialId().value()));
fields.put("formatId", text(credential.formatId().value()));
fields.put(FORMAT_ID, text(credential.formatId().value()));
fields.put("issuerId", text(credential.issuerRef().caId().value()));
fields.put("publicKeyId", text(credential.publicKeyId().value()));
fields.put("profileId", text(profile.profileId()));
@@ -135,6 +302,19 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
return result(request.name(), fields);
}
private PkiOperationResult issueCredential(PkiOperation.IssueCredential request) {
ParsedCertificationRequest parsed = requireCapability(requests, "certification-request")
.get(request.requestId()).orElseThrow(MissingObjectException::new);
IssueEndEntityCommand command = new IssueEndEntityCommand(request.issuerCaId(), parsed, request.profileId(),
Optional.empty());
CredentialBundle bundle = requireCapability(issuance, "issuance").issueEndEntity(command);
Map<String, PkiOperationValue> fields = fields();
fields.put("credentialId", text(bundle.credential().credentialId().value()));
fields.put("issuerCaId", text(bundle.credential().issuerRef().caId().value()));
fields.put("supportingObjectCount", integer(bundle.supportingObjects().size()));
return result(request.name(), fields);
}
private PkiOperationResult revokeCredential(PkiOperation.RevokeCredential request) {
RevocationCommand.RevokePermanently command = new RevocationCommand.RevokePermanently(request.credentialId(),
request.reason(), new SimpleAttributeSet());
@@ -142,6 +322,11 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
return revocationResult(request.name(), record);
}
private PkiOperationResult inspectRevocation(PkiOperation.InspectRevocation request) {
RevocationRecord record = revocations.get(request.credentialId()).orElseThrow(MissingObjectException::new);
return revocationResult(request.name(), record);
}
private PkiOperationResult readHistory(PkiOperation.ReadRevocationHistory request, CancellationSignal signal)
throws IOException {
List<PkiOperationValue> transitions = new ArrayList<>();
@@ -154,7 +339,7 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
}
Map<String, PkiOperationValue> fields = fields();
fields.put("credentialId", text(request.credentialId().value()));
fields.put("count", integer(transitions.size()));
fields.put(COUNT, integer(transitions.size()));
fields.put("truncated", bool(truncated));
fields.put("transitions", new PkiOperationValue.ListValue(transitions));
return result(request.name(), fields);
@@ -176,6 +361,120 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
return result(request.name(), fields);
}
private PkiOperationResult snapshotRevocations(PkiOperation.SnapshotRevocations request,
CancellationSignal signal) throws IOException {
List<PkiOperationValue> records = new ArrayList<>();
boolean truncated;
Map<String, PkiOperationValue> fields = fields();
RevocationQuery query = new RevocationQuery(Optional.empty(), Optional.empty(), Optional.empty(),
Optional.empty());
try (RevocationSnapshot snapshot = revocations.search(query);
RevocationSnapshot.Cursor cursor = snapshot.openCursor()) {
while (records.size() < request.limit() && cursor.next(signal)) {
records.add(revocationValue(cursor.current()));
}
truncated = records.size() == request.limit() && cursor.next(signal);
fields.put("snapshotId", text(snapshot.snapshotId()));
fields.put("revision", integer(snapshot.revision()));
fields.put("boundary", integer(snapshot.boundary()));
fields.put("commitment", text(snapshot.commitment()));
}
fields.put(COUNT, integer(records.size()));
fields.put("truncated", bool(truncated));
fields.put("records", new PkiOperationValue.ListValue(records));
return result(request.name(), fields);
}
private PkiOperationResult inspectStatus(PkiOperation.InspectStatus request) {
StatusObject object = store.getStatusObject(request.statusObjectId())
.orElseThrow(MissingObjectException::new);
return statusResult(request.name(), object);
}
private PkiOperationResult generateStatus(PkiOperation.GenerateStatus request) {
StatusObjectGenerateCommand command = new StatusObjectGenerateCommand(request.issuerCaId(), request.type(),
request.formatId(), new SimpleAttributeSet());
StatusObject object = requireCapability(statusObjects, "status-object").generate(command);
return statusResult(request.name(), object);
}
private PkiOperationResult listStatus(PkiOperation.ListStatus request) {
List<StatusObject> records = store.listStatusObjects(request.issuerCaId());
int count = Math.min(records.size(), request.limit());
List<PkiOperationValue> values = records.subList(0, count).stream()
.map(DefaultPkiOperationExecutor::statusValue).toList();
Map<String, PkiOperationValue> fields = fields();
fields.put("issuerCaId", text(request.issuerCaId().value()));
fields.put(COUNT, integer(count));
fields.put("truncated", bool(records.size() > count));
fields.put("statusObjects", new PkiOperationValue.ListValue(values));
return result(request.name(), fields);
}
private PkiOperationResult listPublications(PkiOperation.ListPublications request, CancellationSignal signal)
throws IOException {
List<PkiOperationValue> records = new ArrayList<>();
boolean truncated;
try (PublicationCursor cursor = store.openPublicationRecords()) {
Optional<PublicationRecord> next = cursor.next(signal);
while (records.size() < request.limit() && next.isPresent()) {
records.add(publicationValue(next.orElseThrow()));
next = cursor.next(signal);
}
truncated = next.isPresent();
}
Map<String, PkiOperationValue> fields = fields();
fields.put(COUNT, integer(records.size()));
fields.put("truncated", bool(truncated));
fields.put("publications", new PkiOperationValue.ListValue(records));
return result(request.name(), fields);
}
private PkiOperationResult registerPublication(PkiOperation.RegisterPublication request) {
PublicationRequest registration = new PublicationRequest(request.publicationId(), request.sourceType(),
request.sourceId(), request.target());
PublicationRecord record = requireCapability(publications, "publication").register(registration);
return publicationRecordResult(request.name(), record);
}
private PkiOperationResult processPublication(PkiOperation.ProcessPublication request) {
return publicationResult(request.name(),
requireCapability(publications, "publication").process(request.publicationId()));
}
private PkiOperationResult retryPublication(PkiOperation.RetryPublication request) {
return publicationResult(request.name(),
requireCapability(publications, "publication").retry(request.publicationId()));
}
private PkiOperationResult reconcilePublication(PkiOperation.ReconcilePublication request) {
return publicationResult(request.name(),
requireCapability(publications, "publication").reconcile(request.publicationId()));
}
private static PkiOperationResult publicationResult(String operation, PublicationResult publication) {
if (publication.status() == PublicationStatus.OUTCOME_UNKNOWN) {
throw new ExternalOutcomeUnknownException();
}
Map<String, PkiOperationValue> fields = fields();
fields.put("publicationId", text(publication.publicationId().value()));
fields.put("status", text(publication.status().name()));
fields.put("attemptNumber", integer(publication.attemptNumber()));
return result(operation, fields);
}
private static PkiOperationResult publicationRecordResult(String operation, PublicationRecord publication) {
Map<String, PkiOperationValue> fields = fields();
fields.put("publicationId", text(publication.publicationId().value()));
fields.put("status", text(publication.status().name()));
fields.put("attemptNumber", integer(publication.attemptNumber()));
return result(operation, fields);
}
private static <T> T requireCapability(Optional<T> service, String capability) {
return service.orElseThrow(() -> new CapabilityUnavailableException(capability));
}
private static PkiOperationResult revocationResult(String operation, RevocationRecord record) {
Map<String, PkiOperationValue> fields = fields();
fields.put("credentialId", text(record.credentialId().value()));
@@ -183,6 +482,95 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
return result(operation, fields);
}
private static PkiOperationResult profileReferenceResult(String operation, CertificateProfileRef reference) {
Map<String, PkiOperationValue> fields = fields();
appendProfileReference(fields, reference);
return result(operation, fields);
}
private static PkiOperationResult profileVersionResult(String operation,
ImportedCertificateProfileVersion version) {
Map<String, PkiOperationValue> fields = fields();
appendProfileVersion(fields, version);
return result(operation, fields);
}
private static PkiOperationValue profileVersionValue(ImportedCertificateProfileVersion version) {
Map<String, PkiOperationValue> fields = fields();
appendProfileVersion(fields, version);
return new PkiOperationValue.ObjectValue(fields);
}
private static void appendProfileVersion(Map<String, PkiOperationValue> fields,
ImportedCertificateProfileVersion version) {
appendProfileReference(fields, version.reference());
fields.put("certificateType", text(version.definition().certificateType().name()));
fields.put(FORMAT_ID, text(version.definition().formatId().value()));
fields.put("importedAt", text(version.importedAt().toString()));
}
private static void appendProfileReference(Map<String, PkiOperationValue> fields,
CertificateProfileRef reference) {
fields.put("profileId", text(reference.profileId()));
fields.put("profileVersion", integer(reference.profileVersion()));
fields.put("fingerprint", text(reference.shortFingerprint()));
}
private static PkiOperationResult authorityResult(String operation, CaRecord record) {
return result(operation, authorityFields(record));
}
private static PkiOperationValue authorityValue(CaRecord record) {
return new PkiOperationValue.ObjectValue(authorityFields(record));
}
private static Map<String, PkiOperationValue> authorityFields(CaRecord record) {
Map<String, PkiOperationValue> fields = fields();
fields.put("caId", text(record.caId().value()));
fields.put("kind", text(record.kind().name()));
fields.put("state", text(record.state().name()));
fields.put("credentialCount", integer(record.credentialIds().size()));
return fields;
}
private static PkiOperationValue revocationValue(RevocationRecord record) {
Map<String, PkiOperationValue> fields = fields();
fields.put("credentialId", text(record.credentialId().value()));
appendTransition(fields, record.transition());
return new PkiOperationValue.ObjectValue(fields);
}
private static PkiOperationResult statusResult(String operation, StatusObject object) {
return result(operation, statusFields(object));
}
private static PkiOperationValue statusValue(StatusObject object) {
return new PkiOperationValue.ObjectValue(statusFields(object));
}
private static Map<String, PkiOperationValue> statusFields(StatusObject object) {
Map<String, PkiOperationValue> fields = fields();
fields.put("statusObjectId", text(object.statusObjectId().value()));
fields.put("issuerCaId", text(object.issuerCaId().value()));
fields.put("type", text(object.type().name()));
fields.put(FORMAT_ID, text(object.formatId().value()));
fields.put("thisUpdate", text(object.thisUpdate().toString()));
object.nextUpdate().ifPresent(value -> fields.put("nextUpdate", text(value.toString())));
fields.put("contentLength", integer(object.content().length()));
fields.put("contentSha256", text(object.content().sha256()));
return fields;
}
private static PkiOperationValue publicationValue(PublicationRecord record) {
Map<String, PkiOperationValue> fields = fields();
fields.put("publicationId", text(record.publicationId().value()));
fields.put("sourceType", text(record.sourceType().name()));
fields.put("sourceId", text(record.sourceId().value()));
fields.put("status", text(record.status().name()));
fields.put("attemptNumber", integer(record.attemptNumber()));
return new PkiOperationValue.ObjectValue(fields);
}
private static PkiOperationValue transitionValue(RevocationTransition transition) {
Map<String, PkiOperationValue> fields = fields();
appendTransition(fields, transition);
@@ -207,6 +595,12 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
}
private static PkiOperationOutcome classify(String operation, RuntimeException failure) {
if (failure instanceof CapabilityUnavailableException) {
return failure(operation, PkiOperationFailure.VALIDATION_FAILURE, "CAPABILITY_NOT_CONFIGURED");
}
if (failure instanceof ExternalOutcomeUnknownException) {
return failure(operation, PkiOperationFailure.EXTERNAL_OUTCOME_UNKNOWN, "EXTERNAL_OUTCOME_UNKNOWN");
}
if (failure instanceof MissingObjectException) {
return failure(operation, PkiOperationFailure.NOT_FOUND, "OBJECT_NOT_FOUND");
}
@@ -220,6 +614,10 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
if (message.contains("CREDENTIAL_NOT_FOUND")) {
return failure(operation, PkiOperationFailure.NOT_FOUND, "OBJECT_NOT_FOUND");
}
String lowerMessage = message.toLowerCase(java.util.Locale.ROOT);
if (lowerMessage.contains("not found") || lowerMessage.contains("does not exist")) {
return failure(operation, PkiOperationFailure.NOT_FOUND, "OBJECT_NOT_FOUND");
}
if (message.contains("CONFLICT") || message.contains("changed concurrently")) {
return failure(operation, PkiOperationFailure.CONFLICT, "OPERATION_CONFLICT");
}
@@ -266,4 +664,18 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
private static final class MissingObjectException extends RuntimeException {
private static final long serialVersionUID = 1L;
}
/** Internal marker for an operation whose explicitly configured service is unavailable. */
private static final class CapabilityUnavailableException extends RuntimeException {
private static final long serialVersionUID = 1L;
private CapabilityUnavailableException(String capability) {
super(capability);
}
}
/** Internal marker preserving an unresolved external publication outcome. */
private static final class ExternalOutcomeUnknownException extends RuntimeException {
private static final long serialVersionUID = 1L;
}
}

View File

@@ -33,17 +33,53 @@
******************************************************************************/
package zeroecho.pki.application;
import java.nio.file.Path;
import java.time.Clock;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import zeroecho.core.alg.BootstrapAlgorithmIdentities;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.spec.AlgorithmSuite;
import zeroecho.core.spi.AlgorithmExecutionCapability;
import zeroecho.core.spi.AlgorithmExecutionCapabilityProvider;
import zeroecho.core.spi.KeyringUnlockProvider;
import zeroecho.core.storage.KeyringPassword;
import zeroecho.pki.api.CaService;
import zeroecho.pki.api.CertificationRequestService;
import zeroecho.pki.api.IssuanceService;
import zeroecho.pki.api.PublicationService;
import zeroecho.pki.api.ProfileService;
import zeroecho.pki.api.RevocationService;
import zeroecho.pki.api.StatusObjectService;
import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver;
import zeroecho.pki.impl.core.DefaultProfileService;
import zeroecho.pki.impl.core.DefaultRevocationService;
import zeroecho.pki.impl.core.DefaultCaService;
import zeroecho.pki.impl.core.DefaultCertificationRequestService;
import zeroecho.pki.impl.core.DefaultIssuanceService;
import zeroecho.pki.impl.core.DefaultPublicationService;
import zeroecho.pki.impl.core.DefaultStatusObjectService;
import zeroecho.pki.impl.core.StoreBackedEffectiveCredentialStatusResolver;
import zeroecho.pki.impl.core.async.PkiSigningBus;
import zeroecho.pki.impl.framework.x509.X509AlgorithmResolver;
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialFramework;
import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialIssuerBackend;
import zeroecho.pki.impl.framework.x509.bc.BcX509ProofOfPossessionVerifier;
import zeroecho.pki.impl.framework.x509.bc.BcX509StatusObjectGenerator;
import zeroecho.pki.impl.framework.x509.bc.BcX509VerificationExecutor;
import zeroecho.pki.spi.ProviderConfig;
import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.spi.bootstrap.PkiBootstrap;
import zeroecho.pki.spi.crypto.PublicKeyInfoSource;
import zeroecho.pki.spi.crypto.SignatureWorkflow;
import zeroecho.pki.spi.crypto.SignatureWorkflowRuntimeDependencies;
import zeroecho.pki.spi.publish.Publisher;
import zeroecho.pki.spi.store.PkiStore;
/** Default synchronous session composition. */
@@ -53,37 +89,76 @@ final class DefaultPkiSession implements PkiSession {
private final AuditSink audit;
private final ProfileService profiles;
private final RevocationService revocations;
private final Optional<CaService> authorities;
private final Optional<CertificationRequestService> requests;
private final Optional<IssuanceService> issuance;
private final Optional<StatusObjectService> statusObjects;
private final Optional<PublicationService> publications;
private final Optional<PkiSigningBus> signingBus;
private final Optional<SignatureWorkflow> signatureWorkflow;
private final PkiOperationExecutor operations;
private final AtomicBoolean closed = new AtomicBoolean();
private DefaultPkiSession(PkiSessionConfiguration configuration, PkiStore store, AuditSink audit, Clock clock) {
private DefaultPkiSession(PkiSessionConfiguration configuration, PkiStore store, AuditSink audit,
ProfileService profiles, RevocationService revocations, ServiceGraph graph) {
this.store = store;
this.audit = audit;
this.profiles = new DefaultProfileService(store, clock, audit);
this.revocations = new DefaultRevocationService(store, clock, audit);
this.operations = new DefaultPkiOperationExecutor(configuration, store, revocations,
this::requireOpen);
this.profiles = Objects.requireNonNull(profiles, "profiles");
this.revocations = Objects.requireNonNull(revocations, "revocations");
this.authorities = graph.authorities();
this.requests = graph.requests();
this.issuance = graph.issuance();
this.statusObjects = graph.statusObjects();
this.publications = graph.publications();
this.signingBus = graph.signingBus();
this.signatureWorkflow = graph.signatureWorkflow();
this.operations = new DefaultPkiOperationExecutor(configuration, store, profiles, revocations, authorities,
requests, issuance, statusObjects, publications, this::requireOpen);
}
/* default */ static PkiSession open(PkiSessionConfiguration configuration) {
return open(configuration, Clock.systemUTC(), ProductionBootstrap.INSTANCE);
return open(configuration, runtimeDependencies(configuration), Clock.systemUTC(), ProductionBootstrap.INSTANCE);
}
/* default */ static PkiSession open(PkiSessionConfiguration configuration,
PkiSessionRuntimeDependencies dependencies) {
return open(configuration, dependencies, Clock.systemUTC(), ProductionBootstrap.INSTANCE);
}
/* default */ static PkiSession open(PkiSessionConfiguration configuration, Clock clock, Bootstrap bootstrap) {
return open(configuration, PkiSessionRuntimeDependencies.none(), clock, bootstrap);
}
@SuppressWarnings("PMD.AvoidCatchingGenericException")
/* default */ static PkiSession open(PkiSessionConfiguration configuration, Clock clock, Bootstrap bootstrap) {
/* default */ static PkiSession open(PkiSessionConfiguration configuration,
PkiSessionRuntimeDependencies dependencies, Clock clock, Bootstrap bootstrap) {
PkiSessionConfiguration exact = Objects.requireNonNull(configuration, "configuration");
Objects.requireNonNull(clock, "clock");
Objects.requireNonNull(bootstrap, "bootstrap");
Objects.requireNonNull(dependencies, "dependencies");
bootstrap.validateStore(exact.store());
bootstrap.validateAudit(exact.audit());
exact.signing().ifPresent(signing -> {
bootstrap.validateSignatureWorkflow(signing.workflow());
bootstrap.validateCredentialFramework(signing.framework());
if (!"x509-bc".equals(signing.framework().backendId())) {
throw new IllegalArgumentException("Configured framework cannot compose the X.509 service graph");
}
});
exact.publishers().forEach(bootstrap::validatePublisher);
PkiStore store = null;
AuditSink audit = null;
ServiceGraph graph = ServiceGraph.empty();
try {
store = Objects.requireNonNull(bootstrap.openStore(exact.store()), "opened store");
audit = Objects.requireNonNull(bootstrap.openAudit(exact.audit()), "opened audit sink");
return new DefaultPkiSession(exact, store, audit, clock);
ProfileService profiles = new DefaultProfileService(store, clock, audit);
RevocationService revocations = new DefaultRevocationService(store, clock, audit);
graph = composeGraph(exact, dependencies, store, audit, profiles, clock, bootstrap);
return new DefaultPkiSession(exact, store, audit, profiles, revocations, graph);
} catch (RuntimeException | Error primary) {
closeGraphAfterConstructionFailure(graph, primary);
closeAfterConstructionFailure(audit, store, primary);
throw primary;
}
@@ -101,6 +176,36 @@ final class DefaultPkiSession implements PkiSession {
return revocations;
}
@Override
public Optional<CaService> authorities() {
requireOpen();
return authorities;
}
@Override
public Optional<CertificationRequestService> requests() {
requireOpen();
return requests;
}
@Override
public Optional<IssuanceService> issuance() {
requireOpen();
return issuance;
}
@Override
public Optional<StatusObjectService> statusObjects() {
requireOpen();
return statusObjects;
}
@Override
public Optional<PublicationService> publications() {
requireOpen();
return publications;
}
@Override
public PkiOperationExecutor operations() {
requireOpen();
@@ -113,6 +218,9 @@ final class DefaultPkiSession implements PkiSession {
return;
}
Throwable primary = null;
primary = closeResource(publications.orElse(null), primary);
primary = closeResource(signingBus.orElse(null), primary);
primary = closeResource(signatureWorkflow.orElse(null), primary);
try {
audit.close();
} catch (Throwable failure) { // NOPMD - preserve Error and checked close failures.
@@ -136,6 +244,154 @@ final class DefaultPkiSession implements PkiSession {
}
}
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.CloseResource", "PMD.ExceptionAsFlowControl" })
private static ServiceGraph composeGraph(PkiSessionConfiguration configuration,
PkiSessionRuntimeDependencies dependencies, PkiStore store, AuditSink audit, ProfileService profiles,
Clock clock, Bootstrap bootstrap) {
Optional<SignatureWorkflow> workflow = Optional.empty();
Optional<PkiSigningBus> bus = Optional.empty();
Optional<CaService> authorities = Optional.empty();
Optional<CertificationRequestService> requests = Optional.empty();
Optional<IssuanceService> issuance = Optional.empty();
Optional<StatusObjectService> statuses = Optional.empty();
Optional<PublicationService> publications = Optional.empty();
try {
if (configuration.signing().isPresent()) {
PkiSessionConfiguration.SigningConfiguration signing = configuration.signing().orElseThrow();
KeyringUnlockProvider unlock = dependencies.keyringUnlockProvider().orElseThrow(
() -> new IllegalStateException("Configured signing requires key-unlock capability"));
SignatureWorkflow openedWorkflow = bootstrap.openSignatureWorkflow(signing.workflow(),
SignatureWorkflowRuntimeDependencies.withKeyringUnlockProvider(unlock));
workflow = Optional.of(openedWorkflow);
if (!(openedWorkflow instanceof PublicKeyInfoSource publicKeys)) {
throw new IllegalStateException("Signature workflow lacks managed public-key capability");
}
BcX509VerificationExecutor verification = new BcX509VerificationExecutor();
X509AuthoritySnapshot authority = authority(openedWorkflow, verification);
PkiSigningBus openedBus = new PkiSigningBus(store, openedWorkflow, Path.of(signing.busPath()),
authority);
bus = Optional.of(openedBus);
BcX509CredentialIssuerBackend issuerBackend = new BcX509CredentialIssuerBackend(openedBus,
signing.signatureAlgorithm(), signing.signingTtl());
BcX509StatusObjectGenerator statusGenerator = new BcX509StatusObjectGenerator(openedBus,
signing.signatureAlgorithm(), signing.signingTtl());
BcX509CredentialFramework framework = new BcX509CredentialFramework(authority, verification)
.wired(statusGenerator, new BcX509ProofOfPossessionVerifier(authority, verification));
EffectiveCredentialStatusResolver statusResolver = new StoreBackedEffectiveCredentialStatusResolver(
store, clock);
requests = Optional.of(new DefaultCertificationRequestService(store, framework));
issuance = Optional.of(new DefaultIssuanceService(store, framework, issuerBackend, audit,
statusResolver, profiles, clock));
statuses = Optional.of(new DefaultStatusObjectService(store, framework, audit, statusResolver,
authority));
authorities = Optional.of(new DefaultCaService(store, framework, issuerBackend,
publicKeys::resolvePublicKeyInfo, openedBus, audit, statusResolver, profiles, clock,
signing.signatureAlgorithm(), signing.signingTtl()));
}
List<Publisher> configuredPublishers = new ArrayList<>();
for (ProviderConfig publisher : configuration.publishers()) {
configuredPublishers.add(bootstrap.openPublisher(publisher));
}
publications = configuredPublishers.isEmpty() ? Optional.empty()
: Optional.of(new DefaultPublicationService(store, clock, configuredPublishers));
return new ServiceGraph(authorities, requests, issuance, statuses, publications, bus, workflow);
} catch (RuntimeException | Error primary) {
Throwable cleanup = closeResource(publications.orElse(null), primary);
cleanup = closeResource(bus.orElse(null), cleanup);
closeResource(workflow.orElse(null), cleanup);
throw primary;
}
}
private static X509AuthoritySnapshot authority(SignatureWorkflow workflow,
BcX509VerificationExecutor verification) {
Set<String> supported = Set.copyOf(workflow.supportedAlgorithms());
Set<AlgorithmIdentity> identities = BootstrapAlgorithmIdentities.catalog().identities().stream()
.filter(identity -> identity.kind() == AlgorithmIdentity.Kind.SIGNATURE)
.filter(identity -> supported.contains(identity.canonicalForm())
|| BootstrapAlgorithmIdentities.compatibilityAliases().entrySet().stream()
.anyMatch(entry -> entry.getValue().equals(identity) && supported.contains(entry.getKey())))
.collect(java.util.stream.Collectors.toUnmodifiableSet());
String implementationId = "workflow." + workflow.id();
AlgorithmExecutionCapability signing = new AlgorithmExecutionCapability() {
@Override
public String implementationId() {
return implementationId;
}
@Override
public String domainFingerprint() {
return "session-signing-v1:" + workflow.id() + ":" + identities.stream()
.map(AlgorithmIdentity::canonicalForm).sorted().reduce("", String::concat);
}
@Override
public boolean supports(AlgorithmIdentity identity, AlgorithmSuite suite, Direction direction) {
return direction == Direction.SIGN && identities.contains(identity) && identity.equals(suite.signature());
}
};
AlgorithmExecutionCapabilityProvider signingProvider = () -> List.of(signing);
X509AlgorithmResolver.Policy policy = new X509AlgorithmResolver.Policy() {
@Override
public boolean permits(AlgorithmSuite suite, AlgorithmExecutionCapability.Direction direction) {
return true;
}
@Override
public String semanticFingerprint() {
return "pki-session-bootstrap-policy-v1:configured-capabilities-only";
}
};
return X509AuthoritySnapshot.compose(List.of(), List.of(signingProvider, verification),
List.of(X509AuthoritySnapshot.bindExecutor(implementationId,
AlgorithmExecutionCapability.Direction.SIGN, workflow),
X509AuthoritySnapshot.bindExecutor(BcX509VerificationExecutor.IMPLEMENTATION_ID,
AlgorithmExecutionCapability.Direction.VERIFY, verification)), policy);
}
private static PkiSessionRuntimeDependencies runtimeDependencies(PkiSessionConfiguration configuration) {
Optional<String> environment = configuration.signing()
.flatMap(PkiSessionConfiguration.SigningConfiguration::unlockEnvironmentVariable);
if (environment.isEmpty()) {
return PkiSessionRuntimeDependencies.none();
}
KeyringUnlockProvider provider = () -> {
String value = System.getenv(environment.orElseThrow());
if (value == null || value.isEmpty()) {
throw new java.io.IOException("Configured key-unlock source is unavailable");
}
char[] password = value.toCharArray();
try {
return new KeyringPassword(password);
} finally {
java.util.Arrays.fill(password, '\0');
}
};
return PkiSessionRuntimeDependencies.withKeyringUnlockProvider(provider);
}
@SuppressWarnings("PMD.AvoidCatchingGenericException")
private static Throwable closeResource(AutoCloseable resource, Throwable primary) {
if (resource == null) {
return primary;
}
try {
resource.close();
} catch (Throwable failure) {
if (primary == null) {
return failure;
}
primary.addSuppressed(failure);
}
return primary;
}
private static void closeGraphAfterConstructionFailure(ServiceGraph graph, Throwable primary) {
Throwable ignored = closeResource(graph.publications().orElse(null), primary);
ignored = closeResource(graph.signingBus().orElse(null), ignored);
closeResource(graph.signatureWorkflow().orElse(null), ignored);
}
private static void closeAfterConstructionFailure(AuditSink audit, PkiStore store, Throwable primary) {
if (audit != null) {
try {
@@ -180,6 +436,42 @@ final class DefaultPkiSession implements PkiSession {
/** Opens the configured audit sink. */
AuditSink openAudit(ProviderConfig configuration);
/** Validates signing-workflow configuration. */
default void validateSignatureWorkflow(ProviderConfig configuration) {
PkiBootstrap.validateSignatureWorkflowConfiguration(configuration);
}
/** Validates credential-framework configuration. */
default void validateCredentialFramework(ProviderConfig configuration) {
PkiBootstrap.validateCredentialFrameworkConfiguration(configuration);
}
/** Validates publisher configuration. */
default void validatePublisher(ProviderConfig configuration) {
PkiBootstrap.validatePublisherConfiguration(configuration);
}
/** Opens the configured signature workflow. */
default SignatureWorkflow openSignatureWorkflow(ProviderConfig configuration,
SignatureWorkflowRuntimeDependencies dependencies) {
return PkiBootstrap.openConfiguredSignatureWorkflow(configuration, dependencies);
}
/** Opens one configured publisher. */
default Publisher openPublisher(ProviderConfig configuration) {
return PkiBootstrap.openPublisher(configuration);
}
}
private record ServiceGraph(Optional<CaService> authorities, Optional<CertificationRequestService> requests,
Optional<IssuanceService> issuance, Optional<StatusObjectService> statusObjects,
Optional<PublicationService> publications, Optional<PkiSigningBus> signingBus,
Optional<SignatureWorkflow> signatureWorkflow) {
private static ServiceGraph empty() {
return new ServiceGraph(Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty());
}
}
/** Production provider bootstrap implementation. */

View File

@@ -35,8 +35,16 @@ package zeroecho.pki.application;
import java.util.Objects;
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.ca.CaState;
import zeroecho.pki.api.publication.PublicationSourceType;
import zeroecho.pki.api.publication.PublicationTarget;
import zeroecho.pki.api.revocation.RevocationReason;
import zeroecho.pki.api.status.StatusObjectType;
/**
* Closed set of typed synchronous PKI operations available to transports.
@@ -45,8 +53,18 @@ import zeroecho.pki.api.revocation.RevocationReason;
* explicit type and never by reflection or Java class name.</p>
*/
public sealed interface PkiOperation permits PkiOperation.ValidateConfiguration, PkiOperation.ValidateProfile,
PkiOperation.InspectCredential, PkiOperation.RevokeCredential, PkiOperation.ReadRevocationHistory,
PkiOperation.InspectPublication {
PkiOperation.RegisterProfile, PkiOperation.InspectProfile, PkiOperation.ListProfileVersions,
PkiOperation.ActivateProfile, PkiOperation.InspectAuthority, PkiOperation.ListAuthorities,
PkiOperation.CreateAuthority, PkiOperation.TransitionAuthority, PkiOperation.InspectRequest,
PkiOperation.ImportRequest, PkiOperation.VerifyRequest, PkiOperation.InspectCredential,
PkiOperation.IssueCredential, PkiOperation.RevokeCredential,
PkiOperation.InspectRevocation, PkiOperation.ReadRevocationHistory, PkiOperation.SnapshotRevocations,
PkiOperation.InspectStatus, PkiOperation.ListStatus, PkiOperation.GenerateStatus,
PkiOperation.InspectPublication, PkiOperation.ListPublications, PkiOperation.RegisterPublication,
PkiOperation.ProcessPublication, PkiOperation.RetryPublication, PkiOperation.ReconcilePublication {
/** Maximum finite entries returned by one presentation operation. */
int MAXIMUM_RESULT_ENTRIES = 1_000;
/** @return stable semantic operation name */
String name();
@@ -83,6 +101,204 @@ public sealed interface PkiOperation permits PkiOperation.ValidateConfiguration,
}
}
/** Imports one bounded validated certificate-profile document. */
record RegisterProfile(byte[] document) implements PkiOperation {
/** Stable operation name. */
public static final String NAME = "profile.register";
/** Defensively snapshots the document. */
public RegisterProfile {
document = Objects.requireNonNull(document, "document").clone();
}
@Override
public byte[] document() {
return document.clone();
}
@Override
public String name() {
return NAME;
}
}
/** Reads one exact imported certificate-profile version. */
record InspectProfile(String profileId, long profileVersion) implements PkiOperation {
/** Stable operation name. */
public static final String NAME = "profile.inspect";
/** Validates the exact profile identity. */
public InspectProfile {
if (profileId == null || profileId.isBlank() || profileVersion <= 0) {
throw new IllegalArgumentException("profile identity is invalid");
}
}
@Override
public String name() {
return NAME;
}
}
/** Lists the finite imported versions of one logical profile. */
record ListProfileVersions(String profileId) implements PkiOperation {
/** Stable operation name. */
public static final String NAME = "profile.list";
/** Validates the logical profile identity. */
public ListProfileVersions {
if (profileId == null || profileId.isBlank()) {
throw new IllegalArgumentException("profileId must not be blank");
}
}
@Override
public String name() {
return NAME;
}
}
/** Activates one already imported certificate-profile version. */
record ActivateProfile(String profileId, long profileVersion) implements PkiOperation {
/** Stable operation name. */
public static final String NAME = "profile.activate";
/** Validates the exact profile identity. */
public ActivateProfile {
if (profileId == null || profileId.isBlank() || profileVersion <= 0) {
throw new IllegalArgumentException("profile identity is invalid");
}
}
@Override
public String name() {
return NAME;
}
}
/** Reads safe metadata for one committed certificate authority. */
record InspectAuthority(PkiId caId) implements PkiOperation {
/** Stable operation name. */
public static final String NAME = "ca.inspect";
/** Validates the authority identity. */
public InspectAuthority {
Objects.requireNonNull(caId, "caId");
}
@Override
public String name() {
return NAME;
}
}
/** Lists committed certificate authorities with a finite presentation limit. */
record ListAuthorities(int limit) implements PkiOperation {
/** Stable operation name. */
public static final String NAME = "ca.list";
/** Validates the presentation limit. */
public ListAuthorities {
requireLimit(limit);
}
@Override
public String name() {
return NAME;
}
}
/** Creates one root certificate authority using an existing managed key reference. */
record CreateAuthority(FormatId formatId, SubjectRef subjectRef, String profileId, KeyRef keyRef)
implements PkiOperation {
/** Stable operation name. */
public static final String NAME = "ca.create";
/** Validates the closed authority-creation input. */
public CreateAuthority {
Objects.requireNonNull(formatId, "formatId");
Objects.requireNonNull(subjectRef, "subjectRef");
Objects.requireNonNull(keyRef, "keyRef");
if (profileId == null || profileId.isBlank()) {
throw new IllegalArgumentException("profileId must not be blank");
}
}
@Override
public String name() {
return NAME;
}
}
/** Applies one explicit lifecycle transition to an existing authority. */
record TransitionAuthority(PkiId caId, CaState state, String reason) implements PkiOperation {
/** Stable operation name. */
public static final String NAME = "ca.transition";
/** Validates the lifecycle-transition input. */
public TransitionAuthority {
Objects.requireNonNull(caId, "caId");
Objects.requireNonNull(state, "state");
if (reason == null || reason.isBlank()) {
throw new IllegalArgumentException("reason must not be blank");
}
}
@Override
public String name() {
return NAME;
}
}
/** Reads safe metadata for one stored certification request. */
record InspectRequest(PkiId requestId) implements PkiOperation {
/** Stable operation name. */
public static final String NAME = "request.inspect";
/** Validates the request identity. */
public InspectRequest {
Objects.requireNonNull(requestId, "requestId");
}
@Override
public String name() {
return NAME;
}
}
/** Strictly parses and durably imports one certification request. */
record ImportRequest(FormatId formatId, EncodedObject encoded) implements PkiOperation {
/** Stable operation name. */
public static final String NAME = "request.import";
/** Validates the finite request input. */
public ImportRequest {
Objects.requireNonNull(formatId, "formatId");
Objects.requireNonNull(encoded, "encoded");
}
@Override
public String name() {
return NAME;
}
}
/** Verifies proof of possession for one stored request. */
record VerifyRequest(PkiId requestId) implements PkiOperation {
/** Stable operation name. */
public static final String NAME = "request.verify";
/** Validates the request identity. */
public VerifyRequest {
Objects.requireNonNull(requestId, "requestId");
}
@Override
public String name() {
return NAME;
}
}
/** Reads safe metadata for one committed credential. */
record InspectCredential(PkiId credentialId) implements PkiOperation {
/** Stable operation name. */
@@ -99,6 +315,26 @@ public sealed interface PkiOperation permits PkiOperation.ValidateConfiguration,
}
}
/** Issues one end-entity credential from an already imported request. */
record IssueCredential(PkiId issuerCaId, PkiId requestId, String profileId) implements PkiOperation {
/** Stable operation name. */
public static final String NAME = "credential.issue";
/** Validates the issuance input. */
public IssueCredential {
Objects.requireNonNull(issuerCaId, "issuerCaId");
Objects.requireNonNull(requestId, "requestId");
if (profileId == null || profileId.isBlank()) {
throw new IllegalArgumentException("profileId must not be blank");
}
}
@Override
public String name() {
return NAME;
}
}
/** Permanently revokes one committed credential. */
record RevokeCredential(PkiId credentialId, RevocationReason reason) implements PkiOperation {
/** Stable operation name. */
@@ -140,6 +376,89 @@ public sealed interface PkiOperation permits PkiOperation.ValidateConfiguration,
}
}
/** Reads the validated current revocation state for one credential. */
record InspectRevocation(PkiId credentialId) implements PkiOperation {
/** Stable operation name. */
public static final String NAME = "revocation.inspect";
/** Validates the credential identity. */
public InspectRevocation {
Objects.requireNonNull(credentialId, "credentialId");
}
@Override
public String name() {
return NAME;
}
}
/** Reads a bounded page from one immutable current-revocation snapshot. */
record SnapshotRevocations(int limit) implements PkiOperation {
/** Stable operation name. */
public static final String NAME = "revocation.snapshot";
/** Validates the presentation limit. */
public SnapshotRevocations {
requireLimit(limit);
}
@Override
public String name() {
return NAME;
}
}
/** Reads safe metadata for one committed status object. */
record InspectStatus(PkiId statusObjectId) implements PkiOperation {
/** Stable operation name. */
public static final String NAME = "status.inspect";
/** Validates the status-object identity. */
public InspectStatus {
Objects.requireNonNull(statusObjectId, "statusObjectId");
}
@Override
public String name() {
return NAME;
}
}
/** Lists status objects for one issuer with a finite presentation limit. */
record ListStatus(PkiId issuerCaId, int limit) implements PkiOperation {
/** Stable operation name. */
public static final String NAME = "inventory.status";
/** Validates the issuer identity and presentation limit. */
public ListStatus {
Objects.requireNonNull(issuerCaId, "issuerCaId");
requireLimit(limit);
}
@Override
public String name() {
return NAME;
}
}
/** Generates one signed status object from a stable revocation view. */
record GenerateStatus(PkiId issuerCaId, StatusObjectType type, FormatId formatId) implements PkiOperation {
/** Stable operation name. */
public static final String NAME = "status.generate";
/** Validates the generation input. */
public GenerateStatus {
Objects.requireNonNull(issuerCaId, "issuerCaId");
Objects.requireNonNull(type, "type");
Objects.requireNonNull(formatId, "formatId");
}
@Override
public String name() {
return NAME;
}
}
/** Reads safe durable state for one publication operation. */
record InspectPublication(PkiId publicationId) implements PkiOperation {
/** Stable operation name. */
@@ -155,4 +474,94 @@ public sealed interface PkiOperation permits PkiOperation.ValidateConfiguration,
return NAME;
}
}
/** Lists publication records using a bounded-memory cursor and finite result page. */
record ListPublications(int limit) implements PkiOperation {
/** Stable operation name. */
public static final String NAME = "publication.list";
/** Validates the presentation limit. */
public ListPublications {
requireLimit(limit);
}
@Override
public String name() {
return NAME;
}
}
/** Registers post-commit publication of one authoritative source object. */
record RegisterPublication(PkiId publicationId, PublicationSourceType sourceType, PkiId sourceId,
PublicationTarget target) implements PkiOperation {
/** Stable operation name. */
public static final String NAME = "publication.register";
/** Validates the immutable publication registration fields. */
public RegisterPublication {
Objects.requireNonNull(publicationId, "publicationId");
Objects.requireNonNull(sourceType, "sourceType");
Objects.requireNonNull(sourceId, "sourceId");
Objects.requireNonNull(target, "target");
}
@Override
public String name() {
return NAME;
}
}
/** Processes one explicitly selected pending publication operation. */
record ProcessPublication(PkiId publicationId) implements PkiOperation {
/** Stable operation name. */
public static final String NAME = "publication.process";
/** Validates the publication identity. */
public ProcessPublication {
Objects.requireNonNull(publicationId, "publicationId");
}
@Override
public String name() {
return NAME;
}
}
/** Explicitly retries one eligible publication operation. */
record RetryPublication(PkiId publicationId) implements PkiOperation {
/** Stable operation name. */
public static final String NAME = "publication.retry";
/** Validates the publication identity. */
public RetryPublication {
Objects.requireNonNull(publicationId, "publicationId");
}
@Override
public String name() {
return NAME;
}
}
/** Reconciles one publication operation whose external outcome is unknown. */
record ReconcilePublication(PkiId publicationId) implements PkiOperation {
/** Stable operation name. */
public static final String NAME = "publication.reconcile";
/** Validates the publication identity. */
public ReconcilePublication {
Objects.requireNonNull(publicationId, "publicationId");
}
@Override
public String name() {
return NAME;
}
}
private static void requireLimit(int limit) {
if (limit < 1 || limit > MAXIMUM_RESULT_ENTRIES) {
throw new IllegalArgumentException("limit must be between 1 and 1000");
}
}
}

View File

@@ -33,8 +33,15 @@
******************************************************************************/
package zeroecho.pki.application;
import java.util.Optional;
import zeroecho.pki.api.CaService;
import zeroecho.pki.api.CertificationRequestService;
import zeroecho.pki.api.IssuanceService;
import zeroecho.pki.api.PublicationService;
import zeroecho.pki.api.ProfileService;
import zeroecho.pki.api.RevocationService;
import zeroecho.pki.api.StatusObjectService;
/**
* Lifecycle-owned synchronous PKI backend session.
@@ -58,12 +65,48 @@ public interface PkiSession extends AutoCloseable {
return DefaultPkiSession.open(configuration);
}
/**
* Opens a production session with explicit process-local security capabilities.
*
* @param configuration validated immutable provider configuration
* @param dependencies process-local key-access capabilities
* @return opened lifecycle-owned session
*/
static PkiSession open(PkiSessionConfiguration configuration, PkiSessionRuntimeDependencies dependencies) {
return DefaultPkiSession.open(configuration, dependencies);
}
/** @return profile lifecycle service owned by this session */
ProfileService profiles();
/** @return revocation service owned by this session */
RevocationService revocations();
/** @return configured CA service, or empty for a read-only session */
default Optional<CaService> authorities() {
return Optional.empty();
}
/** @return configured certification-request service, or empty when X.509 is unavailable */
default Optional<CertificationRequestService> requests() {
return Optional.empty();
}
/** @return configured issuance service, or empty when signing is unavailable */
default Optional<IssuanceService> issuance() {
return Optional.empty();
}
/** @return configured status-object service, or empty when signing is unavailable */
default Optional<StatusObjectService> statusObjects() {
return Optional.empty();
}
/** @return configured publication service, or empty when no destination is enabled */
default Optional<PublicationService> publications() {
return Optional.empty();
}
/** @return shared typed operation executor owned by this session */
PkiOperationExecutor operations();

View File

@@ -33,7 +33,10 @@
******************************************************************************/
package zeroecho.pki.application;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import zeroecho.pki.spi.ProviderConfig;
@@ -47,22 +50,75 @@ import zeroecho.pki.spi.ProviderConfig;
* @param version configuration schema version
* @param store store-provider configuration
* @param audit audit-provider configuration
* @param signing optional signing and X.509 service composition
* @param publishers explicitly enabled publication destinations
*/
public record PkiSessionConfiguration(int version, ProviderConfig store, ProviderConfig audit) {
public record PkiSessionConfiguration(int version, ProviderConfig store, ProviderConfig audit,
Optional<SigningConfiguration> signing, List<ProviderConfig> publishers) {
/** Current configuration schema version. */
public static final int CURRENT_VERSION = 1;
public static final int CURRENT_VERSION = 2;
/** Creates a read-only-compatible version-one configuration. */
public PkiSessionConfiguration(int version, ProviderConfig store, ProviderConfig audit) {
this(version, store, audit, Optional.empty(), List.of());
}
/** Validates and snapshots the configuration. */
public PkiSessionConfiguration {
if (version != CURRENT_VERSION) {
if (version < 1 || version > CURRENT_VERSION) {
throw new IllegalArgumentException("Unsupported PKI session configuration version");
}
store = snapshot(Objects.requireNonNull(store, "store"));
audit = snapshot(Objects.requireNonNull(audit, "audit"));
signing = Objects.requireNonNull(signing, "signing").map(SigningConfiguration::snapshot);
publishers = Objects.requireNonNull(publishers, "publishers").stream()
.map(PkiSessionConfiguration::snapshot).toList();
if (version == 1 && (signing.isPresent() || !publishers.isEmpty())) {
throw new IllegalArgumentException("Version-one configuration cannot enable optional capabilities");
}
}
private static ProviderConfig snapshot(ProviderConfig config) {
return new ProviderConfig(config.backendId(), config.properties());
}
/**
* Explicit signing and X.509 service-graph configuration.
*
* @param workflow signature-workflow provider configuration
* @param framework credential-framework provider configuration
* @param busPath durable signing-bus path
* @param signatureAlgorithm canonical configured signing algorithm
* @param signingTtl positive synchronous signing deadline
* @param unlockEnvironmentVariable optional environment-variable reference used
* only by the default process composition
*/
public record SigningConfiguration(ProviderConfig workflow, ProviderConfig framework, String busPath,
String signatureAlgorithm, Duration signingTtl, Optional<String> unlockEnvironmentVariable) {
/** Validates and snapshots the signing configuration. */
public SigningConfiguration {
workflow = PkiSessionConfiguration.snapshot(Objects.requireNonNull(workflow, "workflow"));
framework = PkiSessionConfiguration.snapshot(Objects.requireNonNull(framework, "framework"));
if (busPath == null || busPath.isBlank() || signatureAlgorithm == null || signatureAlgorithm.isBlank()) {
throw new IllegalArgumentException("Signing paths and algorithm must not be blank");
}
if (signingTtl == null || signingTtl.isZero() || signingTtl.isNegative()) {
throw new IllegalArgumentException("signingTtl must be positive");
}
unlockEnvironmentVariable = Objects.requireNonNull(unlockEnvironmentVariable,
"unlockEnvironmentVariable");
unlockEnvironmentVariable.ifPresent(value -> {
if (!value.matches("[A-Z][A-Z0-9_]{0,127}")) {
throw new IllegalArgumentException("Unlock environment-variable reference is invalid");
}
});
}
private static SigningConfiguration snapshot(SigningConfiguration source) {
return new SigningConfiguration(source.workflow, source.framework, source.busPath,
source.signatureAlgorithm, source.signingTtl, source.unlockEnvironmentVariable);
}
}
}

View File

@@ -0,0 +1,63 @@
/*******************************************************************************
* 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.application;
import java.util.Objects;
import java.util.Optional;
import zeroecho.core.spi.KeyringUnlockProvider;
/** Immutable process-local capabilities for one PKI session composition. */
public record PkiSessionRuntimeDependencies(Optional<KeyringUnlockProvider> keyringUnlockProvider) {
/** Validates the optional capability container. */
public PkiSessionRuntimeDependencies {
keyringUnlockProvider = Objects.requireNonNull(keyringUnlockProvider, "keyringUnlockProvider");
}
/** @return an immutable dependency set without key-unlock access */
public static PkiSessionRuntimeDependencies none() {
return new PkiSessionRuntimeDependencies(Optional.empty());
}
/**
* Creates dependencies containing one explicit process-local unlock provider.
*
* @param provider secure unlock capability
* @return immutable runtime dependencies
*/
public static PkiSessionRuntimeDependencies withKeyringUnlockProvider(KeyringUnlockProvider provider) {
return new PkiSessionRuntimeDependencies(Optional.of(Objects.requireNonNull(provider, "provider")));
}
}

View File

@@ -1,7 +1,36 @@
/*******************************************************************************
* 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.IOException;

View File

@@ -60,6 +60,7 @@ import java.util.Base64;
import java.util.Collections;
import java.util.HexFormat;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
@@ -97,9 +98,11 @@ import zeroecho.core.storage.KeyringStore;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.orch.SigningSubmissionId;
import zeroecho.pki.spi.crypto.SignatureWorkflow;
import zeroecho.pki.spi.crypto.PublicKeyInfoSource;
import zeroecho.sdk.ZeroEchoSession;
/**
@@ -238,7 +241,7 @@ import zeroecho.sdk.ZeroEchoSession;
*/
// The provider deliberately centralizes operation lifecycle and cleanup in one implementation.
@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity", "PMD.TooManyMethods" })
public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow, PublicKeyInfoSource {
private static final Logger LOG = Logger.getLogger(ZeroEchoLibSignatureWorkflow.class.getName());
@@ -306,7 +309,7 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
KeyringUnlockProvider keyringUnlockProvider, KeyringStore keyring) {
this(id, keyringPath, operationRoot, clock, operationHorizon, keyRefPrefix, requireComponentSuffix,
keyringUnlockProvider);
this.keyringOrNull = java.util.Objects.requireNonNull(keyring, "keyring must not be null");
this.keyringOrNull = Objects.requireNonNull(keyring, "keyring must not be null");
}
/* default */ ZeroEchoLibSignatureWorkflow(String id, Path keyringPath, Path operationRoot, Clock clock,
@@ -362,7 +365,7 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
this.requireComponentSuffix = requireComponentSuffix;
this.cleanupObserver = cleanupObserver;
this.keyringUnlockProvider = keyringUnlockProvider;
this.session = java.util.Objects.requireNonNull(session, "session must not be null");
this.session = Objects.requireNonNull(session, "session must not be null");
this.statuses = new ConcurrentHashMap<>();
this.fingerprints = new ConcurrentHashMap<>();
@@ -1004,6 +1007,21 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
throw new InvalidRequestException(DC_UNSUPPORTED_PUBLICKEY_FORM);
}
@Override
public EncodedObject resolvePublicKeyInfo(KeyRef keyRef) {
Objects.requireNonNull(keyRef, "keyRef");
try {
KeyRefParts parts = parseKeyRefOrThrow(keyRef, true);
String privateAlias = parts.privateAlias;
String logicalAlias = privateAlias.endsWith(".prv")
? privateAlias.substring(0, privateAlias.length() - ".prv".length()) : privateAlias;
PublicKey publicKey = requireKeyringOrThrow().getPublic(logicalAlias);
return new EncodedObject(Encoding.DER, publicKey.getEncoded());
} catch (InvalidRequestException | IOException | GeneralSecurityException failure) {
throw new PkiException("Managed public key resolution failed: code=PUBLIC_KEY_RESOLUTION_FAILED", failure);
}
}
private <S extends AlgorithmKeySpec> PublicKey importPublic(String algorithmId, S spec)
throws GeneralSecurityException {
@SuppressWarnings("unchecked")
@@ -1642,8 +1660,8 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
/** Package-local dependencies used to exercise provider failure boundaries. */
/* default */ record SigningDependencies(KeyringStore keyring, ZeroEchoSession session) {
SigningDependencies {
java.util.Objects.requireNonNull(keyring, "keyring must not be null");
java.util.Objects.requireNonNull(session, "session must not be null");
Objects.requireNonNull(keyring, "keyring must not be null");
Objects.requireNonNull(session, "session must not be null");
}
}

View File

@@ -303,7 +303,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
}
}
LOG.log(Level.INFO, "running in {0}", root);
LOG.log(Level.INFO, "Filesystem PKI store opened");
this.ownership = acquiredOwnership;
ownershipTransferred = true;
} catch (IOException e) {

View File

@@ -0,0 +1,188 @@
/*******************************************************************************
* 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.publish;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Objects;
import java.util.Optional;
import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.api.publication.PublicationTarget;
import zeroecho.pki.spi.publish.PublicationAttempt;
import zeroecho.pki.spi.publish.PublicationOutcome;
import zeroecho.pki.spi.publish.Publisher;
/** Bounded-memory, idempotent publisher for one administrator-owned directory. */
final class FilesystemPublisher implements Publisher {
private static final int BUFFER_BYTES = 16 * 1024;
private final PublicationTarget target;
private final Path root;
/* default */ FilesystemPublisher(PublicationTarget target, Path root) {
this.target = Objects.requireNonNull(target, "target");
this.root = Objects.requireNonNull(root, "root").toAbsolutePath().normalize();
}
@Override
public PublicationTarget target() {
return target;
}
@Override
public PublicationOutcome publish(PublicationAttempt attempt, RepeatableContent payload) {
Objects.requireNonNull(attempt, "attempt");
Objects.requireNonNull(payload, "payload");
Path destination = destination(attempt);
Path temporary = destination.resolveSibling(destination.getFileName() + ".tmp-" + attempt.attemptToken());
try {
Files.createDirectories(root);
if (Files.exists(destination)) {
return matches(destination, attempt) ? PublicationOutcome.SUCCESS : PublicationOutcome.TERMINAL_FAILURE;
}
writeValidated(temporary, payload, attempt);
try {
Files.move(temporary, destination, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException failure) {
return PublicationOutcome.RETRYABLE_FAILURE;
}
forceDirectory();
return PublicationOutcome.SUCCESS;
} catch (IOException failure) {
return Files.exists(destination) && matchesUnchecked(destination, attempt) ? PublicationOutcome.SUCCESS
: PublicationOutcome.RETRYABLE_FAILURE;
} finally {
try {
Files.deleteIfExists(temporary);
} catch (IOException ignored) {
// Definite publication outcome is not changed by temporary cleanup.
}
}
}
@Override
public Optional<PublicationOutcome> reconcile(PublicationAttempt attempt) {
Objects.requireNonNull(attempt, "attempt");
Path destination = destination(attempt);
if (!Files.exists(destination)) {
return Optional.of(PublicationOutcome.RETRYABLE_FAILURE);
}
return Optional.of(matchesUnchecked(destination, attempt) ? PublicationOutcome.SUCCESS
: PublicationOutcome.TERMINAL_FAILURE);
}
private Path destination(PublicationAttempt attempt) {
byte[] identity = attempt.publicationId().value().getBytes(StandardCharsets.UTF_8);
return root.resolve(HexFormat.of().formatHex(sha256().digest(identity)) + ".bin");
}
private static void writeValidated(Path path, RepeatableContent payload, PublicationAttempt attempt)
throws IOException {
MessageDigest digest = sha256();
long count = 0L;
ByteBuffer buffer = ByteBuffer.allocate(BUFFER_BYTES);
try (InputStream input = payload.openStream(); FileChannel output = FileChannel.open(path,
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
byte[] bytes = buffer.array();
int read;
while ((read = input.read(bytes)) >= 0) {
if (read == 0) {
continue;
}
count = Math.addExact(count, read);
digest.update(bytes, 0, read);
buffer.clear().limit(read);
while (buffer.hasRemaining()) {
output.write(buffer);
}
}
output.force(true);
}
String actual = HexFormat.of().formatHex(digest.digest());
if (count != attempt.length() || !actual.equals(attempt.sha256())) {
throw new IOException("Publication content commitment mismatch");
}
}
private static boolean matches(Path path, PublicationAttempt attempt) throws IOException {
if (Files.size(path) != attempt.length()) {
return false;
}
MessageDigest digest = sha256();
byte[] buffer = new byte[BUFFER_BYTES];
try (InputStream input = Files.newInputStream(path)) {
int read;
while ((read = input.read(buffer)) >= 0) {
if (read > 0) {
digest.update(buffer, 0, read);
}
}
}
return HexFormat.of().formatHex(digest.digest()).equals(attempt.sha256());
}
private static boolean matchesUnchecked(Path path, PublicationAttempt attempt) {
try {
return matches(path, attempt);
} catch (IOException failure) {
return false;
}
}
private void forceDirectory() throws IOException {
try (FileChannel directory = FileChannel.open(root, StandardOpenOption.READ)) {
directory.force(true);
}
}
private static MessageDigest sha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException failure) {
throw new IllegalStateException("SHA-256 is unavailable", failure);
}
}
}

View File

@@ -0,0 +1,74 @@
/*******************************************************************************
* 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.publish;
import java.nio.file.Path;
import java.util.Set;
import zeroecho.pki.api.publication.PublicationTarget;
import zeroecho.pki.api.publication.PublicationTargetType;
import zeroecho.pki.spi.ProviderConfig;
import zeroecho.pki.spi.publish.Publisher;
import zeroecho.pki.spi.publish.PublisherProvider;
/** Explicit filesystem publication-destination provider. */
public final class FilesystemPublisherProvider implements PublisherProvider {
private static final String ROOT = "root";
private static final String TARGET_ID = "targetId";
@Override
public String id() {
return "filesystem";
}
@Override
public Set<String> supportedKeys() {
return Set.of(ROOT, TARGET_ID);
}
@Override
public void validateConfig(ProviderConfig config) {
PublisherProvider.super.validateConfig(config);
Path.of(config.require(ROOT));
new PublicationTarget(PublicationTargetType.FILESYSTEM, config.require(TARGET_ID));
}
@Override
public Publisher allocate(ProviderConfig config) {
validateConfig(config);
PublicationTarget target = new PublicationTarget(PublicationTargetType.FILESYSTEM,
config.require(TARGET_ID));
return new FilesystemPublisher(target, Path.of(config.require(ROOT)));
}
}

View File

@@ -51,6 +51,8 @@ import zeroecho.pki.spi.crypto.SignatureWorkflowProvider;
import zeroecho.pki.spi.crypto.SignatureWorkflowRuntimeDependencies;
import zeroecho.pki.spi.framework.CredentialFramework;
import zeroecho.pki.spi.framework.CredentialFrameworkProvider;
import zeroecho.pki.spi.publish.Publisher;
import zeroecho.pki.spi.publish.PublisherProvider;
import zeroecho.pki.spi.store.PkiStore;
import zeroecho.pki.spi.store.PkiStoreProvider;
import zeroecho.pki.util.async.AsyncBus;
@@ -286,6 +288,38 @@ public final class PkiBootstrap {
return provider.allocate(config, dependencies);
}
/**
* Validates one explicit signature-workflow provider configuration without
* allocating key access or workflow resources.
*
* @param config immutable workflow-provider configuration
* @throws RuntimeException if the provider identity, property set, or
* provider-specific values are invalid
*/
public static void validateSignatureWorkflowConfiguration(ProviderConfig config) {
SignatureWorkflowProvider provider = selectSignatureWorkflowProvider(
Objects.requireNonNull(config, "config").backendId());
requireKnownKeys(provider, config);
provider.validateConfig(config);
}
/**
* Opens one explicitly configured signature workflow with process-local key
* access supplied outside persistent configuration.
*
* @param config immutable workflow-provider configuration
* @param dependencies process-local security capabilities
* @return opened workflow owned by the caller
* @throws IllegalArgumentException if configuration or capabilities are invalid
* @throws RuntimeException if secure workflow allocation fails
*/
public static SignatureWorkflow openConfiguredSignatureWorkflow(ProviderConfig config,
SignatureWorkflowRuntimeDependencies dependencies) {
validateSignatureWorkflowConfiguration(config);
return selectSignatureWorkflowProvider(config.backendId()).allocate(config,
Objects.requireNonNull(dependencies, "dependencies"));
}
/**
* Opens an async operation bus using {@link AsyncBusProvider} discovered via
* ServiceLoader.
@@ -380,6 +414,44 @@ public final class PkiBootstrap {
return provider.allocate(config);
}
/**
* Validates one explicit credential-framework provider configuration without
* constructing a framework instance.
*
* @param config immutable framework-provider configuration
* @throws RuntimeException if provider selection or configuration is invalid
*/
public static void validateCredentialFrameworkConfiguration(ProviderConfig config) {
CredentialFrameworkProvider provider = selectCredentialFrameworkProvider(
Objects.requireNonNull(config, "config").backendId());
requireKnownKeys(provider, config);
provider.validateConfig(config);
}
/**
* Validates one explicitly enabled publication destination without opening it.
*
* @param config immutable publisher-provider configuration
* @throws RuntimeException if provider selection or configuration is invalid
*/
public static void validatePublisherConfiguration(ProviderConfig config) {
PublisherProvider provider = selectPublisherProvider(Objects.requireNonNull(config, "config").backendId());
requireKnownKeys(provider, config);
provider.validateConfig(config);
}
/**
* Opens one explicitly configured publication destination.
*
* @param config immutable publisher-provider configuration
* @return configured publisher selected by stable provider identity
* @throws RuntimeException if provider selection, configuration, or allocation fails
*/
public static Publisher openPublisher(ProviderConfig config) {
validatePublisherConfiguration(config);
return selectPublisherProvider(config.backendId()).allocate(config);
}
/**
* Logs provider help information (supported keys) for diagnostics.
*
@@ -414,6 +486,33 @@ public final class PkiBootstrap {
});
}
private static SignatureWorkflowProvider selectSignatureWorkflowProvider(String requestedId) {
return SpiSelector.select(SignatureWorkflowProvider.class, requestedId, new SpiSelector.ProviderId<>() {
@Override
public String id(SignatureWorkflowProvider provider) {
return provider.id();
}
});
}
private static CredentialFrameworkProvider selectCredentialFrameworkProvider(String requestedId) {
return SpiSelector.select(CredentialFrameworkProvider.class, requestedId, new SpiSelector.ProviderId<>() {
@Override
public String id(CredentialFrameworkProvider provider) {
return provider.id();
}
});
}
private static PublisherProvider selectPublisherProvider(String requestedId) {
return SpiSelector.select(PublisherProvider.class, requestedId, new SpiSelector.ProviderId<>() {
@Override
public String id(PublisherProvider provider) {
return provider.id();
}
});
}
private static void requireKnownKeys(ConfigurableProvider<?> provider, ProviderConfig config) {
for (String key : config.properties().keySet()) {
if (!provider.supportedKeys().contains(key)) {

View File

@@ -0,0 +1,58 @@
/*******************************************************************************
* 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.crypto;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.KeyRef;
/**
* Resolves exportable public-key information from the same managed-key authority
* used by a signature workflow.
*
* <p>This capability exposes only canonical DER SubjectPublicKeyInfo. It must
* never return private, secret, seed, unlock, or provider credential material.</p>
*/
@FunctionalInterface
public interface PublicKeyInfoSource {
/**
* Resolves one managed key reference to canonical DER SubjectPublicKeyInfo.
*
* @param keyRef managed signing or public-key reference
* @return immutable DER-encoded public-key information
* @throws IllegalArgumentException if the reference is malformed or unknown
* @throws RuntimeException if secure key-store access fails
*/
EncodedObject resolvePublicKeyInfo(KeyRef keyRef);
}

View File

@@ -0,0 +1,40 @@
/*******************************************************************************
* 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.publish;
import zeroecho.pki.spi.ConfigurableProvider;
/** Service-provider contract for explicitly configured publication destinations. */
public interface PublisherProvider extends ConfigurableProvider<Publisher> {
}

View File

@@ -0,0 +1 @@
zeroecho.pki.impl.publish.FilesystemPublisherProvider

View File

@@ -49,8 +49,19 @@ import org.junit.jupiter.api.Test;
import zeroecho.core.io.CancellationSignal;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.ProfileService;
import zeroecho.pki.api.PublicationService;
import zeroecho.pki.api.RevocationService;
import zeroecho.pki.api.SubjectRef;
import zeroecho.pki.api.profile.BuiltInCertificateProfileCatalog;
import zeroecho.pki.api.publication.PublicationCursor;
import zeroecho.pki.api.publication.PublicationQuery;
import zeroecho.pki.api.publication.PublicationRecord;
import zeroecho.pki.api.publication.PublicationRequest;
import zeroecho.pki.api.publication.PublicationResult;
import zeroecho.pki.api.publication.PublicationStatus;
import zeroecho.pki.api.revocation.RevocationCommand;
import zeroecho.pki.api.revocation.RevocationQuery;
import zeroecho.pki.api.revocation.RevocationReason;
@@ -120,6 +131,36 @@ class PkiOperationExecutorTest {
System.out.println("...ok");
}
@Test
void rejectsSigningDependentOperationWhenCapabilityIsNotConfigured() {
System.out.println("rejectsSigningDependentOperationWhenCapabilityIsNotConfigured");
DefaultPkiOperationExecutor executor = executor(new AtomicInteger());
PkiOperation operation = new PkiOperation.CreateAuthority(new FormatId("x509"),
new SubjectRef("CN=Unavailable"), "root-ca", new KeyRef("managed:root.prv"));
PkiOperationOutcome.Failure failure = (PkiOperationOutcome.Failure) executor.execute(operation,
CancellationSignal.NONE);
System.out.println("...classification=" + failure.classification());
assertEquals(PkiOperationFailure.VALIDATION_FAILURE, failure.classification());
assertEquals("CAPABILITY_NOT_CONFIGURED", failure.code());
System.out.println("...ok");
}
@Test
void preservesExplicitPublicationResultsAndUnknownOutcome() {
System.out.println("preservesExplicitPublicationResultsAndUnknownOutcome");
PkiId publicationId = new PkiId("publication:test");
DefaultPkiOperationExecutor executor = publicationExecutor();
PkiOperationOutcome processed = executor.execute(new PkiOperation.ProcessPublication(publicationId),
CancellationSignal.NONE);
PkiOperationOutcome reconciled = executor.execute(new PkiOperation.ReconcilePublication(publicationId),
CancellationSignal.NONE);
System.out.println("...process=" + processed.getClass().getSimpleName());
assertInstanceOf(PkiOperationOutcome.Success.class, processed);
assertEquals(PkiOperationFailure.EXTERNAL_OUTCOME_UNKNOWN,
((PkiOperationOutcome.Failure) reconciled).classification());
System.out.println("...ok");
}
private static DefaultPkiOperationExecutor executor(AtomicInteger mutations) {
PkiSessionConfiguration configuration = new PkiSessionConfiguration(1,
new ProviderConfig("fs", Map.of("root", "unused")), new ProviderConfig("memory", Map.of()));
@@ -129,10 +170,67 @@ class PkiOperationExecutorTest {
case "close" -> null;
default -> throw new UnsupportedOperationException(method.getName());
});
return new DefaultPkiOperationExecutor(configuration, store, revocations(mutations), () -> {
ProfileService profiles = (ProfileService) Proxy.newProxyInstance(ProfileService.class.getClassLoader(),
new Class<?>[] { ProfileService.class }, (proxy, method, arguments) -> {
throw new UnsupportedOperationException(method.getName());
});
return new DefaultPkiOperationExecutor(configuration, store, profiles, revocations(mutations), () -> {
});
}
private static DefaultPkiOperationExecutor publicationExecutor() {
PkiSessionConfiguration configuration = new PkiSessionConfiguration(1,
new ProviderConfig("fs", Map.of("root", "unused")), new ProviderConfig("memory", Map.of()));
PkiStore store = (PkiStore) Proxy.newProxyInstance(PkiStore.class.getClassLoader(),
new Class<?>[] { PkiStore.class }, (proxy, method, arguments) -> {
throw new UnsupportedOperationException(method.getName());
});
ProfileService profiles = (ProfileService) Proxy.newProxyInstance(ProfileService.class.getClassLoader(),
new Class<?>[] { ProfileService.class }, (proxy, method, arguments) -> {
throw new UnsupportedOperationException(method.getName());
});
PublicationService publications = new PublicationService() {
@Override
public PublicationRecord register(PublicationRequest request) {
throw new UnsupportedOperationException();
}
@Override
public PublicationResult process(PkiId selected) {
return new PublicationResult(selected, PublicationStatus.SUCCEEDED, 1L);
}
@Override
public PublicationResult retry(PkiId selected) {
return new PublicationResult(selected, PublicationStatus.SUCCEEDED, 2L);
}
@Override
public PublicationResult reconcile(PkiId selected) {
return new PublicationResult(selected, PublicationStatus.OUTCOME_UNKNOWN, 1L);
}
@Override
public Optional<PublicationRecord> find(PkiId selected) {
return Optional.empty();
}
@Override
public PublicationCursor openPublications(PublicationQuery query) {
throw new UnsupportedOperationException();
}
@Override
public void close() {
// no-op
}
};
return new DefaultPkiOperationExecutor(configuration, store, profiles, revocations(new AtomicInteger()),
Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(publications),
() -> {
});
}
private static RevocationService revocations(AtomicInteger mutations) {
return new RevocationService() {
@Override

View File

@@ -36,23 +36,38 @@ package zeroecho.pki.application;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.lang.reflect.Proxy;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.storage.KeyringPassword;
import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.SubjectRef;
import zeroecho.pki.api.profile.BuiltInCertificateProfileCatalog;
import zeroecho.pki.api.profile.BuiltInCertificateProfileTemplate;
import zeroecho.pki.spi.bootstrap.PkiBootstrap;
import zeroecho.pki.spi.crypto.SignatureWorkflow;
import zeroecho.pki.spi.crypto.SignatureWorkflowRuntimeDependencies;
import zeroecho.pki.spi.ProviderConfig;
import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.spi.store.PkiStore;
import zeroecho.pki.testkit.InMemorySignatureWorkflow;
class PkiSessionLifecycleTest {
@@ -116,6 +131,99 @@ class PkiSessionLifecycleTest {
System.out.println("...ok");
}
@Test
void configuredSigningRequiresExplicitUnlockAndCleansAllocatedResources() {
System.out.println("configuredSigningRequiresExplicitUnlockAndCleansAllocatedResources");
AtomicInteger storesOpened = new AtomicInteger();
AtomicInteger storesClosed = new AtomicInteger();
DefaultPkiSession.Bootstrap bootstrap = bootstrap(storesOpened, storesClosed, false, false, false, false);
ProviderConfig placeholder = new ProviderConfig("test", Map.of());
PkiSessionConfiguration.SigningConfiguration signing = new PkiSessionConfiguration.SigningConfiguration(
placeholder, new ProviderConfig("x509-bc", Map.of()),
temporaryDirectory.resolve("signing-bus").toString(), "RSA-2048-PSS-SHA256", Duration.ofSeconds(5),
Optional.empty());
PkiSessionConfiguration configuration = new PkiSessionConfiguration(2,
new ProviderConfig("fs", Map.of("root", temporaryDirectory.resolve("store-signing").toString())),
new ProviderConfig("memory", Map.of("size", "16")), Optional.of(signing), java.util.List.of());
IllegalStateException failure = assertThrows(IllegalStateException.class,
() -> DefaultPkiSession.open(configuration, PkiSessionRuntimeDependencies.none(), CLOCK, bootstrap));
System.out.println("...storesOpened=" + storesOpened.get() + ", storesClosed=" + storesClosed.get());
assertEquals("Configured signing requires key-unlock capability", failure.getMessage());
assertEquals(1, storesOpened.get());
assertEquals(1, storesClosed.get());
System.out.println("...ok");
}
@Test
void opensExplicitPublisherCapabilityWithoutSigning() throws Exception {
System.out.println("opensExplicitPublisherCapabilityWithoutSigning");
ProviderConfig publisher = new ProviderConfig("filesystem",
Map.of("root", temporaryDirectory.resolve("published").toString(), "targetId", "local-crls"));
PkiSessionConfiguration configuration = new PkiSessionConfiguration(2,
new ProviderConfig("fs", Map.of("root", temporaryDirectory.resolve("store-publisher").toString())),
new ProviderConfig("memory", Map.of("size", "16")), Optional.empty(), java.util.List.of(publisher));
try (PkiSession session = PkiSession.open(configuration)) {
System.out.println("...publicationConfigured=" + session.publications().isPresent());
assertTrue(session.publications().isPresent());
assertTrue(session.authorities().isEmpty());
}
System.out.println("...ok");
}
@Test
void rejectsUnknownPublisherBeforeStoreAllocation() {
System.out.println("rejectsUnknownPublisherBeforeStoreAllocation");
Path storeRoot = temporaryDirectory.resolve("must-not-open");
PkiSessionConfiguration configuration = new PkiSessionConfiguration(2,
new ProviderConfig("fs", Map.of("root", storeRoot.toString())),
new ProviderConfig("memory", Map.of("size", "16")), Optional.empty(),
java.util.List.of(new ProviderConfig("unknown-publisher", Map.of())));
assertThrows(IllegalStateException.class, () -> PkiSession.open(configuration));
System.out.println("...storeAllocated=" + java.nio.file.Files.exists(storeRoot));
assertTrue(java.nio.file.Files.notExists(storeRoot));
System.out.println("...ok");
}
@Test
void composesOneSharedSigningAndX509ServiceGraph() throws Exception {
System.out.println("composesOneSharedSigningAndX509ServiceGraph");
KeyRef keyRef = new KeyRef("managed:test-root");
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
KeyPair keyPair = generator.generateKeyPair();
InMemorySignatureWorkflow workflow = new InMemorySignatureWorkflow(Map.of(keyRef.value(), keyPair));
ProviderConfig placeholder = new ProviderConfig("test", Map.of());
PkiSessionConfiguration.SigningConfiguration signing = new PkiSessionConfiguration.SigningConfiguration(
placeholder, new ProviderConfig("x509-bc", Map.of()),
temporaryDirectory.resolve("composed-signing-bus").toString(), "SHA256withRSA",
Duration.ofSeconds(5), Optional.empty());
PkiSessionConfiguration configuration = new PkiSessionConfiguration(2,
new ProviderConfig("fs", Map.of("root", temporaryDirectory.resolve("composed-store").toString())),
new ProviderConfig("memory", Map.of("size", "16")), Optional.of(signing), java.util.List.of());
DefaultPkiSession.Bootstrap bootstrap = signingBootstrap(workflow);
PkiSessionRuntimeDependencies dependencies = PkiSessionRuntimeDependencies.withKeyringUnlockProvider(
() -> new KeyringPassword("unused-test-unlock".toCharArray()));
try (PkiSession session = DefaultPkiSession.open(configuration, dependencies, CLOCK, bootstrap)) {
System.out.println("...services=ca,request,issuance,status");
assertTrue(session.authorities().isPresent());
assertTrue(session.requests().isPresent());
assertTrue(session.issuance().isPresent());
assertTrue(session.statusObjects().isPresent());
assertTrue(session.publications().isEmpty());
BuiltInCertificateProfileTemplate root = BuiltInCertificateProfileCatalog
.load(getClass().getClassLoader()).stream()
.filter(candidate -> "root-ca".equals(candidate.definition().profileId())).findFirst()
.orElseThrow();
session.profiles().importProfile(root.canonicalJson());
session.profiles().activateProfile("root-ca", root.definition().profileVersion());
PkiOperationOutcome outcome = session.operations().execute(new PkiOperation.CreateAuthority(
new FormatId("x509"), new SubjectRef("CN=Composed Root"), "root-ca", keyRef),
CancellationSignal.NONE);
assertInstanceOf(PkiOperationOutcome.Success.class, outcome);
}
System.out.println("...ok");
}
private static PkiSessionConfiguration configuration(Path storeRoot) {
return new PkiSessionConfiguration(1, new ProviderConfig("fs", Map.of("root", storeRoot.toString())),
new ProviderConfig("memory", Map.of("size", "16")));
@@ -136,6 +244,16 @@ class PkiSessionLifecycleTest {
}
}
@Override
public void validateSignatureWorkflow(ProviderConfig configuration) {
// valid test provider
}
@Override
public void validateCredentialFramework(ProviderConfig configuration) {
// valid test framework
}
@Override
public PkiStore openStore(ProviderConfig configuration) {
storesOpened.incrementAndGet();
@@ -169,6 +287,46 @@ class PkiSessionLifecycleTest {
};
}
private static DefaultPkiSession.Bootstrap signingBootstrap(SignatureWorkflow workflow) {
return new DefaultPkiSession.Bootstrap() {
@Override
public void validateStore(ProviderConfig configuration) {
PkiBootstrap.validateStoreConfiguration(configuration);
}
@Override
public void validateAudit(ProviderConfig configuration) {
PkiBootstrap.validateAuditConfiguration(configuration);
}
@Override
public PkiStore openStore(ProviderConfig configuration) {
return PkiBootstrap.openStore(configuration);
}
@Override
public AuditSink openAudit(ProviderConfig configuration) {
return PkiBootstrap.openAudit(configuration);
}
@Override
public void validateSignatureWorkflow(ProviderConfig configuration) {
// Explicit deterministic test provider.
}
@Override
public void validateCredentialFramework(ProviderConfig configuration) {
PkiBootstrap.validateCredentialFrameworkConfiguration(configuration);
}
@Override
public SignatureWorkflow openSignatureWorkflow(ProviderConfig configuration,
SignatureWorkflowRuntimeDependencies dependencies) {
return workflow;
}
};
}
private static PkiStore proxyStore(CloseAction close) {
return (PkiStore) Proxy.newProxyInstance(PkiStore.class.getClassLoader(), new Class<?>[] { PkiStore.class },
(proxy, method, arguments) -> {

View File

@@ -48,12 +48,13 @@ import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.spi.crypto.PublicKeyInfoSource;
import zeroecho.pki.spi.crypto.SignatureWorkflow;
/**
* Test-only in-memory signature workflow.
*/
public final class InMemorySignatureWorkflow implements SignatureWorkflow {
public final class InMemorySignatureWorkflow implements SignatureWorkflow, PublicKeyInfoSource {
private final Map<String, KeyPair> keys;
private final Map<PkiId, OperationStatus> status;
@@ -245,6 +246,15 @@ public final class InMemorySignatureWorkflow implements SignatureWorkflow {
return Set.of("SHA256withRSA");
}
@Override
public EncodedObject resolvePublicKeyInfo(KeyRef keyRef) {
KeyPair keyPair = keys.get(Objects.requireNonNull(keyRef, "keyRef").value());
if (keyPair == null) {
throw new IllegalArgumentException("Managed test key is unavailable");
}
return new EncodedObject(Encoding.DER, keyPair.getPublic().getEncoded());
}
public int submittedSignCount() {
return submittedSignCount.get();
}