diff --git a/README.md b/README.md
index 6220aa2..2e01597 100644
--- a/README.md
+++ b/README.md
@@ -41,6 +41,53 @@ Whether you're working on secure file storage, encrypted communication, or priva
- CLI Tools & Keystore Management
+## PKI administration CLI
+
+The application distribution provides one synchronous PKI administration entry
+point. Direct commands and JSON plans use the same typed backend operation
+executor and one lifecycle-owned `PkiSession`:
+
+```text
+zeroecho pki --help
+zeroecho pki configuration.validate --config pki-config.json
+zeroecho pki run administration-plan.json --config pki-config.json --output json
+```
+
+The administration surface covers every administratively meaningful capability
+implemented by the current backend: configuration validation; persisted profile
+validation/import/inspection/activation; CA creation, inspection, listing, and
+lifecycle transitions; strict CSR import and proof verification; credential
+issuance and inspection; revocation inspection and bounded history/snapshot
+presentation; signed status/CRL generation and inspection; and explicit
+publication registration, processing, retry, reconciliation, inspection, and
+listing. Presentation limits bound terminal output only; backend revocation and
+publication cursors remain streaming and do not acquire an aggregate population
+limit.
+
+Plans are versioned JSON documents with ordered, uniquely named operations.
+References use exact whole values from earlier successful steps. Plans support
+only `FAIL_FAST` and `CONTINUE_INDEPENDENT`; they have no scripts, expressions,
+loops, implicit retries, or rollback across already committed operations. Binary
+artifacts and secrets are never emitted in terminal JSON.
+
+Version-two session configuration enables capabilities explicitly. A signing
+section selects one signature-workflow provider, one X.509 framework, a durable
+signing-bus location, an algorithm, a finite signing deadline, and the name of an
+environment variable supplying the process-local keyring unlock capability.
+Publisher entries select configured destinations independently, so publication
+can be administered without enabling signing. Values of unlock variables,
+private keys, and publisher credentials are never operation arguments or output.
+Read-only version-one configurations remain valid and allocate neither signing
+nor publisher capabilities.
+
+The CLI and a future server share the same transport-neutral `PkiSession` and
+typed operations; batch execution opens that service graph once. Backup and
+import/export commands remain unavailable because the current backend has no
+production service implementation for them. Final backend release readiness is
+intentionally deferred until the planned extensible X.509 algorithm/OID binding
+work and its CLI integration are complete; this CLI does not anticipate or add
+placeholders for that future work.
+
## Development Status
diff --git a/app/src/main/java/zeroecho/pki/cli/PkiCli.java b/app/src/main/java/zeroecho/pki/cli/PkiCli.java
index f4c2c4e..2059985 100644
--- a/app/src/main/java/zeroecho/pki/cli/PkiCli.java
+++ b/app/src/main/java/zeroecho/pki/cli/PkiCli.java
@@ -56,6 +56,18 @@ public final class PkiCli {
private static final String CONFIG = "--config";
private static final String OUTPUT = "--output";
private static final String RUN = "run";
+ private static final Map OPERATION_ARGUMENT_NAMES = Map.ofEntries(
+ Map.entry("--profile-file", "profileFile"), Map.entry("--profile-id", "profileId"),
+ Map.entry("--profile-version", "profileVersion"), Map.entry("--ca-id", "caId"),
+ Map.entry("--issuer-ca-id", "issuerCaId"), Map.entry("--format-id", "formatId"),
+ Map.entry("--subject-ref", "subjectRef"), Map.entry("--key-ref", "keyRef"),
+ Map.entry("--state", "state"), Map.entry("--request-id", "requestId"),
+ Map.entry("--request-file", "requestFile"), Map.entry("--encoding", "encoding"),
+ Map.entry("--credential-id", "credentialId"), Map.entry("--status-object-id", "statusObjectId"),
+ Map.entry("--type", "type"), Map.entry("--publication-id", "publicationId"),
+ Map.entry("--source-type", "sourceType"), Map.entry("--source-id", "sourceId"),
+ Map.entry("--target-type", "targetType"), Map.entry("--target-id", "targetId"),
+ Map.entry("--reason", "reason"), Map.entry("--limit", "limit"));
private static final int RUN_TOKEN_COUNT = 2;
private PkiCli() {
@@ -195,8 +207,8 @@ public final class PkiCli {
for (int index = 0; index < tokens.size(); index += 2) {
String name = argumentName(tokens.get(index));
String raw = tokens.get(index + 1);
- PkiOperationValue value = "limit".equals(name) ? new PkiOperationValue.IntegerValue(parseLong(raw))
- : new PkiOperationValue.Text(raw);
+ PkiOperationValue value = "limit".equals(name) || "profileVersion".equals(name)
+ ? new PkiOperationValue.IntegerValue(parseLong(raw)) : new PkiOperationValue.Text(raw);
if (arguments.putIfAbsent(name, value) != null) {
throw new IllegalArgumentException("Operation argument is duplicated");
}
@@ -205,14 +217,11 @@ public final class PkiCli {
}
private static String argumentName(String option) {
- return switch (option) {
- case "--profile-file" -> "profileFile";
- case "--credential-id" -> "credentialId";
- case "--publication-id" -> "publicationId";
- case "--reason" -> "reason";
- case "--limit" -> "limit";
- default -> throw new IllegalArgumentException("Unknown PKI operation argument");
- };
+ String name = OPERATION_ARGUMENT_NAMES.get(option);
+ if (name == null) {
+ throw new IllegalArgumentException("Unknown PKI operation argument");
+ }
+ return name;
}
private static PkiPlan singlePlan(Invocation invocation, PkiOperationRegistry registry) throws IOException {
@@ -256,10 +265,33 @@ public final class PkiCli {
writer.println("Operations:");
writer.println(" configuration.validate");
writer.println(" profile.validate --profile-file ");
+ writer.println(" profile.register --profile-file ");
+ writer.println(" profile.inspect --profile-id --profile-version ");
+ writer.println(" profile.list --profile-id ");
+ writer.println(" profile.activate --profile-id --profile-version ");
+ writer.println(" ca.inspect --ca-id ");
+ writer.println(" ca.list --limit <1..1000>");
+ writer.println(" ca.create --format-id --subject-ref --profile-id --key-ref [");
+ writer.println(" ca.transition --ca-id --state --reason ");
+ writer.println(" request.inspect --request-id ");
+ writer.println(" request.import --format-id --encoding DER --request-file ");
+ writer.println(" request.verify --request-id ");
writer.println(" credential.inspect --credential-id ");
+ writer.println(" credential.issue --issuer-ca-id --request-id --profile-id ");
writer.println(" credential.revoke --credential-id --reason ");
+ writer.println(" revocation.inspect --credential-id ");
writer.println(" revocation.history --credential-id --limit <1..1000>");
+ writer.println(" revocation.snapshot --limit <1..1000>");
+ writer.println(" status.inspect --status-object-id ");
+ writer.println(" status.generate --issuer-ca-id --type --format-id ");
+ writer.println(" inventory.status --issuer-ca-id --limit <1..1000>");
writer.println(" publication.inspect --publication-id ");
+ writer.println(" publication.list --limit <1..1000>");
+ writer.println(" publication.register --publication-id --source-type --source-id ");
+ writer.println(" --target-type --target-id ");
+ writer.println(" publication.process --publication-id ");
+ writer.println(" publication.retry --publication-id ");
+ writer.println(" publication.reconcile --publication-id ");
writer.flush();
}
diff --git a/app/src/main/java/zeroecho/pki/cli/PkiCliConfiguration.java b/app/src/main/java/zeroecho/pki/cli/PkiCliConfiguration.java
index a263752..cb40775 100644
--- a/app/src/main/java/zeroecho/pki/cli/PkiCliConfiguration.java
+++ b/app/src/main/java/zeroecho/pki/cli/PkiCliConfiguration.java
@@ -34,6 +34,8 @@
package zeroecho.pki.cli;
import java.nio.file.Path;
+import java.time.Duration;
+import java.util.List;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
@@ -45,8 +47,12 @@ import zeroecho.pki.spi.ProviderConfig;
/** Strict versioned CLI configuration loader. */
final class PkiCliConfiguration {
- private static final Set ROOT_FIELDS = Set.of("version", "store", "audit");
+ private static final int VERSION_ONE = 1;
+ private static final Set ROOT_FIELDS_V1 = Set.of("version", "store", "audit");
+ private static final Set ROOT_FIELDS_V2 = Set.of("version", "store", "audit", "signing", "publishers");
private static final Set PROVIDER_FIELDS = Set.of("provider", "properties");
+ private static final Set SIGNING_FIELDS = Set.of("workflow", "framework", "busPath",
+ "signatureAlgorithm", "signingTtlSeconds", "unlockEnvironmentVariable");
private static final String STDOUT_PROVIDER = "stdout";
private PkiCliConfiguration() {
@@ -55,14 +61,42 @@ final class PkiCliConfiguration {
/* default */ static PkiSessionConfiguration read(Path path) throws java.io.IOException {
byte[] document = PkiCliFiles.readRegularFile(path, PkiCliJson.MAXIMUM_DOCUMENT_BYTES);
PkiOperationValue.ObjectValue root = object(PkiCliJson.parse(document));
- requireExactFields(root.fields(), ROOT_FIELDS);
int version = Math.toIntExact(integer(required(root, "version")));
+ if (version == VERSION_ONE) {
+ requireExactFields(root.fields(), ROOT_FIELDS_V1);
+ } else {
+ requireVersionTwoFields(root.fields());
+ }
ProviderConfig store = provider(required(root, "store"));
ProviderConfig audit = provider(required(root, "audit"));
if (STDOUT_PROVIDER.equals(audit.backendId())) {
throw new IllegalArgumentException("CLI audit provider must not write to standard output");
}
- return new PkiSessionConfiguration(version, store, audit);
+ if (version == VERSION_ONE) {
+ return new PkiSessionConfiguration(version, store, audit);
+ }
+ java.util.Optional signing = java.util.Optional
+ .ofNullable(root.fields().get("signing")).map(PkiCliConfiguration::signing);
+ PkiOperationValue publisherValue = root.fields().get("publishers");
+ List publishers = publisherValue == null ? List.of()
+ : list(publisherValue).values().stream().map(PkiCliConfiguration::provider).toList();
+ return new PkiSessionConfiguration(version, store, audit, signing, publishers);
+ }
+
+ private static void requireVersionTwoFields(Map actual) {
+ if (!actual.keySet().containsAll(ROOT_FIELDS_V1) || !ROOT_FIELDS_V2.containsAll(actual.keySet())) {
+ throw new IllegalArgumentException("CLI document fields are invalid");
+ }
+ }
+
+ private static PkiSessionConfiguration.SigningConfiguration signing(PkiOperationValue value) {
+ PkiOperationValue.ObjectValue object = object(value);
+ requireExactFields(object.fields(), SIGNING_FIELDS);
+ return new PkiSessionConfiguration.SigningConfiguration(provider(required(object, "workflow")),
+ provider(required(object, "framework")), text(required(object, "busPath")),
+ text(required(object, "signatureAlgorithm")),
+ Duration.ofSeconds(integer(required(object, "signingTtlSeconds"))),
+ java.util.Optional.of(text(required(object, "unlockEnvironmentVariable"))));
}
private static ProviderConfig provider(PkiOperationValue value) {
@@ -92,6 +126,13 @@ final class PkiCliConfiguration {
throw new IllegalArgumentException("CLI field has the wrong type");
}
+ private static PkiOperationValue.ListValue list(PkiOperationValue value) {
+ if (value instanceof PkiOperationValue.ListValue list) {
+ return list;
+ }
+ throw new IllegalArgumentException("CLI field has the wrong type");
+ }
+
/* default */ static String text(PkiOperationValue value) {
if (value instanceof PkiOperationValue.Text text) {
return text.value();
diff --git a/app/src/main/java/zeroecho/pki/cli/PkiOperationRegistry.java b/app/src/main/java/zeroecho/pki/cli/PkiOperationRegistry.java
index 39ea0b9..d60f223 100644
--- a/app/src/main/java/zeroecho/pki/cli/PkiOperationRegistry.java
+++ b/app/src/main/java/zeroecho/pki/cli/PkiOperationRegistry.java
@@ -39,9 +39,19 @@ import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
+import zeroecho.pki.api.EncodedObject;
+import zeroecho.pki.api.Encoding;
+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.profile.CertificateProfileDocumentCodec;
+import zeroecho.pki.api.publication.PublicationSourceType;
+import zeroecho.pki.api.publication.PublicationTarget;
+import zeroecho.pki.api.publication.PublicationTargetType;
import zeroecho.pki.api.revocation.RevocationReason;
+import zeroecho.pki.api.status.StatusObjectType;
import zeroecho.pki.application.PkiOperation;
import zeroecho.pki.application.PkiOperationValue;
@@ -50,6 +60,15 @@ import zeroecho.pki.application.PkiOperationValue;
final class PkiOperationRegistry {
private static final String CREDENTIAL_ID = "credentialId";
+ private static final String FORMAT_ID = "formatId";
+ private static final String ISSUER_CA_ID = "issuerCaId";
+ private static final String LIMIT = "limit";
+ private static final String PUBLICATION_ID = "publicationId";
+ private static final String PROFILE_FILE = "profileFile";
+ private static final String PROFILE_ID = "profileId";
+ private static final String PROFILE_VERSION = "profileVersion";
+ private static final String REQUEST_ID = "requestId";
+ private static final int MAXIMUM_REQUEST_BYTES = 1024 * 1024;
private final Map bindings;
@@ -57,10 +76,32 @@ final class PkiOperationRegistry {
Map configured = new LinkedHashMap<>();
add(configured, PkiOperation.ValidateConfiguration.NAME, this::configuration);
add(configured, PkiOperation.ValidateProfile.NAME, this::profile);
+ add(configured, PkiOperation.RegisterProfile.NAME, this::registerProfile);
+ add(configured, PkiOperation.InspectProfile.NAME, this::inspectProfile);
+ add(configured, PkiOperation.ListProfileVersions.NAME, this::listProfiles);
+ add(configured, PkiOperation.ActivateProfile.NAME, this::activateProfile);
+ add(configured, PkiOperation.InspectAuthority.NAME, this::inspectAuthority);
+ add(configured, PkiOperation.ListAuthorities.NAME, this::listAuthorities);
+ add(configured, PkiOperation.CreateAuthority.NAME, this::createAuthority);
+ add(configured, PkiOperation.TransitionAuthority.NAME, this::transitionAuthority);
+ add(configured, PkiOperation.InspectRequest.NAME, this::inspectRequest);
+ add(configured, PkiOperation.ImportRequest.NAME, this::importRequest);
+ add(configured, PkiOperation.VerifyRequest.NAME, this::verifyRequest);
add(configured, PkiOperation.InspectCredential.NAME, this::credential);
+ add(configured, PkiOperation.IssueCredential.NAME, this::issueCredential);
add(configured, PkiOperation.RevokeCredential.NAME, this::revoke);
+ add(configured, PkiOperation.InspectRevocation.NAME, this::inspectRevocation);
add(configured, PkiOperation.ReadRevocationHistory.NAME, this::history);
+ add(configured, PkiOperation.SnapshotRevocations.NAME, this::snapshotRevocations);
+ add(configured, PkiOperation.InspectStatus.NAME, this::inspectStatus);
+ add(configured, PkiOperation.ListStatus.NAME, this::listStatus);
+ add(configured, PkiOperation.GenerateStatus.NAME, this::generateStatus);
add(configured, PkiOperation.InspectPublication.NAME, this::publication);
+ add(configured, PkiOperation.ListPublications.NAME, this::listPublications);
+ add(configured, PkiOperation.RegisterPublication.NAME, this::registerPublication);
+ add(configured, PkiOperation.ProcessPublication.NAME, this::processPublication);
+ add(configured, PkiOperation.RetryPublication.NAME, this::retryPublication);
+ add(configured, PkiOperation.ReconcilePublication.NAME, this::reconcilePublication);
bindings = Map.copyOf(configured);
}
@@ -83,17 +124,88 @@ final class PkiOperationRegistry {
}
private PkiOperation profile(PkiOperationValue.ObjectValue arguments, Path baseDirectory) throws IOException {
- requireFields(arguments, Set.of("profileFile"));
- Path path = resolve(baseDirectory, text(arguments, "profileFile"));
+ requireFields(arguments, Set.of(PROFILE_FILE));
+ Path path = resolve(baseDirectory, text(arguments, PROFILE_FILE));
byte[] document = PkiCliFiles.readRegularFile(path, CertificateProfileDocumentCodec.MAXIMUM_DOCUMENT_BYTES);
return new PkiOperation.ValidateProfile(document);
}
+ private PkiOperation registerProfile(PkiOperationValue.ObjectValue arguments, Path baseDirectory)
+ throws IOException {
+ requireFields(arguments, Set.of(PROFILE_FILE));
+ Path path = resolve(baseDirectory, text(arguments, PROFILE_FILE));
+ byte[] document = PkiCliFiles.readRegularFile(path, CertificateProfileDocumentCodec.MAXIMUM_DOCUMENT_BYTES);
+ return new PkiOperation.RegisterProfile(document);
+ }
+
+ private PkiOperation inspectProfile(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
+ requireFields(arguments, Set.of(PROFILE_ID, PROFILE_VERSION));
+ return new PkiOperation.InspectProfile(text(arguments, PROFILE_ID), integer(arguments, PROFILE_VERSION));
+ }
+
+ private PkiOperation listProfiles(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
+ requireFields(arguments, Set.of(PROFILE_ID));
+ return new PkiOperation.ListProfileVersions(text(arguments, PROFILE_ID));
+ }
+
+ private PkiOperation activateProfile(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
+ requireFields(arguments, Set.of(PROFILE_ID, PROFILE_VERSION));
+ return new PkiOperation.ActivateProfile(text(arguments, PROFILE_ID), integer(arguments, PROFILE_VERSION));
+ }
+
+ private PkiOperation inspectAuthority(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
+ requireFields(arguments, Set.of("caId"));
+ return new PkiOperation.InspectAuthority(new PkiId(text(arguments, "caId")));
+ }
+
+ private PkiOperation listAuthorities(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
+ requireFields(arguments, Set.of(LIMIT));
+ return new PkiOperation.ListAuthorities(limit(arguments));
+ }
+
+ private PkiOperation createAuthority(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
+ requireFields(arguments, Set.of(FORMAT_ID, "subjectRef", PROFILE_ID, "keyRef"));
+ return new PkiOperation.CreateAuthority(new FormatId(text(arguments, FORMAT_ID)),
+ new SubjectRef(text(arguments, "subjectRef")), text(arguments, PROFILE_ID),
+ new KeyRef(text(arguments, "keyRef")));
+ }
+
+ private PkiOperation transitionAuthority(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
+ requireFields(arguments, Set.of("caId", "state", "reason"));
+ return new PkiOperation.TransitionAuthority(new PkiId(text(arguments, "caId")),
+ CaState.valueOf(text(arguments, "state")), text(arguments, "reason"));
+ }
+
+ private PkiOperation inspectRequest(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
+ requireFields(arguments, Set.of(REQUEST_ID));
+ return new PkiOperation.InspectRequest(new PkiId(text(arguments, REQUEST_ID)));
+ }
+
+ private PkiOperation importRequest(PkiOperationValue.ObjectValue arguments, Path baseDirectory)
+ throws IOException {
+ requireFields(arguments, Set.of(FORMAT_ID, "encoding", "requestFile"));
+ Path path = resolve(baseDirectory, text(arguments, "requestFile"));
+ byte[] document = PkiCliFiles.readRegularFile(path, MAXIMUM_REQUEST_BYTES);
+ return new PkiOperation.ImportRequest(new FormatId(text(arguments, FORMAT_ID)),
+ new EncodedObject(Encoding.valueOf(text(arguments, "encoding")), document));
+ }
+
+ private PkiOperation verifyRequest(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
+ requireFields(arguments, Set.of(REQUEST_ID));
+ return new PkiOperation.VerifyRequest(new PkiId(text(arguments, REQUEST_ID)));
+ }
+
private PkiOperation credential(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
requireFields(arguments, Set.of(CREDENTIAL_ID));
return new PkiOperation.InspectCredential(new PkiId(text(arguments, CREDENTIAL_ID)));
}
+ private PkiOperation issueCredential(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
+ requireFields(arguments, Set.of(ISSUER_CA_ID, REQUEST_ID, PROFILE_ID));
+ return new PkiOperation.IssueCredential(new PkiId(text(arguments, ISSUER_CA_ID)),
+ new PkiId(text(arguments, REQUEST_ID)), text(arguments, PROFILE_ID));
+ }
+
private PkiOperation revoke(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
requireFields(arguments, Set.of(CREDENTIAL_ID, "reason"));
RevocationReason reason = RevocationReason.valueOf(text(arguments, "reason"));
@@ -101,14 +213,71 @@ final class PkiOperationRegistry {
}
private PkiOperation history(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
- requireFields(arguments, Set.of(CREDENTIAL_ID, "limit"));
- int limit = Math.toIntExact(integer(arguments, "limit"));
- return new PkiOperation.ReadRevocationHistory(new PkiId(text(arguments, CREDENTIAL_ID)), limit);
+ requireFields(arguments, Set.of(CREDENTIAL_ID, LIMIT));
+ return new PkiOperation.ReadRevocationHistory(new PkiId(text(arguments, CREDENTIAL_ID)), limit(arguments));
+ }
+
+ private PkiOperation inspectRevocation(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
+ requireFields(arguments, Set.of(CREDENTIAL_ID));
+ return new PkiOperation.InspectRevocation(new PkiId(text(arguments, CREDENTIAL_ID)));
+ }
+
+ private PkiOperation snapshotRevocations(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
+ requireFields(arguments, Set.of(LIMIT));
+ return new PkiOperation.SnapshotRevocations(limit(arguments));
+ }
+
+ private PkiOperation inspectStatus(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
+ requireFields(arguments, Set.of("statusObjectId"));
+ return new PkiOperation.InspectStatus(new PkiId(text(arguments, "statusObjectId")));
+ }
+
+ private PkiOperation listStatus(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
+ requireFields(arguments, Set.of(ISSUER_CA_ID, LIMIT));
+ return new PkiOperation.ListStatus(new PkiId(text(arguments, ISSUER_CA_ID)), limit(arguments));
+ }
+
+ private PkiOperation generateStatus(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
+ requireFields(arguments, Set.of(ISSUER_CA_ID, "type", FORMAT_ID));
+ return new PkiOperation.GenerateStatus(new PkiId(text(arguments, ISSUER_CA_ID)),
+ StatusObjectType.valueOf(text(arguments, "type")), new FormatId(text(arguments, FORMAT_ID)));
}
private PkiOperation publication(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
- requireFields(arguments, Set.of("publicationId"));
- return new PkiOperation.InspectPublication(new PkiId(text(arguments, "publicationId")));
+ requireFields(arguments, Set.of(PUBLICATION_ID));
+ return new PkiOperation.InspectPublication(new PkiId(text(arguments, PUBLICATION_ID)));
+ }
+
+ private PkiOperation listPublications(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
+ requireFields(arguments, Set.of(LIMIT));
+ return new PkiOperation.ListPublications(limit(arguments));
+ }
+
+ private PkiOperation registerPublication(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
+ requireFields(arguments,
+ Set.of(PUBLICATION_ID, "sourceType", "sourceId", "targetType", "targetId"));
+ PublicationTarget target = new PublicationTarget(PublicationTargetType.valueOf(text(arguments, "targetType")),
+ text(arguments, "targetId"));
+ return new PkiOperation.RegisterPublication(new PkiId(text(arguments, PUBLICATION_ID)),
+ PublicationSourceType.valueOf(text(arguments, "sourceType")),
+ new PkiId(text(arguments, "sourceId")), target);
+ }
+
+ private PkiOperation processPublication(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
+ return new PkiOperation.ProcessPublication(publicationId(arguments));
+ }
+
+ private PkiOperation retryPublication(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
+ return new PkiOperation.RetryPublication(publicationId(arguments));
+ }
+
+ private PkiOperation reconcilePublication(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
+ return new PkiOperation.ReconcilePublication(publicationId(arguments));
+ }
+
+ private static PkiId publicationId(PkiOperationValue.ObjectValue arguments) {
+ requireFields(arguments, Set.of(PUBLICATION_ID));
+ return new PkiId(text(arguments, PUBLICATION_ID));
}
private static void add(Map target, String name, Binding binding) {
@@ -131,6 +300,10 @@ final class PkiOperationRegistry {
return PkiCliConfiguration.integer(PkiCliConfiguration.required(arguments, name));
}
+ private static int limit(PkiOperationValue.ObjectValue arguments) {
+ return Math.toIntExact(integer(arguments, LIMIT));
+ }
+
private static Path resolve(Path baseDirectory, String value) {
Path supplied = Path.of(value);
return (supplied.isAbsolute() ? supplied : baseDirectory.resolve(supplied)).toAbsolutePath().normalize();
diff --git a/app/src/test/java/zeroecho/pki/cli/PkiCliTest.java b/app/src/test/java/zeroecho/pki/cli/PkiCliTest.java
index f71d2ae..448681d 100644
--- a/app/src/test/java/zeroecho/pki/cli/PkiCliTest.java
+++ b/app/src/test/java/zeroecho/pki/cli/PkiCliTest.java
@@ -51,6 +51,8 @@ import org.junit.jupiter.api.io.TempDir;
import zeroecho.pki.api.ProfileService;
import zeroecho.pki.api.RevocationService;
+import zeroecho.pki.api.profile.BuiltInCertificateProfileCatalog;
+import zeroecho.pki.api.profile.BuiltInCertificateProfileTemplate;
import zeroecho.pki.application.PkiOperation;
import zeroecho.pki.application.PkiOperationFailure;
import zeroecho.pki.application.PkiOperationOutcome;
@@ -64,6 +66,88 @@ class PkiCliTest {
@TempDir
Path temporaryDirectory;
+ @Test
+ void helpListsCompletedSafeAdministrationFamilies() {
+ System.out.println("helpListsCompletedSafeAdministrationFamilies");
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ int code = PkiCli.execute(new String[] { "--help" }, output);
+ String help = output.toString(StandardCharsets.UTF_8);
+ System.out.println("...helpBytes=" + help.length());
+ assertEquals(PkiExitCodes.SUCCESS, code);
+ assertTrue(help.contains("profile.register"));
+ assertTrue(help.contains("ca.list"));
+ assertTrue(help.contains("request.inspect"));
+ assertTrue(help.contains("revocation.snapshot"));
+ assertTrue(help.contains("inventory.status"));
+ assertTrue(help.contains("publication.list"));
+ assertTrue(help.contains("ca.create"));
+ assertTrue(help.contains("credential.issue"));
+ assertTrue(help.contains("status.generate"));
+ assertTrue(help.contains("publication.process"));
+ System.out.println("...ok");
+ }
+
+ @Test
+ void signingAndPublicationFamiliesUseOneTypedBatchSession() throws IOException {
+ System.out.println("signingAndPublicationFamiliesUseOneTypedBatchSession");
+ Path configuration = configuration("capability-workflow");
+ Path request = temporaryDirectory.resolve("request.der");
+ Files.write(request, new byte[] { 0x30, 0x00 });
+ Path workflow = plan("capability-workflow.json", """
+ {"version":1,"failurePolicy":"FAIL_FAST","operations":[
+ {"id":"ca","operation":"ca.create","arguments":{"formatId":"x509","subjectRef":"CN=Root","profileId":"root-ca","keyRef":"managed:root.prv"}},
+ {"id":"request","operation":"request.import","arguments":{"formatId":"x509","encoding":"DER","requestFile":"request.der"}},
+ {"id":"issue","operation":"credential.issue","arguments":{"issuerCaId":"${ca.caId}","requestId":"${request.requestId}","profileId":"server"}},
+ {"id":"publish","operation":"publication.register","arguments":{"publicationId":"publication-1","sourceType":"CREDENTIAL","sourceId":"${issue.credentialId}","targetType":"FILESYSTEM","targetId":"local"}},
+ {"id":"process","operation":"publication.process","arguments":{"publicationId":"${publish.publicationId}"}}
+ ]}
+ """);
+ RecordingOpener opener = new RecordingOpener();
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ int code = PkiCli.execute(new String[] { "run", workflow.toString(), "--config", configuration.toString(),
+ "--output", "json" }, output, opener, () -> false);
+ System.out.println("...operations=" + opener.operationTypes.size());
+ assertEquals(PkiExitCodes.SUCCESS, code);
+ assertEquals(1, opener.sessions.get());
+ assertEquals(List.of(PkiOperation.CreateAuthority.class, PkiOperation.ImportRequest.class,
+ PkiOperation.IssueCredential.class, PkiOperation.RegisterPublication.class,
+ PkiOperation.ProcessPublication.class), opener.operationTypes);
+ assertTrue(output.toString(StandardCharsets.UTF_8).contains("\"operation\":\"publication.process\""));
+ System.out.println("...ok");
+ }
+
+ @Test
+ void productionProfileWorkflowUsesTypedOperationsAndDurableStore() throws IOException {
+ System.out.println("productionProfileWorkflowUsesTypedOperationsAndDurableStore");
+ Path configuration = configuration("profile-workflow");
+ BuiltInCertificateProfileTemplate template = BuiltInCertificateProfileCatalog
+ .load(getClass().getClassLoader()).get(0);
+ Path profile = temporaryDirectory.resolve("profile.json");
+ Files.write(profile, template.canonicalJson());
+ ByteArrayOutputStream registered = new ByteArrayOutputStream();
+ int registerCode = PkiCli.execute(new String[] { "profile.register", "--profile-file", profile.toString(),
+ "--config", configuration.toString(), "--output", "json" }, registered);
+ ByteArrayOutputStream activated = new ByteArrayOutputStream();
+ int activateCode = PkiCli.execute(new String[] { "profile.activate", "--profile-id",
+ template.definition().profileId(), "--profile-version",
+ Long.toString(template.definition().profileVersion()), "--config", configuration.toString(),
+ "--output", "json" }, activated);
+ ByteArrayOutputStream inspected = new ByteArrayOutputStream();
+ int inspectCode = PkiCli.execute(new String[] { "profile.inspect", "--profile-id",
+ template.definition().profileId(), "--profile-version",
+ Long.toString(template.definition().profileVersion()), "--config", configuration.toString(),
+ "--output", "json" }, inspected);
+ String rendered = registered.toString(StandardCharsets.UTF_8)
+ + activated.toString(StandardCharsets.UTF_8) + inspected.toString(StandardCharsets.UTF_8);
+ System.out.println("...profileId=" + template.definition().profileId());
+ assertEquals(PkiExitCodes.SUCCESS, registerCode);
+ assertEquals(PkiExitCodes.SUCCESS, activateCode);
+ assertEquals(PkiExitCodes.SUCCESS, inspectCode);
+ assertTrue(rendered.contains("\"profileVersion\":" + template.definition().profileVersion()));
+ assertFalse(rendered.contains("canonicalJson"));
+ System.out.println("...ok");
+ }
+
@Test
void productionSessionExecutesMachineAndHumanSingleCommands() throws IOException {
System.out.println("productionSessionExecutesMachineAndHumanSingleCommands");
@@ -84,6 +168,27 @@ class PkiCliTest {
System.out.println("...ok");
}
+ @Test
+ void versionTwoConfigurationKeepsCapabilitiesExplicit() throws IOException {
+ System.out.println("versionTwoConfigurationKeepsCapabilitiesExplicit");
+ Path configuration = temporaryDirectory.resolve("capabilities.json");
+ Files.writeString(configuration, """
+ {"version":2,
+ "store":{"provider":"fs","properties":{"root":"store"}},
+ "audit":{"provider":"memory","properties":{"size":"16"}},
+ "signing":{"workflow":{"provider":"zeroecho-lib","properties":{"keyringPath":"keys.zek","operationRoot":"signing-operations"}},
+ "framework":{"provider":"x509-bc","properties":{}},
+ "busPath":"signing-bus","signatureAlgorithm":"SHA256withRSA","signingTtlSeconds":30,
+ "unlockEnvironmentVariable":"ZEROECHO_TEST_UNLOCK"},
+ "publishers":[{"provider":"filesystem","properties":{"root":"published","targetId":"local"}}]}
+ """);
+ PkiSessionConfiguration parsed = PkiCliConfiguration.read(configuration);
+ System.out.println("...publishers=" + parsed.publishers().size());
+ assertTrue(parsed.signing().isPresent());
+ assertEquals(1, parsed.publishers().size());
+ System.out.println("...ok");
+ }
+
@Test
void singleAndBatchUseTheSameTypedExecutorAndSessionLifecycle() throws IOException {
System.out.println("singleAndBatchUseTheSameTypedExecutorAndSessionLifecycle");
@@ -128,6 +233,22 @@ class PkiCliTest {
System.out.println("...ok");
}
+ @Test
+ void directSigningDependentCommandUsesTheSharedTypedExecutor() throws IOException {
+ System.out.println("directSigningDependentCommandUsesTheSharedTypedExecutor");
+ Path configuration = configuration("direct-ca");
+ RecordingOpener opener = new RecordingOpener();
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ int code = PkiCli.execute(new String[] { "ca.create", "--format-id", "x509", "--subject-ref",
+ "CN=Root", "--profile-id", "root-ca", "--key-ref", "managed:root.prv", "--config",
+ configuration.toString(), "--output", "json" }, output, opener, () -> false);
+ System.out.println("...exit=" + code);
+ assertEquals(PkiExitCodes.SUCCESS, code);
+ assertEquals(List.of(PkiOperation.CreateAuthority.class), opener.operationTypes);
+ assertTrue(output.toString(StandardCharsets.UTF_8).contains("\"caId\":\"ca-1\""));
+ System.out.println("...ok");
+ }
+
@Test
void resolvesEarlierFieldsAndContinuesOnlyIndependentSteps() throws IOException {
System.out.println("resolvesEarlierFieldsAndContinuesOnlyIndependentSteps");
@@ -302,6 +423,17 @@ class PkiCliTest {
} else if (operation instanceof PkiOperation.RevokeCredential revoke) {
fields.put("credentialId", new PkiOperationValue.Text(revoke.credentialId().value()));
fields.put("state", new PkiOperationValue.Text("PERMANENTLY_REVOKED"));
+ } else if (operation instanceof PkiOperation.CreateAuthority) {
+ fields.put("caId", new PkiOperationValue.Text("ca-1"));
+ } else if (operation instanceof PkiOperation.ImportRequest) {
+ fields.put("requestId", new PkiOperationValue.Text("request-1"));
+ } else if (operation instanceof PkiOperation.IssueCredential) {
+ fields.put("credentialId", new PkiOperationValue.Text("credential-1"));
+ } else if (operation instanceof PkiOperation.RegisterPublication register) {
+ fields.put("publicationId", new PkiOperationValue.Text(register.publicationId().value()));
+ } else if (operation instanceof PkiOperation.ProcessPublication process) {
+ fields.put("publicationId", new PkiOperationValue.Text(process.publicationId().value()));
+ fields.put("status", new PkiOperationValue.Text("SUCCEEDED"));
} else {
fields.put("status", new PkiOperationValue.Text("OK"));
}
diff --git a/app/src/test/resources/pki-administration-config.json b/app/src/test/resources/pki-administration-config.json
new file mode 100644
index 0000000..9787fbc
--- /dev/null
+++ b/app/src/test/resources/pki-administration-config.json
@@ -0,0 +1,24 @@
+{
+ "version": 2,
+ "store": {
+ "provider": "fs",
+ "properties": {
+ "root": "build/packaged-cli-store"
+ }
+ },
+ "audit": {
+ "provider": "memory",
+ "properties": {
+ "size": "64"
+ }
+ },
+ "publishers": [
+ {
+ "provider": "filesystem",
+ "properties": {
+ "root": "build/packaged-cli-publication",
+ "targetId": "local-artifacts"
+ }
+ }
+ ]
+}
diff --git a/app/src/test/resources/pki-administration-plan.json b/app/src/test/resources/pki-administration-plan.json
new file mode 100644
index 0000000..e1eee8c
--- /dev/null
+++ b/app/src/test/resources/pki-administration-plan.json
@@ -0,0 +1,18 @@
+{
+ "version": 1,
+ "failurePolicy": "FAIL_FAST",
+ "operations": [
+ {
+ "id": "validate-configuration",
+ "operation": "configuration.validate",
+ "arguments": {}
+ },
+ {
+ "id": "list-publications",
+ "operation": "publication.list",
+ "arguments": {
+ "limit": 25
+ }
+ }
+ ]
+}
diff --git a/pki/src/main/java/zeroecho/pki/application/DefaultPkiOperationExecutor.java b/pki/src/main/java/zeroecho/pki/application/DefaultPkiOperationExecutor.java
index 5b6182e..d92f6b2 100644
--- a/pki/src/main/java/zeroecho/pki/application/DefaultPkiOperationExecutor.java
+++ b/pki/src/main/java/zeroecho/pki/application/DefaultPkiOperationExecutor.java
@@ -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 authorities;
+ private final Optional requests;
+ private final Optional issuance;
+ private final Optional statusObjects;
+ private final Optional 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 authorities,
+ Optional requests, Optional issuance,
+ Optional statusObjects, Optional 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 versions = profiles.listImportedVersions(request.profileId()).stream()
+ .map(DefaultPkiOperationExecutor::profileVersionValue).toList();
+ Map 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 records = store.listCas();
+ int count = Math.min(records.size(), request.limit());
+ List values = records.subList(0, count).stream()
+ .map(DefaultPkiOperationExecutor::authorityValue).toList();
+ Map 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 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 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 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 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 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 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 transitions = new ArrayList<>();
@@ -154,7 +339,7 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
}
Map 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 records = new ArrayList<>();
+ boolean truncated;
+ Map 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 records = store.listStatusObjects(request.issuerCaId());
+ int count = Math.min(records.size(), request.limit());
+ List values = records.subList(0, count).stream()
+ .map(DefaultPkiOperationExecutor::statusValue).toList();
+ Map 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 records = new ArrayList<>();
+ boolean truncated;
+ try (PublicationCursor cursor = store.openPublicationRecords()) {
+ Optional next = cursor.next(signal);
+ while (records.size() < request.limit() && next.isPresent()) {
+ records.add(publicationValue(next.orElseThrow()));
+ next = cursor.next(signal);
+ }
+ truncated = next.isPresent();
+ }
+ Map 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 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 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 requireCapability(Optional service, String capability) {
+ return service.orElseThrow(() -> new CapabilityUnavailableException(capability));
+ }
+
private static PkiOperationResult revocationResult(String operation, RevocationRecord record) {
Map 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 fields = fields();
+ appendProfileReference(fields, reference);
+ return result(operation, fields);
+ }
+
+ private static PkiOperationResult profileVersionResult(String operation,
+ ImportedCertificateProfileVersion version) {
+ Map fields = fields();
+ appendProfileVersion(fields, version);
+ return result(operation, fields);
+ }
+
+ private static PkiOperationValue profileVersionValue(ImportedCertificateProfileVersion version) {
+ Map fields = fields();
+ appendProfileVersion(fields, version);
+ return new PkiOperationValue.ObjectValue(fields);
+ }
+
+ private static void appendProfileVersion(Map 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 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 authorityFields(CaRecord record) {
+ Map 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 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 statusFields(StatusObject object) {
+ Map 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 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 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;
+ }
}
diff --git a/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java b/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java
index 71eb528..ff28ce4 100644
--- a/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java
+++ b/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java
@@ -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 authorities;
+ private final Optional requests;
+ private final Optional issuance;
+ private final Optional statusObjects;
+ private final Optional publications;
+ private final Optional signingBus;
+ private final Optional 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 authorities() {
+ requireOpen();
+ return authorities;
+ }
+
+ @Override
+ public Optional requests() {
+ requireOpen();
+ return requests;
+ }
+
+ @Override
+ public Optional issuance() {
+ requireOpen();
+ return issuance;
+ }
+
+ @Override
+ public Optional statusObjects() {
+ requireOpen();
+ return statusObjects;
+ }
+
+ @Override
+ public Optional 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 workflow = Optional.empty();
+ Optional bus = Optional.empty();
+ Optional authorities = Optional.empty();
+ Optional requests = Optional.empty();
+ Optional issuance = Optional.empty();
+ Optional statuses = Optional.empty();
+ Optional 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 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 supported = Set.copyOf(workflow.supportedAlgorithms());
+ Set 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 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 authorities, Optional requests,
+ Optional issuance, Optional statusObjects,
+ Optional publications, Optional signingBus,
+ Optional 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. */
diff --git a/pki/src/main/java/zeroecho/pki/application/PkiOperation.java b/pki/src/main/java/zeroecho/pki/application/PkiOperation.java
index f35f9cd..b00c932 100644
--- a/pki/src/main/java/zeroecho/pki/application/PkiOperation.java
+++ b/pki/src/main/java/zeroecho/pki/application/PkiOperation.java
@@ -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.]
*/
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");
+ }
+ }
}
diff --git a/pki/src/main/java/zeroecho/pki/application/PkiSession.java b/pki/src/main/java/zeroecho/pki/application/PkiSession.java
index a376c0f..786e9fe 100644
--- a/pki/src/main/java/zeroecho/pki/application/PkiSession.java
+++ b/pki/src/main/java/zeroecho/pki/application/PkiSession.java
@@ -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 authorities() {
+ return Optional.empty();
+ }
+
+ /** @return configured certification-request service, or empty when X.509 is unavailable */
+ default Optional requests() {
+ return Optional.empty();
+ }
+
+ /** @return configured issuance service, or empty when signing is unavailable */
+ default Optional issuance() {
+ return Optional.empty();
+ }
+
+ /** @return configured status-object service, or empty when signing is unavailable */
+ default Optional statusObjects() {
+ return Optional.empty();
+ }
+
+ /** @return configured publication service, or empty when no destination is enabled */
+ default Optional publications() {
+ return Optional.empty();
+ }
+
/** @return shared typed operation executor owned by this session */
PkiOperationExecutor operations();
diff --git a/pki/src/main/java/zeroecho/pki/application/PkiSessionConfiguration.java b/pki/src/main/java/zeroecho/pki/application/PkiSessionConfiguration.java
index f130d45..87cc55b 100644
--- a/pki/src/main/java/zeroecho/pki/application/PkiSessionConfiguration.java
+++ b/pki/src/main/java/zeroecho/pki/application/PkiSessionConfiguration.java
@@ -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 signing, List 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 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);
+ }
+ }
}
diff --git a/pki/src/main/java/zeroecho/pki/application/PkiSessionRuntimeDependencies.java b/pki/src/main/java/zeroecho/pki/application/PkiSessionRuntimeDependencies.java
new file mode 100644
index 0000000..9cadb11
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/application/PkiSessionRuntimeDependencies.java
@@ -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) {
+
+ /** 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")));
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultPublicationService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultPublicationService.java
index 8f79947..55d695e 100644
--- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultPublicationService.java
+++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultPublicationService.java
@@ -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;
diff --git a/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflow.java b/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflow.java
index 79e13b6..3548243 100644
--- a/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflow.java
+++ b/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflow.java
@@ -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 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");
}
}
diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java
index 169e0bc..cd7adfa 100644
--- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java
@@ -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) {
diff --git a/pki/src/main/java/zeroecho/pki/impl/publish/FilesystemPublisher.java b/pki/src/main/java/zeroecho/pki/impl/publish/FilesystemPublisher.java
new file mode 100644
index 0000000..4e885d5
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/impl/publish/FilesystemPublisher.java
@@ -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 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);
+ }
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/impl/publish/FilesystemPublisherProvider.java b/pki/src/main/java/zeroecho/pki/impl/publish/FilesystemPublisherProvider.java
new file mode 100644
index 0000000..e17fa15
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/impl/publish/FilesystemPublisherProvider.java
@@ -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 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)));
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java b/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java
index aab4d61..ce2508b 100644
--- a/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java
+++ b/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java
@@ -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)) {
diff --git a/pki/src/main/java/zeroecho/pki/spi/crypto/PublicKeyInfoSource.java b/pki/src/main/java/zeroecho/pki/spi/crypto/PublicKeyInfoSource.java
new file mode 100644
index 0000000..c2588c9
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/spi/crypto/PublicKeyInfoSource.java
@@ -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.
+ *
+ * This capability exposes only canonical DER SubjectPublicKeyInfo. It must
+ * never return private, secret, seed, unlock, or provider credential material.
+ */
+@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);
+}
diff --git a/pki/src/main/java/zeroecho/pki/spi/publish/PublisherProvider.java b/pki/src/main/java/zeroecho/pki/spi/publish/PublisherProvider.java
new file mode 100644
index 0000000..2e28df4
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/spi/publish/PublisherProvider.java
@@ -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 {
+}
diff --git a/pki/src/main/resources/META-INF/services/zeroecho.pki.spi.publish.PublisherProvider b/pki/src/main/resources/META-INF/services/zeroecho.pki.spi.publish.PublisherProvider
new file mode 100644
index 0000000..5c4ebd9
--- /dev/null
+++ b/pki/src/main/resources/META-INF/services/zeroecho.pki.spi.publish.PublisherProvider
@@ -0,0 +1 @@
+zeroecho.pki.impl.publish.FilesystemPublisherProvider
diff --git a/pki/src/test/java/zeroecho/pki/application/PkiOperationExecutorTest.java b/pki/src/test/java/zeroecho/pki/application/PkiOperationExecutorTest.java
index 0fefa9e..3d6217b 100644
--- a/pki/src/test/java/zeroecho/pki/application/PkiOperationExecutorTest.java
+++ b/pki/src/test/java/zeroecho/pki/application/PkiOperationExecutorTest.java
@@ -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 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
diff --git a/pki/src/test/java/zeroecho/pki/application/PkiSessionLifecycleTest.java b/pki/src/test/java/zeroecho/pki/application/PkiSessionLifecycleTest.java
index 11327a4..3b0b748 100644
--- a/pki/src/test/java/zeroecho/pki/application/PkiSessionLifecycleTest.java
+++ b/pki/src/test/java/zeroecho/pki/application/PkiSessionLifecycleTest.java
@@ -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) -> {
diff --git a/pki/src/test/java/zeroecho/pki/testkit/InMemorySignatureWorkflow.java b/pki/src/test/java/zeroecho/pki/testkit/InMemorySignatureWorkflow.java
index 3de329a..0c022df 100644
--- a/pki/src/test/java/zeroecho/pki/testkit/InMemorySignatureWorkflow.java
+++ b/pki/src/test/java/zeroecho/pki/testkit/InMemorySignatureWorkflow.java
@@ -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 keys;
private final Map 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();
}