diff --git a/app/build.gradle b/app/build.gradle
index 7ccb9d8..aff01eb 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -8,8 +8,11 @@ group='org.egothor'
dependencies {
implementation 'org.apache.commons:commons-text'
implementation 'commons-cli:commons-cli'
+ implementation platform('tools.jackson:jackson-bom:3.1.5')
+ implementation 'tools.jackson.core:jackson-core'
implementation project(':lib')
implementation project(':ext')
+ implementation project(':pki')
// might be removed if I move BC ops to the lib
testImplementation 'org.bouncycastle:bcpkix-jdk18on'
}
@@ -57,4 +60,3 @@ jar {
javadoc {
options.links("https://www.egothor.org/javadoc/zeroecho/lib")
}
-
diff --git a/app/src/main/java/zeroecho/ZeroEcho.java b/app/src/main/java/zeroecho/ZeroEcho.java
index 75113ab..7256195 100644
--- a/app/src/main/java/zeroecho/ZeroEcho.java
+++ b/app/src/main/java/zeroecho/ZeroEcho.java
@@ -35,6 +35,7 @@ package zeroecho;
import java.io.IOException;
import java.security.GeneralSecurityException;
+import java.util.Arrays;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -48,6 +49,7 @@ import org.apache.commons.cli.ParseException;
import org.apache.commons.cli.help.HelpFormatter;
import zeroecho.sdk.util.BouncyCastleActivator;
+import zeroecho.pki.cli.PkiCli;
/**
* ZeroEcho is a command-line utility for managing asymmetric keys and
@@ -102,6 +104,12 @@ public final class ZeroEcho {
public static void main(final String[] args) throws IOException {
final int errorCode = mainProcess(args);
+ if (args.length > 0 && "pki".equals(args[0])) {
+ if (errorCode != 0) {
+ System.exit(errorCode);
+ }
+ return;
+ }
if (errorCode == 0) {
System.out.println("OK");
} else {
@@ -119,6 +127,9 @@ public final class ZeroEcho {
* @throws IOException If the output could not be written
*/
public static int mainProcess(final String... args) throws IOException {
+ if (args.length > 0 && "pki".equals(args[0])) {
+ return PkiCli.execute(Arrays.copyOfRange(args, 1, args.length), System.out);
+ }
final Option KEM_OPTION = Option.builder("E").longOpt("kem").desc("KEM encryption/decryption").get();
final Option GUARD_OPTION = Option.builder("G").longOpt("guard")
.desc("multi-recipient encryption/decryption (keys+passwords), AES/ChaCha").get();
diff --git a/app/src/main/java/zeroecho/pki/cli/PkiCli.java b/app/src/main/java/zeroecho/pki/cli/PkiCli.java
new file mode 100644
index 0000000..f4c2c4e
--- /dev/null
+++ b/app/src/main/java/zeroecho/pki/cli/PkiCli.java
@@ -0,0 +1,300 @@
+/*******************************************************************************
+ * 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.cli;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import zeroecho.core.io.CancellationSignal;
+import zeroecho.pki.application.PkiOperationFailure;
+import zeroecho.pki.application.PkiOperationValue;
+import zeroecho.pki.application.PkiSession;
+import zeroecho.pki.application.PkiSessionConfiguration;
+
+/** Composition root for the synchronous {@code zeroecho pki} command family. */
+@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.AvoidInstantiatingObjectsInLoops",
+ "PMD.AvoidReassigningLoopVariables", "PMD.CloseResource", "PMD.DoNotUseThreads", "PMD.UseVarargs" })
+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 int RUN_TOKEN_COUNT = 2;
+
+ private PkiCli() {
+ }
+
+ /**
+ * Executes one direct PKI operation or one sequential plan.
+ *
+ * @param arguments arguments following the {@code pki} command
+ * @param output exclusive result output stream
+ * @return stable PKI CLI process exit code
+ * @throws NullPointerException if an argument container is {@code null}
+ */
+ public static int execute(String[] arguments, OutputStream output) {
+ return execute(arguments, output, PkiSession::open, () -> Thread.currentThread().isInterrupted());
+ }
+
+ /* default */ static int execute(String[] arguments, OutputStream output, SessionOpener opener,
+ CancellationSignal cancellation) {
+ Objects.requireNonNull(arguments, "arguments");
+ Objects.requireNonNull(output, "output");
+ Objects.requireNonNull(opener, "opener");
+ Objects.requireNonNull(cancellation, "cancellation");
+ PkiOutputRenderer renderer = new PkiOutputRenderer();
+ Invocation invocation;
+ try {
+ invocation = parseInvocation(arguments);
+ } catch (IllegalArgumentException failure) {
+ return renderFailure(renderer, PkiOutputMode.HUMAN, "INVALID_INVOCATION", "INVOCATION_INVALID",
+ PkiExitCodes.INVALID_INVOCATION, output);
+ }
+ if (invocation.help()) {
+ renderHelp(output);
+ return PkiExitCodes.SUCCESS;
+ }
+
+ PkiSessionConfiguration configuration;
+ try {
+ configuration = PkiCliConfiguration.read(invocation.configuration());
+ } catch (IOException | RuntimeException failure) {
+ return renderFailure(renderer, invocation.outputMode(), "CONFIGURATION_FAILURE", "CONFIGURATION_INVALID",
+ PkiExitCodes.CONFIGURATION_FAILURE, output);
+ }
+
+ PkiOperationRegistry registry = new PkiOperationRegistry();
+ PkiPlan plan;
+ try {
+ plan = invocation.batch() ? PkiPlanParser.read(invocation.plan(), registry.names())
+ : singlePlan(invocation, registry);
+ } catch (IOException | RuntimeException failure) {
+ return renderFailure(renderer, invocation.outputMode(), "INVALID_PLAN", "PLAN_INVALID",
+ PkiExitCodes.INVALID_INVOCATION, output);
+ }
+
+ PkiSession session;
+ try {
+ session = opener.open(configuration);
+ } catch (IllegalArgumentException failure) {
+ return renderFailure(renderer, invocation.outputMode(), "CONFIGURATION_FAILURE", "CONFIGURATION_INVALID",
+ PkiExitCodes.CONFIGURATION_FAILURE, output);
+ } catch (RuntimeException failure) {
+ return renderFailure(renderer, invocation.outputMode(), "RESOURCE_FAILURE", "SESSION_OPEN_FAILED",
+ PkiExitCodes.RESOURCE_FAILURE, output);
+ }
+
+ PkiPlanExecution execution;
+ try {
+ execution = new PkiPlanExecutor(registry).execute(plan, session, cancellation);
+ } catch (RuntimeException failure) {
+ execution = internalFailure(plan);
+ }
+ try {
+ session.close();
+ } catch (Exception failure) {
+ execution = execution.withPlanFailure(PkiOperationFailure.RESOURCE_FAILURE, "SESSION_CLOSE_FAILED");
+ }
+ try {
+ renderer.render(execution, invocation.outputMode(), invocation.batch(), output);
+ } catch (IOException failure) {
+ return PkiExitCodes.RESOURCE_FAILURE;
+ }
+ return PkiExitCodes.from(execution);
+ }
+
+ @SuppressWarnings("PMD.CyclomaticComplexity")
+ private static Invocation parseInvocation(String[] arguments) {
+ if (arguments.length == 0) {
+ throw new IllegalArgumentException("PKI command is missing");
+ }
+ Path configuration = null;
+ PkiOutputMode outputMode = PkiOutputMode.HUMAN;
+ List commandTokens = new ArrayList<>();
+ boolean help = false;
+ boolean outputSelected = false;
+ for (int index = 0; index < arguments.length; index++) {
+ String argument = Objects.requireNonNull(arguments[index], "argument");
+ if (CONFIG.equals(argument)) {
+ if (configuration != null) {
+ throw new IllegalArgumentException("PKI configuration option is duplicated");
+ }
+ configuration = Path.of(requireValue(arguments, ++index));
+ } else if (OUTPUT.equals(argument)) {
+ if (outputSelected) {
+ throw new IllegalArgumentException("PKI output option is duplicated");
+ }
+ outputMode = parseOutput(requireValue(arguments, ++index));
+ outputSelected = true;
+ } else if ("--help".equals(argument) || "-h".equals(argument)) {
+ help = true;
+ } else {
+ commandTokens.add(argument);
+ }
+ }
+ if (help) {
+ return new Invocation(null, outputMode, false, null, null, null, true);
+ }
+ if (configuration == null || commandTokens.isEmpty()) {
+ throw new IllegalArgumentException("PKI configuration or operation is missing");
+ }
+ String command = commandTokens.get(0);
+ if (RUN.equals(command)) {
+ if (commandTokens.size() != RUN_TOKEN_COUNT) {
+ throw new IllegalArgumentException("Plan path is invalid");
+ }
+ return new Invocation(configuration, outputMode, true, Path.of(commandTokens.get(1)), null, null, false);
+ }
+ PkiOperationValue.ObjectValue operationArguments = directArguments(commandTokens.subList(1,
+ commandTokens.size()));
+ return new Invocation(configuration, outputMode, false, null, command, operationArguments, false);
+ }
+
+ private static PkiOperationValue.ObjectValue directArguments(List tokens) {
+ if ((tokens.size() & 1) != 0) {
+ throw new IllegalArgumentException("Operation arguments must be option-value pairs");
+ }
+ Map arguments = new LinkedHashMap<>();
+ 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);
+ if (arguments.putIfAbsent(name, value) != null) {
+ throw new IllegalArgumentException("Operation argument is duplicated");
+ }
+ }
+ return new PkiOperationValue.ObjectValue(arguments);
+ }
+
+ 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");
+ };
+ }
+
+ private static PkiPlan singlePlan(Invocation invocation, PkiOperationRegistry registry) throws IOException {
+ if (!registry.names().contains(invocation.operation())) {
+ throw new IllegalArgumentException("Unknown PKI operation");
+ }
+ Path base = Path.of(".").toAbsolutePath().normalize();
+ PkiPlan.Step step = new PkiPlan.Step("command", invocation.operation(), invocation.arguments());
+ return new PkiPlan(PkiPlan.CURRENT_VERSION, PkiPlan.FailurePolicy.FAIL_FAST, List.of(step), base);
+ }
+
+ private static PkiPlanExecution internalFailure(PkiPlan plan) {
+ List results = new ArrayList<>(plan.steps().size());
+ boolean first = true;
+ for (PkiPlan.Step step : plan.steps()) {
+ if (first) {
+ results.add(PkiPlanExecution.StepResult.failure(step.id(), step.operation(),
+ PkiOperationFailure.INTERNAL_FAILURE, "INTERNAL_FAILURE"));
+ first = false;
+ } else {
+ results.add(PkiPlanExecution.StepResult.skipped(step.id(), step.operation(), "FAIL_FAST"));
+ }
+ }
+ return new PkiPlanExecution(results);
+ }
+
+ private static int renderFailure(PkiOutputRenderer renderer, PkiOutputMode mode, String category, String code,
+ int exitCode, OutputStream output) {
+ try {
+ renderer.renderFailure(mode, category, code, output);
+ } catch (IOException ignored) {
+ return PkiExitCodes.RESOURCE_FAILURE;
+ }
+ return exitCode;
+ }
+
+ private static void renderHelp(OutputStream output) {
+ java.io.PrintWriter writer = new java.io.PrintWriter(output, false, java.nio.charset.StandardCharsets.UTF_8);
+ writer.println("Usage: zeroecho pki --config [--output human|json] [arguments]");
+ writer.println(" zeroecho pki run --config [--output human|json]");
+ writer.println("Operations:");
+ writer.println(" configuration.validate");
+ writer.println(" profile.validate --profile-file ");
+ writer.println(" credential.inspect --credential-id ");
+ writer.println(" credential.revoke --credential-id --reason ");
+ writer.println(" revocation.history --credential-id --limit <1..1000>");
+ writer.println(" publication.inspect --publication-id ");
+ writer.flush();
+ }
+
+ private static PkiOutputMode parseOutput(String raw) {
+ return switch (raw) {
+ case "human" -> PkiOutputMode.HUMAN;
+ case "json" -> PkiOutputMode.JSON;
+ default -> throw new IllegalArgumentException("Output mode is invalid");
+ };
+ }
+
+ private static String requireValue(String[] arguments, int index) {
+ if (index >= arguments.length) {
+ throw new IllegalArgumentException("Option value is missing");
+ }
+ return arguments[index];
+ }
+
+ private static long parseLong(String raw) {
+ try {
+ return Long.parseLong(raw);
+ } catch (NumberFormatException failure) {
+ throw new IllegalArgumentException("Numeric operation argument is invalid", failure);
+ }
+ }
+
+ /** Lifecycle construction seam for CLI orchestration tests. */
+ @FunctionalInterface
+ @SuppressWarnings("PMD.CommentDefaultAccessModifier")
+ interface SessionOpener {
+ /** Opens one lifecycle-owned backend session. */
+ PkiSession open(PkiSessionConfiguration configuration);
+ }
+
+ private record Invocation(Path configuration, PkiOutputMode outputMode, boolean batch, Path plan,
+ String operation, PkiOperationValue.ObjectValue arguments, boolean help) {
+ }
+}
diff --git a/app/src/main/java/zeroecho/pki/cli/PkiCliConfiguration.java b/app/src/main/java/zeroecho/pki/cli/PkiCliConfiguration.java
new file mode 100644
index 0000000..a263752
--- /dev/null
+++ b/app/src/main/java/zeroecho/pki/cli/PkiCliConfiguration.java
@@ -0,0 +1,114 @@
+/*******************************************************************************
+ * 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.cli;
+
+import java.nio.file.Path;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
+import zeroecho.pki.application.PkiOperationValue;
+import zeroecho.pki.application.PkiSessionConfiguration;
+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 Set PROVIDER_FIELDS = Set.of("provider", "properties");
+ private static final String STDOUT_PROVIDER = "stdout";
+
+ private 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")));
+ 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);
+ }
+
+ private static ProviderConfig provider(PkiOperationValue value) {
+ PkiOperationValue.ObjectValue object = object(value);
+ requireExactFields(object.fields(), PROVIDER_FIELDS);
+ String provider = text(required(object, "provider"));
+ PkiOperationValue.ObjectValue properties = object(required(object, "properties"));
+ Map result = new LinkedHashMap<>();
+ for (Map.Entry entry : properties.fields().entrySet()) {
+ result.put(entry.getKey(), text(entry.getValue()));
+ }
+ return new ProviderConfig(provider, result);
+ }
+
+ /* default */ static PkiOperationValue required(PkiOperationValue.ObjectValue object, String name) {
+ PkiOperationValue value = object.fields().get(name);
+ if (value == null) {
+ throw new IllegalArgumentException("Required CLI field is missing");
+ }
+ return value;
+ }
+
+ /* default */ static PkiOperationValue.ObjectValue object(PkiOperationValue value) {
+ if (value instanceof PkiOperationValue.ObjectValue object) {
+ return object;
+ }
+ throw new IllegalArgumentException("CLI field has the wrong type");
+ }
+
+ /* default */ static String text(PkiOperationValue value) {
+ if (value instanceof PkiOperationValue.Text text) {
+ return text.value();
+ }
+ throw new IllegalArgumentException("CLI field has the wrong type");
+ }
+
+ /* default */ static long integer(PkiOperationValue value) {
+ if (value instanceof PkiOperationValue.IntegerValue integer) {
+ return integer.value();
+ }
+ throw new IllegalArgumentException("CLI field has the wrong type");
+ }
+
+ /* default */ static void requireExactFields(Map actual, Set expected) {
+ if (!actual.keySet().equals(expected)) {
+ throw new IllegalArgumentException("CLI document fields are invalid");
+ }
+ }
+}
diff --git a/app/src/main/java/zeroecho/pki/cli/PkiCliFiles.java b/app/src/main/java/zeroecho/pki/cli/PkiCliFiles.java
new file mode 100644
index 0000000..33297c3
--- /dev/null
+++ b/app/src/main/java/zeroecho/pki/cli/PkiCliFiles.java
@@ -0,0 +1,92 @@
+/*******************************************************************************
+ * 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.cli;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.channels.Channels;
+import java.nio.channels.SeekableByteChannel;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.Objects;
+
+/** Bounded regular-file input used by the CLI trust boundary. */
+final class PkiCliFiles {
+
+ private static final int BUFFER_BYTES = 8_192;
+
+ private PkiCliFiles() {
+ }
+
+ /* default */ static byte[] readRegularFile(Path path, int maximumBytes) throws IOException {
+ Path exact = Objects.requireNonNull(path, "path").toAbsolutePath().normalize();
+ if (maximumBytes <= 0) {
+ throw new IllegalArgumentException("maximumBytes must be positive");
+ }
+ int readLimit = Math.addExact(maximumBytes, 1);
+ BasicFileAttributes attributes = Files.readAttributes(exact, BasicFileAttributes.class,
+ LinkOption.NOFOLLOW_LINKS);
+ if (!attributes.isRegularFile() || attributes.isSymbolicLink() || attributes.size() < 0L
+ || attributes.size() > maximumBytes) {
+ throw new IOException("CLI input file is invalid");
+ }
+ ByteArrayOutputStream output = new ByteArrayOutputStream((int) Math.min(attributes.size(), BUFFER_BYTES));
+ byte[] buffer = new byte[BUFFER_BYTES];
+ int total = 0;
+ try (SeekableByteChannel channel = Files.newByteChannel(exact, StandardOpenOption.READ,
+ LinkOption.NOFOLLOW_LINKS);
+ InputStream input = Channels.newInputStream(channel)) {
+ while (true) {
+ int remaining = readLimit - total;
+ int count = input.read(buffer, 0, Math.min(buffer.length, remaining));
+ if (count < 0) {
+ break;
+ }
+ if (count == 0) {
+ continue;
+ }
+ output.write(buffer, 0, count);
+ total = Math.addExact(total, count);
+ if (total > maximumBytes) {
+ throw new IOException("CLI input file is too large");
+ }
+ }
+ }
+ return output.toByteArray();
+ }
+}
diff --git a/app/src/main/java/zeroecho/pki/cli/PkiCliJson.java b/app/src/main/java/zeroecho/pki/cli/PkiCliJson.java
new file mode 100644
index 0000000..bb09bd8
--- /dev/null
+++ b/app/src/main/java/zeroecho/pki/cli/PkiCliJson.java
@@ -0,0 +1,147 @@
+/*******************************************************************************
+ * 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.cli;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import tools.jackson.core.JacksonException;
+import tools.jackson.core.JsonParser;
+import tools.jackson.core.JsonToken;
+import tools.jackson.core.ObjectReadContext;
+import tools.jackson.core.StreamReadConstraints;
+import tools.jackson.core.StreamReadFeature;
+import tools.jackson.core.json.JsonFactory;
+import tools.jackson.core.json.JsonFactoryBuilder;
+import tools.jackson.core.json.JsonReadFeature;
+import zeroecho.pki.application.PkiOperationValue;
+
+/** Strict bounded JSON reader for CLI configuration and operation plans. */
+final class PkiCliJson {
+
+ /* default */ static final int MAXIMUM_DOCUMENT_BYTES = 1_048_576;
+ /* default */ static final int MAXIMUM_CONTAINER_ENTRIES = 2_048;
+
+ private static final int MAXIMUM_DEPTH = 16;
+ private static final int MAXIMUM_STRING_LENGTH = 4_096;
+ private static final JsonFactory JSON_FACTORY = createFactory();
+
+ private PkiCliJson() {
+ }
+
+ /* default */ static PkiOperationValue parse(byte[] document) {
+ if (document == null || document.length == 0 || document.length > MAXIMUM_DOCUMENT_BYTES || hasBom(document)) {
+ throw invalid();
+ }
+ try (JsonParser parser = JSON_FACTORY.createParser(ObjectReadContext.empty(), document, 0, document.length)) {
+ JsonToken first = parser.nextToken();
+ if (first == null) {
+ throw invalid();
+ }
+ PkiOperationValue value = readValue(parser, first);
+ if (parser.nextToken() != null) {
+ throw invalid();
+ }
+ return value;
+ } catch (JacksonException failure) {
+ throw invalid(failure);
+ }
+ }
+
+ private static PkiOperationValue readValue(JsonParser parser, JsonToken token) {
+ return switch (token) {
+ case START_OBJECT -> readObject(parser);
+ case START_ARRAY -> readArray(parser);
+ case VALUE_STRING -> new PkiOperationValue.Text(parser.getString());
+ case VALUE_NUMBER_INT -> new PkiOperationValue.IntegerValue(parser.getLongValue());
+ case VALUE_TRUE -> new PkiOperationValue.BooleanValue(true);
+ case VALUE_FALSE -> new PkiOperationValue.BooleanValue(false);
+ default -> throw invalid();
+ };
+ }
+
+ private static PkiOperationValue.ObjectValue readObject(JsonParser parser) {
+ Map fields = new LinkedHashMap<>();
+ while (parser.nextToken() != JsonToken.END_OBJECT) {
+ if (parser.currentToken() != JsonToken.PROPERTY_NAME || fields.size() >= MAXIMUM_CONTAINER_ENTRIES) {
+ throw invalid();
+ }
+ String name = parser.currentName();
+ JsonToken valueToken = parser.nextToken();
+ if (valueToken == null || fields.putIfAbsent(name, readValue(parser, valueToken)) != null) {
+ throw invalid();
+ }
+ }
+ return new PkiOperationValue.ObjectValue(fields);
+ }
+
+ private static PkiOperationValue.ListValue readArray(JsonParser parser) {
+ List values = new ArrayList<>();
+ JsonToken token;
+ while ((token = parser.nextToken()) != JsonToken.END_ARRAY) {
+ if (token == null || values.size() >= MAXIMUM_CONTAINER_ENTRIES) {
+ throw invalid();
+ }
+ values.add(readValue(parser, token));
+ }
+ return new PkiOperationValue.ListValue(values);
+ }
+
+ private static JsonFactory createFactory() {
+ StreamReadConstraints constraints = StreamReadConstraints.builder().maxNestingDepth(MAXIMUM_DEPTH)
+ .maxDocumentLength(MAXIMUM_DOCUMENT_BYTES).maxTokenCount(32_768).maxNumberLength(20)
+ .maxStringLength(MAXIMUM_STRING_LENGTH).maxNameLength(64).build();
+ JsonFactoryBuilder builder = JsonFactory.builder().streamReadConstraints(constraints)
+ .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION).disable(StreamReadFeature.AUTO_CLOSE_SOURCE);
+ for (JsonReadFeature feature : JsonReadFeature.values()) {
+ builder.disable(feature);
+ }
+ return builder.build();
+ }
+
+ private static boolean hasBom(byte[] document) {
+ return document.length >= 3 && document[0] == (byte) 0xef && document[1] == (byte) 0xbb
+ && document[2] == (byte) 0xbf;
+ }
+
+ private static IllegalArgumentException invalid() {
+ return new IllegalArgumentException("CLI JSON document is invalid");
+ }
+
+ private static IllegalArgumentException invalid(JacksonException failure) {
+ return new IllegalArgumentException("CLI JSON document is invalid", failure);
+ }
+}
diff --git a/app/src/main/java/zeroecho/pki/cli/PkiExitCodes.java b/app/src/main/java/zeroecho/pki/cli/PkiExitCodes.java
new file mode 100644
index 0000000..bea4461
--- /dev/null
+++ b/app/src/main/java/zeroecho/pki/cli/PkiExitCodes.java
@@ -0,0 +1,76 @@
+/*******************************************************************************
+ * 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.cli;
+
+import zeroecho.pki.application.PkiOperationFailure;
+
+/** Stable process exit-code mapping for the PKI command family. */
+final class PkiExitCodes {
+
+ /* default */ static final int SUCCESS = 0;
+ /* default */ static final int INVALID_INVOCATION = 2;
+ /* default */ static final int CONFIGURATION_FAILURE = 3;
+ /* default */ static final int VALIDATION_OR_POLICY = 4;
+ /* default */ static final int NOT_FOUND_OR_CONFLICT = 5;
+ /* default */ static final int RECOVERY_REQUIRED = 6;
+ /* default */ static final int EXTERNAL_OUTCOME_UNKNOWN = 7;
+ /* default */ static final int RESOURCE_FAILURE = 8;
+ /* default */ static final int INTERNAL_FAILURE = 9;
+ /* default */ static final int CANCELLED = 130;
+
+ private PkiExitCodes() {
+ }
+
+ /* default */ static int from(PkiPlanExecution execution) {
+ int result = execution.planFailure().map(PkiExitCodes::from).orElse(SUCCESS);
+ for (PkiPlanExecution.StepResult step : execution.steps()) {
+ if (step.failure().isPresent()) {
+ result = Math.max(result, from(step.failure().orElseThrow()));
+ }
+ }
+ return result == SUCCESS && !execution.succeeded() ? VALIDATION_OR_POLICY : result;
+ }
+
+ private static int from(PkiOperationFailure failure) {
+ return switch (failure) {
+ case VALIDATION_FAILURE, POLICY_REJECTION -> VALIDATION_OR_POLICY;
+ case NOT_FOUND, CONFLICT -> NOT_FOUND_OR_CONFLICT;
+ case RECOVERY_REQUIRED -> RECOVERY_REQUIRED;
+ case EXTERNAL_OUTCOME_UNKNOWN -> EXTERNAL_OUTCOME_UNKNOWN;
+ case RESOURCE_FAILURE -> RESOURCE_FAILURE;
+ case INTERNAL_FAILURE -> INTERNAL_FAILURE;
+ case CANCELLED -> CANCELLED;
+ };
+ }
+}
diff --git a/app/src/main/java/zeroecho/pki/cli/PkiOperationRegistry.java b/app/src/main/java/zeroecho/pki/cli/PkiOperationRegistry.java
new file mode 100644
index 0000000..39ea0b9
--- /dev/null
+++ b/app/src/main/java/zeroecho/pki/cli/PkiOperationRegistry.java
@@ -0,0 +1,145 @@
+/*******************************************************************************
+ * 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.cli;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
+import zeroecho.pki.api.PkiId;
+import zeroecho.pki.api.profile.CertificateProfileDocumentCodec;
+import zeroecho.pki.api.revocation.RevocationReason;
+import zeroecho.pki.application.PkiOperation;
+import zeroecho.pki.application.PkiOperationValue;
+
+/** Explicit stable operation-name to typed-request binding registry. */
+@SuppressWarnings("PMD.UnusedFormalParameter")
+final class PkiOperationRegistry {
+
+ private static final String CREDENTIAL_ID = "credentialId";
+
+ private final Map bindings;
+
+ /* default */ PkiOperationRegistry() {
+ Map configured = new LinkedHashMap<>();
+ add(configured, PkiOperation.ValidateConfiguration.NAME, this::configuration);
+ add(configured, PkiOperation.ValidateProfile.NAME, this::profile);
+ add(configured, PkiOperation.InspectCredential.NAME, this::credential);
+ add(configured, PkiOperation.RevokeCredential.NAME, this::revoke);
+ add(configured, PkiOperation.ReadRevocationHistory.NAME, this::history);
+ add(configured, PkiOperation.InspectPublication.NAME, this::publication);
+ bindings = Map.copyOf(configured);
+ }
+
+ /* default */ Set names() {
+ return bindings.keySet();
+ }
+
+ /* default */ PkiOperation bind(String name, PkiOperationValue.ObjectValue arguments, Path baseDirectory)
+ throws IOException {
+ Binding binding = bindings.get(name);
+ if (binding == null) {
+ throw new IllegalArgumentException("Unknown PKI operation");
+ }
+ return binding.bind(arguments, baseDirectory);
+ }
+
+ private PkiOperation configuration(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
+ requireFields(arguments, Set.of());
+ return new PkiOperation.ValidateConfiguration();
+ }
+
+ private PkiOperation profile(PkiOperationValue.ObjectValue arguments, Path baseDirectory) throws IOException {
+ requireFields(arguments, Set.of("profileFile"));
+ Path path = resolve(baseDirectory, text(arguments, "profileFile"));
+ byte[] document = PkiCliFiles.readRegularFile(path, CertificateProfileDocumentCodec.MAXIMUM_DOCUMENT_BYTES);
+ return new PkiOperation.ValidateProfile(document);
+ }
+
+ 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 revoke(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
+ requireFields(arguments, Set.of(CREDENTIAL_ID, "reason"));
+ RevocationReason reason = RevocationReason.valueOf(text(arguments, "reason"));
+ return new PkiOperation.RevokeCredential(new PkiId(text(arguments, CREDENTIAL_ID)), reason);
+ }
+
+ 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);
+ }
+
+ private PkiOperation publication(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
+ requireFields(arguments, Set.of("publicationId"));
+ return new PkiOperation.InspectPublication(new PkiId(text(arguments, "publicationId")));
+ }
+
+ private static void add(Map target, String name, Binding binding) {
+ if (target.putIfAbsent(name, binding) != null) {
+ throw new IllegalStateException("Duplicate PKI operation identity");
+ }
+ }
+
+ private static void requireFields(PkiOperationValue.ObjectValue arguments, Set expected) {
+ if (!arguments.fields().keySet().equals(expected)) {
+ throw new IllegalArgumentException("PKI operation arguments are invalid");
+ }
+ }
+
+ private static String text(PkiOperationValue.ObjectValue arguments, String name) {
+ return PkiCliConfiguration.text(PkiCliConfiguration.required(arguments, name));
+ }
+
+ private static long integer(PkiOperationValue.ObjectValue arguments, String name) {
+ return PkiCliConfiguration.integer(PkiCliConfiguration.required(arguments, name));
+ }
+
+ private static Path resolve(Path baseDirectory, String value) {
+ Path supplied = Path.of(value);
+ return (supplied.isAbsolute() ? supplied : baseDirectory.resolve(supplied)).toAbsolutePath().normalize();
+ }
+
+ /** One explicit typed binding. */
+ @FunctionalInterface
+ private interface Binding {
+ /** Binds validated structured arguments to one typed operation. */
+ PkiOperation bind(PkiOperationValue.ObjectValue arguments, Path baseDirectory) throws IOException;
+ }
+}
diff --git a/app/src/main/java/zeroecho/pki/cli/PkiOutputMode.java b/app/src/main/java/zeroecho/pki/cli/PkiOutputMode.java
new file mode 100644
index 0000000..28a0554
--- /dev/null
+++ b/app/src/main/java/zeroecho/pki/cli/PkiOutputMode.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.cli;
+
+/** Supported administrator output representations. */
+enum PkiOutputMode {
+ HUMAN,
+ JSON
+}
diff --git a/app/src/main/java/zeroecho/pki/cli/PkiOutputRenderer.java b/app/src/main/java/zeroecho/pki/cli/PkiOutputRenderer.java
new file mode 100644
index 0000000..13e6676
--- /dev/null
+++ b/app/src/main/java/zeroecho/pki/cli/PkiOutputRenderer.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.cli;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.PrintWriter;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+
+import tools.jackson.core.JacksonException;
+import tools.jackson.core.JsonGenerator;
+import tools.jackson.core.ObjectWriteContext;
+import tools.jackson.core.json.JsonFactory;
+import zeroecho.pki.application.PkiOperationResult;
+import zeroecho.pki.application.PkiOperationValue;
+
+/** Deterministic safe human and versioned machine result renderer. */
+final class PkiOutputRenderer {
+
+ private static final JsonFactory JSON_FACTORY = JsonFactory.builder().build();
+
+ /* default */ void render(PkiPlanExecution execution, PkiOutputMode mode, boolean batch, OutputStream output)
+ throws IOException {
+ if (mode == PkiOutputMode.JSON) {
+ renderJson(execution, batch, output);
+ } else {
+ renderHuman(execution, output);
+ }
+ }
+
+ /* default */ void renderFailure(PkiOutputMode mode, String category, String code, OutputStream output)
+ throws IOException {
+ if (mode == PkiOutputMode.HUMAN) {
+ PrintWriter writer = new PrintWriter(output, false, StandardCharsets.UTF_8);
+ writer.println("PKI command: FAILED");
+ writer.println("category: " + category);
+ writer.println("code: " + code);
+ writer.flush();
+ return;
+ }
+ ByteArrayOutputStream encoded = new ByteArrayOutputStream();
+ try (JsonGenerator generator = JSON_FACTORY.createGenerator(ObjectWriteContext.empty(), encoded)) {
+ generator.writeStartObject();
+ generator.writeNumberProperty("version", 1);
+ generator.writeStringProperty("status", "FAILED");
+ generator.writeStringProperty("category", category);
+ generator.writeStringProperty("code", code);
+ generator.writeEndObject();
+ } catch (JacksonException failure) {
+ throw new IOException("Machine output generation failed", failure);
+ }
+ output.write(encoded.toByteArray());
+ output.write('\n');
+ output.flush();
+ }
+
+ private static void renderHuman(PkiPlanExecution execution, OutputStream output) {
+ PrintWriter writer = new PrintWriter(output, false, StandardCharsets.UTF_8);
+ writer.println("PKI plan: " + (execution.succeeded() ? "SUCCEEDED" : "FAILED"));
+ if (execution.planFailure().isPresent()) {
+ writer.println("code: " + execution.planCode().orElseThrow());
+ }
+ for (PkiPlanExecution.StepResult step : execution.steps()) {
+ writer.println(step.id() + " " + step.operation() + " " + step.status());
+ if (step.output().isPresent()) {
+ for (Map.Entry field : step.output().orElseThrow().fields().entrySet()) {
+ writer.println(" " + field.getKey() + ": " + human(field.getValue()));
+ }
+ } else {
+ step.code().ifPresent(code -> writer.println(" code: " + code));
+ }
+ }
+ writer.flush();
+ }
+
+ private static void renderJson(PkiPlanExecution execution, boolean batch, OutputStream output) throws IOException {
+ ByteArrayOutputStream encoded = new ByteArrayOutputStream();
+ try (JsonGenerator generator = JSON_FACTORY.createGenerator(ObjectWriteContext.empty(), encoded)) {
+ generator.writeStartObject();
+ generator.writeNumberProperty("version", 1);
+ generator.writeStringProperty("mode", batch ? "batch" : "single");
+ generator.writeStringProperty("status", execution.succeeded() ? "SUCCEEDED" : "FAILED");
+ if (execution.planFailure().isPresent()) {
+ generator.writeStringProperty("failure", execution.planFailure().orElseThrow().name());
+ generator.writeStringProperty("code", execution.planCode().orElseThrow());
+ }
+ generator.writeArrayPropertyStart("steps");
+ for (PkiPlanExecution.StepResult step : execution.steps()) {
+ writeStep(generator, step);
+ }
+ generator.writeEndArray();
+ generator.writeEndObject();
+ } catch (JacksonException failure) {
+ throw new IOException("Machine output generation failed", failure);
+ }
+ output.write(encoded.toByteArray());
+ output.write('\n');
+ output.flush();
+ }
+
+ private static void writeStep(JsonGenerator generator, PkiPlanExecution.StepResult step) {
+ generator.writeStartObject();
+ generator.writeStringProperty("id", step.id());
+ generator.writeStringProperty("operation", step.operation());
+ generator.writeStringProperty("status", step.status().name());
+ if (step.output().isPresent()) {
+ generator.writeObjectPropertyStart("output");
+ PkiOperationResult result = step.output().orElseThrow();
+ for (Map.Entry field : result.fields().entrySet()) {
+ generator.writeName(field.getKey());
+ writeValue(generator, field.getValue());
+ }
+ generator.writeEndObject();
+ } else {
+ if (step.failure().isPresent()) {
+ generator.writeStringProperty("failure", step.failure().orElseThrow().name());
+ }
+ generator.writeStringProperty("code", step.code().orElseThrow());
+ }
+ generator.writeEndObject();
+ }
+
+ private static void writeValue(JsonGenerator generator, PkiOperationValue value) {
+ switch (value) {
+ case PkiOperationValue.Text text -> generator.writeString(text.value());
+ case PkiOperationValue.IntegerValue integer -> generator.writeNumber(integer.value());
+ case PkiOperationValue.BooleanValue bool -> generator.writeBoolean(bool.value());
+ case PkiOperationValue.ObjectValue object -> {
+ generator.writeStartObject();
+ for (Map.Entry entry : object.fields().entrySet()) {
+ generator.writeName(entry.getKey());
+ writeValue(generator, entry.getValue());
+ }
+ generator.writeEndObject();
+ }
+ case PkiOperationValue.ListValue list -> {
+ generator.writeStartArray();
+ for (PkiOperationValue entry : list.values()) {
+ writeValue(generator, entry);
+ }
+ generator.writeEndArray();
+ }
+ }
+ }
+
+ private static String human(PkiOperationValue value) {
+ return switch (value) {
+ case PkiOperationValue.Text text -> text.value();
+ case PkiOperationValue.IntegerValue integer -> Long.toString(integer.value());
+ case PkiOperationValue.BooleanValue bool -> Boolean.toString(bool.value());
+ case PkiOperationValue.ObjectValue object -> "object(" + object.fields().size() + ")";
+ case PkiOperationValue.ListValue list -> "list(" + list.values().size() + ")";
+ };
+ }
+}
diff --git a/app/src/main/java/zeroecho/pki/cli/PkiPlan.java b/app/src/main/java/zeroecho/pki/cli/PkiPlan.java
new file mode 100644
index 0000000..03725d6
--- /dev/null
+++ b/app/src/main/java/zeroecho/pki/cli/PkiPlan.java
@@ -0,0 +1,70 @@
+/*******************************************************************************
+ * 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.cli;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Objects;
+
+import zeroecho.pki.application.PkiOperationValue;
+
+/** Immutable validated sequential operation plan. */
+record PkiPlan(int version, FailurePolicy failurePolicy, List steps, Path baseDirectory) {
+
+ /* default */ static final int CURRENT_VERSION = 1;
+
+ PkiPlan {
+ if (version != CURRENT_VERSION || steps == null || steps.isEmpty()) {
+ throw new IllegalArgumentException("Plan version or steps are invalid");
+ }
+ Objects.requireNonNull(failurePolicy, "failurePolicy");
+ steps = List.copyOf(steps);
+ baseDirectory = Objects.requireNonNull(baseDirectory, "baseDirectory").toAbsolutePath().normalize();
+ }
+
+ /** Supported sequential failure policies. */
+ /* default */ enum FailurePolicy {
+ FAIL_FAST,
+ CONTINUE_INDEPENDENT
+ }
+
+ /** One validated ordered plan step. */
+ /* default */ record Step(String id, String operation, PkiOperationValue.ObjectValue arguments) {
+ Step {
+ if (id == null || operation == null || arguments == null) {
+ throw new IllegalArgumentException("Plan step is incomplete");
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/zeroecho/pki/cli/PkiPlanExecution.java b/app/src/main/java/zeroecho/pki/cli/PkiPlanExecution.java
new file mode 100644
index 0000000..24cb99e
--- /dev/null
+++ b/app/src/main/java/zeroecho/pki/cli/PkiPlanExecution.java
@@ -0,0 +1,115 @@
+/*******************************************************************************
+ * 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.cli;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import zeroecho.pki.application.PkiOperationFailure;
+import zeroecho.pki.application.PkiOperationResult;
+
+/** Ordered complete result of one single-command or batch plan execution. */
+record PkiPlanExecution(List steps, Optional planFailure, Optional planCode) {
+
+ /* default */ PkiPlanExecution {
+ steps = List.copyOf(steps);
+ Objects.requireNonNull(planFailure, "planFailure");
+ Objects.requireNonNull(planCode, "planCode");
+ if (steps.isEmpty()) {
+ throw new IllegalArgumentException("execution requires at least one step");
+ }
+ if (planFailure.isPresent() != planCode.isPresent()) {
+ throw new IllegalArgumentException("plan failure state is inconsistent");
+ }
+ }
+
+ /* default */ PkiPlanExecution(List steps) {
+ this(steps, Optional.empty(), Optional.empty());
+ }
+
+ /* default */ boolean succeeded() {
+ return planFailure.isEmpty() && steps.stream().allMatch(step -> step.status() == StepStatus.SUCCEEDED);
+ }
+
+ /* default */ PkiPlanExecution withPlanFailure(PkiOperationFailure failure, String code) {
+ return new PkiPlanExecution(steps, Optional.of(failure), Optional.of(code));
+ }
+
+ /** Step completion state. */
+ /* default */ enum StepStatus {
+ SUCCEEDED,
+ FAILED,
+ SKIPPED
+ }
+
+ /** One safe ordered step result. */
+ /* default */ record StepResult(String id, String operation, StepStatus status,
+ Optional output,
+ Optional failure, Optional code) {
+ StepResult {
+ if (id == null || id.isBlank() || operation == null || operation.isBlank()) {
+ throw new IllegalArgumentException("step result identity is invalid");
+ }
+ Objects.requireNonNull(status, "status");
+ Objects.requireNonNull(output, "output");
+ Objects.requireNonNull(failure, "failure");
+ Objects.requireNonNull(code, "code");
+ boolean valid = switch (status) {
+ case SUCCEEDED -> output.isPresent() && failure.isEmpty() && code.isEmpty();
+ case FAILED -> output.isEmpty() && failure.isPresent() && code.isPresent();
+ case SKIPPED -> output.isEmpty() && failure.isEmpty() && code.isPresent();
+ };
+ if (!valid) {
+ throw new IllegalArgumentException("step result state is inconsistent");
+ }
+ }
+
+ /* default */ static StepResult success(String id, String operation, PkiOperationResult result) {
+ return new StepResult(id, operation, StepStatus.SUCCEEDED, Optional.of(result), Optional.empty(),
+ Optional.empty());
+ }
+
+ /* default */ static StepResult failure(String id, String operation, PkiOperationFailure failure,
+ String code) {
+ return new StepResult(id, operation, StepStatus.FAILED, Optional.empty(), Optional.of(failure),
+ Optional.of(code));
+ }
+
+ /* default */ static StepResult skipped(String id, String operation, String code) {
+ return new StepResult(id, operation, StepStatus.SKIPPED, Optional.empty(), Optional.empty(),
+ Optional.of(code));
+ }
+ }
+}
diff --git a/app/src/main/java/zeroecho/pki/cli/PkiPlanExecutor.java b/app/src/main/java/zeroecho/pki/cli/PkiPlanExecutor.java
new file mode 100644
index 0000000..6f429fd
--- /dev/null
+++ b/app/src/main/java/zeroecho/pki/cli/PkiPlanExecutor.java
@@ -0,0 +1,198 @@
+/*******************************************************************************
+ * 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.cli;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Objects;
+import java.util.regex.Matcher;
+
+import zeroecho.core.io.CancellationSignal;
+import zeroecho.pki.application.PkiOperation;
+import zeroecho.pki.application.PkiOperationFailure;
+import zeroecho.pki.application.PkiOperationOutcome;
+import zeroecho.pki.application.PkiOperationResult;
+import zeroecho.pki.application.PkiOperationValue;
+import zeroecho.pki.application.PkiSession;
+
+/** Sequential shared executor used by direct commands and batch plans. */
+final class PkiPlanExecutor {
+
+ private final PkiOperationRegistry registry;
+
+ /* default */ PkiPlanExecutor(PkiOperationRegistry registry) {
+ this.registry = Objects.requireNonNull(registry, "registry");
+ }
+
+ /* default */ PkiPlanExecution execute(PkiPlan plan, PkiSession session, CancellationSignal cancellation) {
+ List results = new ArrayList<>(plan.steps().size());
+ Map successful = new LinkedHashMap<>();
+ Map states = new LinkedHashMap<>();
+ boolean failFast = false;
+ boolean cancelled = false;
+ for (PkiPlan.Step step : plan.steps()) {
+ if (failFast) {
+ addSkipped(results, states, step, "FAIL_FAST");
+ continue;
+ }
+ if (cancelled) {
+ addSkipped(results, states, step, "CANCELLED");
+ continue;
+ }
+ if (cancellation.isCancelled()) {
+ PkiPlanExecution.StepResult result = PkiPlanExecution.StepResult.failure(step.id(), step.operation(),
+ PkiOperationFailure.CANCELLED, "OPERATION_CANCELLED");
+ results.add(result);
+ states.put(step.id(), result.status());
+ cancelled = true;
+ continue;
+ }
+ Resolution resolution;
+ try {
+ resolution = resolve(step.arguments(), successful, states);
+ } catch (IllegalArgumentException failure) {
+ PkiPlanExecution.StepResult result = PkiPlanExecution.StepResult.failure(step.id(), step.operation(),
+ PkiOperationFailure.VALIDATION_FAILURE, "REFERENCE_FIELD_INVALID");
+ results.add(result);
+ states.put(step.id(), result.status());
+ if (plan.failurePolicy() == PkiPlan.FailurePolicy.FAIL_FAST) {
+ failFast = true;
+ }
+ continue;
+ }
+ if (resolution.dependencyUnavailable()) {
+ addSkipped(results, states, step, "DEPENDENCY_UNAVAILABLE");
+ continue;
+ }
+ PkiPlanExecution.StepResult result = executeStep(step, resolution.value(), plan, session, cancellation);
+ results.add(result);
+ states.put(step.id(), result.status());
+ result.output().ifPresent(value -> successful.put(step.id(), value));
+ if (result.failure().filter(value -> value == PkiOperationFailure.CANCELLED).isPresent()) {
+ cancelled = true;
+ }
+ if (result.status() == PkiPlanExecution.StepStatus.FAILED
+ && plan.failurePolicy() == PkiPlan.FailurePolicy.FAIL_FAST) {
+ failFast = true;
+ }
+ }
+ return new PkiPlanExecution(results);
+ }
+
+ private PkiPlanExecution.StepResult executeStep(PkiPlan.Step step, PkiOperationValue value, PkiPlan plan,
+ PkiSession session, CancellationSignal cancellation) {
+ try {
+ PkiOperation operation = registry.bind(step.operation(), PkiCliConfiguration.object(value),
+ plan.baseDirectory());
+ PkiOperationOutcome outcome = session.operations().execute(operation, cancellation);
+ if (outcome instanceof PkiOperationOutcome.Success success) {
+ return PkiPlanExecution.StepResult.success(step.id(), step.operation(), success.result());
+ }
+ PkiOperationOutcome.Failure failure = (PkiOperationOutcome.Failure) outcome;
+ return PkiPlanExecution.StepResult.failure(step.id(), step.operation(), failure.classification(),
+ failure.code());
+ } catch (IllegalArgumentException failure) {
+ return PkiPlanExecution.StepResult.failure(step.id(), step.operation(),
+ PkiOperationFailure.VALIDATION_FAILURE, "OPERATION_ARGUMENTS_INVALID");
+ } catch (IOException failure) {
+ return PkiPlanExecution.StepResult.failure(step.id(), step.operation(),
+ PkiOperationFailure.RESOURCE_FAILURE, "OPERATION_INPUT_FAILED");
+ } catch (RuntimeException failure) { // NOPMD - application boundary redacts unexpected provider details.
+ return PkiPlanExecution.StepResult.failure(step.id(), step.operation(),
+ PkiOperationFailure.INTERNAL_FAILURE, "INTERNAL_FAILURE");
+ }
+ }
+
+ private static Resolution resolve(PkiOperationValue value, Map successful,
+ Map states) {
+ if (value instanceof PkiOperationValue.Text text) {
+ Matcher matcher = PkiPlanParser.REFERENCE.matcher(text.value());
+ if (!matcher.matches()) {
+ return Resolution.available(value);
+ }
+ String stepId = matcher.group(1);
+ if (states.get(stepId) != PkiPlanExecution.StepStatus.SUCCEEDED) {
+ return Resolution.unavailable();
+ }
+ Optional field = successful.get(stepId).field(matcher.group(2));
+ if (field.isEmpty()) {
+ throw new IllegalArgumentException("Referenced output field does not exist");
+ }
+ return Resolution.available(field.orElseThrow());
+ }
+ if (value instanceof PkiOperationValue.ObjectValue object) {
+ Map fields = new LinkedHashMap<>();
+ for (Map.Entry entry : object.fields().entrySet()) {
+ Resolution child = resolve(entry.getValue(), successful, states);
+ if (child.dependencyUnavailable()) {
+ return Resolution.unavailable();
+ }
+ fields.put(entry.getKey(), child.value());
+ }
+ return Resolution.available(new PkiOperationValue.ObjectValue(fields));
+ }
+ if (value instanceof PkiOperationValue.ListValue list) {
+ List values = new ArrayList<>(list.values().size());
+ for (PkiOperationValue item : list.values()) {
+ Resolution child = resolve(item, successful, states);
+ if (child.dependencyUnavailable()) {
+ return Resolution.unavailable();
+ }
+ values.add(child.value());
+ }
+ return Resolution.available(new PkiOperationValue.ListValue(values));
+ }
+ return Resolution.available(value);
+ }
+
+ private static void addSkipped(List results,
+ Map states, PkiPlan.Step step, String code) {
+ results.add(PkiPlanExecution.StepResult.skipped(step.id(), step.operation(), code));
+ states.put(step.id(), PkiPlanExecution.StepStatus.SKIPPED);
+ }
+
+ private record Resolution(PkiOperationValue value, boolean dependencyUnavailable) {
+ /* default */ static Resolution available(PkiOperationValue value) {
+ return new Resolution(value, false);
+ }
+
+ /* default */ static Resolution unavailable() {
+ return new Resolution(new PkiOperationValue.BooleanValue(false), true);
+ }
+ }
+}
diff --git a/app/src/main/java/zeroecho/pki/cli/PkiPlanParser.java b/app/src/main/java/zeroecho/pki/cli/PkiPlanParser.java
new file mode 100644
index 0000000..57efafa
--- /dev/null
+++ b/app/src/main/java/zeroecho/pki/cli/PkiPlanParser.java
@@ -0,0 +1,150 @@
+/*******************************************************************************
+ * 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.cli;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import zeroecho.pki.application.PkiOperationValue;
+
+/** Strict parser and reference-order validator for plan schema version 1. */
+final class PkiPlanParser {
+
+ /* default */ static final int MAXIMUM_STEPS = 1_000;
+ /* default */ static final Pattern REFERENCE = Pattern
+ .compile("\\$\\{([a-z][a-z0-9-]{0,63})\\.([a-z][a-zA-Z0-9]{0,63})}");
+
+ private static final Pattern STEP_ID = Pattern.compile("[a-z][a-z0-9-]{0,63}");
+ private static final Pattern OPERATION_NAME = Pattern.compile("[a-z][a-z0-9.-]{2,63}");
+ private static final Set ROOT_FIELDS = Set.of("version", "failurePolicy", "operations");
+ private static final Set STEP_FIELDS = Set.of("id", "operation", "arguments");
+
+ private PkiPlanParser() {
+ }
+
+ /* default */ static PkiPlan read(Path path, Set supportedOperations) throws IOException {
+ Path exact = path.toAbsolutePath().normalize();
+ PkiOperationValue.ObjectValue root = PkiCliConfiguration
+ .object(PkiCliJson.parse(PkiCliFiles.readRegularFile(exact, PkiCliJson.MAXIMUM_DOCUMENT_BYTES)));
+ PkiCliConfiguration.requireExactFields(root.fields(), ROOT_FIELDS);
+ int version = Math.toIntExact(PkiCliConfiguration.integer(PkiCliConfiguration.required(root, "version")));
+ if (version != PkiPlan.CURRENT_VERSION) {
+ throw new IllegalArgumentException("Plan version is unsupported");
+ }
+ PkiPlan.FailurePolicy policy = parsePolicy(PkiCliConfiguration.text(
+ PkiCliConfiguration.required(root, "failurePolicy")));
+ List encodedSteps = list(PkiCliConfiguration.required(root, "operations"));
+ if (encodedSteps.isEmpty() || encodedSteps.size() > MAXIMUM_STEPS) {
+ throw new IllegalArgumentException("Plan step count is invalid");
+ }
+ List steps = new ArrayList<>(encodedSteps.size());
+ Set ids = new HashSet<>();
+ Map positions = new HashMap<>();
+ for (int index = 0; index < encodedSteps.size(); index++) {
+ PkiPlan.Step step = parseStep(encodedSteps.get(index), supportedOperations);
+ if (!ids.add(step.id())) {
+ throw new IllegalArgumentException("Plan step identifiers must be unique");
+ }
+ positions.put(step.id(), index);
+ steps.add(step);
+ }
+ validateReferences(steps, positions);
+ Path parent = exact.getParent();
+ return new PkiPlan(version, policy, steps, parent == null ? Path.of(".") : parent);
+ }
+
+ private static PkiPlan.Step parseStep(PkiOperationValue value, Set supportedOperations) {
+ PkiOperationValue.ObjectValue object = PkiCliConfiguration.object(value);
+ PkiCliConfiguration.requireExactFields(object.fields(), STEP_FIELDS);
+ String id = PkiCliConfiguration.text(PkiCliConfiguration.required(object, "id"));
+ String operation = PkiCliConfiguration.text(PkiCliConfiguration.required(object, "operation"));
+ if (!STEP_ID.matcher(id).matches() || !OPERATION_NAME.matcher(operation).matches()
+ || !supportedOperations.contains(operation)) {
+ throw new IllegalArgumentException("Plan step identity or operation is invalid");
+ }
+ PkiOperationValue.ObjectValue arguments = PkiCliConfiguration
+ .object(PkiCliConfiguration.required(object, "arguments"));
+ return new PkiPlan.Step(id, operation, arguments);
+ }
+
+ private static void validateReferences(List steps, Map positions) {
+ for (int index = 0; index < steps.size(); index++) {
+ validateValueReferences(steps.get(index).arguments(), index, positions);
+ }
+ }
+
+ @SuppressWarnings("PMD.AvoidDeeplyNestedIfStmts")
+ private static void validateValueReferences(PkiOperationValue value, int current,
+ Map positions) {
+ if (value instanceof PkiOperationValue.Text text) {
+ String raw = text.value();
+ Matcher matcher = REFERENCE.matcher(raw);
+ if (matcher.matches()) {
+ Integer referenced = positions.get(matcher.group(1));
+ if (referenced == null || referenced >= current) {
+ throw new IllegalArgumentException("Plan reference must target an earlier step");
+ }
+ } else if (raw.contains("${")) {
+ throw new IllegalArgumentException("Plan interpolation is not supported");
+ }
+ } else if (value instanceof PkiOperationValue.ObjectValue object) {
+ for (PkiOperationValue child : object.fields().values()) {
+ validateValueReferences(child, current, positions);
+ }
+ } else if (value instanceof PkiOperationValue.ListValue list) {
+ for (PkiOperationValue child : list.values()) {
+ validateValueReferences(child, current, positions);
+ }
+ }
+ }
+
+ private static PkiPlan.FailurePolicy parsePolicy(String value) {
+ return PkiPlan.FailurePolicy.valueOf(value);
+ }
+
+ private static List list(PkiOperationValue value) {
+ if (value instanceof PkiOperationValue.ListValue list) {
+ return list.values();
+ }
+ throw new IllegalArgumentException("CLI field has the wrong type");
+ }
+}
diff --git a/app/src/main/java/zeroecho/pki/cli/package-info.java b/app/src/main/java/zeroecho/pki/cli/package-info.java
new file mode 100644
index 0000000..0eda304
--- /dev/null
+++ b/app/src/main/java/zeroecho/pki/cli/package-info.java
@@ -0,0 +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.
+ ******************************************************************************/
+
+/** Strict PKI command, plan, binding and output adapters owned by the application module. */
+package zeroecho.pki.cli;
diff --git a/app/src/test/java/zeroecho/pki/cli/PkiCliTest.java b/app/src/test/java/zeroecho/pki/cli/PkiCliTest.java
new file mode 100644
index 0000000..f71d2ae
--- /dev/null
+++ b/app/src/test/java/zeroecho/pki/cli/PkiCliTest.java
@@ -0,0 +1,311 @@
+/*******************************************************************************
+ * 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.cli;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import zeroecho.pki.api.ProfileService;
+import zeroecho.pki.api.RevocationService;
+import zeroecho.pki.application.PkiOperation;
+import zeroecho.pki.application.PkiOperationFailure;
+import zeroecho.pki.application.PkiOperationOutcome;
+import zeroecho.pki.application.PkiOperationResult;
+import zeroecho.pki.application.PkiOperationValue;
+import zeroecho.pki.application.PkiSession;
+import zeroecho.pki.application.PkiSessionConfiguration;
+
+class PkiCliTest {
+
+ @TempDir
+ Path temporaryDirectory;
+
+ @Test
+ void productionSessionExecutesMachineAndHumanSingleCommands() throws IOException {
+ System.out.println("productionSessionExecutesMachineAndHumanSingleCommands");
+ Path configuration = configuration("production");
+ ByteArrayOutputStream machine = new ByteArrayOutputStream();
+ int first = PkiCli.execute(new String[] { "configuration.validate", "--config", configuration.toString(),
+ "--output", "json" }, machine);
+ ByteArrayOutputStream human = new ByteArrayOutputStream();
+ int second = PkiCli.execute(new String[] { "configuration.validate", "--config", configuration.toString() },
+ human);
+ String json = machine.toString(StandardCharsets.UTF_8);
+ System.out.println("...machineBytes=" + json.length());
+ assertEquals(0, first);
+ assertEquals(0, second);
+ assertTrue(json.contains("\"mode\":\"single\""));
+ assertTrue(json.contains("\"storeProvider\":\"fs\""));
+ assertTrue(human.toString(StandardCharsets.UTF_8).contains("configuration.validate SUCCEEDED"));
+ System.out.println("...ok");
+ }
+
+ @Test
+ void singleAndBatchUseTheSameTypedExecutorAndSessionLifecycle() throws IOException {
+ System.out.println("singleAndBatchUseTheSameTypedExecutorAndSessionLifecycle");
+ Path configuration = configuration("shared");
+ Path plan = plan("shared.json", """
+ {"version":1,"failurePolicy":"FAIL_FAST","operations":[
+ {"id":"validate","operation":"configuration.validate","arguments":{}}
+ ]}
+ """);
+ RecordingOpener opener = new RecordingOpener();
+ ByteArrayOutputStream single = new ByteArrayOutputStream();
+ int singleCode = PkiCli.execute(new String[] { "configuration.validate", "--config",
+ configuration.toString(), "--output", "json" }, single, opener, () -> false);
+ ByteArrayOutputStream batch = new ByteArrayOutputStream();
+ int batchCode = PkiCli.execute(new String[] { "run", plan.toString(), "--config", configuration.toString(),
+ "--output", "json" }, batch, opener, () -> false);
+ System.out.println("...sessions=" + opener.sessions.get());
+ assertEquals(0, singleCode);
+ assertEquals(0, batchCode);
+ assertEquals(List.of(PkiOperation.ValidateConfiguration.class, PkiOperation.ValidateConfiguration.class),
+ opener.operationTypes);
+ assertEquals(2, opener.sessions.get());
+ assertEquals(2, opener.closes.get());
+ System.out.println("...ok");
+ }
+
+ @Test
+ void directMutationUsesTheSharedTypedExecutor() throws IOException {
+ System.out.println("directMutationUsesTheSharedTypedExecutor");
+ Path configuration = configuration("mutation");
+ RecordingOpener opener = new RecordingOpener();
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ int code = PkiCli.execute(new String[] { "credential.revoke", "--credential-id", "credential-1",
+ "--reason", "KEY_COMPROMISE", "--config", configuration.toString(), "--output", "json" },
+ output, opener, () -> false);
+ String rendered = output.toString(StandardCharsets.UTF_8);
+ System.out.println("...exit=" + code);
+ assertEquals(PkiExitCodes.SUCCESS, code);
+ assertEquals(List.of(PkiOperation.RevokeCredential.class), opener.operationTypes);
+ assertTrue(rendered.contains("\"state\":\"PERMANENTLY_REVOKED\""));
+ assertEquals(1, opener.closes.get());
+ System.out.println("...ok");
+ }
+
+ @Test
+ void resolvesEarlierFieldsAndContinuesOnlyIndependentSteps() throws IOException {
+ System.out.println("resolvesEarlierFieldsAndContinuesOnlyIndependentSteps");
+ Path configuration = configuration("references");
+ Path plan = plan("references.json", """
+ {"version":1,"failurePolicy":"CONTINUE_INDEPENDENT","operations":[
+ {"id":"config","operation":"configuration.validate","arguments":{}},
+ {"id":"inspect","operation":"credential.inspect","arguments":{"credentialId":"${config.storeProvider}"}},
+ {"id":"unknown-field","operation":"credential.inspect","arguments":{"credentialId":"${config.absentField}"}},
+ {"id":"missing","operation":"publication.inspect","arguments":{"publicationId":"missing"}},
+ {"id":"independent","operation":"configuration.validate","arguments":{}},
+ {"id":"dependent","operation":"credential.inspect","arguments":{"credentialId":"${missing.sourceId}"}}
+ ]}
+ """);
+ RecordingOpener opener = new RecordingOpener();
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ int code = PkiCli.execute(new String[] { "run", plan.toString(), "--config", configuration.toString(),
+ "--output", "json" }, output, opener, () -> false);
+ String json = output.toString(StandardCharsets.UTF_8);
+ System.out.println("...exit=" + code);
+ assertEquals(PkiExitCodes.NOT_FOUND_OR_CONFLICT, code);
+ assertTrue(json.contains("\"id\":\"independent\",\"operation\":\"configuration.validate\",\"status\":\"SUCCEEDED\""));
+ assertTrue(json.contains("\"id\":\"unknown-field\",\"operation\":\"credential.inspect\",\"status\":\"FAILED\""));
+ assertTrue(json.contains("\"id\":\"dependent\",\"operation\":\"credential.inspect\",\"status\":\"SKIPPED\""));
+ assertEquals(1, opener.sessions.get());
+ assertEquals(1, opener.closes.get());
+ System.out.println("...ok");
+ }
+
+ @Test
+ void rejectsFutureReferencesScriptsInterpolationAndSecretArgumentsBeforeOpening() throws IOException {
+ System.out.println("rejectsFutureReferencesScriptsInterpolationAndSecretArgumentsBeforeOpening");
+ Path configuration = configuration("hostile");
+ Path plan = plan("hostile.json", """
+ {"version":1,"failurePolicy":"FAIL_FAST","operations":[
+ {"id":"first","operation":"credential.inspect","arguments":{"credentialId":"${later.credentialId}"}},
+ {"id":"later","operation":"configuration.validate","arguments":{}}
+ ]}
+ """);
+ RecordingOpener opener = new RecordingOpener();
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ int future = PkiCli.execute(new String[] { "run", plan.toString(), "--config", configuration.toString(),
+ "--output", "json" }, output, opener, () -> false);
+ int script = PkiCli.execute(new String[] { "java.lang.Runtime", "--config", configuration.toString() },
+ new ByteArrayOutputStream(), opener, () -> false);
+ int secret = PkiCli.execute(new String[] { "configuration.validate", "--config", configuration.toString(),
+ "--password", "do-not-print" }, new ByteArrayOutputStream(), opener, () -> false);
+ System.out.println("...rejections=3");
+ assertEquals(PkiExitCodes.INVALID_INVOCATION, future);
+ assertEquals(PkiExitCodes.INVALID_INVOCATION, script);
+ assertEquals(PkiExitCodes.INVALID_INVOCATION, secret);
+ assertEquals(0, opener.sessions.get());
+ assertFalse(output.toString(StandardCharsets.UTF_8).contains("later.credentialId"));
+ System.out.println("...ok");
+ }
+
+ @Test
+ void failFastCancellationAndCloseFailuresRemainExplicit() throws IOException {
+ System.out.println("failFastCancellationAndCloseFailuresRemainExplicit");
+ Path configuration = configuration("failures");
+ Path plan = plan("fail-fast.json", """
+ {"version":1,"failurePolicy":"FAIL_FAST","operations":[
+ {"id":"missing","operation":"publication.inspect","arguments":{"publicationId":"missing"}},
+ {"id":"later","operation":"configuration.validate","arguments":{}}
+ ]}
+ """);
+ RecordingOpener opener = new RecordingOpener();
+ ByteArrayOutputStream failed = new ByteArrayOutputStream();
+ int failFast = PkiCli.execute(new String[] { "run", plan.toString(), "--config", configuration.toString(),
+ "--output", "json" }, failed, opener, () -> false);
+ ByteArrayOutputStream cancelled = new ByteArrayOutputStream();
+ int cancel = PkiCli.execute(new String[] { "configuration.validate", "--config", configuration.toString(),
+ "--output", "json" }, cancelled, opener, () -> true);
+ opener.failClose = true;
+ ByteArrayOutputStream close = new ByteArrayOutputStream();
+ int closeCode = PkiCli.execute(new String[] { "configuration.validate", "--config",
+ configuration.toString(), "--output", "json" }, close, opener, () -> false);
+ System.out.println("...codes=" + failFast + "," + cancel + "," + closeCode);
+ assertEquals(PkiExitCodes.NOT_FOUND_OR_CONFLICT, failFast);
+ assertTrue(failed.toString(StandardCharsets.UTF_8).contains("\"code\":\"FAIL_FAST\""));
+ assertEquals(PkiExitCodes.CANCELLED, cancel);
+ assertEquals(PkiExitCodes.RESOURCE_FAILURE, closeCode);
+ assertTrue(close.toString(StandardCharsets.UTF_8).contains("SESSION_CLOSE_FAILED"));
+ System.out.println("...ok");
+ }
+
+ @Test
+ void rejectsMalformedConfigurationAndNeverPrintsProviderExceptions() throws IOException {
+ System.out.println("rejectsMalformedConfigurationAndNeverPrintsProviderExceptions");
+ Path malformed = plan("bad-config.json",
+ "{\"version\":1,\"store\":{},\"audit\":{},\"secret\":\"hidden\"}");
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ int invalid = PkiCli.execute(new String[] { "configuration.validate", "--config", malformed.toString(),
+ "--output", "json" }, output, configuration -> {
+ throw new IllegalStateException("/sensitive/path provider-secret");
+ }, () -> false);
+ Path valid = configuration("provider-failure");
+ ByteArrayOutputStream providerOutput = new ByteArrayOutputStream();
+ int providerFailure = PkiCli.execute(new String[] { "configuration.validate", "--config", valid.toString(),
+ "--output", "json" }, providerOutput, configuration -> {
+ throw new IllegalStateException("/sensitive/path provider-secret");
+ }, () -> false);
+ String rendered = output.toString(StandardCharsets.UTF_8)
+ + providerOutput.toString(StandardCharsets.UTF_8);
+ assertEquals(PkiExitCodes.CONFIGURATION_FAILURE, invalid);
+ assertEquals(PkiExitCodes.RESOURCE_FAILURE, providerFailure);
+ assertFalse(rendered.contains("hidden"));
+ assertFalse(rendered.contains("sensitive"));
+ assertFalse(rendered.contains("provider-secret"));
+ System.out.println("...ok");
+ }
+
+ private Path configuration(String name) throws IOException {
+ Path store = temporaryDirectory.resolve(name + "-store");
+ return plan(name + "-config.json", "{\"version\":1,\"store\":{\"provider\":\"fs\",\"properties\":{\"root\":\""
+ + store + "\"}},\"audit\":{\"provider\":\"memory\",\"properties\":{\"size\":\"32\"}}}");
+ }
+
+ private Path plan(String name, String content) throws IOException {
+ Path path = temporaryDirectory.resolve(name);
+ Files.writeString(path, content, StandardCharsets.UTF_8);
+ return path;
+ }
+
+ private static final class RecordingOpener implements PkiCli.SessionOpener {
+ private final AtomicInteger sessions = new AtomicInteger();
+ private final AtomicInteger closes = new AtomicInteger();
+ private final List> operationTypes = new java.util.ArrayList<>();
+ private boolean failClose;
+
+ @Override
+ public PkiSession open(PkiSessionConfiguration configuration) {
+ sessions.incrementAndGet();
+ return new PkiSession() {
+ @Override
+ public ProfileService profiles() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public RevocationService revocations() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public zeroecho.pki.application.PkiOperationExecutor operations() {
+ return (operation, cancellation) -> outcome(operation);
+ }
+
+ @Override
+ public void close() throws Exception {
+ closes.incrementAndGet();
+ if (failClose) {
+ throw new IOException("sensitive-close-detail");
+ }
+ }
+ };
+ }
+
+ private PkiOperationOutcome outcome(PkiOperation operation) {
+ operationTypes.add(operation.getClass());
+ if (operation instanceof PkiOperation.InspectPublication) {
+ return new PkiOperationOutcome.Failure(operation.name(), PkiOperationFailure.NOT_FOUND,
+ "OBJECT_NOT_FOUND");
+ }
+ LinkedHashMap fields = new LinkedHashMap<>();
+ if (operation instanceof PkiOperation.ValidateConfiguration) {
+ fields.put("version", new PkiOperationValue.IntegerValue(1));
+ fields.put("storeProvider", new PkiOperationValue.Text("fs"));
+ } else if (operation instanceof PkiOperation.InspectCredential inspect) {
+ fields.put("credentialId", new PkiOperationValue.Text(inspect.credentialId().value()));
+ } 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 {
+ fields.put("status", new PkiOperationValue.Text("OK"));
+ }
+ return new PkiOperationOutcome.Success(new PkiOperationResult(operation.name(), fields));
+ }
+ }
+}
diff --git a/app/src/test/java/zeroecho/pki/cli/PkiPlanParserTest.java b/app/src/test/java/zeroecho/pki/cli/PkiPlanParserTest.java
new file mode 100644
index 0000000..11955f4
--- /dev/null
+++ b/app/src/test/java/zeroecho/pki/cli/PkiPlanParserTest.java
@@ -0,0 +1,119 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification,
+ * are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.pki.cli;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class PkiPlanParserTest {
+
+ @TempDir
+ Path temporaryDirectory;
+
+ @Test
+ void rejectsEmptyDuplicateAndUnknownOperationPlans() throws IOException {
+ System.out.println("rejectsEmptyDuplicateAndUnknownOperationPlans");
+ PkiOperationRegistry registry = new PkiOperationRegistry();
+ assertInvalid("empty.json", "{\"version\":1,\"failurePolicy\":\"FAIL_FAST\",\"operations\":[]}", registry);
+ assertInvalid("duplicate.json", """
+ {"version":1,"failurePolicy":"FAIL_FAST","operations":[
+ {"id":"same","operation":"configuration.validate","arguments":{}},
+ {"id":"same","operation":"configuration.validate","arguments":{}}
+ ]}
+ """, registry);
+ assertInvalid("unknown.json", """
+ {"version":1,"failurePolicy":"FAIL_FAST","operations":[
+ {"id":"unknown","operation":"system.exec","arguments":{}}
+ ]}
+ """, registry);
+ System.out.println("...rejections=3");
+ System.out.println("...ok");
+ }
+
+ @Test
+ void rejectsUnknownFutureAndEmbeddedReferences() throws IOException {
+ System.out.println("rejectsUnknownFutureAndEmbeddedReferences");
+ PkiOperationRegistry registry = new PkiOperationRegistry();
+ assertInvalid("unknown-reference.json", """
+ {"version":1,"failurePolicy":"FAIL_FAST","operations":[
+ {"id":"inspect","operation":"credential.inspect","arguments":{"credentialId":"${absent.id}"}}
+ ]}
+ """, registry);
+ assertInvalid("future-reference.json", """
+ {"version":1,"failurePolicy":"FAIL_FAST","operations":[
+ {"id":"inspect","operation":"credential.inspect","arguments":{"credentialId":"${later.id}"}},
+ {"id":"later","operation":"configuration.validate","arguments":{}}
+ ]}
+ """, registry);
+ assertInvalid("embedded-reference.json", """
+ {"version":1,"failurePolicy":"FAIL_FAST","operations":[
+ {"id":"config","operation":"configuration.validate","arguments":{}},
+ {"id":"inspect","operation":"credential.inspect","arguments":{"credentialId":"prefix-${config.storeProvider}"}}
+ ]}
+ """, registry);
+ System.out.println("...rejections=3");
+ System.out.println("...ok");
+ }
+
+ @Test
+ void rejectsDuplicateJsonKeysAndTrailingDocuments() throws IOException {
+ System.out.println("rejectsDuplicateJsonKeysAndTrailingDocuments");
+ PkiOperationRegistry registry = new PkiOperationRegistry();
+ assertInvalid("duplicate-key.json", """
+ {"version":1,"version":1,"failurePolicy":"FAIL_FAST","operations":[
+ {"id":"config","operation":"configuration.validate","arguments":{}}
+ ]}
+ """, registry);
+ assertInvalid("trailing.json", """
+ {"version":1,"failurePolicy":"FAIL_FAST","operations":[
+ {"id":"config","operation":"configuration.validate","arguments":{}}
+ ]} {}
+ """, registry);
+ System.out.println("...rejections=2");
+ System.out.println("...ok");
+ }
+
+ private void assertInvalid(String name, String content, PkiOperationRegistry registry) throws IOException {
+ Path path = temporaryDirectory.resolve(name);
+ Files.writeString(path, content, StandardCharsets.UTF_8);
+ assertThrows(IllegalArgumentException.class, () -> PkiPlanParser.read(path, registry.names()));
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/application/DefaultPkiOperationExecutor.java b/pki/src/main/java/zeroecho/pki/application/DefaultPkiOperationExecutor.java
new file mode 100644
index 0000000..5b6182e
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/application/DefaultPkiOperationExecutor.java
@@ -0,0 +1,269 @@
+/*******************************************************************************
+ * 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.io.IOException;
+import java.io.InterruptedIOException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+import zeroecho.core.io.CancellationSignal;
+import zeroecho.pki.api.PkiException;
+import zeroecho.pki.api.RevocationService;
+import zeroecho.pki.api.credential.CaProfileBinding;
+import zeroecho.pki.api.credential.Credential;
+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.publication.PublicationRecord;
+import zeroecho.pki.api.revocation.RevocationCommand;
+import zeroecho.pki.api.revocation.RevocationRecord;
+import zeroecho.pki.api.revocation.RevocationTransition;
+import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
+import zeroecho.pki.spi.store.PkiStore;
+import zeroecho.pki.spi.store.RevocationHistory;
+
+/** Explicit non-reflective executor used by one backend session. */
+final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
+
+ private final PkiSessionConfiguration configuration;
+ private final PkiStore store;
+ private final RevocationService revocations;
+ private final Runnable openCheck;
+
+ /* default */ DefaultPkiOperationExecutor(PkiSessionConfiguration configuration, PkiStore store,
+ RevocationService revocations, Runnable openCheck) {
+ this.configuration = Objects.requireNonNull(configuration, "configuration");
+ this.store = Objects.requireNonNull(store, "store");
+ this.revocations = Objects.requireNonNull(revocations, "revocations");
+ this.openCheck = Objects.requireNonNull(openCheck, "openCheck");
+ }
+
+ @Override
+ public PkiOperationOutcome execute(PkiOperation operation, CancellationSignal cancellation) {
+ openCheck.run();
+ PkiOperation exact = Objects.requireNonNull(operation, "operation");
+ CancellationSignal signal = Objects.requireNonNull(cancellation, "cancellation");
+ try {
+ signal.throwIfCancelled();
+ PkiOperationResult result = switch (exact) {
+ case PkiOperation.ValidateConfiguration ignored -> validateConfiguration();
+ case PkiOperation.ValidateProfile request -> validateProfile(request);
+ case PkiOperation.InspectCredential request -> inspectCredential(request);
+ case PkiOperation.RevokeCredential request -> revokeCredential(request);
+ case PkiOperation.ReadRevocationHistory request -> readHistory(request, signal);
+ case PkiOperation.InspectPublication request -> inspectPublication(request);
+ };
+ return new PkiOperationOutcome.Success(result);
+ } catch (InterruptedIOException failure) {
+ return failure(exact.name(), PkiOperationFailure.CANCELLED, "OPERATION_CANCELLED");
+ } catch (IOException failure) {
+ return failure(exact.name(), PkiOperationFailure.RESOURCE_FAILURE, "OPERATION_RESOURCE_FAILED");
+ } catch (RuntimeException failure) { // NOPMD - normalize provider failures at the application boundary.
+ return classify(exact.name(), failure);
+ }
+ }
+
+ private PkiOperationResult validateConfiguration() {
+ Map fields = fields();
+ fields.put("version", integer(configuration.version()));
+ fields.put("storeProvider", text(configuration.store().backendId()));
+ fields.put("auditProvider", text(configuration.audit().backendId()));
+ return result(PkiOperation.ValidateConfiguration.NAME, fields);
+ }
+
+ private static PkiOperationResult validateProfile(PkiOperation.ValidateProfile request) {
+ CertificateProfileDefinition definition = CertificateProfileDocumentCodec.parse(request.document());
+ Map fields = fields();
+ 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()));
+ return result(request.name(), fields);
+ }
+
+ private PkiOperationResult inspectCredential(PkiOperation.InspectCredential request) {
+ Credential credential = store.getCredential(request.credentialId())
+ .orElseThrow(() -> new MissingObjectException());
+ CertificateProfileRef profile = profileReference(credential);
+ Map fields = fields();
+ fields.put("credentialId", text(credential.credentialId().value()));
+ fields.put("formatId", 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()));
+ fields.put("profileVersion", integer(profile.profileVersion()));
+ fields.put("status", text(credential.status().name()));
+ fields.put("notBefore", text(credential.validity().notBefore().toString()));
+ fields.put("notAfter", text(credential.validity().notAfter().toString()));
+ return result(request.name(), fields);
+ }
+
+ private PkiOperationResult revokeCredential(PkiOperation.RevokeCredential request) {
+ RevocationCommand.RevokePermanently command = new RevocationCommand.RevokePermanently(request.credentialId(),
+ request.reason(), new SimpleAttributeSet());
+ RevocationRecord record = revocations.revokePermanently(command);
+ return revocationResult(request.name(), record);
+ }
+
+ private PkiOperationResult readHistory(PkiOperation.ReadRevocationHistory request, CancellationSignal signal)
+ throws IOException {
+ List transitions = new ArrayList<>();
+ boolean truncated;
+ try (RevocationHistory history = revocations.history(request.credentialId())) {
+ while (transitions.size() < request.limit() && history.next(signal)) {
+ transitions.add(transitionValue(history.current()));
+ }
+ truncated = transitions.size() == request.limit() && history.next(signal);
+ }
+ Map fields = fields();
+ fields.put("credentialId", text(request.credentialId().value()));
+ fields.put("count", integer(transitions.size()));
+ fields.put("truncated", bool(truncated));
+ fields.put("transitions", new PkiOperationValue.ListValue(transitions));
+ return result(request.name(), fields);
+ }
+
+ private PkiOperationResult inspectPublication(PkiOperation.InspectPublication request) {
+ PublicationRecord record = store.getPublicationRecord(request.publicationId())
+ .orElseThrow(() -> new MissingObjectException());
+ 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()));
+ fields.put("createdAt", text(record.createdAt().toString()));
+ fields.put("updatedAt", text(record.updatedAt().toString()));
+ record.failure().ifPresent(value -> fields.put("failure", text(value.name())));
+ record.evidence().ifPresent(value -> fields.put("evidence", text(value.name())));
+ return result(request.name(), fields);
+ }
+
+ private static PkiOperationResult revocationResult(String operation, RevocationRecord record) {
+ Map fields = fields();
+ fields.put("credentialId", text(record.credentialId().value()));
+ appendTransition(fields, record.transition());
+ return result(operation, fields);
+ }
+
+ private static PkiOperationValue transitionValue(RevocationTransition transition) {
+ Map fields = fields();
+ appendTransition(fields, transition);
+ return new PkiOperationValue.ObjectValue(fields);
+ }
+
+ private static void appendTransition(Map fields, RevocationTransition transition) {
+ fields.put("revision", integer(transition.revision()));
+ fields.put("state", text(transition.state().name()));
+ fields.put("time", text(transition.time().toString()));
+ transition.permanentReason().ifPresent(reason -> fields.put("reason", text(reason.name())));
+ }
+
+ private static CertificateProfileRef profileReference(Credential credential) {
+ if (credential.profileBinding() instanceof EndEntityProfileBinding binding) {
+ return binding.reference();
+ }
+ if (credential.profileBinding() instanceof CaProfileBinding binding) {
+ return binding.reference();
+ }
+ throw new IllegalStateException("Credential profile binding is unsupported");
+ }
+
+ private static PkiOperationOutcome classify(String operation, RuntimeException failure) {
+ if (failure instanceof MissingObjectException) {
+ return failure(operation, PkiOperationFailure.NOT_FOUND, "OBJECT_NOT_FOUND");
+ }
+ String message = Optional.ofNullable(failure.getMessage()).orElse("");
+ if (message.contains("RECOVERY_REQUIRED") || message.contains("DURABILITY_UNCONFIRMED")) {
+ return failure(operation, PkiOperationFailure.RECOVERY_REQUIRED, "RECOVERY_REQUIRED");
+ }
+ if (message.contains("OUTCOME_UNKNOWN") || message.contains("RESULT_UNCONFIRMED")) {
+ return failure(operation, PkiOperationFailure.EXTERNAL_OUTCOME_UNKNOWN, "EXTERNAL_OUTCOME_UNKNOWN");
+ }
+ if (message.contains("CREDENTIAL_NOT_FOUND")) {
+ return failure(operation, PkiOperationFailure.NOT_FOUND, "OBJECT_NOT_FOUND");
+ }
+ if (message.contains("CONFLICT") || message.contains("changed concurrently")) {
+ return failure(operation, PkiOperationFailure.CONFLICT, "OPERATION_CONFLICT");
+ }
+ if (message.contains("TRANSITION_ILLEGAL") || message.contains("REVOCATION_TERMINAL")) {
+ return failure(operation, PkiOperationFailure.POLICY_REJECTION, "PKI_POLICY_REJECTED");
+ }
+ if (failure instanceof IllegalArgumentException) {
+ return failure(operation, PkiOperationFailure.VALIDATION_FAILURE, "OPERATION_INPUT_INVALID");
+ }
+ if (failure instanceof PkiException) {
+ return failure(operation, PkiOperationFailure.POLICY_REJECTION, "PKI_POLICY_REJECTED");
+ }
+ if (failure instanceof IllegalStateException) {
+ return failure(operation, PkiOperationFailure.RESOURCE_FAILURE, "PKI_RESOURCE_FAILED");
+ }
+ return failure(operation, PkiOperationFailure.INTERNAL_FAILURE, "INTERNAL_FAILURE");
+ }
+
+ private static PkiOperationOutcome failure(String operation, PkiOperationFailure classification, String code) {
+ return new PkiOperationOutcome.Failure(operation, classification, code);
+ }
+
+ private static PkiOperationResult result(String operation, Map fields) {
+ return new PkiOperationResult(operation, fields);
+ }
+
+ private static Map fields() {
+ return new LinkedHashMap<>();
+ }
+
+ private static PkiOperationValue text(String value) {
+ return new PkiOperationValue.Text(value);
+ }
+
+ private static PkiOperationValue integer(long value) {
+ return new PkiOperationValue.IntegerValue(value);
+ }
+
+ private static PkiOperationValue bool(boolean value) {
+ return new PkiOperationValue.BooleanValue(value);
+ }
+
+ /** Internal marker normalized to the public NOT_FOUND classification. */
+ private static final class MissingObjectException 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
new file mode 100644
index 0000000..71eb528
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java
@@ -0,0 +1,209 @@
+/*******************************************************************************
+ * 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.time.Clock;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import zeroecho.pki.api.ProfileService;
+import zeroecho.pki.api.RevocationService;
+import zeroecho.pki.impl.core.DefaultProfileService;
+import zeroecho.pki.impl.core.DefaultRevocationService;
+import zeroecho.pki.spi.ProviderConfig;
+import zeroecho.pki.spi.audit.AuditSink;
+import zeroecho.pki.spi.bootstrap.PkiBootstrap;
+import zeroecho.pki.spi.store.PkiStore;
+
+/** Default synchronous session composition. */
+final class DefaultPkiSession implements PkiSession {
+
+ private final PkiStore store;
+ private final AuditSink audit;
+ private final ProfileService profiles;
+ private final RevocationService revocations;
+ private final PkiOperationExecutor operations;
+ private final AtomicBoolean closed = new AtomicBoolean();
+
+ private DefaultPkiSession(PkiSessionConfiguration configuration, PkiStore store, AuditSink audit, Clock clock) {
+ 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);
+ }
+
+ /* default */ static PkiSession open(PkiSessionConfiguration configuration) {
+ return open(configuration, Clock.systemUTC(), ProductionBootstrap.INSTANCE);
+ }
+
+ @SuppressWarnings("PMD.AvoidCatchingGenericException")
+ /* default */ static PkiSession open(PkiSessionConfiguration configuration, Clock clock, Bootstrap bootstrap) {
+ PkiSessionConfiguration exact = Objects.requireNonNull(configuration, "configuration");
+ Objects.requireNonNull(clock, "clock");
+ Objects.requireNonNull(bootstrap, "bootstrap");
+ bootstrap.validateStore(exact.store());
+ bootstrap.validateAudit(exact.audit());
+
+ PkiStore store = null;
+ AuditSink audit = null;
+ 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);
+ } catch (RuntimeException | Error primary) {
+ closeAfterConstructionFailure(audit, store, primary);
+ throw primary;
+ }
+ }
+
+ @Override
+ public ProfileService profiles() {
+ requireOpen();
+ return profiles;
+ }
+
+ @Override
+ public RevocationService revocations() {
+ requireOpen();
+ return revocations;
+ }
+
+ @Override
+ public PkiOperationExecutor operations() {
+ requireOpen();
+ return operations;
+ }
+
+ @Override
+ public void close() throws Exception {
+ if (!closed.compareAndSet(false, true)) {
+ return;
+ }
+ Throwable primary = null;
+ try {
+ audit.close();
+ } catch (Throwable failure) { // NOPMD - preserve Error and checked close failures.
+ primary = failure;
+ }
+ try {
+ store.close();
+ } catch (Throwable failure) { // NOPMD - preserve Error and checked close failures.
+ if (primary == null) {
+ primary = failure;
+ } else {
+ primary.addSuppressed(failure);
+ }
+ }
+ rethrow(primary);
+ }
+
+ private void requireOpen() {
+ if (closed.get()) {
+ throw new IllegalStateException("PKI session is closed");
+ }
+ }
+
+ private static void closeAfterConstructionFailure(AuditSink audit, PkiStore store, Throwable primary) {
+ if (audit != null) {
+ try {
+ audit.close();
+ } catch (Throwable failure) { // NOPMD - construction failure remains primary.
+ primary.addSuppressed(failure);
+ }
+ }
+ if (store != null) {
+ try {
+ store.close();
+ } catch (Throwable failure) { // NOPMD - construction failure remains primary.
+ primary.addSuppressed(failure);
+ }
+ }
+ }
+
+ @SuppressWarnings("PMD.SignatureDeclareThrowsException")
+ private static void rethrow(Throwable failure) throws Exception {
+ if (failure == null) {
+ return;
+ }
+ if (failure instanceof Exception exception) {
+ throw exception;
+ }
+ if (failure instanceof Error error) {
+ throw error;
+ }
+ throw new IllegalStateException("PKI session close failed", failure);
+ }
+
+ /** Resource bootstrap seam used for deterministic lifecycle verification. */
+ /* default */ interface Bootstrap {
+ /** Validates store configuration without allocating a store. */
+ void validateStore(ProviderConfig configuration);
+
+ /** Validates audit configuration without allocating a sink. */
+ void validateAudit(ProviderConfig configuration);
+
+ /** Opens the configured store. */
+ PkiStore openStore(ProviderConfig configuration);
+
+ /** Opens the configured audit sink. */
+ AuditSink openAudit(ProviderConfig configuration);
+ }
+
+ /** Production provider bootstrap implementation. */
+ private enum ProductionBootstrap implements Bootstrap {
+ INSTANCE;
+
+ @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);
+ }
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/application/PkiOperation.java b/pki/src/main/java/zeroecho/pki/application/PkiOperation.java
new file mode 100644
index 0000000..f35f9cd
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/application/PkiOperation.java
@@ -0,0 +1,158 @@
+/*******************************************************************************
+ * 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 zeroecho.pki.api.PkiId;
+import zeroecho.pki.api.revocation.RevocationReason;
+
+/**
+ * Closed set of typed synchronous PKI operations available to transports.
+ *
+ * Names are stable external semantic identities. Implementations dispatch by
+ * 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 {
+
+ /** @return stable semantic operation name */
+ String name();
+
+ /** Validates the already-open session configuration. */
+ record ValidateConfiguration() implements PkiOperation {
+ /** Stable operation name. */
+ public static final String NAME = "configuration.validate";
+
+ @Override
+ public String name() {
+ return NAME;
+ }
+ }
+
+ /** Validates one bounded certificate-profile JSON document without persisting it. */
+ record ValidateProfile(byte[] document) implements PkiOperation {
+ /** Stable operation name. */
+ public static final String NAME = "profile.validate";
+
+ /** Defensively snapshots the document. */
+ public ValidateProfile {
+ document = Objects.requireNonNull(document, "document").clone();
+ }
+
+ @Override
+ public byte[] document() {
+ return document.clone();
+ }
+
+ @Override
+ public String name() {
+ return NAME;
+ }
+ }
+
+ /** Reads safe metadata for one committed credential. */
+ record InspectCredential(PkiId credentialId) implements PkiOperation {
+ /** Stable operation name. */
+ public static final String NAME = "credential.inspect";
+
+ /** Validates the identifier. */
+ public InspectCredential {
+ Objects.requireNonNull(credentialId, "credentialId");
+ }
+
+ @Override
+ public String name() {
+ return NAME;
+ }
+ }
+
+ /** Permanently revokes one committed credential. */
+ record RevokeCredential(PkiId credentialId, RevocationReason reason) implements PkiOperation {
+ /** Stable operation name. */
+ public static final String NAME = "credential.revoke";
+
+ /** Validates permanent-revocation inputs. */
+ public RevokeCredential {
+ Objects.requireNonNull(credentialId, "credentialId");
+ Objects.requireNonNull(reason, "reason");
+ if (reason == RevocationReason.CERTIFICATE_HOLD || reason == RevocationReason.REMOVE_FROM_CRL) {
+ throw new IllegalArgumentException("reason must be permanent");
+ }
+ }
+
+ @Override
+ public String name() {
+ return NAME;
+ }
+ }
+
+ /** Reads a bounded prefix from one closeable authoritative history cursor. */
+ record ReadRevocationHistory(PkiId credentialId, int limit) implements PkiOperation {
+ /** Stable operation name. */
+ public static final String NAME = "revocation.history";
+ /** Maximum finite result entries for one invocation. */
+ public static final int MAXIMUM_RESULT_ENTRIES = 1_000;
+
+ /** Validates the identifier and finite result limit. */
+ public ReadRevocationHistory {
+ Objects.requireNonNull(credentialId, "credentialId");
+ if (limit < 1 || limit > MAXIMUM_RESULT_ENTRIES) {
+ throw new IllegalArgumentException("limit must be between 1 and 1000");
+ }
+ }
+
+ @Override
+ public String name() {
+ return NAME;
+ }
+ }
+
+ /** Reads safe durable state for one publication operation. */
+ record InspectPublication(PkiId publicationId) implements PkiOperation {
+ /** Stable operation name. */
+ public static final String NAME = "publication.inspect";
+
+ /** Validates the identifier. */
+ public InspectPublication {
+ Objects.requireNonNull(publicationId, "publicationId");
+ }
+
+ @Override
+ public String name() {
+ return NAME;
+ }
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/application/PkiOperationExecutor.java b/pki/src/main/java/zeroecho/pki/application/PkiOperationExecutor.java
new file mode 100644
index 0000000..d62482e
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/application/PkiOperationExecutor.java
@@ -0,0 +1,57 @@
+/*******************************************************************************
+ * 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 zeroecho.core.io.CancellationSignal;
+
+/**
+ * Synchronous transport-neutral executor for the closed PKI operation model.
+ *
+ * Implementations execute on the caller thread, perform no hidden retry and
+ * create no thread or executor. A returned cancellation or failure never implies
+ * rollback of an already committed backend transition.
+ */
+@FunctionalInterface
+public interface PkiOperationExecutor {
+
+ /**
+ * Executes one typed operation synchronously.
+ *
+ * @param operation validated typed operation
+ * @param cancellation cooperative cancellation signal
+ * @return successful result or safely classified failure
+ * @throws IllegalStateException if the owning session is closed
+ */
+ PkiOperationOutcome execute(PkiOperation operation, CancellationSignal cancellation);
+}
diff --git a/pki/src/main/java/zeroecho/pki/application/PkiOperationFailure.java b/pki/src/main/java/zeroecho/pki/application/PkiOperationFailure.java
new file mode 100644
index 0000000..27fedf4
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/application/PkiOperationFailure.java
@@ -0,0 +1,56 @@
+/*******************************************************************************
+ * 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;
+
+/** Stable safe classifications for synchronous PKI operation failures. */
+public enum PkiOperationFailure {
+ /** Typed input or operation precondition is malformed. */
+ VALIDATION_FAILURE,
+ /** A valid request is rejected by PKI policy. */
+ POLICY_REJECTION,
+ /** The selected authoritative object does not exist. */
+ NOT_FOUND,
+ /** The requested state conflicts with committed state. */
+ CONFLICT,
+ /** Durable recovery is required before the operation can continue. */
+ RECOVERY_REQUIRED,
+ /** An external operation may have completed and must be reconciled. */
+ EXTERNAL_OUTCOME_UNKNOWN,
+ /** Filesystem, channel or another required resource failed. */
+ RESOURCE_FAILURE,
+ /** Cooperative cancellation was observed. */
+ CANCELLED,
+ /** An unexpected implementation defect prevented a safe result. */
+ INTERNAL_FAILURE
+}
diff --git a/pki/src/main/java/zeroecho/pki/application/PkiOperationOutcome.java b/pki/src/main/java/zeroecho/pki/application/PkiOperationOutcome.java
new file mode 100644
index 0000000..0dda093
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/application/PkiOperationOutcome.java
@@ -0,0 +1,60 @@
+/*******************************************************************************
+ * 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;
+
+/** Closed success-or-safe-failure outcome returned by the operation executor. */
+public sealed interface PkiOperationOutcome permits PkiOperationOutcome.Success, PkiOperationOutcome.Failure {
+
+ /** Successful operation outcome. */
+ record Success(PkiOperationResult result) implements PkiOperationOutcome {
+ /** Validates the result. */
+ public Success {
+ Objects.requireNonNull(result, "result");
+ }
+ }
+
+ /** Safely classified operation failure without a throwable graph. */
+ record Failure(String operationName, PkiOperationFailure classification, String code)
+ implements PkiOperationOutcome {
+ /** Validates stable, non-sensitive failure data. */
+ public Failure {
+ if (operationName == null || operationName.isBlank() || code == null || !code.matches("[A-Z0-9_]{3,64}")) {
+ throw new IllegalArgumentException("Operation failure identity or code is invalid");
+ }
+ Objects.requireNonNull(classification, "classification");
+ }
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/application/PkiOperationResult.java b/pki/src/main/java/zeroecho/pki/application/PkiOperationResult.java
new file mode 100644
index 0000000..1806d56
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/application/PkiOperationResult.java
@@ -0,0 +1,71 @@
+/*******************************************************************************
+ * 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.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Finite structured result of one successful typed PKI operation.
+ *
+ * @param operationName stable operation identity
+ * @param fields ordered allowlisted result fields
+ */
+public record PkiOperationResult(String operationName, Map fields) {
+
+ /** Validates and snapshots the result. */
+ public PkiOperationResult {
+ if (operationName == null || operationName.isBlank()) {
+ throw new IllegalArgumentException("operationName must not be blank");
+ }
+ Objects.requireNonNull(fields, "fields");
+ fields = Collections.unmodifiableMap(new LinkedHashMap<>(fields));
+ if (fields.containsKey(null) || fields.containsValue(null)) {
+ throw new IllegalArgumentException("result fields must not contain null");
+ }
+ }
+
+ /**
+ * Returns one declared safe field.
+ *
+ * @param name field name
+ * @return field value when declared
+ */
+ public Optional field(String name) {
+ return Optional.ofNullable(fields.get(Objects.requireNonNull(name, "name")));
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/application/PkiOperationValue.java b/pki/src/main/java/zeroecho/pki/application/PkiOperationValue.java
new file mode 100644
index 0000000..4d06261
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/application/PkiOperationValue.java
@@ -0,0 +1,94 @@
+/*******************************************************************************
+ * 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.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Closed transport-neutral value model used by safe operation results and plan
+ * arguments.
+ *
+ * The model deliberately excludes byte arrays and arbitrary Java objects, so
+ * private keys, unrestricted DER and provider exception graphs cannot become
+ * operation output accidentally.
+ */
+public sealed interface PkiOperationValue permits PkiOperationValue.Text, PkiOperationValue.IntegerValue,
+ PkiOperationValue.BooleanValue, PkiOperationValue.ObjectValue, PkiOperationValue.ListValue {
+
+ /** Text scalar. */
+ record Text(String value) implements PkiOperationValue {
+ /** Validates the value. */
+ public Text {
+ Objects.requireNonNull(value, "value");
+ }
+ }
+
+ /** Signed 64-bit integer scalar. */
+ record IntegerValue(long value) implements PkiOperationValue {
+ }
+
+ /** Boolean scalar. */
+ record BooleanValue(boolean value) implements PkiOperationValue {
+ }
+
+ /** Deterministically ordered object value. */
+ record ObjectValue(Map fields) implements PkiOperationValue {
+ /** Validates names and snapshots insertion order. */
+ public ObjectValue {
+ Objects.requireNonNull(fields, "fields");
+ Map copy = new LinkedHashMap<>();
+ for (Map.Entry entry : fields.entrySet()) {
+ String name = Objects.requireNonNull(entry.getKey(), "field name");
+ if (name.isBlank() || copy.putIfAbsent(name, Objects.requireNonNull(entry.getValue(), "field value"))
+ != null) {
+ throw new IllegalArgumentException("Operation object field names must be unique and nonblank");
+ }
+ }
+ fields = Collections.unmodifiableMap(copy);
+ }
+ }
+
+ /** Immutable ordered list value. */
+ record ListValue(List values) implements PkiOperationValue {
+ /** Snapshots the list and rejects null entries. */
+ public ListValue {
+ Objects.requireNonNull(values, "values");
+ values = List.copyOf(values);
+ }
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/application/PkiSession.java b/pki/src/main/java/zeroecho/pki/application/PkiSession.java
new file mode 100644
index 0000000..a376c0f
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/application/PkiSession.java
@@ -0,0 +1,78 @@
+/*******************************************************************************
+ * 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 zeroecho.pki.api.ProfileService;
+import zeroecho.pki.api.RevocationService;
+
+/**
+ * Lifecycle-owned synchronous PKI backend session.
+ *
+ * A session is constructed from one validated immutable configuration and is
+ * suitable for one CLI plan or for the complete lifetime of a future server.
+ * It creates no scheduler, thread or executor. Implementations reject service
+ * access after close.
+ */
+public interface PkiSession extends AutoCloseable {
+
+ /**
+ * Opens a production session.
+ *
+ * @param configuration validated immutable configuration
+ * @return opened session
+ * @throws IllegalArgumentException if configuration is invalid
+ * @throws RuntimeException if resource construction fails
+ */
+ static PkiSession open(PkiSessionConfiguration configuration) {
+ return DefaultPkiSession.open(configuration);
+ }
+
+ /** @return profile lifecycle service owned by this session */
+ ProfileService profiles();
+
+ /** @return revocation service owned by this session */
+ RevocationService revocations();
+
+ /** @return shared typed operation executor owned by this session */
+ PkiOperationExecutor operations();
+
+ /**
+ * Closes services and backend resources in reverse construction order.
+ * Repeated calls are harmless; primary and suppressed failures are preserved.
+ *
+ * @throws Exception if resource closure fails
+ */
+ @Override
+ void close() throws Exception;
+}
diff --git a/pki/src/main/java/zeroecho/pki/application/PkiSessionConfiguration.java b/pki/src/main/java/zeroecho/pki/application/PkiSessionConfiguration.java
new file mode 100644
index 0000000..f130d45
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/application/PkiSessionConfiguration.java
@@ -0,0 +1,68 @@
+/*******************************************************************************
+ * 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 zeroecho.pki.spi.ProviderConfig;
+
+/**
+ * Immutable, versioned configuration for one synchronous PKI backend session.
+ *
+ * Provider values may be sensitive. Callers must not render or log this
+ * object. Validation of provider identities and closed property sets occurs
+ * before backend allocation.
+ *
+ * @param version configuration schema version
+ * @param store store-provider configuration
+ * @param audit audit-provider configuration
+ */
+public record PkiSessionConfiguration(int version, ProviderConfig store, ProviderConfig audit) {
+
+ /** Current configuration schema version. */
+ public static final int CURRENT_VERSION = 1;
+
+ /** Validates and snapshots the configuration. */
+ public PkiSessionConfiguration {
+ if (version != CURRENT_VERSION) {
+ throw new IllegalArgumentException("Unsupported PKI session configuration version");
+ }
+ store = snapshot(Objects.requireNonNull(store, "store"));
+ audit = snapshot(Objects.requireNonNull(audit, "audit"));
+ }
+
+ private static ProviderConfig snapshot(ProviderConfig config) {
+ return new ProviderConfig(config.backendId(), config.properties());
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/application/package-info.java b/pki/src/main/java/zeroecho/pki/application/package-info.java
new file mode 100644
index 0000000..00845c0
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/application/package-info.java
@@ -0,0 +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.
+ ******************************************************************************/
+
+/** Lifecycle-owned synchronous PKI sessions and transport-neutral typed operations. */
+package zeroecho.pki.application;
diff --git a/pki/src/main/java/zeroecho/pki/spi/ProviderConfig.java b/pki/src/main/java/zeroecho/pki/spi/ProviderConfig.java
index fef2cec..6ba6eb0 100644
--- a/pki/src/main/java/zeroecho/pki/spi/ProviderConfig.java
+++ b/pki/src/main/java/zeroecho/pki/spi/ProviderConfig.java
@@ -33,7 +33,6 @@
******************************************************************************/
package zeroecho.pki.spi;
-import java.util.Collections;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
@@ -76,7 +75,7 @@ public record ProviderConfig(String backendId, Map properties) {
if (backendId.isBlank()) {
throw new IllegalArgumentException("backendId must not be blank");
}
- properties = Collections.unmodifiableMap(properties);
+ properties = Map.copyOf(properties);
}
/**
diff --git a/pki/src/main/java/zeroecho/pki/spi/audit/AuditSink.java b/pki/src/main/java/zeroecho/pki/spi/audit/AuditSink.java
index c9d679a..e3de091 100644
--- a/pki/src/main/java/zeroecho/pki/spi/audit/AuditSink.java
+++ b/pki/src/main/java/zeroecho/pki/spi/audit/AuditSink.java
@@ -44,7 +44,7 @@ import zeroecho.pki.api.audit.AuditEvent;
*
*/
@FunctionalInterface
-public interface AuditSink {
+public interface AuditSink extends AutoCloseable {
/**
* Persists an audit event.
@@ -54,4 +54,16 @@ public interface AuditSink {
* @throws RuntimeException if persistence fails
*/
void record(AuditEvent event);
+
+ /**
+ * Releases lifecycle-owned sink resources.
+ *
+ * The default implementation owns no closeable resource. Stateful
+ * providers may override this method; repeated close calls should be
+ * harmless.
+ */
+ @Override
+ default void close() {
+ // Most current sinks open resources only for the duration of record(...).
+ }
}
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 d7a2114..aab4d61 100644
--- a/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java
+++ b/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java
@@ -140,27 +140,47 @@ public final class PkiBootstrap {
*/
public static PkiStore openStore() {
String requestedId = System.getProperty(PROP_STORE_BACKEND);
-
- PkiStoreProvider provider = SpiSelector.select(PkiStoreProvider.class, requestedId,
- new SpiSelector.ProviderId<>() {
- @Override
- public String id(PkiStoreProvider p) {
- return p.id();
- }
- });
-
Map props = SpiSystemProperties.readPrefixed(PROP_STORE_PREFIX);
-
+ PkiStoreProvider provider = selectStoreProvider(requestedId);
if ("fs".equals(provider.id()) && !props.containsKey("root")) {
props.put("root", Path.of("pki-store").toString());
}
-
ProviderConfig config = new ProviderConfig(provider.id(), props);
-
if (LOG.isLoggable(Level.INFO)) {
LOG.info("Selected store provider: " + provider.id() + " (keys: " + props.keySet() + ")");
}
+ return provider.allocate(config);
+ }
+ /**
+ * Validates an explicit store configuration without allocating a store.
+ *
+ * This entry point is intended for lifecycle composition that must reject
+ * every invalid configuration before exposing or allocating backend
+ * resources. Unknown provider properties are rejected because an explicit
+ * application configuration is versioned and closed.
+ *
+ * @param config immutable store-provider configuration
+ * @throws NullPointerException if {@code config} is {@code null}
+ * @throws IllegalArgumentException if the provider or configuration is invalid
+ */
+ public static void validateStoreConfiguration(ProviderConfig config) {
+ PkiStoreProvider provider = selectStoreProvider(Objects.requireNonNull(config, "config").backendId());
+ requireKnownKeys(provider, config);
+ provider.validateConfig(config);
+ }
+
+ /**
+ * Opens a store from an explicit, strictly validated configuration.
+ *
+ * @param config immutable store-provider configuration
+ * @return opened store owned by the caller
+ * @throws IllegalArgumentException if the configuration is invalid
+ * @throws RuntimeException if store allocation fails
+ */
+ public static PkiStore openStore(ProviderConfig config) {
+ validateStoreConfiguration(config);
+ PkiStoreProvider provider = selectStoreProvider(config.backendId());
return provider.allocate(config);
}
@@ -200,6 +220,32 @@ public final class PkiBootstrap {
return provider.allocate(config);
}
+ /**
+ * Validates an explicit audit configuration without allocating a sink.
+ *
+ * @param config immutable audit-provider configuration
+ * @throws NullPointerException if {@code config} is {@code null}
+ * @throws IllegalArgumentException if the provider or configuration is invalid
+ */
+ public static void validateAuditConfiguration(ProviderConfig config) {
+ AuditSinkProvider provider = selectAuditProvider(Objects.requireNonNull(config, "config").backendId());
+ requireKnownKeys(provider, config);
+ provider.validateConfig(config);
+ }
+
+ /**
+ * Opens an audit sink from an explicit, strictly validated configuration.
+ *
+ * @param config immutable audit-provider configuration
+ * @return configured audit sink
+ * @throws IllegalArgumentException if the configuration is invalid
+ * @throws RuntimeException if sink allocation fails
+ */
+ public static AuditSink openAudit(ProviderConfig config) {
+ validateAuditConfiguration(config);
+ return selectAuditProvider(config.backendId()).allocate(config);
+ }
+
/**
* Opens a {@link SignatureWorkflow} using {@link SignatureWorkflowProvider}
* discovered via ServiceLoader.
@@ -349,4 +395,30 @@ public final class PkiBootstrap {
LOG.fine("Provider '" + provider.id() + "' supports keys: " + provider.supportedKeys());
}
}
+
+ private static PkiStoreProvider selectStoreProvider(String requestedId) {
+ return SpiSelector.select(PkiStoreProvider.class, requestedId, new SpiSelector.ProviderId<>() {
+ @Override
+ public String id(PkiStoreProvider provider) {
+ return provider.id();
+ }
+ });
+ }
+
+ private static AuditSinkProvider selectAuditProvider(String requestedId) {
+ return SpiSelector.select(AuditSinkProvider.class, requestedId, new SpiSelector.ProviderId<>() {
+ @Override
+ public String id(AuditSinkProvider provider) {
+ return provider.id();
+ }
+ });
+ }
+
+ private static void requireKnownKeys(ConfigurableProvider> provider, ProviderConfig config) {
+ for (String key : config.properties().keySet()) {
+ if (!provider.supportedKeys().contains(key)) {
+ throw new IllegalArgumentException("Unknown provider configuration key: " + key);
+ }
+ }
+ }
}
diff --git a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java
index db64f3c..f38e5c2 100644
--- a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java
+++ b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java
@@ -90,7 +90,7 @@ import zeroecho.pki.api.status.StatusObject;
* {@link IllegalStateException} when an operation cannot be completed safely.
*
*/
-public interface PkiStore extends SignWorkflowStore {
+public interface PkiStore extends SignWorkflowStore, AutoCloseable {
/**
* Returns the runtime-owned durable streaming-content store.
@@ -430,4 +430,17 @@ public interface PkiStore extends SignWorkflowStore {
* @throws IllegalStateException if listing fails
*/
List listWorkflowStates();
+
+ /**
+ * Closes this store and releases all lifecycle-owned resources.
+ *
+ * After this method returns, implementations must reject new operations.
+ * Repeated close calls must follow the implementation's documented idempotence
+ * contract. A caller that coordinates multiple resources must preserve the
+ * primary close failure and attach later failures as suppressed exceptions.
+ *
+ * @throws Exception if a lifecycle-owned resource cannot be closed
+ */
+ @Override
+ void close() throws Exception;
}
diff --git a/pki/src/test/java/zeroecho/pki/application/PkiOperationExecutorTest.java b/pki/src/test/java/zeroecho/pki/application/PkiOperationExecutorTest.java
new file mode 100644
index 0000000..0fefa9e
--- /dev/null
+++ b/pki/src/test/java/zeroecho/pki/application/PkiOperationExecutorTest.java
@@ -0,0 +1,206 @@
+/*******************************************************************************
+ * 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 static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.lang.reflect.Proxy;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+
+import zeroecho.core.io.CancellationSignal;
+import zeroecho.pki.api.PkiId;
+import zeroecho.pki.api.RevocationService;
+import zeroecho.pki.api.profile.BuiltInCertificateProfileCatalog;
+import zeroecho.pki.api.revocation.RevocationCommand;
+import zeroecho.pki.api.revocation.RevocationQuery;
+import zeroecho.pki.api.revocation.RevocationReason;
+import zeroecho.pki.api.revocation.RevocationRecord;
+import zeroecho.pki.api.revocation.RevocationState;
+import zeroecho.pki.api.revocation.RevocationTransition;
+import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
+import zeroecho.pki.spi.ProviderConfig;
+import zeroecho.pki.spi.store.PkiStore;
+import zeroecho.pki.spi.store.RevocationHistory;
+import zeroecho.pki.spi.store.RevocationSnapshot;
+
+class PkiOperationExecutorTest {
+
+ private static final PkiId CREDENTIAL_ID = new PkiId("credential:test");
+
+ @Test
+ void validatesConfigurationAndProfilesWithSafeTypedResults() {
+ System.out.println("validatesConfigurationAndProfilesWithSafeTypedResults");
+ DefaultPkiOperationExecutor executor = executor(new AtomicInteger());
+ PkiOperationOutcome configuration = executor.execute(new PkiOperation.ValidateConfiguration(),
+ CancellationSignal.NONE);
+ byte[] profile = BuiltInCertificateProfileCatalog.load(getClass().getClassLoader()).get(0).canonicalJson();
+ PkiOperationOutcome validated = executor.execute(new PkiOperation.ValidateProfile(profile),
+ CancellationSignal.NONE);
+ PkiOperationOutcome malformed = executor.execute(new PkiOperation.ValidateProfile(new byte[] { 1 }),
+ CancellationSignal.NONE);
+ System.out.println("...profileBytes=" + profile.length);
+ assertInstanceOf(PkiOperationOutcome.Success.class, configuration);
+ PkiOperationResult profileResult = ((PkiOperationOutcome.Success) validated).result();
+ assertTrue(profileResult.field("profileId").isPresent());
+ assertEquals(PkiOperationFailure.POLICY_REJECTION,
+ ((PkiOperationOutcome.Failure) malformed).classification());
+ System.out.println("...ok");
+ }
+
+ @Test
+ void delegatesMutationAndStreamsBoundedHistory() {
+ System.out.println("delegatesMutationAndStreamsBoundedHistory");
+ AtomicInteger revocations = new AtomicInteger();
+ DefaultPkiOperationExecutor executor = executor(revocations);
+ PkiOperationOutcome mutation = executor.execute(
+ new PkiOperation.RevokeCredential(CREDENTIAL_ID, RevocationReason.KEY_COMPROMISE),
+ CancellationSignal.NONE);
+ PkiOperationOutcome history = executor.execute(new PkiOperation.ReadRevocationHistory(CREDENTIAL_ID, 1),
+ CancellationSignal.NONE);
+ System.out.println("...mutations=" + revocations.get());
+ assertInstanceOf(PkiOperationOutcome.Success.class, mutation);
+ PkiOperationResult historyResult = ((PkiOperationOutcome.Success) history).result();
+ assertEquals(new PkiOperationValue.BooleanValue(true), historyResult.field("truncated").orElseThrow());
+ assertEquals(1, revocations.get());
+ System.out.println("...ok");
+ }
+
+ @Test
+ void classifiesMissingObjectsAndCancellationWithoutThrowableDetails() {
+ System.out.println("classifiesMissingObjectsAndCancellationWithoutThrowableDetails");
+ DefaultPkiOperationExecutor executor = executor(new AtomicInteger());
+ PkiOperationOutcome missing = executor.execute(new PkiOperation.InspectPublication(new PkiId("missing")),
+ CancellationSignal.NONE);
+ PkiOperationOutcome cancelled = executor.execute(new PkiOperation.InspectCredential(CREDENTIAL_ID),
+ () -> true);
+ assertEquals(PkiOperationFailure.NOT_FOUND, ((PkiOperationOutcome.Failure) missing).classification());
+ PkiOperationOutcome.Failure cancelledFailure = (PkiOperationOutcome.Failure) cancelled;
+ assertEquals(PkiOperationFailure.CANCELLED, cancelledFailure.classification());
+ assertEquals("OPERATION_CANCELLED", cancelledFailure.code());
+ 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()));
+ PkiStore store = (PkiStore) Proxy.newProxyInstance(PkiStore.class.getClassLoader(),
+ new Class>[] { PkiStore.class }, (proxy, method, arguments) -> switch (method.getName()) {
+ case "getCredential", "getPublicationRecord" -> Optional.empty();
+ case "close" -> null;
+ default -> throw new UnsupportedOperationException(method.getName());
+ });
+ return new DefaultPkiOperationExecutor(configuration, store, revocations(mutations), () -> {
+ });
+ }
+
+ private static RevocationService revocations(AtomicInteger mutations) {
+ return new RevocationService() {
+ @Override
+ public RevocationRecord hold(RevocationCommand.Hold command) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public RevocationRecord unhold(RevocationCommand.Unhold command) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public RevocationRecord revokePermanently(RevocationCommand.RevokePermanently command) {
+ mutations.incrementAndGet();
+ return new RevocationRecord(command.credentialId(), transitions().get(1));
+ }
+
+ @Override
+ public Optional get(PkiId credentialId) {
+ return Optional.empty();
+ }
+
+ @Override
+ public RevocationHistory history(PkiId credentialId) {
+ return historyCursor(credentialId);
+ }
+
+ @Override
+ public RevocationSnapshot search(RevocationQuery query) {
+ throw new UnsupportedOperationException();
+ }
+ };
+ }
+
+ private static RevocationHistory historyCursor(PkiId credentialId) {
+ return new RevocationHistory() {
+ private int index = -1;
+
+ @Override
+ public PkiId credentialId() {
+ return credentialId;
+ }
+
+ @Override
+ public boolean next(CancellationSignal cancellation) throws IOException {
+ cancellation.throwIfCancelled();
+ index++;
+ return index < transitions().size();
+ }
+
+ @Override
+ public RevocationTransition current() {
+ return transitions().get(index);
+ }
+
+ @Override
+ public void close() {
+ // no-op
+ }
+ };
+ }
+
+ private static List transitions() {
+ return List.of(new RevocationTransition(1L, RevocationState.HELD, Instant.parse("2026-08-04T00:00:00Z"),
+ Optional.empty(), new SimpleAttributeSet()),
+ new RevocationTransition(2L, RevocationState.PERMANENTLY_REVOKED,
+ Instant.parse("2026-08-04T00:01:00Z"), Optional.of(RevocationReason.KEY_COMPROMISE),
+ new SimpleAttributeSet()));
+ }
+}
diff --git a/pki/src/test/java/zeroecho/pki/application/PkiSessionLifecycleTest.java b/pki/src/test/java/zeroecho/pki/application/PkiSessionLifecycleTest.java
new file mode 100644
index 0000000..11327a4
--- /dev/null
+++ b/pki/src/test/java/zeroecho/pki/application/PkiSessionLifecycleTest.java
@@ -0,0 +1,190 @@
+/*******************************************************************************
+ * 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 static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.lang.reflect.Proxy;
+import java.nio.file.Path;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Map;
+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.pki.spi.ProviderConfig;
+import zeroecho.pki.spi.audit.AuditSink;
+import zeroecho.pki.spi.store.PkiStore;
+
+class PkiSessionLifecycleTest {
+
+ private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-08-04T00:00:00Z"), ZoneOffset.UTC);
+
+ @TempDir
+ Path temporaryDirectory;
+
+ @Test
+ void opensReusesAndDeterministicallyClosesOneSession() throws Exception {
+ System.out.println("opensReusesAndDeterministicallyClosesOneSession");
+ PkiSessionConfiguration configuration = configuration(temporaryDirectory.resolve("store"));
+ PkiSession session = PkiSession.open(configuration);
+ PkiOperationOutcome first = session.operations().execute(new PkiOperation.ValidateConfiguration(),
+ CancellationSignal.NONE);
+ PkiOperationOutcome second = session.operations().execute(new PkiOperation.ValidateConfiguration(),
+ CancellationSignal.NONE);
+ System.out.println("...executions=2");
+ assertInstanceOf(PkiOperationOutcome.Success.class, first);
+ assertInstanceOf(PkiOperationOutcome.Success.class, second);
+ session.close();
+ session.close();
+ assertThrows(IllegalStateException.class, session::operations);
+ assertThrows(UnsupportedOperationException.class,
+ () -> ((PkiOperationOutcome.Success) first).result().fields().put("x", new PkiOperationValue.Text("y")));
+ System.out.println("...ok");
+ }
+
+ @Test
+ void validatesBeforeAllocationAndCleansPartialConstruction() {
+ System.out.println("validatesBeforeAllocationAndCleansPartialConstruction");
+ PkiSessionConfiguration configuration = configuration(temporaryDirectory.resolve("unused"));
+ AtomicInteger storesOpened = new AtomicInteger();
+ AtomicInteger storesClosed = new AtomicInteger();
+ DefaultPkiSession.Bootstrap invalid = bootstrap(storesOpened, storesClosed, true, false, false, false);
+ assertThrows(IllegalArgumentException.class,
+ () -> DefaultPkiSession.open(configuration, CLOCK, invalid));
+ assertEquals(0, storesOpened.get());
+
+ DefaultPkiSession.Bootstrap partial = bootstrap(storesOpened, storesClosed, false, true, false, false);
+ assertThrows(IllegalStateException.class,
+ () -> DefaultPkiSession.open(configuration, CLOCK, partial));
+ System.out.println("...partialStoreCloses=" + storesClosed.get());
+ assertEquals(1, storesClosed.get());
+ System.out.println("...ok");
+ }
+
+ @Test
+ void preservesPrimaryAndSuppressedCloseFailures() throws Exception {
+ System.out.println("preservesPrimaryAndSuppressedCloseFailures");
+ PkiSessionConfiguration configuration = configuration(temporaryDirectory.resolve("unused-close"));
+ DefaultPkiSession.Bootstrap bootstrap = bootstrap(new AtomicInteger(), new AtomicInteger(), false, false,
+ true, true);
+ PkiSession session = DefaultPkiSession.open(configuration, CLOCK, bootstrap);
+ Exception failure = assertThrows(Exception.class, session::close);
+ System.out.println("...suppressed=" + failure.getSuppressed().length);
+ assertEquals("audit-close", failure.getMessage());
+ assertEquals(1, failure.getSuppressed().length);
+ assertInstanceOf(IOException.class, failure.getSuppressed()[0]);
+ session.close();
+ 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")));
+ }
+
+ private static DefaultPkiSession.Bootstrap bootstrap(AtomicInteger storesOpened, AtomicInteger storesClosed,
+ boolean invalidAudit, boolean failAuditOpen, boolean failAuditClose, boolean failStoreClose) {
+ return new DefaultPkiSession.Bootstrap() {
+ @Override
+ public void validateStore(ProviderConfig configuration) {
+ // valid
+ }
+
+ @Override
+ public void validateAudit(ProviderConfig configuration) {
+ if (invalidAudit) {
+ throw new IllegalArgumentException("invalid");
+ }
+ }
+
+ @Override
+ public PkiStore openStore(ProviderConfig configuration) {
+ storesOpened.incrementAndGet();
+ return proxyStore(() -> {
+ storesClosed.incrementAndGet();
+ if (failStoreClose) {
+ throw new IOException("store-close");
+ }
+ });
+ }
+
+ @Override
+ public AuditSink openAudit(ProviderConfig configuration) {
+ if (failAuditOpen) {
+ throw new IllegalStateException("audit-open");
+ }
+ return new AuditSink() {
+ @Override
+ public void record(zeroecho.pki.api.audit.AuditEvent event) {
+ // no-op
+ }
+
+ @Override
+ public void close() {
+ if (failAuditClose) {
+ throw new IllegalStateException("audit-close");
+ }
+ }
+ };
+ }
+ };
+ }
+
+ private static PkiStore proxyStore(CloseAction close) {
+ return (PkiStore) Proxy.newProxyInstance(PkiStore.class.getClassLoader(), new Class>[] { PkiStore.class },
+ (proxy, method, arguments) -> {
+ if ("close".equals(method.getName())) {
+ close.close();
+ return null;
+ }
+ if ("toString".equals(method.getName())) {
+ return "test-store";
+ }
+ throw new UnsupportedOperationException(method.getName());
+ });
+ }
+
+ @FunctionalInterface
+ private interface CloseAction {
+ void close() throws Exception;
+ }
+}