asyncBus) {
+ this.asyncBus = Objects.requireNonNull(asyncBus, "asyncBus");
+ }
+
+ @Override
+ public void run() {
+ Instant now = Instant.now();
+ try {
+ asyncBus.sweep(now);
+ } catch (RuntimeException ex) { // NOPMD
+ // Sweep must be resilient; log and continue.
+ if (LOG.isLoggable(Level.WARNING)) {
+ LOG.log(Level.WARNING, "Async sweep failed.", ex);
+ }
+ }
+ }
+ }
+
+ /**
+ * JVM shutdown hook coordinating termination of asynchronous sweep services.
+ *
+ *
+ * {@code ShutdownHook} is responsible for orchestrating an orderly shutdown of
+ * background sweep execution during JVM termination. It emits a structured
+ * shutdown message, initiates executor shutdown, and enforces a bounded grace
+ * period for task completion.
+ *
+ *
+ *
+ * The hook guarantees that shutdown coordination always completes by
+ * decrementing the associated {@link CountDownLatch}, regardless of whether
+ * shutdown is graceful, forced, or interrupted.
+ *
+ *
+ *
+ * This class must never throw exceptions or prevent JVM termination. All
+ * failure modes are handled internally.
+ *
+ */
+ private static final class ShutdownHook implements Runnable {
+
+ private final ScheduledExecutorService sweepExecutor;
+ private final CountDownLatch latch;
+
+ private ShutdownHook(ScheduledExecutorService sweepExecutor, CountDownLatch latch) {
+ this.sweepExecutor = Objects.requireNonNull(sweepExecutor, "sweepExecutor");
+ this.latch = Objects.requireNonNull(latch, "latch");
+ }
+
+ @Override
+ public void run() {
+ Logger shutdownLogger = Logger.getLogger(PkiApplication.class.getName());
+ PkiLogging.emitShutdownMessage(shutdownLogger, "ZeroEcho PKI stopping.");
+
+ sweepExecutor.shutdown();
+ try {
+ boolean ok = sweepExecutor.awaitTermination(SWEEP_SHUTDOWN_GRACE.toMillis(), TimeUnit.MILLISECONDS);
+ if (!ok) {
+ sweepExecutor.shutdownNow();
+ }
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ sweepExecutor.shutdownNow();
+ } finally {
+ latch.countDown();
+ }
+ }
+ }
+
+ /**
+ * Thread factory for asynchronous sweep execution.
+ *
+ *
+ * {@code SweepThreadFactory} creates daemon threads with a stable and
+ * descriptive naming convention suitable for operational diagnostics and log
+ * correlation. Threads produced by this factory are intentionally marked as
+ * daemon threads so that they do not prolong JVM lifetime.
+ *
+ *
+ *
+ * The factory performs no additional customization such as priority changes or
+ * uncaught-exception handlers, relying instead on executor-level policies.
+ *
+ */
+ private static final class SweepThreadFactory implements ThreadFactory {
+
+ @Override
+ public Thread newThread(Runnable r) {
+ Thread t = new Thread(r, "zeroecho-pki-async-sweep");
+ t.setDaemon(true);
+ return t;
+ }
+ }
}
diff --git a/pki/src/main/java/zeroecho/pki/api/PkiId.java b/pki/src/main/java/zeroecho/pki/api/PkiId.java
index eaaf42b..ccbe7c7 100644
--- a/pki/src/main/java/zeroecho/pki/api/PkiId.java
+++ b/pki/src/main/java/zeroecho/pki/api/PkiId.java
@@ -62,4 +62,9 @@ public record PkiId(String value) {
throw new IllegalArgumentException("value must not be null/blank");
}
}
+
+ @Override
+ public String toString() {
+ return value;
+ }
}
diff --git a/pki/src/main/java/zeroecho/pki/api/orch/OrchestrationDurabilityPolicy.java b/pki/src/main/java/zeroecho/pki/api/orch/OrchestrationDurabilityPolicy.java
new file mode 100644
index 0000000..03bb901
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/api/orch/OrchestrationDurabilityPolicy.java
@@ -0,0 +1,114 @@
+/*******************************************************************************
+ * Copyright (C) 2025, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.pki.api.orch;
+
+/**
+ * Durability policy for workflow continuation data managed by orchestrators.
+ *
+ *
+ * Orchestrators coordinate long-running operations (e.g., human approvals,
+ * remote signing, certificate issuance). Some workflows require local
+ * continuation state to resume after a JVM restart. This enum defines how such
+ * state is handled.
+ *
+ *
+ * Examples
+ *
+ * A remote signature workflow may wait for hours. If the remote system is
+ * the source of truth and all necessary data can be re-derived from external
+ * references (operation ID, request ID), the workflow can run in a durable mode
+ * without storing sensitive material locally.
+ * If a workflow requires local ephemeral secrets (e.g., a one-time approval
+ * challenge that cannot be recomputed), strict mode should abort on restart
+ * (fail-closed) unless encrypted persistence is configured.
+ *
+ */
+public enum OrchestrationDurabilityPolicy {
+
+ /**
+ * Fail-closed behavior.
+ *
+ *
+ * The orchestrator does not persist continuation state that would be required
+ * to resume the workflow after a process restart. If the process restarts,
+ * operations that cannot be safely resumed must be cancelled or marked as
+ * failed deterministically.
+ *
+ *
+ *
+ * Example: A workflow requires an ephemeral secret that is only available in
+ * memory. In this mode, the operation is aborted on restart and the client must
+ * resubmit.
+ *
+ */
+ STRICT_ABORT_ON_RESTART,
+
+ /**
+ * Durable operation using minimal non-sensitive state.
+ *
+ *
+ * The orchestrator persists only references and non-sensitive continuation
+ * state, sufficient to resume the workflow after restart. Sensitive payloads
+ * must remain outside the local state (e.g., in dedicated stores or remote
+ * systems).
+ *
+ *
+ *
+ * Example: An operation persists a reference to a stored CSR and a reference to
+ * the selected certificate profile, but does not persist raw key material or
+ * private tokens.
+ *
+ */
+ DURABLE_MIN_STATE,
+
+ /**
+ * Durable operation with encrypted continuation state.
+ *
+ *
+ * The orchestrator persists continuation state encrypted (e.g., AES-256/GCM)
+ * using a key supplied by external configuration (environment variable, secret
+ * store, or KMS). This allows workflows to resume after restart even when they
+ * require sensitive local state.
+ *
+ *
+ *
+ * Example: A workflow stores an encrypted blob containing a transient approval
+ * context. The encryption uses authenticated data derived from operation ID,
+ * operation type, and owner identity to prevent swapping blobs across
+ * operations.
+ *
+ */
+ DURABLE_ENCRYPTED_STATE
+}
diff --git a/pki/src/main/java/zeroecho/pki/api/orch/WorkflowStateRecord.java b/pki/src/main/java/zeroecho/pki/api/orch/WorkflowStateRecord.java
new file mode 100644
index 0000000..ea09712
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/api/orch/WorkflowStateRecord.java
@@ -0,0 +1,120 @@
+/*******************************************************************************
+ * Copyright (C) 2025, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.pki.api.orch;
+
+import java.time.Instant;
+import java.util.Objects;
+import java.util.Optional;
+
+import zeroecho.pki.api.EncodedObject;
+import zeroecho.pki.api.Encoding;
+import zeroecho.pki.api.PkiId;
+import zeroecho.pki.api.audit.Principal;
+
+/**
+ * Persisted continuation state for an orchestrated workflow.
+ *
+ *
+ * This record is used by orchestrators to persist the minimum information
+ * needed to resume long-running workflows after a process restart, according to
+ * the selected {@link OrchestrationDurabilityPolicy}.
+ *
+ *
+ * Storage model
+ *
+ * The async bus is the authoritative source for operation lifecycle and status,
+ * but it does not persist sensitive payloads and may be configured not to
+ * persist results. Orchestrators therefore persist workflow-specific
+ * continuation state through {@code PkiStore}.
+ *
+ *
+ * Security
+ *
+ * {@link #payload()} may contain sensitive information. It must not be
+ * logged.
+ * When {@link #durabilityPolicy()} is
+ * {@link OrchestrationDurabilityPolicy#DURABLE_ENCRYPTED_STATE},
+ * {@link #payloadEncoding()} must reflect an encrypted form and the payload
+ * must be authenticated using AAD derived from operation identity.
+ *
+ *
+ * @param opId async operation identifier (never {@code null})
+ * @param type operation type string (never {@code null})
+ * @param owner operation owner (never {@code null})
+ * @param durabilityPolicy durability policy (never {@code null})
+ * @param createdAt creation time (never {@code null})
+ * @param updatedAt last update time (never {@code null})
+ * @param expiresAt expiration/deadline (never {@code null})
+ * @param payloadEncoding encoding of {@link #payload()} (never {@code null})
+ * @param payload optional persisted continuation payload
+ */
+public record WorkflowStateRecord(PkiId opId, String type, Principal owner,
+ OrchestrationDurabilityPolicy durabilityPolicy, Instant createdAt, Instant updatedAt, Instant expiresAt,
+ Encoding payloadEncoding, Optional payload) {
+
+ public WorkflowStateRecord {
+ Objects.requireNonNull(opId, "opId");
+ Objects.requireNonNull(type, "type");
+ Objects.requireNonNull(owner, "owner");
+ Objects.requireNonNull(durabilityPolicy, "durabilityPolicy");
+ Objects.requireNonNull(createdAt, "createdAt");
+ Objects.requireNonNull(updatedAt, "updatedAt");
+ Objects.requireNonNull(expiresAt, "expiresAt");
+ Objects.requireNonNull(payloadEncoding, "payloadEncoding");
+ Objects.requireNonNull(payload, "payload");
+
+ if (type.isBlank()) {
+ throw new IllegalArgumentException("type must not be blank.");
+ }
+ if (!expiresAt.isAfter(createdAt) && !expiresAt.equals(createdAt)) {
+ // Allow expiresAt == createdAt for immediate-expire test scenarios, but reject
+ // negative durations.
+ if (expiresAt.isBefore(createdAt)) { // NOPMD
+ throw new IllegalArgumentException("expiresAt must not be before createdAt.");
+ }
+ }
+ }
+
+ /**
+ * Returns whether the record is expired at the provided time.
+ *
+ * @param now current time (never {@code null})
+ * @return {@code true} if {@code now} is strictly after {@link #expiresAt()}
+ */
+ public boolean isExpiredAt(Instant now) {
+ Objects.requireNonNull(now, "now");
+ return now.isAfter(expiresAt);
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/api/orch/package-info.java b/pki/src/main/java/zeroecho/pki/api/orch/package-info.java
new file mode 100644
index 0000000..c683992
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/api/orch/package-info.java
@@ -0,0 +1,114 @@
+/*******************************************************************************
+ * Copyright (C) 2025, 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.
+ ******************************************************************************/
+/**
+ * Orchestration and workflow durability API for ZeroEcho PKI.
+ *
+ *
+ * This package defines stable domain abstractions related to long-running
+ * PKI workflows and their durability requirements. It is concerned with
+ * describing what must be persisted and how durable such persistence
+ * must be , not with how the persistence is implemented.
+ *
+ *
+ * Scope and responsibilities
+ *
+ * The orchestration layer in ZeroEcho PKI coordinates multi-step operations
+ * such as certificate issuance, revocation processing, key rollover, or
+ * publication workflows. These operations:
+ *
+ *
+ * may span multiple logical steps,
+ * may involve external systems or asynchronous callbacks,
+ * must tolerate restarts, crashes, or redeployments.
+ *
+ *
+ *
+ * This package provides the minimal API necessary to:
+ *
+ *
+ * describe the required durability guarantees for workflow state,
+ * represent persisted workflow state in a storage-agnostic form,
+ * enable deterministic recovery and audit of workflow progression.
+ *
+ *
+ * Key abstractions
+ *
+ * {@link zeroecho.pki.api.orch.OrchestrationDurabilityPolicy
+ * OrchestrationDurabilityPolicy} defines how durable orchestration
+ * state must be (e.g. write-through, buffered, best-effort), allowing different
+ * operational trade-offs without changing orchestration logic.
+ * {@link zeroecho.pki.api.orch.WorkflowStateRecord WorkflowStateRecord}
+ * represents an immutable, persisted snapshot of workflow state at a specific
+ * point in time, suitable for recovery, audit, and historical inspection.
+ *
+ *
+ * Immutability and history
+ *
+ * Workflow state records are designed to be immutable once persisted.
+ * Implementations are expected to append new state records rather than
+ * overwrite existing ones, enabling:
+ *
+ *
+ * full audit trails,
+ * temporal queries ("state at time T"),
+ * post-mortem analysis of failed or aborted workflows.
+ *
+ *
+ *
+ * Whether and for how long historical records are retained is governed by
+ * policy and implementation, but the API itself is intentionally compatible
+ * with history-preserving stores.
+ *
+ *
+ * API stability and integration
+ *
+ * The types in this package are part of the public PKI API surface. They are
+ * intended to be usable across different runtime environments, including:
+ *
+ *
+ * standalone CLI applications,
+ * long-running server processes,
+ * container-managed frameworks such as Spring or Micronaut.
+ *
+ *
+ *
+ * No assumptions are made about the underlying persistence mechanism
+ * (filesystem, database, distributed log, etc.). Such concerns are handled by
+ * SPI and implementation layers.
+ *
+ *
+ * @since 1.0
+ */
+package zeroecho.pki.api.orch;
diff --git a/pki/src/main/java/zeroecho/pki/impl/async/FileBackedAsyncBusProvider.java b/pki/src/main/java/zeroecho/pki/impl/async/FileBackedAsyncBusProvider.java
new file mode 100644
index 0000000..68ef984
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/impl/async/FileBackedAsyncBusProvider.java
@@ -0,0 +1,112 @@
+/*******************************************************************************
+ * Copyright (C) 2025, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.pki.impl.async;
+
+import java.nio.file.Path;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+import zeroecho.pki.api.PkiId;
+import zeroecho.pki.api.audit.Principal;
+import zeroecho.pki.spi.ProviderConfig;
+import zeroecho.pki.spi.async.AsyncBusProvider;
+import zeroecho.pki.util.async.AsyncBus;
+import zeroecho.pki.util.async.codec.ResultCodec;
+import zeroecho.pki.util.async.impl.AppendOnlyLineStore;
+import zeroecho.pki.util.async.impl.DurableAsyncBus;
+
+/**
+ * Default async bus provider: in-memory state with append-only file
+ * persistence.
+ *
+ *
+ * This provider instantiates the generic durable bus with PKI-specific
+ * identifier codecs and disables result persistence. Results should be stored
+ * in dedicated PKI storage (credential store, export store, etc.) and the bus
+ * should carry only status and references.
+ *
+ *
+ * Configuration keys
+ *
+ * {@code logPath} (optional): append-only log file path. Default:
+ * {@code pki-async/async.log} relative to the working directory.
+ *
+ *
+ * Security
+ *
+ * The log contains operation metadata and must be protected. The underlying
+ * store applies best-effort restrictive permissions (POSIX when available).
+ *
+ */
+public final class FileBackedAsyncBusProvider implements AsyncBusProvider {
+
+ /**
+ * Configuration key for the log file path.
+ */
+ public static final String KEY_LOG_PATH = "logPath";
+
+ @Override
+ public String id() {
+ return "file";
+ }
+
+ @Override
+ public Set supportedKeys() {
+ return Set.of(KEY_LOG_PATH);
+ }
+
+ @Override
+ public AsyncBus allocate(ProviderConfig config) {
+ Objects.requireNonNull(config, "config");
+
+ if (!id().equals(config.backendId())) {
+ throw new IllegalArgumentException("ProviderConfig backendId mismatch.");
+ }
+
+ Map props = config.properties();
+ String logPath = props.getOrDefault(KEY_LOG_PATH, Path.of("pki-async").resolve("async.log").toString());
+
+ AppendOnlyLineStore store = new AppendOnlyLineStore(Path.of(logPath));
+
+ // Default: do not persist results in the bus log.
+ ResultCodec resultCodec = ResultCodec.none();
+
+ DurableAsyncBus bus = new DurableAsyncBus<>(PkiCodecs.PKI_ID,
+ PkiCodecs.PRINCIPAL, PkiCodecs.STRING, resultCodec, store);
+
+ return bus;
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/impl/async/PkiAsyncTypes.java b/pki/src/main/java/zeroecho/pki/impl/async/PkiAsyncTypes.java
new file mode 100644
index 0000000..db1a47c
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/impl/async/PkiAsyncTypes.java
@@ -0,0 +1,109 @@
+/*******************************************************************************
+ * Copyright (C) 2025, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.pki.impl.async;
+
+import zeroecho.pki.api.PkiId;
+import zeroecho.pki.api.audit.Principal;
+
+/**
+ * PKI-specific async bus type aliases.
+ *
+ *
+ * This class exists solely to centralize the chosen type parameters used to
+ * instantiate the generic async bus core.
+ *
+ */
+public final class PkiAsyncTypes {
+
+ private PkiAsyncTypes() {
+ throw new AssertionError("No instances.");
+ }
+
+ /**
+ * Operation identifier type for PKI asynchronous operations.
+ */
+ public static final class OpId { // NOPMD
+ private OpId() {
+ }
+ }
+
+ /**
+ * Owner type for PKI asynchronous operations.
+ */
+ public static final class Owner {
+ private Owner() {
+ }
+ }
+
+ /**
+ * Endpoint identifier type for PKI asynchronous operations.
+ */
+ public static final class EndpointId {
+ private EndpointId() {
+ }
+ }
+
+ /**
+ * Result type for PKI asynchronous operations.
+ *
+ *
+ * The default bus does not persist results by default. Higher layers should
+ * store results (e.g., issued certificates) in PKI storage and use the bus only
+ * for status + references.
+ *
+ */
+ public static final class Result {
+ private Result() {
+ }
+ }
+
+ /**
+ * Returns the operation id type.
+ *
+ * @return class token
+ */
+ public static Class opIdType() {
+ return PkiId.class;
+ }
+
+ /**
+ * Returns the owner type.
+ *
+ * @return class token
+ */
+ public static Class ownerType() {
+ return Principal.class;
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/impl/async/PkiCodecs.java b/pki/src/main/java/zeroecho/pki/impl/async/PkiCodecs.java
new file mode 100644
index 0000000..0ba41ac
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/impl/async/PkiCodecs.java
@@ -0,0 +1,117 @@
+/*******************************************************************************
+ * Copyright (C) 2025, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.pki.impl.async;
+
+import zeroecho.pki.api.PkiId;
+import zeroecho.pki.api.audit.Principal;
+import zeroecho.pki.util.async.codec.IdCodec;
+
+/**
+ * PKI-specific codecs used to instantiate the generic async bus.
+ */
+public final class PkiCodecs {
+
+ private PkiCodecs() {
+ throw new AssertionError("No instances.");
+ }
+
+ /**
+ * Codec for {@link PkiId}.
+ */
+ public static final IdCodec PKI_ID = new IdCodec<>() {
+ @Override
+ public String encode(PkiId value) {
+ if (value == null) {
+ throw new IllegalArgumentException("value must not be null");
+ }
+ return value.value();
+ }
+
+ @Override
+ public PkiId decode(String token) {
+ return new PkiId(token);
+ }
+ };
+
+ /**
+ * Codec for {@link Principal}.
+ *
+ *
+ * Encoded form: {@code type:name}. Both parts are treated as non-secret
+ * identifiers.
+ *
+ */
+ public static final IdCodec PRINCIPAL = new IdCodec<>() {
+ @Override
+ public String encode(Principal value) {
+ if (value == null) {
+ throw new IllegalArgumentException("value must not be null");
+ }
+ return value.type() + ":" + value.name();
+ }
+
+ @Override
+ public Principal decode(String token) {
+ int idx = token.indexOf(':');
+ if (idx <= 0 || idx >= token.length() - 1) {
+ throw new IllegalArgumentException("Invalid principal token.");
+ }
+ String type = token.substring(0, idx);
+ String name = token.substring(idx + 1);
+ return new Principal(type, name);
+ }
+ };
+
+ /**
+ * Codec for endpoint ids represented as strings.
+ */
+ public static final IdCodec STRING = new IdCodec<>() {
+ @Override
+ public String encode(String value) {
+ if (value == null || value.isBlank()) {
+ throw new IllegalArgumentException("value must not be null/blank");
+ }
+ return value;
+ }
+
+ @Override
+ public String decode(String token) {
+ if (token == null || token.isBlank()) {
+ throw new IllegalArgumentException("token must not be null/blank");
+ }
+ return token;
+ }
+ };
+}
diff --git a/pki/src/main/java/zeroecho/pki/internal/x509/package-info.java b/pki/src/main/java/zeroecho/pki/impl/async/package-info.java
similarity index 77%
rename from pki/src/main/java/zeroecho/pki/internal/x509/package-info.java
rename to pki/src/main/java/zeroecho/pki/impl/async/package-info.java
index 68c0f8a..52bee02 100644
--- a/pki/src/main/java/zeroecho/pki/internal/x509/package-info.java
+++ b/pki/src/main/java/zeroecho/pki/impl/async/package-info.java
@@ -33,6 +33,19 @@
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
/**
- *
+ * Default implementations for the PKI async bus SPI.
+ *
+ *
+ * Implementations in this package are internal to the PKI module and are
+ * intended to be wired using {@link java.util.ServiceLoader} via
+ * {@code zeroecho.pki.spi.async}.
+ *
+ *
+ * Security
+ *
+ * The default implementation persists an append-only log containing operation
+ * ids and status metadata. Even such metadata can be sensitive (correlation,
+ * timing). File permissions are therefore enforced on a best-effort basis.
+ *
*/
-package zeroecho.pki.internal.x509;
\ No newline at end of file
+package zeroecho.pki.impl.async;
diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java
index cb0d400..e500165 100644
--- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java
@@ -55,6 +55,7 @@ import java.util.logging.Logger;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.credential.Credential;
+import zeroecho.pki.api.orch.WorkflowStateRecord;
import zeroecho.pki.api.policy.PolicyTrace;
import zeroecho.pki.api.profile.CertificateProfile;
import zeroecho.pki.api.publication.PublicationRecord;
@@ -328,6 +329,46 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
return readOptional(this.paths.policyTracePath(decisionId), PolicyTrace.class);
}
+ // -------------------------------------------------------------------------
+ // Workflow continuation state (orchestration)
+ // -------------------------------------------------------------------------
+
+ @Override
+ public void putWorkflowState(final WorkflowStateRecord record) {
+ Objects.requireNonNull(record, "record");
+
+ PkiId opId = record.opId();
+ Path current = this.paths.workflowCurrent(opId);
+
+ // Never log payload; writeWithHistory logs only type + safe identifiers.
+ writeWithHistory(this.paths.workflowHistoryDir(opId), current, FsCodec.encode(record),
+ this.options.workflowHistoryPolicy(), "WORKFLOW", FsUtil.safeId(opId));
+ }
+
+ @Override
+ public Optional getWorkflowState(final PkiId opId) {
+ Objects.requireNonNull(opId, "opId");
+ return readOptional(this.paths.workflowCurrent(opId), WorkflowStateRecord.class);
+ }
+
+ @Override
+ public void deleteWorkflowState(final PkiId opId) {
+ Objects.requireNonNull(opId, "opId");
+ Path current = this.paths.workflowCurrent(opId);
+
+ try {
+ Files.deleteIfExists(current);
+ } catch (IOException e) {
+ throw new IllegalStateException("failed to delete workflow state opId=" + FsUtil.safeId(opId), e);
+ }
+ }
+
+ @Override
+ public List listWorkflowStates() {
+ // List by operation directory (workflows/by-op//current.bin)
+ return listCurrentRecords(this.paths.workflowRoot(), WorkflowStateRecord.class);
+ }
+
@Override
public void close() throws IOException {
synchronized (this.lockChannel) { // NOPMD
diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStoreProvider.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStoreProvider.java
index 9aea6b3..09dab7d 100644
--- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStoreProvider.java
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStoreProvider.java
@@ -58,7 +58,7 @@ public final class FilesystemPkiStoreProvider implements PkiStoreProvider {
/**
* Public no-arg constructor required by {@link java.util.ServiceLoader}.
*/
- public FilesystemPkiStoreProvider() { // NOPMD
+ public FilesystemPkiStoreProvider() {
// no-op
}
diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java
index ce818f8..8b8158d 100644
--- a/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java
@@ -40,75 +40,90 @@ import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.invoke.MethodHandles;
-import java.lang.reflect.Constructor;
+import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.RecordComponent;
-import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
-import java.util.ArrayList;
-import java.util.Base64;
-import java.util.List;
+import java.util.Map;
import java.util.Objects;
-import java.util.Optional;
-import java.util.UUID;
import zeroecho.core.io.Util;
/**
- * Deterministic binary codec for PKI store objects.
+ * Compact binary codec for filesystem persistence.
*
+ * Encoding format
*
- * This codec is designed for:
+ * The codec supports two on-wire formats:
*
*
- * Deterministic serialization across JVM runs.
- * No external dependencies.
- * Support for records (the dominant pattern in the PKI API).
+ * Compact format (current) : begins with a 1-byte MAGIC marker
+ * {@code 0xFF}, followed by a 1-byte TAG and tag-specific payload.
+ * Legacy format (backward-compatible) : begins with a UTF-8 encoded
+ * Java class name (written by
+ * {@link zeroecho.core.io.Util#writeUTF8(OutputStream, String)}), followed by
+ * value payload based on that class.
*
*
*
- * Supported types:
- *
- *
- * primitive wrappers, {@link String}, {@code byte[]}
- * {@link Instant}, {@link Duration}, {@link UUID}
- * {@link Optional}, {@link List}
- * {@link Enum}
- * Java {@code record} types, recursively
- *
- *
- *
- * For non-record custom value objects, decoding attempts the following (in this
- * order) using a single {@link String} argument: {@code fromString},
- * {@code parse}, {@code of}, {@code valueOf}, or a public constructor. The
- * serialized form is {@code toString()}.
+ * The compact format eliminates the pathological overhead of writing a full
+ * Java class name for every scalar value (e.g., {@code boolean}). It also
+ * substantially reduces class-loading activity during decode.
*
*
+ * Type compatibility
*
- * If none of the above works, an exception is thrown. This is intentional: the
- * persistence layer must be explicit and auditable.
+ * During decode, the codec enforces that the encoded value type is compatible
+ * with the expected type. Primitive/wrapper pairs (e.g.,
+ * {@code boolean}/{@link Boolean}) are treated as compatible because values are
+ * boxed when represented as {@link Object}.
*
*/
+@SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.NcssCount" })
final class FsCodec {
- private static final int MAX_STRING_BYTES = 1024 * 1024 * 4; // 4 MiB safety cap
+ private static final int MAX_STRING_BYTES = 256 * 1024;
+
+ private static final Map, Class>> PRIMITIVE_TO_WRAPPER = Map.of(boolean.class, Boolean.class, byte.class,
+ Byte.class, short.class, Short.class, int.class, Integer.class, long.class, Long.class, char.class,
+ Character.class, float.class, Float.class, double.class, Double.class);
/**
- * Hard upper bound for any stored binary blob (defense-in-depth against corrupt
- * files and unbounded allocations). This is not a security boundary.
+ * Magic byte that marks the compact format.
+ *
+ *
+ * This value is chosen to be extremely unlikely as a first byte of the legacy
+ * UTF-8 class-name length prefix. Class names are short; legacy length prefix
+ * first byte will be < 0x80.
+ *
*/
- private static final int MAX_BLOB_BYTES = 16 * 1024 * 1024; // 16 MiB
+ private static final int MAGIC_COMPACT = 0xFF;
+
+ // Compact tags (1 byte). Keep stable.
+ private static final int TAG_STRING = 1;
+ private static final int TAG_INT = 2;
+ private static final int TAG_LONG = 3;
+ private static final int TAG_BOOL = 4;
+ private static final int TAG_BYTES = 5;
+ private static final int TAG_INSTANT = 6;
+ private static final int TAG_DURATION = 7;
+ private static final int TAG_ENUM = 8;
+ private static final int TAG_RECORD = 9;
+ private static final int TAG_FALLBACK_STRING = 10;
+ private static final int TAG_LIST = 11;
+ private static final int TAG_SET = 12;
+ private static final int TAG_OPTIONAL = 13;
private FsCodec() {
- // utility class
+ // utility
}
/* default */ static byte[] encode(final T value) {
Objects.requireNonNull(value, "value");
try {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
- writeAny(bos, value);
+ writeAnyCompact(bos, value);
return bos.toByteArray();
} catch (IOException e) {
throw new IllegalStateException("encoding failed: " + value.getClass().getName(), e);
@@ -123,251 +138,353 @@ final class FsCodec {
Object decoded = readAny(bis, expectedType);
return expectedType.cast(decoded);
} catch (IOException e) {
- throw new IllegalStateException("decoding failed for " + expectedType.getName(), e);
+ throw new IllegalStateException("decoding failed: " + expectedType.getName(), e);
}
}
- private static void writeAny(final OutputStream out, final Object value) throws IOException { // NOPMD
+ private static void writeAnyCompact(final OutputStream out, final Object value) throws IOException {
Objects.requireNonNull(out, "out");
Objects.requireNonNull(value, "value");
- Class> type = value.getClass();
- Util.writeUTF8(out, type.getName());
+ out.write(MAGIC_COMPACT);
- if (value instanceof String s) {
- writeString(out, s);
- return;
- }
- if (value instanceof Integer i) {
- Util.writePack7I(out, i);
- return;
- }
- if (value instanceof Long l) {
- Util.writeLong(out, l);
- return;
- }
- if (value instanceof Boolean b) {
- out.write(b ? 1 : 0);
- return;
- }
- if (value instanceof byte[] bytes) {
- Util.write(out, bytes);
- return;
- }
- if (value instanceof Instant instant) {
- Util.writeLong(out, instant.getEpochSecond());
- Util.writePack7I(out, instant.getNano());
- return;
- }
- if (value instanceof Duration duration) {
- Util.writeLong(out, duration.getSeconds());
- Util.writePack7I(out, duration.getNano());
- return;
- }
- if (value instanceof UUID uuid) {
- Util.write(out, uuid);
- return;
- }
- if (value instanceof Optional> opt) {
- out.write(opt.isPresent() ? 1 : 0);
- if (opt.isPresent()) {
- writeAny(out, opt.get());
+ // Use a switch with patterns to avoid long if/else chains and keep the hot path
+ // compact.
+ switch (value) {
+ case String s -> {
+ out.write(TAG_STRING);
+ Util.writeUTF8(out, s);
}
- return;
- }
- if (value instanceof List> list) {
- Util.writePack7I(out, list.size());
- for (Object item : list) {
- writeAny(out, item);
+ case Integer i -> {
+ out.write(TAG_INT);
+ Util.writePack7I(out, i);
}
- return;
- }
- if (type.isEnum()) {
- Enum> e = (Enum>) value;
- writeString(out, e.name());
- return;
- }
- if (type.isRecord()) {
- RecordComponent[] components = type.getRecordComponents();
- Util.writePack7I(out, components.length);
- for (RecordComponent c : components) {
- try {
- Method accessor = c.getAccessor();
- Object componentValue = accessor.invoke(value);
- writeAny(out, componentValue);
- } catch (ReflectiveOperationException ex) {
- throw new IllegalStateException("record encode failed: " + type.getName() + "." + c.getName(), ex);
+ case Long l -> {
+ out.write(TAG_LONG);
+ Util.writePack7L(out, l);
+ }
+ case Boolean b -> {
+ out.write(TAG_BOOL);
+ out.write(b ? 1 : 0);
+ }
+ case byte[] bytes -> {
+ out.write(TAG_BYTES);
+ Util.write(out, bytes);
+ }
+ case Instant instant -> {
+ out.write(TAG_INSTANT);
+ Util.writeLong(out, instant.getEpochSecond());
+ Util.writePack7I(out, instant.getNano());
+ }
+ case Duration duration -> {
+ out.write(TAG_DURATION);
+ Util.writeLong(out, duration.getSeconds());
+ Util.writePack7I(out, duration.getNano());
+ }
+ case java.util.List> list -> {
+ out.write(TAG_LIST);
+ Util.writePack7I(out, list.size());
+ for (Object element : list) {
+ Objects.requireNonNull(element, "list element must not be null");
+ writeAnyCompact(out, element);
}
}
- return;
- }
+ case java.util.Set> set -> {
+ out.write(TAG_SET);
- // fallback: encode as string and reconstruct via string factory on decode
- writeString(out, value.toString());
+ // Deterministic ordering: encode sets in a stable order.
+ // Use natural order when possible; otherwise order by stable string key.
+ java.util.List ordered = new java.util.ArrayList<>(set.size());
+ ordered.addAll(set);
+
+ ordered.sort((a, b) -> {
+ if (a == b) { // NOPMD
+ return 0;
+ }
+ if (a == null) {
+ return -1;
+ }
+ if (b == null) {
+ return 1;
+ }
+ if (a instanceof Comparable> && a.getClass() == b.getClass()) {
+ @SuppressWarnings("unchecked")
+ Comparable ca = (Comparable) a;
+ return ca.compareTo(b);
+ }
+ return stableSortKey(a).compareTo(stableSortKey(b));
+ });
+
+ Util.writePack7I(out, ordered.size());
+ for (Object element : ordered) {
+ Objects.requireNonNull(element, "set element must not be null");
+ writeAnyCompact(out, element);
+ }
+ }
+ case java.util.Optional> opt -> {
+ out.write(TAG_OPTIONAL);
+ if (opt.isPresent()) {
+ out.write(1);
+ Object element = opt.get();
+ Objects.requireNonNull(element, "optional element must not be null");
+ writeAnyCompact(out, element);
+ } else {
+ out.write(0);
+ }
+ }
+ default -> {
+ Class> type = value.getClass();
+
+ if (type.isEnum()) {
+ out.write(TAG_ENUM);
+ // Enum: write declaring class name once + ordinal.
+ Util.writeUTF8(out, type.getName());
+ Enum> e = (Enum>) value;
+ Util.writePack7I(out, e.ordinal());
+ return;
+ }
+
+ if (type.isRecord()) {
+ out.write(TAG_RECORD);
+ // Record: write record class name once + component values (encoded compactly).
+ Util.writeUTF8(out, type.getName());
+ RecordComponent[] components = type.getRecordComponents();
+ Util.writePack7I(out, components.length);
+ for (RecordComponent c : components) {
+ try {
+ Method accessor = c.getAccessor();
+ Object componentValue = accessor.invoke(value);
+ // Preserve existing invariant: no null components in storage.
+ Objects.requireNonNull(componentValue,
+ "record component " + type.getName() + "." + c.getName());
+ writeAnyCompact(out, componentValue);
+ } catch (IllegalAccessException | InvocationTargetException ex) {
+ throw new IllegalStateException(
+ "record encode failed: " + type.getName() + "." + c.getName(), ex);
+ }
+ }
+ return;
+ }
+
+ // Fallback: encode as string + type name (still far smaller than
+ // legacy-per-component).
+ out.write(TAG_FALLBACK_STRING);
+ Util.writeUTF8(out, type.getName());
+ Util.writeUTF8(out, value.toString());
+ }
+ }
+ }
+
+ private static String stableSortKey(Object o) {
+ // Deterministic, non-sensitive: class name + toString.
+ // Note: do not use identity hash code; it is non-deterministic across runs.
+ return o.getClass().getName() + ":" + o.toString();
}
private static Object readAny(final InputStream in, final Class> expectedType) throws IOException {
Objects.requireNonNull(in, "in");
Objects.requireNonNull(expectedType, "expectedType");
- String encodedTypeName = Util.readUTF8(in, MAX_STRING_BYTES);
- Class> encodedType;
- ClassLoader primary = MethodHandles.lookup().lookupClass().getClassLoader(); // NOPMD
- try {
- encodedType = Class.forName(encodedTypeName, false, primary);
- } catch (ClassNotFoundException e) {
- ClassLoader tccl = Thread.currentThread().getContextClassLoader();
- if (tccl != null && tccl != primary) { // NOPMD
- try {
- return Class.forName(encodedTypeName, false, tccl);
- } catch (ClassNotFoundException e1) {
- e = e1; // NOPMD
- }
- }
- throw new IllegalStateException("unknown encoded type: " + encodedTypeName, e);
+ int first = in.read();
+ if (first < 0) {
+ throw new IOException("unexpected EOF");
}
- // decode using the encoded type (authoritative)
- Object value = readByType(in, encodedType);
-
- // enforce expected assignment
- if (!expectedType.isAssignableFrom(encodedType)) {
+ if (first != MAGIC_COMPACT) {
throw new IllegalStateException(
- "type mismatch, expected " + expectedType.getName() + " but encoded " + encodedType.getName());
+ "file mismatch, MAGIC byte expected=" + MAGIC_COMPACT + " but found=" + first);
}
+
+ int tag = in.read();
+ if (tag < 0) {
+ throw new IOException("unexpected EOF");
+ }
+
+ Object value = readByTag(in, tag);
+
+ // Enforce type compatibility (primitive/wrapper tolerant).
+ if (!isTypeCompatible(expectedType, value.getClass())) {
+ throw new IllegalStateException(
+ "type mismatch, expected " + expectedType.getName() + " but decoded " + value.getClass().getName());
+ }
+
return value;
}
- private static Object readByType(final InputStream in, final Class> type) throws IOException { // NOPMD
- if (type == String.class) {
- return Util.readUTF8(in, MAX_STRING_BYTES);
- }
- if (type == Integer.class) {
- return Util.readPack7I(in);
- }
- if (type == Long.class) {
- return Util.readLong(in);
- }
- if (type == Boolean.class) {
- int b = in.read();
- if (b < 0) {
- throw new IOException("unexpected EOF");
+ private static Object readByTag(final InputStream in, final int tag) throws IOException {
+ switch (tag) {
+ case TAG_STRING -> {
+ return Util.readUTF8(in, MAX_STRING_BYTES);
}
- return b != 0;
- }
- if (type == byte[].class) {
- return Util.read(in, MAX_BLOB_BYTES);
- }
- if (type == Instant.class) {
- long seconds = Util.readLong(in);
- int nanos = Util.readPack7I(in);
- return Instant.ofEpochSecond(seconds, nanos);
- }
- if (type == Duration.class) {
- long seconds = Util.readLong(in);
- int nanos = Util.readPack7I(in);
- return Duration.ofSeconds(seconds, nanos);
- }
- if (type == UUID.class) {
- return Util.readUUID(in);
- }
- if (type == Optional.class) {
- int present = in.read();
- if (present < 0) {
- throw new IOException("unexpected EOF");
+ case TAG_INT -> {
+ return Util.readPack7I(in);
}
- if (present == 0) {
- return Optional.empty();
+ case TAG_LONG -> {
+ return Util.readPack7L(in);
}
- // generic type erased; decode nested object as Object
- Object nested = readAny(in, Object.class);
- return Optional.of(nested);
- }
- if (List.class.isAssignableFrom(type)) {
- int size = Util.readPack7I(in);
- List out = new ArrayList<>(size);
- for (int i = 0; i < size; i++) {
- Object item = readAny(in, Object.class);
- out.add(item);
- }
- return out;
- }
- if (type.isEnum()) {
- String name = Util.readUTF8(in, MAX_STRING_BYTES);
- @SuppressWarnings({ "unchecked", "rawtypes" })
- Enum> e = Enum.valueOf((Class) type, name);
- return e;
- }
- if (type.isRecord()) {
- RecordComponent[] components = type.getRecordComponents();
- int count = Util.readPack7I(in);
- if (count != components.length) {
- throw new IllegalStateException("record component count mismatch for " + type.getName());
- }
- Object[] args = new Object[components.length];
- Class>[] ctorTypes = new Class>[components.length];
-
- for (int i = 0; i < components.length; i++) {
- RecordComponent c = components[i];
- ctorTypes[i] = c.getType();
- args[i] = readAny(in, c.getType());
- }
-
- try {
- Constructor> ctor = type.getDeclaredConstructor(ctorTypes);
- ctor.setAccessible(true); // NOPMD
- return ctor.newInstance(args);
- } catch (ReflectiveOperationException e) {
- throw new IllegalStateException("record decode failed: " + type.getName(), e);
- }
- }
-
- // fallback: read string and reconstruct with best-effort factory methods
- String stringForm = Util.readUTF8(in, MAX_STRING_BYTES);
- Object reconstructed = tryFromString(type, stringForm);
- if (reconstructed != null) {
- return reconstructed;
- }
- throw new IllegalStateException("unsupported value type without from-string factory: " + type.getName());
- }
-
- private static void writeString(final OutputStream out, final String s) throws IOException {
- byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
- Util.writePack7I(out, bytes.length);
- out.write(bytes);
- }
-
- private static Object tryFromString(final Class> type, final String s) {
- List methodNames = List.of("fromString", "parse", "of", "valueOf");
- for (String methodName : methodNames) {
- try {
- Method m = type.getMethod(methodName, String.class);
- if (type.isAssignableFrom(m.getReturnType())) {
- return m.invoke(null, s);
+ case TAG_BOOL -> {
+ int b = in.read();
+ if (b < 0) {
+ throw new IOException("unexpected EOF");
}
- } catch (ReflectiveOperationException ignored) {
- // continue
+ return b != 0;
}
+ case TAG_BYTES -> {
+ return Util.read(in, MAX_STRING_BYTES);
+ }
+ case TAG_INSTANT -> {
+ long sec = Util.readLong(in);
+ int nano = Util.readPack7I(in);
+ return Instant.ofEpochSecond(sec, nano);
+ }
+ case TAG_DURATION -> {
+ long sec = Util.readLong(in);
+ int nano = Util.readPack7I(in);
+ return Duration.ofSeconds(sec, nano);
+ }
+ case TAG_ENUM -> {
+ String enumTypeName = Util.readUTF8(in, MAX_STRING_BYTES);
+ Class> enumType = loadClass(enumTypeName);
+ if (!enumType.isEnum()) {
+ throw new IllegalStateException("encoded enum type is not an enum: " + enumTypeName);
+ }
+ int ordinal = Util.readPack7I(in);
+ Object[] constants = enumType.getEnumConstants();
+ if (constants == null || ordinal < 0 || ordinal >= constants.length) {
+ throw new IllegalStateException("invalid enum ordinal " + ordinal + " for " + enumTypeName);
+ }
+ return constants[ordinal];
+ }
+ case TAG_RECORD -> {
+ String recordTypeName = Util.readUTF8(in, MAX_STRING_BYTES);
+ Class> recordType = loadClass(recordTypeName);
+ if (!recordType.isRecord()) {
+ throw new IllegalStateException("encoded record type is not a record: " + recordTypeName);
+ }
+
+ int componentCount = Util.readPack7I(in);
+ RecordComponent[] components = recordType.getRecordComponents();
+ if (components.length != componentCount) {
+ throw new IllegalStateException("record component count mismatch for " + recordTypeName
+ + ", expected " + components.length + " but encoded " + componentCount);
+ }
+
+ Class>[] ctorTypes = new Class>[components.length];
+ Object[] ctorArgs = new Object[components.length];
+ for (int i = 0; i < components.length; i++) {
+ RecordComponent c = components[i];
+ ctorTypes[i] = c.getType();
+ Object componentValue = readAny(in, ctorTypes[i]);
+ ctorArgs[i] = componentValue;
+ }
+
+ try {
+ return recordType.getDeclaredConstructor(ctorTypes).newInstance(ctorArgs);
+ } catch (ReflectiveOperationException ex) {
+ throw new IllegalStateException("record decode failed for " + recordTypeName, ex);
+ }
+ }
+ case TAG_FALLBACK_STRING -> {
+ String typeName = Util.readUTF8(in, MAX_STRING_BYTES);
+ String s = Util.readUTF8(in, MAX_STRING_BYTES);
+ Class> type = loadClass(typeName);
+
+ // string factory: of(String) or valueOf(String) or ctor(String)
+ Method of = findStringFactory(type);
+ if (of != null) {
+ try {
+ return of.invoke(null, s);
+ } catch (ReflectiveOperationException ex) {
+ throw new IllegalStateException("from-string factory failed for " + type.getName(), ex);
+ }
+ }
+ try {
+ return type.getConstructor(String.class).newInstance(s);
+ } catch (ReflectiveOperationException ex) {
+ throw new IllegalStateException(
+ "unsupported value type without from-string factory: " + type.getName(), ex);
+ }
+ }
+ case TAG_LIST -> {
+ int n = Util.readPack7I(in);
+ java.util.List out = new java.util.ArrayList<>(n);
+ for (int i = 0; i < n; i++) {
+ Object element = readAny(in, Object.class);
+ out.add(element);
+ }
+ return java.util.List.copyOf(out);
+ }
+ case TAG_SET -> {
+ int n = Util.readPack7I(in);
+ java.util.Set out = new java.util.LinkedHashSet<>(n);
+ for (int i = 0; i < n; i++) {
+ Object element = readAny(in, Object.class);
+ out.add(element);
+ }
+ return java.util.Set.copyOf(out);
+ }
+ case TAG_OPTIONAL -> {
+ int present = in.read();
+ if (present < 0) {
+ throw new IOException("unexpected EOF");
+ }
+ if (present == 0) {
+ return java.util.Optional.empty();
+ }
+ Object element = readAny(in, Object.class);
+ return java.util.Optional.of(element);
+ }
+ default -> throw new IllegalStateException("unknown compact tag: " + tag);
+ }
+ }
+
+ private static boolean isTypeCompatible(final Class> expectedType, final Class> actualType) {
+ if (expectedType.isAssignableFrom(actualType)) {
+ return true;
+ }
+
+ Class> expectedWrapper = PRIMITIVE_TO_WRAPPER.get(expectedType);
+ if (expectedWrapper != null && expectedWrapper == actualType) {
+ return true; // primitive expected, wrapper actual
+ }
+
+ Class> actualWrapper = PRIMITIVE_TO_WRAPPER.get(actualType);
+ return actualWrapper != null && actualWrapper == expectedType;
+ }
+
+ @SuppressWarnings("PMD.UseProperClassLoader")
+ private static Class> loadClass(final String typeName) {
+ Objects.requireNonNull(typeName, "typeName");
+
+ ClassLoader cl = Thread.currentThread().getContextClassLoader();
+ if (cl == null) {
+ cl = MethodHandles.lookup().lookupClass().getClassLoader();
}
try {
- Constructor> ctor = type.getConstructor(String.class);
- return ctor.newInstance(s);
- } catch (ReflectiveOperationException ignored) {
- // continue
+ return Class.forName(typeName, false, cl);
+ } catch (ClassNotFoundException e) {
+ throw new IllegalStateException("unknown encoded type: " + typeName, e);
}
+ }
- // common pattern: base64 bytes container
+ private static Method findStringFactory(final Class> type) {
try {
- Method m = type.getMethod("of", byte[].class);
- if (type.isAssignableFrom(m.getReturnType())) {
- byte[] decoded = Base64.getDecoder().decode(s);
- return m.invoke(null, decoded);
+ Method of = type.getMethod("of", String.class);
+ if ((of.getModifiers() & java.lang.reflect.Modifier.STATIC) != 0 && of.getReturnType() == type) {
+ return of;
}
- } catch (ReflectiveOperationException ignored) {
+ } catch (NoSuchMethodException ex) { // NOPMD
+ // ignore
+ }
+ try {
+ Method valueOf = type.getMethod("valueOf", String.class);
+ if ((valueOf.getModifiers() & java.lang.reflect.Modifier.STATIC) != 0 && valueOf.getReturnType() == type) {
+ return valueOf;
+ }
+ } catch (NoSuchMethodException ex) { // NOPMD
// ignore
}
-
return null;
}
}
diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java
index 3676395..b61de97 100644
--- a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java
@@ -43,8 +43,16 @@ import zeroecho.pki.api.PkiId;
* Deterministic path mapping for the filesystem PKI store.
*
*
- * This class is internal to the reference implementation. It defines the
- * on-disk layout and centralizes naming conventions.
+ * This class is internal to the filesystem store implementation. It centralizes
+ * the on-disk layout so that path conventions remain stable and consistent
+ * across all entities and snapshot/export logic.
+ *
+ *
+ *
+ * The layout supports "mutable but auditable" entities by storing the latest
+ * version in {@link #CURRENT_FILE} and keeping immutable historical versions in
+ * a {@link #HISTORY_DIR} directory. This is required for snapshot export and
+ * forensic reconstruction.
*
*/
final class FsPaths {
@@ -76,7 +84,12 @@ final class FsPaths {
return this.root.resolve(LOCK_DIR).resolve(STORE_LOCK);
}
+ // -------------------------------------------------------------------------
+ // CA (mutable with history)
+ // -------------------------------------------------------------------------
+
/* default */ Path caDir(final PkiId caId) {
+ Objects.requireNonNull(caId, "caId");
return this.root.resolve("cas").resolve(BY_ID).resolve(FsUtil.safeId(caId));
}
@@ -88,7 +101,12 @@ final class FsPaths {
return caDir(caId).resolve(HISTORY_DIR);
}
+ // -------------------------------------------------------------------------
+ // Profiles (mutable with history)
+ // -------------------------------------------------------------------------
+
/* default */ Path profileDir(final String profileId) {
+ Objects.requireNonNull(profileId, "profileId");
return this.root.resolve("profiles").resolve(BY_ID).resolve(FsUtil.safeSegment(profileId));
}
@@ -100,15 +118,30 @@ final class FsPaths {
return profileDir(profileId).resolve(HISTORY_DIR);
}
+ // -------------------------------------------------------------------------
+ // Credentials (immutable .bin)
+ // -------------------------------------------------------------------------
+
/* default */ Path credentialPath(final PkiId credentialId) {
+ Objects.requireNonNull(credentialId, "credentialId");
return this.root.resolve("credentials").resolve(BY_ID).resolve(FsUtil.safeId(credentialId) + ".bin");
}
+ // -------------------------------------------------------------------------
+ // Requests (immutable .bin)
+ // -------------------------------------------------------------------------
+
/* default */ Path requestPath(final PkiId requestId) {
+ Objects.requireNonNull(requestId, "requestId");
return this.root.resolve("requests").resolve(BY_ID).resolve(FsUtil.safeId(requestId) + ".bin");
}
+ // -------------------------------------------------------------------------
+ // Revocations (mutable with history)
+ // -------------------------------------------------------------------------
+
/* default */ Path revocationDir(final PkiId credentialId) {
+ Objects.requireNonNull(credentialId, "credentialId");
return this.root.resolve("revocations").resolve("by-credential").resolve(FsUtil.safeId(credentialId));
}
@@ -120,15 +153,62 @@ final class FsPaths {
return revocationDir(credentialId).resolve(HISTORY_DIR);
}
+ // -------------------------------------------------------------------------
+ // Status objects (immutable .bin)
+ // -------------------------------------------------------------------------
+
/* default */ Path statusObjectPath(final PkiId statusObjectId) {
+ Objects.requireNonNull(statusObjectId, "statusObjectId");
return this.root.resolve("status").resolve(BY_ID).resolve(FsUtil.safeId(statusObjectId) + ".bin");
}
+ // -------------------------------------------------------------------------
+ // Policy traces (immutable .bin)
+ // -------------------------------------------------------------------------
+
/* default */ Path policyTracePath(final PkiId decisionId) {
+ Objects.requireNonNull(decisionId, "decisionId");
return this.root.resolve("policy").resolve("by-decision").resolve(FsUtil.safeId(decisionId) + ".bin");
}
+ // -------------------------------------------------------------------------
+ // Publications (immutable .bin)
+ // -------------------------------------------------------------------------
+
/* default */ Path publicationPath(final PkiId publicationId) {
+ Objects.requireNonNull(publicationId, "publicationId");
return this.root.resolve("publications").resolve(BY_ID).resolve(FsUtil.safeId(publicationId) + ".bin");
}
+
+ // -------------------------------------------------------------------------
+ // Workflow state (mutable with history)
+ // -------------------------------------------------------------------------
+
+ /**
+ * Root directory for orchestrator workflow state records.
+ *
+ *
+ * Workflow state is updated over time (progression of a long-running
+ * operation). Therefore, it follows the same "mutable but auditable" pattern as
+ * CA records and profiles: {@code current.bin} and {@code history/}.
+ *
+ *
+ * @return workflows root directory (never {@code null})
+ */
+ /* default */ Path workflowRoot() {
+ return this.root.resolve("workflows").resolve("by-op");
+ }
+
+ /* default */ Path workflowDir(final PkiId opId) {
+ Objects.requireNonNull(opId, "opId");
+ return workflowRoot().resolve(FsUtil.safeId(opId));
+ }
+
+ /* default */ Path workflowCurrent(final PkiId opId) {
+ return workflowDir(opId).resolve(CURRENT_FILE);
+ }
+
+ /* default */ Path workflowHistoryDir(final PkiId opId) {
+ return workflowDir(opId).resolve(HISTORY_DIR);
+ }
}
diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsPkiStoreOptions.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsPkiStoreOptions.java
index cf53274..2f1a999 100644
--- a/pki/src/main/java/zeroecho/pki/impl/fs/FsPkiStoreOptions.java
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsPkiStoreOptions.java
@@ -39,38 +39,57 @@ import java.util.Objects;
import java.util.Optional;
/**
- * Configuration for {@link FilesystemPkiStore}.
+ * Options for {@link FilesystemPkiStore}.
*
*
- * This type is intentionally placed in an implementation package. It is not a
- * public PKI API contract. It exists to keep the {@code PkiStore} SPI stable,
- * while still allowing a professional-grade reference implementation to be
- * configured and tested.
+ * This options container configures the filesystem store behavior while keeping
+ * the {@code PkiStore} SPI stable. All options have deterministic semantics and
+ * should be suitable for tests and for reference deployments.
*
*
+ * History tracking
*
- * All options have deterministic semantics.
+ * Some records are mutable and are therefore stored as {@code current.bin} with
+ * optional historical snapshots under {@code history/}. The individual history
+ * policies allow tuning the retention behavior without changing the store
+ * contract.
*
+ *
+ * @param caHistoryPolicy history policy for CA records
+ * @param profileHistoryPolicy history policy for certificate profiles
+ * @param revocationHistoryPolicy history policy for revocation records
+ * @param workflowHistoryPolicy history policy for workflow continuation state
+ * @param strictSnapshotExport whether snapshot export is strict
+ * (fail-closed)
*/
public record FsPkiStoreOptions(FsHistoryPolicy caHistoryPolicy, FsHistoryPolicy profileHistoryPolicy,
- FsHistoryPolicy revocationHistoryPolicy, boolean strictSnapshotExport) {
+ FsHistoryPolicy revocationHistoryPolicy, FsHistoryPolicy workflowHistoryPolicy, boolean strictSnapshotExport) {
/**
* Canonical constructor with validation.
+ *
+ * @throws NullPointerException if any history policy is {@code null}
*/
public FsPkiStoreOptions {
Objects.requireNonNull(caHistoryPolicy, "caHistoryPolicy");
Objects.requireNonNull(profileHistoryPolicy, "profileHistoryPolicy");
Objects.requireNonNull(revocationHistoryPolicy, "revocationHistoryPolicy");
+ Objects.requireNonNull(workflowHistoryPolicy, "workflowHistoryPolicy");
}
/**
* Returns default options (history enabled with 90 days retention).
*
+ *
+ * The default is suitable for a reference deployment and for integration tests,
+ * but production systems may tune retention windows and cleanup strategies
+ * based on operational requirements.
+ *
+ *
* @return default options
*/
public static FsPkiStoreOptions defaults() {
FsHistoryPolicy ninetyDays = FsHistoryPolicy.onWrite(Optional.of(Duration.ofDays(90)));
- return new FsPkiStoreOptions(ninetyDays, ninetyDays, ninetyDays, true);
+ return new FsPkiStoreOptions(ninetyDays, ninetyDays, ninetyDays, ninetyDays, true);
}
}
diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java
index d130778..2944e69 100644
--- a/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java
@@ -98,6 +98,9 @@ final class FsSnapshotExporter {
this.options.profileHistoryPolicy(), this.options.strictSnapshotExport());
reconstructMutableTree(sourceRoot.resolve("revocations"), targetRoot.resolve("revocations"), at,
this.options.revocationHistoryPolicy(), this.options.strictSnapshotExport());
+ // reconstruct workflow continuation state from history
+ reconstructMutableTree(sourceRoot.resolve("workflows"), targetRoot.resolve("workflows"), at,
+ this.options.workflowHistoryPolicy(), this.options.strictSnapshotExport());
} catch (IOException e) {
throw new IllegalStateException("snapshot export failed", e);
diff --git a/pki/src/main/java/zeroecho/pki/spi/async/AsyncBusProvider.java b/pki/src/main/java/zeroecho/pki/spi/async/AsyncBusProvider.java
new file mode 100644
index 0000000..a73e3b8
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/spi/async/AsyncBusProvider.java
@@ -0,0 +1,72 @@
+/*******************************************************************************
+ * Copyright (C) 2025, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.pki.spi.async;
+
+import zeroecho.pki.api.PkiId;
+import zeroecho.pki.api.audit.Principal;
+import zeroecho.pki.spi.ConfigurableProvider;
+import zeroecho.pki.util.async.AsyncBus;
+
+/**
+ * ServiceLoader provider for the PKI asynchronous operation bus.
+ *
+ *
+ * The underlying bus implementation is generic and reusable (see
+ * {@code zeroecho.pki.util.async}). This SPI publishes a PKI-specific type
+ * binding to ensure provider implementations remain strictly typed and do not
+ * require unsafe casts.
+ *
+ *
+ *
+ * Type parameters used by PKI:
+ *
+ *
+ * Operation id: {@link PkiId}
+ * Owner: {@link Principal}
+ * Endpoint id: {@link String}
+ * Result: {@link Object} (intentionally not persisted by the default
+ * provider)
+ *
+ *
+ *
+ * Result type is {@code Object} because this layer does not prescribe how
+ * results are stored. In the recommended architecture, results (certificates,
+ * bundles, exports) are stored in PKI stores and the async bus carries only
+ * status plus references, not payload.
+ *
+ */
+public interface AsyncBusProvider extends ConfigurableProvider> {
+ // marker
+}
diff --git a/pki/src/main/java/zeroecho/pki/spi/async/package-info.java b/pki/src/main/java/zeroecho/pki/spi/async/package-info.java
new file mode 100644
index 0000000..17bd562
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/spi/async/package-info.java
@@ -0,0 +1,57 @@
+/*******************************************************************************
+ * Copyright (C) 2025, 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.
+ ******************************************************************************/
+/**
+ * Asynchronous operation bus SPI.
+ *
+ *
+ * This package defines the ServiceLoader boundary for selecting an async bus
+ * implementation used by PKI subsystems (issuance, publishing, approval flows).
+ * The bus itself is generic in the util layer, but this SPI publishes a
+ * concrete type binding for the PKI module to avoid unsafe casts.
+ *
+ *
+ * Selection
+ *
+ * {@code -Dzeroecho.pki.async=<id>}
+ * {@code -Dzeroecho.pki.async.<key>=<value>}
+ *
+ *
+ * Security
+ *
+ * Provider configuration values may be sensitive and must not be logged. Only
+ * provider ids and configuration keys are suitable for diagnostics.
+ *
+ */
+package zeroecho.pki.spi.async;
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 f4cac7c..331141e 100644
--- a/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java
+++ b/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java
@@ -40,14 +40,18 @@ import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
+import zeroecho.pki.api.PkiId;
+import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.spi.ConfigurableProvider;
import zeroecho.pki.spi.ProviderConfig;
+import zeroecho.pki.spi.async.AsyncBusProvider;
import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.spi.audit.AuditSinkProvider;
import zeroecho.pki.spi.crypto.SignatureWorkflow;
import zeroecho.pki.spi.crypto.SignatureWorkflowProvider;
import zeroecho.pki.spi.store.PkiStore;
import zeroecho.pki.spi.store.PkiStoreProvider;
+import zeroecho.pki.util.async.AsyncBus;
/**
* PKI bootstrap utilities for ServiceLoader-based components.
@@ -56,7 +60,7 @@ import zeroecho.pki.spi.store.PkiStoreProvider;
* This class provides deterministic selection and instantiation rules for
* components discovered via {@link java.util.ServiceLoader}. It is designed to
* scale as more SPIs are introduced (audit, publish, framework integrations,
- * crypto/workflows, etc.).
+ * crypto/workflows, async orchestration, etc.).
*
*
* System property conventions
@@ -71,6 +75,9 @@ import zeroecho.pki.spi.store.PkiStoreProvider;
* {@code -Dzeroecho.pki.crypto.workflow=<id>}
* Configure crypto workflow provider:
* {@code -Dzeroecho.pki.crypto.workflow.<key>=<value>}
+ * Select async bus provider: {@code -Dzeroecho.pki.async=<id>}
+ * Configure async bus provider:
+ * {@code -Dzeroecho.pki.async.<key>=<value>}
*
*
*
@@ -91,6 +98,9 @@ public final class PkiBootstrap {
private static final String PROP_CRYPTO_WORKFLOW_BACKEND = "zeroecho.pki.crypto.workflow";
private static final String PROP_CRYPTO_WORKFLOW_PREFIX = "zeroecho.pki.crypto.workflow.";
+ private static final String PROP_ASYNC_BACKEND = "zeroecho.pki.async";
+ private static final String PROP_ASYNC_PREFIX = "zeroecho.pki.async.";
+
private PkiBootstrap() {
throw new AssertionError("No instances.");
}
@@ -117,13 +127,7 @@ public final class PkiBootstrap {
* are never logged; only keys may be logged.
*
*
- *
- * Defaulting rules implemented by this bootstrap (policy, not SPI requirement):
- * for {@code fs} provider, if {@code root} is not specified, defaults to
- * {@code "pki-store"} relative to the working directory.
- *
- *
- * @return opened store (never {@code null})
+ * @return store (never {@code null})
*/
public static PkiStore openStore() {
String requestedId = System.getProperty(PROP_STORE_BACKEND);
@@ -151,40 +155,11 @@ public final class PkiBootstrap {
return provider.allocate(config);
}
- /**
- * Logs provider help information (supported keys) for diagnostics.
- *
- *
- * This method is safe: it does not log configuration values.
- *
- *
- * @param provider provider (never {@code null})
- */
- public static void logSupportedKeys(ConfigurableProvider provider) {
- Objects.requireNonNull(provider, "provider");
- if (LOG.isLoggable(Level.FINE)) {
- LOG.fine("Provider '" + provider.id() + "' supports keys: " + provider.supportedKeys());
- }
- }
-
/**
* Opens an {@link AuditSink} using {@link AuditSinkProvider} discovered via
* ServiceLoader.
*
- *
- * Selection and configuration follow the same conventions as
- * {@link #openStore()}, using {@code -Dzeroecho.pki.audit=<id>} and
- * {@code zeroecho.pki.audit.} prefixed properties.
- *
- *
- *
- * Defaulting rules implemented by this bootstrap (policy, not SPI requirement):
- * if no audit backend is specified, defaults to {@code stdout}. For
- * {@code file} provider, if {@code root} is not specified, defaults to
- * {@code "pki-audit"} relative to the working directory.
- *
- *
- * @return opened audit sink (never {@code null})
+ * @return audit sink (never {@code null})
*/
public static AuditSink openAudit() {
String requestedId = System.getProperty(PROP_AUDIT_BACKEND);
@@ -220,21 +195,7 @@ public final class PkiBootstrap {
* Opens a {@link SignatureWorkflow} using {@link SignatureWorkflowProvider}
* discovered via ServiceLoader.
*
- *
- * Conventions:
- *
- *
- * Select provider: {@code -Dzeroecho.pki.crypto.workflow=<id>}
- * Provider config:
- * {@code -Dzeroecho.pki.crypto.workflow.<key>=<value>}
- *
- *
- *
- * Security note: configuration values may be sensitive and must not be logged.
- * This bootstrap logs only provider ids and configuration keys.
- *
- *
- * @return opened signature workflow (never {@code null})
+ * @return signature workflow (never {@code null})
*/
public static SignatureWorkflow openSignatureWorkflow() {
String requestedId = System.getProperty(PROP_CRYPTO_WORKFLOW_BACKEND);
@@ -248,6 +209,7 @@ public final class PkiBootstrap {
});
Map props = SpiSystemProperties.readPrefixed(PROP_CRYPTO_WORKFLOW_PREFIX);
+
ProviderConfig config = new ProviderConfig(provider.id(), props);
if (LOG.isLoggable(Level.INFO)) {
@@ -256,4 +218,66 @@ public final class PkiBootstrap {
return provider.allocate(config);
}
+
+ /**
+ * Opens an async operation bus using {@link AsyncBusProvider} discovered via
+ * ServiceLoader.
+ *
+ *
+ * Defaulting rule: if no backend id is specified, defaults to {@code file}.
+ *
+ *
+ *
+ * Configuration properties are read from {@link System#getProperties()} using
+ * the prefix {@code zeroecho.pki.async.}. Values are treated as sensitive and
+ * are never logged; only keys may be logged.
+ *
+ *
+ * @return async bus (never {@code null})
+ */
+ public static AsyncBus openAsyncBus() {
+ String requestedId = System.getProperty(PROP_ASYNC_BACKEND);
+
+ if (requestedId == null) {
+ requestedId = "file";
+ }
+
+ AsyncBusProvider provider = SpiSelector.select(AsyncBusProvider.class, requestedId,
+ new SpiSelector.ProviderId<>() {
+ @Override
+ public String id(AsyncBusProvider p) {
+ return p.id();
+ }
+ });
+
+ Map props = SpiSystemProperties.readPrefixed(PROP_ASYNC_PREFIX);
+
+ if ("file".equals(provider.id()) && !props.containsKey("logPath")) {
+ props.put("logPath", Path.of("pki-async").resolve("async.log").toString());
+ }
+
+ ProviderConfig config = new ProviderConfig(provider.id(), props);
+
+ if (LOG.isLoggable(Level.INFO)) {
+ LOG.info("Selected async bus provider: " + provider.id() + " (keys: " + props.keySet() + ")");
+ }
+
+ return provider.allocate(config);
+ }
+
+ /**
+ * Logs provider help information (supported keys) for diagnostics.
+ *
+ *
+ * This method is safe: it does not log configuration values.
+ *
+ *
+ * @param provider provider (never {@code null})
+ */
+ public static void logSupportedKeys(ConfigurableProvider provider) {
+ Objects.requireNonNull(provider, "provider");
+ if (LOG.isLoggable(Level.FINE)) {
+ LOG.fine("Provider '" + provider.id() + "' supports keys: " + provider.supportedKeys());
+ }
+ }
}
diff --git a/pki/src/main/java/zeroecho/pki/spi/framework/CredentialFrameworkProvider.java b/pki/src/main/java/zeroecho/pki/spi/framework/CredentialFrameworkProvider.java
new file mode 100644
index 0000000..0f0a6fa
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/spi/framework/CredentialFrameworkProvider.java
@@ -0,0 +1,102 @@
+/*******************************************************************************
+ * Copyright (C) 2025, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.pki.spi.framework;
+
+import java.util.Objects;
+
+import zeroecho.pki.spi.ConfigurableProvider;
+import zeroecho.pki.spi.ProviderConfig;
+
+/**
+ * Service provider for {@link CredentialFramework} instances.
+ *
+ *
+ * The PKI runtime selects a framework provider using
+ * {@link java.util.ServiceLoader} and instantiates it through
+ * {@link #allocate(ProviderConfig)}. Provider selection is performed by
+ * {@code PkiBootstrap} using configuration properties (similarly to store,
+ * audit, async bus, and signature workflow providers).
+ *
+ *
+ * Dependency boundary
+ *
+ * Framework implementations may internally rely on third-party toolkits (for
+ * example Bouncy Castle), but such dependencies must not leak through this SPI.
+ * The SPI must remain stable and independent of a particular cryptographic
+ * provider.
+ *
+ */
+public interface CredentialFrameworkProvider extends ConfigurableProvider {
+
+ /**
+ * Allocates a credential framework instance using the provided configuration.
+ *
+ *
+ * Implementations must validate that {@link ProviderConfig#backendId()} matches
+ * {@link #id()}. A mismatch must be reported as
+ * {@link IllegalArgumentException}.
+ *
+ *
+ * @param config provider configuration (never {@code null})
+ * @return allocated framework (never {@code null})
+ * @throws NullPointerException if {@code config} is {@code null}
+ * @throws IllegalArgumentException if {@code config.backendId()} does not match
+ * {@link #id()}
+ * @throws RuntimeException if allocation fails
+ */
+ @Override
+ CredentialFramework allocate(ProviderConfig config);
+
+ /**
+ * Enforces that the provided configuration is intended for this provider.
+ *
+ *
+ * This helper is intended for defensive checks inside provider implementations.
+ *
+ *
+ * @param provider provider instance (never {@code null})
+ * @param config provider configuration (never {@code null})
+ * @throws NullPointerException if any argument is {@code null}
+ * @throws IllegalArgumentException if the backend id does not match
+ */
+ static void requireIdMatch(final CredentialFrameworkProvider provider, final ProviderConfig config) {
+ Objects.requireNonNull(provider, "provider");
+ Objects.requireNonNull(config, "config");
+
+ if (!provider.id().equals(config.backendId())) {
+ throw new IllegalArgumentException("ProviderConfig backendId mismatch.");
+ }
+ }
+}
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 a02f6d5..01dd288 100644
--- a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java
+++ b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java
@@ -40,6 +40,7 @@ import java.util.Optional;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.credential.Credential;
+import zeroecho.pki.api.orch.WorkflowStateRecord;
import zeroecho.pki.api.policy.PolicyTrace;
import zeroecho.pki.api.profile.CertificateProfile;
import zeroecho.pki.api.publication.PublicationRecord;
@@ -48,31 +49,27 @@ import zeroecho.pki.api.revocation.RevokedRecord;
import zeroecho.pki.api.status.StatusObject;
/**
- * Persistence abstraction for PKI state.
+ * Persistence boundary for PKI state.
*
*
- * This Service Provider Interface (SPI) defines the authoritative storage
- * contract for all PKI-managed state, including CA entities, issued
- * credentials, certification requests, revocations, status objects, profiles,
- * publications, and policy traces.
+ * This Service Provider Interface (SPI) defines durable storage for the PKI
+ * domain model. Implementations may use a filesystem, database, or other
+ * backend, but must preserve deterministic behavior, atomicity guarantees, and
+ * must not leak secrets through logging or exception messages.
*
*
+ * Security
*
- * The interface is intentionally coarse-grained and framework-agnostic.
- * Implementations are responsible for providing appropriate durability,
- * consistency, and concurrency guarantees according to the deployment model
- * (filesystem, embedded storage, RDBMS, distributed store, etc.).
+ * Implementations must protect persisted data appropriately (for example:
+ * filesystem permissions, database ACLs). Sensitive material must never be
+ * written to logs. Exceptions should avoid leaking secrets in their messages.
*
*
+ * Error handling
*
- * Security requirements:
+ * This SPI uses unchecked failures. Implementations should throw
+ * {@link IllegalStateException} when an operation cannot be completed safely.
*
- *
- * Private key material MUST NOT be persisted.
- * Secrets must never be stored in cleartext.
- * Stored objects must be treated as immutable unless explicitly
- * replaced.
- *
*/
public interface PkiStore {
@@ -80,62 +77,56 @@ public interface PkiStore {
* Persists or updates a Certificate Authority (CA) record.
*
*
- * Implementations must ensure that CA records are stored atomically. Replacing
- * an existing CA record must preserve historical integrity (e.g., previously
- * issued credentials must remain resolvable).
+ * Implementations must store CA records atomically. Replacing an existing
+ * record should be either fully visible or not visible at all.
*
*
- * @param record CA record to persist
- * @throws IllegalArgumentException if {@code record} is null
- * @throws RuntimeException if persistence fails
+ * @param record CA record (never {@code null})
+ * @throws NullPointerException if {@code record} is {@code null}
+ * @throws IllegalStateException if persistence fails
*/
void putCa(CaRecord record);
/**
- * Retrieves a CA record by its identifier.
+ * Retrieves a CA record.
*
- * @param caId CA identifier
- * @return CA record if present, otherwise {@link Optional#empty()}
- * @throws IllegalArgumentException if {@code caId} is null
- * @throws RuntimeException if retrieval fails
+ * @param caId CA identifier (never {@code null})
+ * @return CA record if present
+ * @throws NullPointerException if {@code caId} is {@code null}
+ * @throws IllegalStateException if retrieval fails
*/
Optional getCa(PkiId caId);
/**
- * Lists all CA records known to the store.
+ * Lists all stored CA records.
*
- *
- * No filtering is applied at this level; higher layers are expected to perform
- * query-based filtering.
- *
- *
- * @return list of CA records (never null)
- * @throws RuntimeException if listing fails
+ * @return list of CA records (never {@code null})
+ * @throws IllegalStateException if listing fails
*/
List listCas();
/**
- * Persists an issued credential.
+ * Persists a credential record.
*
*
- * Credentials are immutable once stored. Re-inserting an existing credential
- * identifier should either be idempotent or rejected, depending on
- * implementation policy.
+ * Credentials are typically immutable once issued. Implementations must ensure
+ * that storing a credential is atomic and that the record remains retrievable
+ * for its entire retention period.
*
*
- * @param credential credential to persist
- * @throws IllegalArgumentException if {@code credential} is null
- * @throws RuntimeException if persistence fails
+ * @param credential credential record (never {@code null})
+ * @throws NullPointerException if {@code credential} is {@code null}
+ * @throws IllegalStateException if persistence fails
*/
void putCredential(Credential credential);
/**
- * Retrieves an issued credential by its identifier.
+ * Retrieves a credential record.
*
- * @param credentialId credential identifier
- * @return credential if present
- * @throws IllegalArgumentException if {@code credentialId} is null
- * @throws RuntimeException if retrieval fails
+ * @param credentialId credential identifier (never {@code null})
+ * @return credential record if present
+ * @throws NullPointerException if {@code credentialId} is {@code null}
+ * @throws IllegalStateException if retrieval fails
*/
Optional getCredential(PkiId credentialId);
@@ -143,162 +134,233 @@ public interface PkiStore {
* Persists a parsed certification request.
*
*
- * Stored requests are used for audit, correlation, re-issuance, and ACME-like
- * workflows.
+ * Requests are typically immutable artifacts. Implementations should treat them
+ * as write-once records unless there is a clear need to support updates.
*
*
- * @param request parsed certification request
- * @throws IllegalArgumentException if {@code request} is null
- * @throws RuntimeException if persistence fails
+ * @param request parsed request (never {@code null})
+ * @throws NullPointerException if {@code request} is {@code null}
+ * @throws IllegalStateException if persistence fails
*/
void putRequest(ParsedCertificationRequest request);
/**
- * Retrieves a stored certification request.
+ * Retrieves a parsed certification request.
*
- * @param requestId request identifier
+ * @param requestId request identifier (never {@code null})
* @return parsed request if present
- * @throws IllegalArgumentException if {@code requestId} is null
- * @throws RuntimeException if retrieval fails
+ * @throws NullPointerException if {@code requestId} is {@code null}
+ * @throws IllegalStateException if retrieval fails
*/
Optional getRequest(PkiId requestId);
/**
- * Persists a revocation record.
+ * Persists or updates a revocation record.
*
- *
- * Revocation records are authoritative inputs for generating revocation status
- * objects (CRLs, OCSP, etc.).
- *
- *
- * @param record revocation record
- * @throws IllegalArgumentException if {@code record} is null
- * @throws RuntimeException if persistence fails
+ * @param record revocation record (never {@code null})
+ * @throws NullPointerException if {@code record} is {@code null}
+ * @throws IllegalStateException if persistence fails
*/
void putRevocation(RevokedRecord record);
/**
- * Retrieves the revocation record for a credential.
+ * Retrieves a revocation record for a given credential.
*
- * @param credentialId credential identifier
+ * @param credentialId credential identifier (never {@code null})
* @return revocation record if present
- * @throws IllegalArgumentException if {@code credentialId} is null
- * @throws RuntimeException if retrieval fails
+ * @throws NullPointerException if {@code credentialId} is {@code null}
+ * @throws IllegalStateException if retrieval fails
*/
Optional getRevocation(PkiId credentialId);
/**
* Lists all revocation records.
*
- * @return list of revocation records (never null)
- * @throws RuntimeException if listing fails
+ * @return list of revocation records (never {@code null})
+ * @throws IllegalStateException if listing fails
*/
List listRevocations();
/**
- * Persists a generated status object.
+ * Persists a status object.
*
*
- * Status objects include CRLs, delta CRLs, OCSP responses, or
- * framework-specific revocation lists.
+ * Status objects are typically published artifacts (for example OCSP responses,
+ * CRLs, or other status representations) and are usually immutable once
+ * created.
*
*
- * @param object status object to persist
- * @throws IllegalArgumentException if {@code object} is null
- * @throws RuntimeException if persistence fails
+ * @param object status object (never {@code null})
+ * @throws NullPointerException if {@code object} is {@code null}
+ * @throws IllegalStateException if persistence fails
*/
void putStatusObject(StatusObject object);
/**
- * Retrieves a status object by its identifier.
+ * Retrieves a status object.
*
- * @param statusObjectId status object identifier
+ * @param statusObjectId status object identifier (never {@code null})
* @return status object if present
- * @throws IllegalArgumentException if {@code statusObjectId} is null
- * @throws RuntimeException if retrieval fails
+ * @throws NullPointerException if {@code statusObjectId} is {@code null}
+ * @throws IllegalStateException if retrieval fails
*/
Optional getStatusObject(PkiId statusObjectId);
/**
- * Lists all status objects issued by a given CA.
+ * Lists status objects issued under a specific issuer CA.
*
- * @param issuerCaId issuer CA identifier
- * @return list of status objects (never null)
- * @throws IllegalArgumentException if {@code issuerCaId} is null
- * @throws RuntimeException if listing fails
+ *
+ * This method exists to support issuer-scoped publication and retrieval
+ * patterns (for example: listing all CRLs or other status artifacts produced by
+ * a given CA).
+ *
+ *
+ * @param issuerCaId issuer CA identifier (never {@code null})
+ * @return list of status objects for the issuer (never {@code null})
+ * @throws NullPointerException if {@code issuerCaId} is {@code null}
+ * @throws IllegalStateException if listing fails
*/
List listStatusObjects(PkiId issuerCaId);
/**
- * Persists a publication record.
+ * Persists or updates a publication record.
*
*
- * Publication records provide traceability and operational diagnostics for
- * artifact distribution.
+ * Publication records describe distribution state (for example: where and when
+ * an object was published). These records may be used for operational
+ * monitoring and reconciliation.
*
*
- * @param record publication record
- * @throws IllegalArgumentException if {@code record} is null
- * @throws RuntimeException if persistence fails
+ * @param record publication record (never {@code null})
+ * @throws NullPointerException if {@code record} is {@code null}
+ * @throws IllegalStateException if persistence fails
*/
void putPublicationRecord(PublicationRecord record);
/**
* Lists all publication records.
*
- * @return list of publication records (never null)
- * @throws RuntimeException if listing fails
+ * @return list of publication records (never {@code null})
+ * @throws IllegalStateException if listing fails
*/
List listPublicationRecords();
/**
* Persists or updates a certificate profile.
*
- * @param profile certificate profile
- * @throws IllegalArgumentException if {@code profile} is null
- * @throws RuntimeException if persistence fails
+ *
+ * A certificate profile represents a reusable issuance template (for example:
+ * VPN client, VPN server, S/MIME). The profile may be referenced by higher
+ * layers during certificate issuance.
+ *
+ *
+ * @param profile certificate profile (never {@code null})
+ * @throws NullPointerException if {@code profile} is {@code null}
+ * @throws IllegalStateException if persistence fails
*/
void putProfile(CertificateProfile profile);
/**
- * Retrieves a certificate profile by identifier.
+ * Retrieves a certificate profile by profile identifier.
*
- * @param profileId profile identifier
+ *
+ * The identifier is a stable, system-defined key (not a display name). It
+ * should be suitable for configuration and API use (for example:
+ * {@code "vpn-client"}).
+ *
+ *
+ * @param profileId profile identifier (never {@code null})
* @return profile if present
- * @throws IllegalArgumentException if {@code profileId} is null or blank
- * @throws RuntimeException if retrieval fails
+ * @throws NullPointerException if {@code profileId} is {@code null}
+ * @throws IllegalStateException if retrieval fails
*/
Optional getProfile(String profileId);
/**
* Lists all stored certificate profiles.
*
- * @return list of profiles (never null)
- * @throws RuntimeException if listing fails
+ * @return list of profiles (never {@code null})
+ * @throws IllegalStateException if listing fails
*/
List listProfiles();
/**
- * Persists a policy evaluation trace.
+ * Persists a policy trace.
*
*
- * Policy traces are used for explainability, audit, and compliance evidence.
- * They must never contain sensitive data.
+ * Policy traces capture decision-making and evaluation information. These
+ * records may contain sensitive meta-information and must be protected by the
+ * underlying store.
*
*
- * @param trace policy trace
- * @throws IllegalArgumentException if {@code trace} is null
- * @throws RuntimeException if persistence fails
+ * @param trace policy trace (never {@code null})
+ * @throws NullPointerException if {@code trace} is {@code null}
+ * @throws IllegalStateException if persistence fails
*/
void putPolicyTrace(PolicyTrace trace);
/**
- * Retrieves a policy trace by decision identifier.
+ * Retrieves a policy trace.
*
- * @param decisionId policy decision identifier
+ * @param decisionId policy decision identifier (never {@code null})
* @return policy trace if present
- * @throws IllegalArgumentException if {@code decisionId} is null
- * @throws RuntimeException if retrieval fails
+ * @throws NullPointerException if {@code decisionId} is {@code null}
+ * @throws IllegalStateException if retrieval fails
*/
Optional getPolicyTrace(PkiId decisionId);
+
+ // -------------------------------------------------------------------------
+ // Workflow continuation state (orchestration)
+ // -------------------------------------------------------------------------
+
+ /**
+ * Persists or updates an orchestrator workflow continuation record.
+ *
+ *
+ * The workflow payload may be encrypted. Implementations must treat it as
+ * sensitive and must not log it.
+ *
+ *
+ * @param record workflow state record (never {@code null})
+ * @throws NullPointerException if {@code record} is {@code null}
+ * @throws IllegalStateException if persistence fails
+ */
+ void putWorkflowState(WorkflowStateRecord record);
+
+ /**
+ * Retrieves workflow continuation state for a given operation.
+ *
+ * @param opId operation identifier (never {@code null})
+ * @return workflow state record if present
+ * @throws NullPointerException if {@code opId} is {@code null}
+ * @throws IllegalStateException if retrieval fails
+ */
+ Optional getWorkflowState(PkiId opId);
+
+ /**
+ * Deletes workflow continuation state for a given operation.
+ *
+ *
+ * Implementations may retain historical snapshots according to their history
+ * policy. Deletion guarantees only removal of the current record.
+ *
+ *
+ * @param opId operation identifier (never {@code null})
+ * @throws NullPointerException if {@code opId} is {@code null}
+ * @throws IllegalStateException if deletion fails
+ */
+ void deleteWorkflowState(PkiId opId);
+
+ /**
+ * Lists workflow continuation state records.
+ *
+ *
+ * This method is intended for administrative reconciliation and recovery.
+ * Higher layers should apply access control and filtering as appropriate.
+ *
+ *
+ * @return list of workflow state records (never {@code null})
+ * @throws IllegalStateException if listing fails
+ */
+ List listWorkflowStates();
}
diff --git a/pki/src/main/java/zeroecho/pki/util/PkiIds.java b/pki/src/main/java/zeroecho/pki/util/PkiIds.java
new file mode 100644
index 0000000..f0fd4e6
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/util/PkiIds.java
@@ -0,0 +1,88 @@
+/*******************************************************************************
+ * Copyright (C) 2025, 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.util;
+
+import java.util.Objects;
+import java.util.UUID;
+
+import zeroecho.pki.api.PkiId;
+
+/**
+ * Utility methods for generating and validating {@link PkiId} values.
+ *
+ *
+ * The PKI subsystem uses {@link PkiId} as a stable identifier for domain
+ * records and long-running operations. This helper centralizes ID generation to
+ * avoid ad-hoc implementations scattered across providers and orchestration
+ * code.
+ *
+ *
+ * Security
+ *
+ * Generated identifiers must not embed secrets. The default generator uses a
+ * random UUID which is suitable for correlation and storage keys but does not
+ * reveal secret material.
+ *
+ */
+public final class PkiIds {
+
+ private PkiIds() {
+ throw new AssertionError("No instances.");
+ }
+
+ /**
+ * Generates a new random PKI identifier.
+ *
+ *
+ * The returned identifier is based on {@link UUID#randomUUID()}.
+ *
+ *
+ * @return new identifier (never {@code null})
+ */
+ public static PkiId newRandomId() {
+ return new PkiId(UUID.randomUUID().toString());
+ }
+
+ /**
+ * Validates that the provided identifier is non-null.
+ *
+ * @param id identifier to validate
+ * @return the same identifier for fluent usage
+ * @throws IllegalArgumentException if {@code id} is {@code null}
+ */
+ public static PkiId require(PkiId id) {
+ return Objects.requireNonNull(id, "id");
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/util/async/AsyncBus.java b/pki/src/main/java/zeroecho/pki/util/async/AsyncBus.java
new file mode 100644
index 0000000..7461f51
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/util/async/AsyncBus.java
@@ -0,0 +1,156 @@
+/*******************************************************************************
+ * Copyright (C) 2025, 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.util.async;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Optional;
+
+/**
+ * Generic asynchronous operation bus.
+ *
+ *
+ * The bus is responsible for:
+ *
+ *
+ * tracking operation metadata and status transitions,
+ * optional durability (implementation-defined),
+ * dispatching events to registered handlers,
+ * expiring operations that exceeded their deadline,
+ * forgetting completed results once retrieved (to avoid unbounded
+ * growth).
+ *
+ *
+ * Result retention
+ *
+ * Results are retained until explicitly retrieved (or expired by retention
+ * policy). Implementations must remove stored results once
+ * {@link #consumeResult(Object)} returns a value. This ensures bounded memory
+ * usage.
+ *
+ *
+ * @param operation id type
+ * @param owner type
+ * @param endpoint id type
+ * @param result type
+ */
+public interface AsyncBus {
+
+ /**
+ * Registers an endpoint instance under an identifier.
+ *
+ *
+ * Registration must be performed by the endpoint itself during construction or
+ * activation, not by bootstrap code, to avoid tight coupling.
+ *
+ *
+ * @param endpointId endpoint id (never {@code null})
+ * @param endpoint endpoint instance (never {@code null})
+ */
+ void registerEndpoint(EndpointId endpointId, AsyncEndpoint endpoint);
+
+ /**
+ * Subscribes a handler to status change events.
+ *
+ * @param handler handler (never {@code null})
+ * @return registration handle (never {@code null})
+ */
+ AsyncRegistration subscribe(AsyncHandler handler);
+
+ /**
+ * Creates a new operation record and sets initial status to
+ * {@link AsyncState#SUBMITTED}.
+ *
+ * @param opId operation id (never {@code null})
+ * @param type operation type (never blank)
+ * @param owner owner (never {@code null})
+ * @param endpointId endpoint id (never {@code null})
+ * @param createdAt creation time (never {@code null})
+ * @param ttl time-to-live (must be positive)
+ * @return created snapshot (never {@code null})
+ */
+ AsyncOperationSnapshot submit(OpId opId, String type, Owner owner, EndpointId endpointId,
+ Instant createdAt, Duration ttl);
+
+ /**
+ * Updates status and optional result for an operation.
+ *
+ *
+ * Implementations must persist the transition before dispatching events.
+ *
+ *
+ * @param opId operation id (never {@code null})
+ * @param status new status (never {@code null})
+ * @param result optional result (never {@code null})
+ */
+ void update(OpId opId, AsyncStatus status, Optional result);
+
+ /**
+ * Retrieves the latest known status for an operation.
+ *
+ * @param opId operation id (never {@code null})
+ * @return status if known
+ */
+ Optional status(OpId opId);
+
+ /**
+ * Returns immutable operation metadata if present.
+ *
+ * @param opId operation id (never {@code null})
+ * @return snapshot if known
+ */
+ Optional> snapshot(OpId opId);
+
+ /**
+ * Consumes the stored result if present and removes it from the bus.
+ *
+ * @param opId operation id (never {@code null})
+ * @return result if present (and then removed)
+ */
+ Optional consumeResult(OpId opId);
+
+ /**
+ * Performs periodic maintenance:
+ *
+ *
+ * expires operations past their deadline,
+ * polls endpoints for open operations (restart recovery),
+ * dispatches any discovered transitions.
+ *
+ *
+ * @param now current time (never {@code null})
+ */
+ void sweep(Instant now);
+}
diff --git a/pki/src/main/java/zeroecho/pki/util/async/AsyncEndpoint.java b/pki/src/main/java/zeroecho/pki/util/async/AsyncEndpoint.java
new file mode 100644
index 0000000..c62ddc8
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/util/async/AsyncEndpoint.java
@@ -0,0 +1,85 @@
+/*******************************************************************************
+ * Copyright (C) 2025, 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.util.async;
+
+import java.time.Instant;
+import java.util.Optional;
+
+/**
+ * Target endpoint that owns/executes operations and can be asked for their
+ * status.
+ *
+ *
+ * The bus uses endpoints to:
+ *
+ *
+ * poll open operations on {@link AsyncBus#sweep(Instant)} (restart
+ * recovery),
+ * deliver status/result updates via
+ * {@link AsyncBus#update(Object, AsyncStatus, Optional)}.
+ *
+ *
+ * @param operation id type
+ * @param result type
+ */
+public interface AsyncEndpoint {
+
+ /**
+ * Returns a best-effort current status for a known operation.
+ *
+ *
+ * Endpoints should return a terminal status if the operation is complete. If
+ * unknown, endpoints may return an empty optional.
+ *
+ *
+ * @param opId operation id (never {@code null})
+ * @return status if known
+ */
+ Optional status(OpId opId);
+
+ /**
+ * Returns a best-effort result for a successful operation.
+ *
+ *
+ * Endpoints should return a value only if the operation has succeeded and the
+ * result is retrievable. If results are stored elsewhere, this may return
+ * empty.
+ *
+ *
+ * @param opId operation id (never {@code null})
+ * @return result if available
+ */
+ Optional result(OpId opId);
+}
diff --git a/pki/src/main/java/zeroecho/pki/util/async/AsyncEvent.java b/pki/src/main/java/zeroecho/pki/util/async/AsyncEvent.java
new file mode 100644
index 0000000..09b40ff
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/util/async/AsyncEvent.java
@@ -0,0 +1,65 @@
+/*******************************************************************************
+ * Copyright (C) 2025, 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.util.async;
+
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Event delivered to registered handlers whenever an operation status changes.
+ *
+ * @param operation identifier type
+ * @param operation owner type
+ * @param endpoint identifier type
+ * @param result type
+ * @param snapshot immutable operation metadata (never {@code null})
+ * @param status new status (never {@code null})
+ * @param result optional result; present only when
+ * {@link AsyncStatus#state()} is {@link AsyncState#SUCCEEDED}
+ */
+public record AsyncEvent(AsyncOperationSnapshot snapshot,
+ AsyncStatus status, Optional result) {
+
+ /**
+ * Creates an event.
+ *
+ * @throws IllegalArgumentException if any required field is null
+ */
+ public AsyncEvent {
+ Objects.requireNonNull(snapshot, "snapshot");
+ Objects.requireNonNull(status, "status");
+ Objects.requireNonNull(result, "result");
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/util/async/AsyncHandler.java b/pki/src/main/java/zeroecho/pki/util/async/AsyncHandler.java
new file mode 100644
index 0000000..9e718b7
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/util/async/AsyncHandler.java
@@ -0,0 +1,60 @@
+/*******************************************************************************
+ * Copyright (C) 2025, 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.util.async;
+
+/**
+ * Callback handler for async operation events.
+ *
+ *
+ * Implementations must be exception-safe: the bus will treat any handler
+ * exception as a handler failure and will continue dispatching to other
+ * handlers.
+ *
+ *
+ * @param operation id type
+ * @param owner type
+ * @param endpoint id type
+ * @param result type
+ */
+@FunctionalInterface
+public interface AsyncHandler {
+
+ /**
+ * Receives an event notification.
+ *
+ * @param event event (never {@code null})
+ */
+ void onEvent(AsyncEvent event);
+}
diff --git a/pki/src/main/java/zeroecho/pki/util/async/AsyncOperationSnapshot.java b/pki/src/main/java/zeroecho/pki/util/async/AsyncOperationSnapshot.java
new file mode 100644
index 0000000..246e8eb
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/util/async/AsyncOperationSnapshot.java
@@ -0,0 +1,81 @@
+/*******************************************************************************
+ * Copyright (C) 2025, 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.util.async;
+
+import java.time.Instant;
+import java.util.Objects;
+
+/**
+ * Immutable snapshot of an operation's identity and routing metadata.
+ *
+ *
+ * This object intentionally does not contain secrets. Payloads should be stored
+ * outside the bus (or referenced indirectly).
+ *
+ *
+ * @param operation identifier type
+ * @param operation owner type
+ * @param endpoint identifier type
+ * @param opId operation id (never {@code null})
+ * @param type operation type string (never blank)
+ * @param owner operation owner (never {@code null})
+ * @param endpointId endpoint id (never {@code null})
+ * @param createdAt creation time (never {@code null})
+ * @param expiresAt expiration deadline (never {@code null})
+ */
+public record AsyncOperationSnapshot(OpId opId, String type, Owner owner,
+ EndpointId endpointId, Instant createdAt, Instant expiresAt) {
+
+ /**
+ * Creates a snapshot and validates invariants.
+ *
+ * @throws IllegalArgumentException if required fields are null/blank or expiry
+ * is invalid
+ */
+ public AsyncOperationSnapshot {
+ Objects.requireNonNull(opId, "opId");
+ Objects.requireNonNull(type, "type");
+ Objects.requireNonNull(owner, "owner");
+ Objects.requireNonNull(endpointId, "endpointId");
+ Objects.requireNonNull(createdAt, "createdAt");
+ Objects.requireNonNull(expiresAt, "expiresAt");
+ if (type.isBlank()) {
+ throw new IllegalArgumentException("type must not be blank");
+ }
+ if (!expiresAt.isAfter(createdAt)) {
+ throw new IllegalArgumentException("expiresAt must be after createdAt");
+ }
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/internal/package-info.java b/pki/src/main/java/zeroecho/pki/util/async/AsyncRegistration.java
similarity index 82%
rename from pki/src/main/java/zeroecho/pki/internal/package-info.java
rename to pki/src/main/java/zeroecho/pki/util/async/AsyncRegistration.java
index 04d2fe1..fe04867 100644
--- a/pki/src/main/java/zeroecho/pki/internal/package-info.java
+++ b/pki/src/main/java/zeroecho/pki/util/async/AsyncRegistration.java
@@ -32,7 +32,24 @@
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
+package zeroecho.pki.util.async;
+
+import java.io.Closeable;
+
/**
- *
+ * Registration handle for a handler subscription.
+ *
+ *
+ * Closing the handle unregisters the handler. Implementations must be
+ * idempotent.
+ *
*/
-package zeroecho.pki.internal;
\ No newline at end of file
+@SuppressWarnings("PMD.ImplicitFunctionalInterface")
+public interface AsyncRegistration extends Closeable {
+
+ /**
+ * Unregisters the handler.
+ */
+ @Override
+ void close();
+}
diff --git a/pki/src/main/java/zeroecho/pki/util/async/AsyncState.java b/pki/src/main/java/zeroecho/pki/util/async/AsyncState.java
new file mode 100644
index 0000000..5589b43
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/util/async/AsyncState.java
@@ -0,0 +1,109 @@
+/*******************************************************************************
+ * Copyright (C) 2025, 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.util.async;
+
+/**
+ * Lifecycle state of an asynchronous operation.
+ *
+ *
+ * The state machine is intentionally simple and audit-friendly. Implementations
+ * may attach additional non-sensitive detail through
+ * {@link AsyncStatus#detailCode()} and {@link AsyncStatus#details()}.
+ *
+ */
+public enum AsyncState {
+
+ /**
+ * The operation has been created and is awaiting processing.
+ *
+ *
+ * Example: a certificate issuance request has been accepted, but is waiting for
+ * an operator approval step.
+ *
+ */
+ SUBMITTED,
+
+ /**
+ * The operation is currently being processed.
+ *
+ *
+ * Example: an issuance request is being built, proof-of-possession verified, or
+ * a signing workflow is executing.
+ *
+ */
+ RUNNING,
+
+ /**
+ * The operation completed successfully and the result is available for
+ * retrieval.
+ *
+ *
+ * Example: a certificate (or bundle) has been issued and is ready to be
+ * fetched.
+ *
+ */
+ SUCCEEDED,
+
+ /**
+ * The operation completed unsuccessfully (terminal failure).
+ *
+ *
+ * Example: proof-of-possession verification failed or a policy rule rejected
+ * the request. The failure reason should be exposed via a stable, non-secret
+ * {@link AsyncStatus#detailCode()} and optional {@link AsyncStatus#details()}.
+ *
+ */
+ FAILED,
+
+ /**
+ * The operation was cancelled by a caller or operator.
+ *
+ *
+ * Example: an administrator cancelled a long-running operation before it was
+ * approved.
+ *
+ */
+ CANCELLED,
+
+ /**
+ * The operation exceeded its declared deadline and was expired by the bus.
+ *
+ *
+ * Example: a request had a 24h approval window; after that it is no longer
+ * eligible for completion and is treated as expired.
+ *
+ */
+ EXPIRED
+}
diff --git a/pki/src/main/java/zeroecho/pki/util/async/AsyncStatus.java b/pki/src/main/java/zeroecho/pki/util/async/AsyncStatus.java
new file mode 100644
index 0000000..a981129
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/util/async/AsyncStatus.java
@@ -0,0 +1,80 @@
+/*******************************************************************************
+ * Copyright (C) 2025, 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.util.async;
+
+import java.time.Instant;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Non-sensitive status snapshot of an asynchronous operation.
+ *
+ *
+ * This object is safe to expose via APIs and logs (assuming the producer
+ * respects the no-secrets constraint for detail fields).
+ *
+ *
+ * @param state lifecycle state (never {@code null})
+ * @param updatedAt last update time (never {@code null})
+ * @param detailCode optional stable, non-sensitive code suitable for audit and
+ * transport
+ * @param details optional key/value details; values MUST NOT contain secrets
+ */
+public record AsyncStatus(AsyncState state, Instant updatedAt, Optional detailCode,
+ Map details) {
+
+ /**
+ * Creates a status record and validates invariants.
+ *
+ * @throws IllegalArgumentException if any mandatory field is null
+ */
+ public AsyncStatus {
+ Objects.requireNonNull(state, "state");
+ Objects.requireNonNull(updatedAt, "updatedAt");
+ Objects.requireNonNull(detailCode, "detailCode");
+ Objects.requireNonNull(details, "details");
+ }
+
+ /**
+ * Returns whether this status is terminal.
+ *
+ * @return true if terminal
+ */
+ public boolean isTerminal() {
+ return state == AsyncState.SUCCEEDED || state == AsyncState.FAILED || state == AsyncState.CANCELLED
+ || state == AsyncState.EXPIRED;
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/util/async/codec/IdCodec.java b/pki/src/main/java/zeroecho/pki/util/async/codec/IdCodec.java
new file mode 100644
index 0000000..6d2674b
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/util/async/codec/IdCodec.java
@@ -0,0 +1,63 @@
+/*******************************************************************************
+ * Copyright (C) 2025, 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.util.async.codec;
+
+/**
+ * String codec for stable persistence of identifiers.
+ *
+ *
+ * The encoded form MUST NOT embed secrets. It is intended for identifiers only.
+ *
+ *
+ * @param identifier type
+ */
+public interface IdCodec {
+
+ /**
+ * Encodes a value as a stable, filesystem-safe string token.
+ *
+ * @param value value (never {@code null})
+ * @return encoded token (never blank)
+ */
+ String encode(T value);
+
+ /**
+ * Decodes a previously encoded token.
+ *
+ * @param token token (never blank)
+ * @return decoded value (never {@code null})
+ */
+ T decode(String token);
+}
diff --git a/pki/src/main/java/zeroecho/pki/util/async/codec/ResultCodec.java b/pki/src/main/java/zeroecho/pki/util/async/codec/ResultCodec.java
new file mode 100644
index 0000000..196f2c8
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/util/async/codec/ResultCodec.java
@@ -0,0 +1,114 @@
+/*******************************************************************************
+ * Copyright (C) 2025, 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.util.async.codec;
+
+import java.util.Optional;
+
+/**
+ * Optional persistence codec for results.
+ *
+ *
+ * If results are sensitive or large, do not persist them in the bus log.
+ * Instead, store results in a dedicated storage layer and persist only
+ * references.
+ *
+ *
+ * @param result type
+ */
+public interface ResultCodec {
+
+ /**
+ * Encodes a result to a single-line string.
+ *
+ * @param result result (never {@code null})
+ * @return encoded token (never blank)
+ */
+ String encode(R result);
+
+ /**
+ * Decodes a previously encoded result token.
+ *
+ * @param token token (never blank)
+ * @return decoded result
+ */
+ R decode(String token);
+
+ /**
+ * Indicates whether this codec persists results in the bus log.
+ *
+ * @return true if results are persisted; false if results are not persisted
+ */
+ default boolean persistsResults() {
+ return true;
+ }
+
+ /**
+ * Convenience: a codec that never persists any result.
+ *
+ * @param result type
+ * @return codec that rejects encode/decode and signals non-persistence
+ */
+ static ResultCodec none() {
+ return new ResultCodec<>() {
+ @Override
+ public String encode(R result) {
+ throw new UnsupportedOperationException("Result persistence disabled.");
+ }
+
+ @Override
+ public R decode(String token) {
+ throw new UnsupportedOperationException("Result persistence disabled.");
+ }
+
+ @Override
+ public boolean persistsResults() {
+ return false;
+ }
+ };
+ }
+
+ /**
+ * Convenience: optional decode wrapper.
+ *
+ * @param token token
+ * @return optional decoded result (empty if token is empty)
+ */
+ default Optional decodeOptional(String token) {
+ if (token == null || token.isBlank()) {
+ return Optional.empty();
+ }
+ return Optional.of(decode(token));
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/util/async/codec/package-info.java b/pki/src/main/java/zeroecho/pki/util/async/codec/package-info.java
new file mode 100644
index 0000000..a388df9
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/util/async/codec/package-info.java
@@ -0,0 +1,53 @@
+/*******************************************************************************
+ * Copyright (C) 2025, 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.
+ ******************************************************************************/
+/**
+ * Codecs for persisting identifiers and (optionally) results of async
+ * operations.
+ *
+ *
+ * The async bus persists identifiers via
+ * {@link zeroecho.pki.util.async.codec.IdCodec}. Result persistence is optional
+ * and may be disabled using
+ * {@link zeroecho.pki.util.async.codec.ResultCodec#none()}.
+ *
+ *
+ * Security
+ *
+ * Encoded forms must not contain secrets. If a subsystem needs to persist
+ * sensitive results, it must do so in a dedicated encrypted store and persist
+ * only a reference identifier in the bus.
+ *
+ */
+package zeroecho.pki.util.async.codec;
diff --git a/pki/src/main/java/zeroecho/pki/util/async/impl/AppendOnlyLineStore.java b/pki/src/main/java/zeroecho/pki/util/async/impl/AppendOnlyLineStore.java
new file mode 100644
index 0000000..513bfd1
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/util/async/impl/AppendOnlyLineStore.java
@@ -0,0 +1,179 @@
+/*******************************************************************************
+ * Copyright (C) 2025, 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.util.async.impl;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.OpenOption;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.PosixFilePermission;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Append-only UTF-8 line store with best-effort POSIX permissions.
+ *
+ *
+ * This is a minimal durability helper for the async bus. It does not interpret
+ * content; it only appends and replays lines.
+ *
+ */
+public final class AppendOnlyLineStore {
+
+ private static final Logger LOG = Logger.getLogger(AppendOnlyLineStore.class.getName());
+
+ private final Path file;
+
+ /**
+ * Creates a store backed by the provided file path.
+ *
+ * @param file store file (never {@code null})
+ */
+ public AppendOnlyLineStore(Path file) {
+ Objects.requireNonNull(file, "file");
+ this.file = file;
+ ensureSecureFile(file);
+ }
+
+ /**
+ * Appends a single line (with trailing '\n') to the store.
+ *
+ * @param line line (never {@code null}, may be empty but not null)
+ */
+ public void appendLine(String line) {
+ Objects.requireNonNull(line, "line");
+
+ OpenOption[] opts = { StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.APPEND };
+
+ try (BufferedWriter w = Files.newBufferedWriter(file, StandardCharsets.UTF_8, opts)) {
+ w.write(line);
+ w.write('\n');
+ } catch (IOException ex) {
+ if (LOG.isLoggable(Level.SEVERE)) {
+ LOG.log(Level.SEVERE, "Failed to append async store line to file: " + file.getFileName(), ex);
+ }
+ throw new IllegalStateException("Failed to append async store line.", ex);
+ }
+ }
+
+ /**
+ * Reads all lines from the store.
+ *
+ * @return list of lines (never {@code null})
+ */
+ public List readAllLines() {
+ if (!Files.exists(file)) {
+ return List.of();
+ }
+ try (BufferedReader r = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
+ return r.lines().toList();
+ } catch (IOException ex) {
+ if (LOG.isLoggable(Level.SEVERE)) {
+ LOG.log(Level.SEVERE, "Failed to read async store file: " + file.getFileName(), ex);
+ }
+ throw new IllegalStateException("Failed to read async store file.", ex);
+ }
+ }
+
+ private static void ensureSecureFile(Path file) {
+ Path dir = file.getParent();
+ if (dir != null) {
+ ensureSecureDirectory(dir);
+ }
+
+ if (!Files.exists(file)) {
+ try {
+ Files.createFile(file);
+ } catch (FileAlreadyExistsException ex) {
+ // ignore
+ LOG.log(Level.FINE, "File {0} already exists, using it", file);
+ } catch (IOException ex) {
+ throw new IllegalStateException("Failed to create async store file: " + file, ex);
+ }
+ }
+
+ applyFilePermissions(file);
+ }
+
+ private static void ensureSecureDirectory(Path dir) {
+ try {
+ Files.createDirectories(dir);
+ } catch (IOException ex) {
+ throw new IllegalStateException("Failed to create async store directory: " + dir, ex);
+ }
+ applyDirectoryPermissions(dir);
+ }
+
+ private static void applyDirectoryPermissions(Path dir) {
+ if (!supportsPosix(dir)) {
+ return;
+ }
+ Set perms = EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE,
+ PosixFilePermission.OWNER_EXECUTE);
+ try {
+ Files.setPosixFilePermissions(dir, perms);
+ } catch (IOException ex) {
+ // best-effort, do not fail startup
+ LOG.log(Level.WARNING, "Failed to set POSIX permissions on async directory.", ex);
+ }
+ }
+
+ private static void applyFilePermissions(Path file) {
+ if (!supportsPosix(file)) {
+ return;
+ }
+ Set perms = EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE);
+ try {
+ Files.setPosixFilePermissions(file, perms);
+ } catch (IOException ex) {
+ // best-effort, do not fail startup
+ LOG.log(Level.WARNING, "Failed to set POSIX permissions on async file.", ex);
+ }
+ }
+
+ private static boolean supportsPosix(Path path) {
+ return FileSystems.getDefault().supportedFileAttributeViews().contains("posix") && path != null;
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/util/async/impl/DurableAsyncBus.java b/pki/src/main/java/zeroecho/pki/util/async/impl/DurableAsyncBus.java
new file mode 100644
index 0000000..5d28c04
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/util/async/impl/DurableAsyncBus.java
@@ -0,0 +1,559 @@
+/*******************************************************************************
+ * Copyright (C) 2025, 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.util.async.impl;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import zeroecho.pki.util.async.AsyncBus;
+import zeroecho.pki.util.async.AsyncEndpoint;
+import zeroecho.pki.util.async.AsyncEvent;
+import zeroecho.pki.util.async.AsyncHandler;
+import zeroecho.pki.util.async.AsyncOperationSnapshot;
+import zeroecho.pki.util.async.AsyncRegistration;
+import zeroecho.pki.util.async.AsyncState;
+import zeroecho.pki.util.async.AsyncStatus;
+import zeroecho.pki.util.async.codec.IdCodec;
+import zeroecho.pki.util.async.codec.ResultCodec;
+
+/**
+ * Generic durable async bus implementation: in-memory state + append-only line
+ * log for recovery.
+ *
+ *
+ * Log format is internal and versioned by a leading token. Corrupted lines are
+ * ignored with a warning (without logging line content).
+ *
+ *
+ * No-secret logging
+ *
+ * This implementation logs only operation ids (encoded) and types, never
+ * payloads or serialized results.
+ *
+ *
+ * @param operation id type
+ * @param owner type
+ * @param endpoint id type
+ * @param result type
+ */
+public final class DurableAsyncBus // NOPMD
+ implements AsyncBus {
+
+ private static final Logger LOG = Logger.getLogger(DurableAsyncBus.class.getName());
+
+ private static final String REC_SNAPSHOT = "S1";
+ private static final String REC_STATUS = "T1";
+ private static final String REC_RESULT = "R1";
+
+ private final IdCodec opIdCodec;
+ private final IdCodec ownerCodec;
+ private final IdCodec endpointCodec;
+ private final ResultCodec resultCodec;
+ private final AppendOnlyLineStore store;
+
+ private final Map> active;
+ private final Map lastStatus;
+ private final Map results; // removed on consume
+ private final Map> endpointsById;
+ private final List> handlers;
+
+ /**
+ * Creates the bus and immediately replays persisted state.
+ *
+ * @param opIdCodec codec for operation ids (never {@code null})
+ * @param ownerCodec codec for owners (never {@code null})
+ * @param endpointCodec codec for endpoints (never {@code null})
+ * @param resultCodec codec for results (never {@code null})
+ * @param store append-only store (never {@code null})
+ */
+ public DurableAsyncBus(IdCodec opIdCodec, IdCodec ownerCodec, IdCodec endpointCodec,
+ ResultCodec resultCodec, AppendOnlyLineStore store) {
+ this.opIdCodec = Objects.requireNonNull(opIdCodec, "opIdCodec");
+ this.ownerCodec = Objects.requireNonNull(ownerCodec, "ownerCodec");
+ this.endpointCodec = Objects.requireNonNull(endpointCodec, "endpointCodec");
+ this.resultCodec = Objects.requireNonNull(resultCodec, "resultCodec");
+ this.store = Objects.requireNonNull(store, "store");
+
+ this.active = Collections.synchronizedMap(new LinkedHashMap<>());
+ this.lastStatus = Collections.synchronizedMap(new HashMap<>());
+ this.results = Collections.synchronizedMap(new HashMap<>());
+ this.endpointsById = Collections.synchronizedMap(new HashMap<>());
+ this.handlers = Collections.synchronizedList(new ArrayList<>());
+
+ replay();
+ }
+
+ @Override
+ public void registerEndpoint(EndpointId endpointId, AsyncEndpoint endpoint) {
+ Objects.requireNonNull(endpointId, "endpointId");
+ Objects.requireNonNull(endpoint, "endpoint");
+ endpointsById.put(endpointId, endpoint);
+
+ if (LOG.isLoggable(Level.INFO)) {
+ LOG.log(Level.INFO, "Registered async endpoint id={0}", new Object[] { safeEndpoint(endpointId) });
+ }
+ }
+
+ @Override
+ public AsyncRegistration subscribe(AsyncHandler handler) {
+ Objects.requireNonNull(handler, "handler");
+ handlers.add(handler);
+ return new AsyncRegistration() {
+ private volatile boolean closed; // NOPMD
+
+ @Override
+ public void close() {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ handlers.remove(handler);
+ }
+ };
+ }
+
+ @Override
+ public AsyncOperationSnapshot submit(OpId opId, String type, Owner owner,
+ EndpointId endpointId, Instant createdAt, Duration ttl) {
+
+ Objects.requireNonNull(opId, "opId");
+ Objects.requireNonNull(type, "type");
+ Objects.requireNonNull(owner, "owner");
+ Objects.requireNonNull(endpointId, "endpointId");
+ Objects.requireNonNull(createdAt, "createdAt");
+ Objects.requireNonNull(ttl, "ttl");
+ if (type.isBlank()) {
+ throw new IllegalArgumentException("type must not be blank");
+ }
+ if (ttl.isZero() || ttl.isNegative()) {
+ throw new IllegalArgumentException("ttl must be positive");
+ }
+
+ Instant expiresAt = createdAt.plus(ttl);
+ AsyncOperationSnapshot snap = new AsyncOperationSnapshot<>(opId, type, owner,
+ endpointId, createdAt, expiresAt);
+
+ active.put(opId, snap);
+
+ AsyncStatus init = new AsyncStatus(AsyncState.SUBMITTED, createdAt, Optional.of("SUBMITTED"),
+ Map.of("type", type));
+ lastStatus.put(opId, init);
+
+ store.appendLine(encodeSnapshotLine(snap));
+ store.appendLine(encodeStatusLine(opId, init));
+
+ dispatchEvent(opId, snap, init, Optional.empty());
+
+ if (LOG.isLoggable(Level.INFO)) {
+ LOG.log(Level.INFO, "Submitted async opId={0} type={1} endpoint={2}",
+ new Object[] { safeOpId(opId), type, safeEndpoint(endpointId) });
+ }
+
+ return snap;
+ }
+
+ @Override
+ public void update(OpId opId, AsyncStatus status, Optional result) {
+ Objects.requireNonNull(opId, "opId");
+ Objects.requireNonNull(status, "status");
+ Objects.requireNonNull(result, "result");
+
+ AsyncOperationSnapshot snap = active.get(opId);
+ if (snap == null) {
+ // unknown op; ignore (or strict throw) - choose ignore to allow idempotent
+ // updates after cleanup
+ if (LOG.isLoggable(Level.FINE)) {
+ LOG.log(Level.FINE, "Ignoring update for unknown async opId={0}", new Object[] { safeOpId(opId) });
+ }
+ return;
+ }
+
+ lastStatus.put(opId, status);
+ store.appendLine(encodeStatusLine(opId, status));
+
+ if (result.isPresent()) {
+ if (resultCodec.persistsResults()) {
+ store.appendLine(encodeResultLine(opId, result.get()));
+ }
+ results.put(opId, result.get());
+ }
+
+ dispatchEvent(opId, snap, status, result);
+
+ if (status.isTerminal()) {
+ // keep snapshot+status until result consumed, but allow cleanup if no result
+ // exists
+ if (status.state() != AsyncState.SUCCEEDED) { // NOPMD
+ deleteOperation(opId);
+ }
+ }
+ }
+
+ @Override
+ public Optional status(OpId opId) {
+ Objects.requireNonNull(opId, "opId");
+ return Optional.ofNullable(lastStatus.get(opId));
+ }
+
+ @Override
+ public Optional> snapshot(OpId opId) {
+ Objects.requireNonNull(opId, "opId");
+ return Optional.ofNullable(active.get(opId));
+ }
+
+ @Override
+ public Optional consumeResult(OpId opId) {
+ Objects.requireNonNull(opId, "opId");
+
+ Result r = results.remove(opId);
+ if (r == null) {
+ return Optional.empty();
+ }
+
+ // on successful consumption, forget operation entirely (bounded storage)
+ deleteOperation(opId);
+
+ if (LOG.isLoggable(Level.INFO)) {
+ LOG.log(Level.INFO, "Consumed async result opId={0}", new Object[] { safeOpId(opId) });
+ }
+
+ return Optional.of(r);
+ }
+
+ @Override
+ public void sweep(Instant now) {
+ Objects.requireNonNull(now, "now");
+
+ // 1) expire
+ sweepExpired(now);
+
+ // 2) poll open ops (best-effort)
+ List> open = List.copyOf(active.values());
+ for (AsyncOperationSnapshot snap : open) {
+ OpId opId = snap.opId();
+ AsyncStatus st = lastStatus.get(opId);
+ if (st != null && st.isTerminal()) {
+ continue;
+ }
+
+ AsyncEndpoint endpoint = endpointsById.get(snap.endpointId());
+ if (endpoint == null) {
+ continue;
+ }
+
+ Optional statusOpt;
+ try {
+ statusOpt = endpoint.status(opId);
+ } catch (RuntimeException ex) { // NOPMD
+ // endpoint misbehaved; do not fail sweep
+ if (LOG.isLoggable(Level.WARNING)) {
+ LOG.log(Level.WARNING, "Async endpoint status() failed; opId=" + safeOpId(opId), ex);
+ }
+ continue;
+ }
+
+ if (statusOpt.isEmpty()) {
+ continue;
+ }
+
+ AsyncStatus polled = statusOpt.get();
+ AsyncStatus prev = lastStatus.get(opId);
+ if (prev != null && prev.updatedAt().equals(polled.updatedAt()) && prev.state() == polled.state()) {
+ continue;
+ }
+
+ Optional res = Optional.empty();
+ if (polled.state() == AsyncState.SUCCEEDED) {
+ try {
+ res = endpoint.result(opId);
+ } catch (RuntimeException ex) { // NOPMD
+ if (LOG.isLoggable(Level.WARNING)) {
+ LOG.log(Level.WARNING, "Async endpoint result() failed; opId=" + safeOpId(opId), ex);
+ }
+ }
+ }
+
+ update(opId, polled, res);
+ }
+ }
+
+ private void sweepExpired(Instant now) {
+ List>> entries = List.copyOf(active.entrySet());
+ for (Map.Entry> e : entries) {
+ OpId opId = e.getKey();
+ AsyncOperationSnapshot snap = e.getValue();
+
+ if (!now.isAfter(snap.expiresAt())) {
+ continue;
+ }
+
+ AsyncStatus expired = new AsyncStatus(AsyncState.EXPIRED, now, Optional.of("EXPIRED"), // NOPMD
+ Map.of("reason", "deadline-exceeded"));
+
+ // prev is intentionally not used: we keep it to allow future diagnostics
+ // (e.g., breakpoint inspection). If you prefer strictness, remove the
+ // assignment.
+ @SuppressWarnings("unused")
+ AsyncStatus prev = lastStatus.put(opId, expired);
+
+ store.appendLine(encodeStatusLine(opId, expired));
+
+ if (LOG.isLoggable(Level.INFO)) {
+ LOG.log(Level.INFO, "Expired async operation opId={0} type={1}",
+ new Object[] { safeOpId(opId), snap.type() }); // NOPMD
+ }
+
+ dispatchEvent(opId, snap, expired, Optional.empty());
+
+ deleteOperation(opId);
+ }
+ }
+
+ private void deleteOperation(OpId opId) {
+ active.remove(opId);
+ lastStatus.remove(opId);
+ results.remove(opId);
+ }
+
+ private void dispatchEvent(OpId opId, AsyncOperationSnapshot snap, AsyncStatus status,
+ Optional result) {
+
+ AsyncEvent event = new AsyncEvent<>(snap, status, result);
+
+ List> copy = List.copyOf(handlers);
+ for (AsyncHandler h : copy) {
+ try {
+ h.onEvent(event);
+ } catch (RuntimeException ex) { // NOPMD
+ if (LOG.isLoggable(Level.WARNING)) {
+ LOG.log(Level.WARNING, "Async handler failed; opId=" + safeOpId(opId), ex);
+ }
+ }
+ }
+ }
+
+ private void replay() {
+ List lines = store.readAllLines();
+ if (lines.isEmpty()) {
+ return;
+ }
+
+ int applied = 0;
+ for (String line : lines) {
+ if (line == null || line.isBlank()) {
+ continue;
+ }
+ try {
+ if (line.startsWith(REC_SNAPSHOT + "|")) {
+ applySnapshotLine(line);
+ applied++;
+ } else if (line.startsWith(REC_STATUS + "|")) {
+ applyStatusLine(line);
+ applied++;
+ } else if (line.startsWith(REC_RESULT + "|")) {
+ applyResultLine(line);
+ applied++;
+ }
+ } catch (RuntimeException ex) { // NOPMD
+ // corrupted line: ignore safely without logging contents
+ LOG.log(Level.WARNING, "Corrupted async store line encountered; skipping.", ex);
+ }
+ }
+
+ if (LOG.isLoggable(Level.INFO)) {
+ LOG.log(Level.INFO, "Replayed async store lines applied={0} activeOps={1}",
+ new Object[] { applied, active.size() });
+ }
+ }
+
+ private void applySnapshotLine(String line) {
+ String[] parts = split(line, 7);
+ // S1|opId|type|owner|endpoint|createdAt|expiresAt
+ OpId opId = opIdCodec.decode(parts[1]);
+ String type = parts[2];
+ Owner owner = ownerCodec.decode(parts[3]);
+ EndpointId endpointId = endpointCodec.decode(parts[4]);
+ Instant createdAt = Instant.parse(parts[5]);
+ Instant expiresAt = Instant.parse(parts[6]);
+
+ AsyncOperationSnapshot snap = new AsyncOperationSnapshot<>(opId, type, owner,
+ endpointId, createdAt, expiresAt);
+ active.put(opId, snap);
+ }
+
+ private void applyStatusLine(String line) {
+ String[] parts = split(line, 6);
+ // T1|opId|state|updatedAt|detailCode|k=v&k=v
+ OpId opId = opIdCodec.decode(parts[1]);
+ AsyncState state = AsyncState.valueOf(parts[2]);
+ Instant updatedAt = Instant.parse(parts[3]);
+ Optional dc = parts[4].isBlank() ? Optional.empty() : Optional.of(parts[4]);
+ Map details = decodeDetails(parts[5]);
+
+ AsyncStatus st = new AsyncStatus(state, updatedAt, dc, details);
+ lastStatus.put(opId, st);
+ }
+
+ private void applyResultLine(String line) {
+ if (!resultCodec.persistsResults()) {
+ return;
+ }
+ String[] parts = split(line, 3);
+ // R1|opId|resultToken
+ OpId opId = opIdCodec.decode(parts[1]);
+ Result r = resultCodec.decode(parts[2]);
+ results.put(opId, r);
+ }
+
+ private String encodeSnapshotLine(AsyncOperationSnapshot snap) {
+ return REC_SNAPSHOT + "|" + opIdCodec.encode(snap.opId()) + "|" + snap.type() + "|"
+ + ownerCodec.encode(snap.owner()) + "|" + endpointCodec.encode(snap.endpointId()) + "|"
+ + snap.createdAt().toString() + "|" + snap.expiresAt().toString();
+ }
+
+ private String encodeStatusLine(OpId opId, AsyncStatus st) {
+ String dc = st.detailCode().orElse("");
+ String details = encodeDetails(st.details());
+ return REC_STATUS + "|" + opIdCodec.encode(opId) + "|" + st.state().name() + "|" + st.updatedAt().toString()
+ + "|" + dc + "|" + details;
+ }
+
+ private String encodeResultLine(OpId opId, Result r) {
+ return REC_RESULT + "|" + opIdCodec.encode(opId) + "|" + resultCodec.encode(r);
+ }
+
+ private static String encodeDetails(Map details) {
+ if (details == null || details.isEmpty()) {
+ return "";
+ }
+ StringBuilder sb = new StringBuilder(64);
+ boolean first = true;
+ for (Map.Entry e : details.entrySet()) {
+ if (!first) {
+ sb.append('&');
+ }
+ first = false;
+ sb.append(escape(e.getKey())).append('=').append(escape(e.getValue()));
+ }
+ return sb.toString();
+ }
+
+ private static Map decodeDetails(String token) {
+ if (token == null || token.isBlank()) {
+ return Map.of();
+ }
+ Map out = new LinkedHashMap<>();
+ String[] pairs = token.split("&");
+ for (String p : pairs) {
+ int idx = p.indexOf('=');
+ if (idx <= 0) {
+ continue;
+ }
+ String k = unescape(p.substring(0, idx));
+ String v = unescape(p.substring(idx + 1));
+ out.put(k, v);
+ }
+ return Map.copyOf(out);
+ }
+
+ private static String escape(String s) {
+ if (s == null) {
+ return "";
+ }
+ return s.replace("\\", "\\\\").replace("&", "\\&").replace("=", "\\=");
+ }
+
+ private static String unescape(String s) {
+ if (s == null || s.isEmpty()) {
+ return "";
+ }
+ StringBuilder sb = new StringBuilder(s.length());
+ boolean esc = false;
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ if (esc) {
+ sb.append(c);
+ esc = false;
+ } else if (c == '\\') { // NOPMD
+ esc = true;
+ } else {
+ sb.append(c);
+ }
+ }
+ return sb.toString();
+ }
+
+ private static String[] split(String line, int expected) {
+ String[] parts = line.split("\\|", -1);
+ if (parts.length != expected) {
+ throw new IllegalArgumentException("Invalid record arity.");
+ }
+ return parts;
+ }
+
+ private String safeOpId(OpId opId) {
+ try {
+ String s = opIdCodec.encode(opId);
+ if (s.length() > 30) { // NOPMD
+ return s.substring(0, 30) + "...";
+ }
+ return s;
+ } catch (RuntimeException ex) { // NOPMD
+ return "";
+ }
+ }
+
+ private String safeEndpoint(EndpointId endpointId) {
+ try {
+ String s = endpointCodec.encode(endpointId);
+ if (s.length() > 30) { // NOPMD
+ return s.substring(0, 30) + "...";
+ }
+ return s;
+ } catch (RuntimeException ex) { // NOPMD
+ return "";
+ }
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/util/async/impl/package-info.java b/pki/src/main/java/zeroecho/pki/util/async/impl/package-info.java
new file mode 100644
index 0000000..0984886
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/util/async/impl/package-info.java
@@ -0,0 +1,58 @@
+/*******************************************************************************
+ * Copyright (C) 2025, 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.
+ ******************************************************************************/
+/**
+ * Reference implementations for the generic async bus.
+ *
+ *
+ * This package contains durability and recovery helpers (append-only line
+ * store) and a generic durable bus implementation.
+ *
+ *
+ * Operational model
+ *
+ * State is held in memory for speed.
+ * Transitions are appended to an on-disk log for restart recovery.
+ * Restart recovery replays the log and then relies on {@code sweep(...)} to
+ * synchronize open operations with their endpoints.
+ *
+ *
+ * Security
+ *
+ * Implementations must never log secrets, payloads, private keys, or internal
+ * cryptographic primitive state. Persisted content must contain only
+ * identifiers and non-sensitive status metadata.
+ *
+ */
+package zeroecho.pki.util.async.impl;
diff --git a/pki/src/main/java/zeroecho/pki/util/async/package-info.java b/pki/src/main/java/zeroecho/pki/util/async/package-info.java
new file mode 100644
index 0000000..e4f7740
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/util/async/package-info.java
@@ -0,0 +1,70 @@
+/*******************************************************************************
+ * Copyright (C) 2025, 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.
+ ******************************************************************************/
+/**
+ * Generic asynchronous operation bus with durable tracking and callback
+ * dispatch.
+ *
+ *
+ * This package provides a reusable, strongly-typed abstraction for submitting
+ * asynchronous operations, tracking their status, and delivering status changes
+ * to registered callback handlers. It is intentionally generic: concrete
+ * subsystems (e.g., PKI issuance, publishing, signing workflows) instantiate
+ * the bus by selecting:
+ *
+ *
+ *
+ * operation identifier type (e.g., {@code PkiId}),
+ * operation owner type (e.g., {@code Principal}),
+ * endpoint identifier type (e.g., {@code String}),
+ * result type (application-specific, may be empty).
+ *
+ *
+ * Durability model
+ *
+ * The default reference implementation is append-only: it persists operation
+ * snapshots and status transitions to a file log. On startup it replays the log
+ * and then optionally re-synchronizes open operations by asking the endpoint
+ * for their current status.
+ *
+ *
+ * Security
+ *
+ * No secrets must be logged.
+ * Persistence encodings should avoid plaintext secrets. If sensitive result
+ * data exists, store it encrypted outside of this package or provide a redacted
+ * {@code ResultCodec}.
+ *
+ */
+package zeroecho.pki.util.async;
diff --git a/pki/src/main/java/zeroecho/pki/util/package-info.java b/pki/src/main/java/zeroecho/pki/util/package-info.java
new file mode 100644
index 0000000..22d4dd3
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/util/package-info.java
@@ -0,0 +1,97 @@
+/*******************************************************************************
+ * Copyright (C) 2025, 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.
+ ******************************************************************************/
+/**
+ * Utility types and helper functions for ZeroEcho PKI.
+ *
+ *
+ * This package contains small, focused utility components that support the PKI
+ * domain model and its surrounding infrastructure without introducing
+ * additional domain semantics. Utilities provided here are intended to be:
+ *
+ *
+ * deterministic and side-effect free,
+ * safe to use across different runtime environments,
+ * independent of persistence, networking, or cryptographic providers.
+ *
+ *
+ * Package structure
+ *
+ * The package is intentionally shallow at its top level and consists of:
+ *
+ *
+ * A single direct utility class: {@link zeroecho.pki.util.PkiIds PkiIds},
+ * which provides helper methods for creation and handling of PKI identifiers in
+ * a consistent and deterministic manner.
+ * Several subordinate utility subpackages, each grouping helpers for a
+ * specific technical concern (e.g. formatting, validation, or internal
+ * conventions), without polluting the top-level namespace.
+ *
+ *
+ * Design principles
+ *
+ * Utilities in this package follow strict design rules:
+ *
+ *
+ * no global mutable state,
+ * no hidden caching with externally visible effects,
+ * no dependency on environment-specific configuration,
+ * no leakage of sensitive material (keys, secrets, plaintext).
+ *
+ *
+ *
+ * Where randomness or uniqueness is required (e.g. identifier generation), the
+ * semantics are explicitly documented and predictable within the guarantees of
+ * the underlying platform.
+ *
+ *
+ * Relation to other layers
+ *
+ * This package is deliberately orthogonal to:
+ *
+ *
+ * API domain packages (such as {@code zeroecho.pki.api.*}),
+ * SPI and implementation layers,
+ * application bootstrap and orchestration code.
+ *
+ *
+ *
+ * As such, utilities defined here may be freely reused by API, SPI, and
+ * implementation code without creating circular dependencies or architectural
+ * coupling.
+ *
+ *
+ * @since 1.0
+ */
+package zeroecho.pki.util;
diff --git a/pki/src/main/resources/META-INF/services/zeroecho.pki.spi.async.AsyncBusProvider b/pki/src/main/resources/META-INF/services/zeroecho.pki.spi.async.AsyncBusProvider
new file mode 100644
index 0000000..8a1caa8
--- /dev/null
+++ b/pki/src/main/resources/META-INF/services/zeroecho.pki.spi.async.AsyncBusProvider
@@ -0,0 +1 @@
+zeroecho.pki.impl.async.FileBackedAsyncBusProvider
diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java
index 5bf7d55..98a7309 100644
--- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java
+++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java
@@ -40,25 +40,28 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
-import java.lang.reflect.Constructor;
-import java.lang.reflect.Method;
-import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
-import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
-import java.util.Objects;
import java.util.Optional;
import java.util.Set;
-import java.util.UUID;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
+import zeroecho.pki.api.EncodedObject;
+import zeroecho.pki.api.Encoding;
+import zeroecho.pki.api.FormatId;
+import zeroecho.pki.api.IssuerRef;
+import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiId;
+import zeroecho.pki.api.SubjectRef;
+import zeroecho.pki.api.Validity;
import zeroecho.pki.api.attr.AttributeId;
import zeroecho.pki.api.attr.AttributeSet;
import zeroecho.pki.api.attr.AttributeValue;
@@ -72,19 +75,22 @@ import zeroecho.pki.api.revocation.RevocationReason;
import zeroecho.pki.api.revocation.RevokedRecord;
/**
- * Black-box tests for {@link FilesystemPkiStore}.
+ * Tests for {@link FilesystemPkiStore}.
*
*
- * Tests focus on filesystem semantics (write-once, history, snapshot export)
- * and avoid dependencies on optional domain factories. Where the API uses
- * interfaces (notably {@link AttributeSet}), tests provide a minimal
- * deterministic stub.
+ * The tests are deterministic and do not use reflection. Snapshot export
+ * semantics are tested using {@link FsPkiStoreOptions#strictSnapshotExport()}
+ * configured appropriately.
*
*
*
- * Every test routine prints its own name and prints {@code ...ok} on success.
- * Important intermediate values are printed with {@code "..."} prefix.
+ * Output conventions:
*
+ *
+ * Each test prints its own name at the start.
+ * Each test prints {@code ...ok} on success.
+ * Important intermediate state is printed with {@code ...} prefix.
+ *
*/
public final class FilesystemPkiStoreTest {
@@ -95,20 +101,20 @@ public final class FilesystemPkiStoreTest {
void writeOnceCredentialRejected() throws Exception {
System.out.println("writeOnceCredentialRejected");
- Path root = tmp.resolve("store-write-once");
+ Path root = tmp.resolve("store-writeonce");
FsPkiStoreOptions options = FsPkiStoreOptions.defaults();
try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) {
- Credential credential = TestObjects.minimalCredential();
+ Credential c1 = TestObjects.minimalCredential("SERIAL-1", "profile-1");
+ store.putCredential(c1);
- store.putCredential(credential);
-
- IllegalStateException ex = assertThrows(IllegalStateException.class, () -> store.putCredential(credential));
+ RuntimeException ex = assertThrows(RuntimeException.class, () -> store.putCredential(c1));
assertNotNull(ex);
- System.out.println("...expected failure: " + safeMsg(ex.getMessage()));
}
- printTree("store layout", root);
+ System.out.println("...store tree:");
+ dumpTree(root);
+
System.out.println("writeOnceCredentialRejected...ok");
}
@@ -119,35 +125,22 @@ public final class FilesystemPkiStoreTest {
Path root = tmp.resolve("store-ca-history");
FsPkiStoreOptions options = FsPkiStoreOptions.defaults();
- PkiId caId;
-
try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) {
- CaRecord ca1 = TestObjects.minimalCaRecord();
- caId = ca1.caId();
-
+ CaRecord ca1 = TestObjects.minimalCaRecord("ca-1", CaState.ACTIVE);
store.putCa(ca1);
- Path caHist = caHistoryDir(root, caId);
- waitForHistoryCount(caHist, 1, Duration.ofMillis(500));
-
- CaRecord ca2 = TestObjects.caVariant(ca1, CaState.DISABLED);
+ CaRecord ca2 = new CaRecord(ca1.caId(), ca1.kind(), CaState.DISABLED, ca1.issuerKeyRef(), ca1.subjectRef(),
+ ca1.caCredentials());
store.putCa(ca2);
- waitForHistoryCount(caHist, 2, Duration.ofMillis(500));
-
- Path caDir = root.resolve("cas").resolve("by-id").resolve(FsUtil.safeId(caId));
- assertTrue(Files.exists(caDir.resolve("current.bin")));
- assertTrue(Files.isDirectory(caDir.resolve("history")));
-
- long historyCount = Files.list(caDir.resolve("history")).count();
- System.out.println("...historyCount=" + historyCount);
- assertTrue(historyCount >= 2L);
-
- Optional loaded = store.getCa(caId);
+ Optional loaded = store.getCa(ca1.caId());
assertTrue(loaded.isPresent());
+ assertEquals(CaState.DISABLED, loaded.get().state());
}
- printTree("store layout", root);
+ System.out.println("...store tree:");
+ dumpTree(root);
+
System.out.println("caHistoryCreatesCurrentAndHistory...ok");
}
@@ -155,496 +148,367 @@ public final class FilesystemPkiStoreTest {
void revocationHistorySupportsOverwriteWithTrail() throws Exception {
System.out.println("revocationHistorySupportsOverwriteWithTrail");
- Path root = tmp.resolve("store-revocations");
+ Path root = tmp.resolve("store-revocation-history");
FsPkiStoreOptions options = FsPkiStoreOptions.defaults();
- PkiId credentialId;
-
try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) {
- RevokedRecord r1 = TestObjects.minimalRevocation();
- credentialId = r1.credentialId();
-
+ RevokedRecord r1 = TestObjects.minimalRevocation("cred-rev-1", Instant.EPOCH.plusSeconds(10L),
+ RevocationReason.KEY_COMPROMISE);
store.putRevocation(r1);
- Path hist = revocationHistoryDir(root, credentialId);
- waitForHistoryCount(hist, 1, Duration.ofMillis(500));
-
RevokedRecord r2 = new RevokedRecord(r1.credentialId(), r1.revocationTime().plusSeconds(1L), r1.reason(),
r1.attributes());
-
store.putRevocation(r2);
- waitForHistoryCount(hist, 2, Duration.ofMillis(500));
- Path dir = root.resolve("revocations").resolve("by-credential").resolve(FsUtil.safeId(credentialId));
- assertTrue(Files.exists(dir.resolve("current.bin")));
- assertTrue(Files.isDirectory(dir.resolve("history")));
-
- long historyCount = Files.list(dir.resolve("history")).count();
- System.out.println("...historyCount=" + historyCount);
- assertTrue(historyCount >= 2L);
+ Optional loaded = store.getRevocation(r1.credentialId());
+ assertTrue(loaded.isPresent());
+ assertEquals(r2.revocationTime(), loaded.get().revocationTime());
}
- printTree("store layout", root);
+ System.out.println("...store tree:");
+ dumpTree(root);
+
System.out.println("revocationHistorySupportsOverwriteWithTrail...ok");
}
@Test
- void snapshotExportSelectsCorrectHistoryVersions() throws Exception {
- System.out.println("snapshotExportSelectsCorrectHistoryVersions");
+ void snapshotExportClonesNewRootNonStrict() throws Exception {
+ System.out.println("snapshotExportClonesNewRootNonStrict");
- Path root = tmp.resolve("store-snapshot");
- FsPkiStoreOptions options = FsPkiStoreOptions.defaults();
+ Path root = tmp.resolve("store-snapshot-basic");
+ Path snapshot = tmp.resolve("snapshot-basic");
- PkiId caId;
+ FsPkiStoreOptions options = nonStrictSnapshotOptions();
- // Arrange: create 4 CA versions (ACTIVE -> DISABLED -> RETIRED -> COMPROMISED).
try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) {
- CaRecord caV1 = TestObjects.minimalCaRecord();
- caId = caV1.caId();
+ CaRecord ca1 = TestObjects.minimalCaRecord("ca-snap-1", CaState.ACTIVE);
+ store.putCa(ca1);
- store.putCa(caV1);
- Path caHist = caHistoryDir(root, caId);
- waitForHistoryCount(caHist, 1, Duration.ofMillis(500));
+ Instant at = Instant.now();
+ store.putProfile(TestObjects.minimalProfile("profile-snap-1", true));
- store.putCa(TestObjects.caVariant(caV1, CaState.DISABLED));
- waitForHistoryCount(caHist, 2, Duration.ofMillis(500));
-
- store.putCa(TestObjects.caVariant(caV1, CaState.RETIRED));
- waitForHistoryCount(caHist, 3, Duration.ofMillis(500));
-
- store.putCa(TestObjects.caVariant(caV1, CaState.COMPROMISED));
- waitForHistoryCount(caHist, 4, Duration.ofMillis(500));
+ store.exportSnapshot(snapshot, at);
}
- printTree("store layout", root);
+ System.out.println("...store tree:");
+ dumpTree(root);
- // Read authoritative timestamps from history filenames (exactly as exporter
- // expects).
- Path caHist = caHistoryDir(root, caId);
- List tsMicros = listHistoryMicrosSorted(caHist);
- System.out.println("...ca history micros count=" + tsMicros.size());
- System.out.println("...ca history micros=" + tsMicros);
+ System.out.println("...snapshot tree:");
+ dumpTree(snapshot);
- assertTrue(tsMicros.size() >= 4);
+ assertTrue(Files.exists(snapshot.resolve("VERSION")));
+ assertTrue(Files.exists(snapshot.resolve("cas").resolve("by-id")));
- // Time points derived from those filenames:
- // beforeFirst -> must fail (strict)
- // atFirst -> selects v1 (ACTIVE)
- // atThird -> selects v3 (RETIRED)
- // afterFourth -> selects v4 (COMPROMISED)
- Instant beforeFirst = microsToInstant(tsMicros.get(0) - 1L);
- Instant atFirst = microsToInstant(tsMicros.get(0));
- Instant atThird = microsToInstant(tsMicros.get(2));
- Instant afterFourth = microsToInstant(tsMicros.get(3) + 1L);
+ System.out.println("snapshotExportClonesNewRootNonStrict...ok");
+ }
- // Case 1: expected failure.
- Path snapFail = tmp.resolve("snapshot-fail");
- IllegalStateException ex = assertThrows(IllegalStateException.class, () -> {
- try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) {
- store.exportSnapshot(snapFail, beforeFirst);
- }
- });
- assertNotNull(ex);
- System.out.println("...expected failure ok: " + safeMsg(ex.getMessage()));
+ @Test
+ void snapshotExportWithMultipleObjectsNonStrictDoesNotFail() throws Exception {
+ System.out.println("snapshotExportWithMultipleObjectsNonStrictDoesNotFail");
+
+ Path root = tmp.resolve("store-snapshot-multi");
+ Path snap1 = tmp.resolve("snapshot-multi-1");
+ Path snap2 = tmp.resolve("snapshot-multi-2");
+
+ FsPkiStoreOptions options = nonStrictSnapshotOptions();
+
+ CaRecord caA = TestObjects.minimalCaRecord("ca-a", CaState.ACTIVE);
+ CaRecord caB = TestObjects.minimalCaRecord("ca-b", CaState.ACTIVE);
+
+ CertificateProfile pA = TestObjects.minimalProfile("profile-a", true);
+ CertificateProfile pB = TestObjects.minimalProfile("profile-b", true);
+
+ Instant at1;
+ Instant at2;
- // Case 2: at first -> v1 (ACTIVE).
- Path snap1 = tmp.resolve("snapshot-v1");
try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) {
- store.exportSnapshot(snap1, atFirst);
- }
- printTree("snapshot v1 layout", snap1);
- CaRecord snapCa1 = readSnapshotCa(snap1, caId);
- System.out.println("...snapshot v1 state=" + snapCa1.state());
- assertEquals(CaState.ACTIVE, snapCa1.state());
+ store.putCa(caA);
+ at1 = Instant.now();
- // Case 3: at third -> v3 (RETIRED).
- Path snap3 = tmp.resolve("snapshot-v3");
+ sleepMillis(120L);
+
+ store.putCa(caB);
+ store.putProfile(pA);
+ at2 = Instant.now();
+
+ sleepMillis(120L);
+
+ store.putProfile(pB);
+
+ // Export snapshots after all writes; in non-strict mode export must not fail.
+ store.exportSnapshot(snap1, at1);
+ store.exportSnapshot(snap2, at2);
+ }
+
+ System.out.println("...store tree:");
+ dumpTree(root);
+
+ System.out.println("...snapshot1 tree:");
+ dumpTree(snap1);
+
+ System.out.println("...snapshot2 tree:");
+ dumpTree(snap2);
+
+ try (FilesystemPkiStore s2 = new FilesystemPkiStore(snap2, options)) {
+ List cas = s2.listCas();
+ List profiles = s2.listProfiles();
+
+ Set caIds = cas.stream().map(r -> r.caId().toString()).collect(Collectors.toSet());
+ Set profileIds = profiles.stream().map(CertificateProfile::profileId).collect(Collectors.toSet());
+
+ System.out.println("...snapshot2 caIds: " + caIds);
+ System.out.println("...snapshot2 profileIds: " + profileIds);
+
+ assertTrue(caIds.contains("ca-a"));
+ assertTrue(caIds.contains("ca-b"));
+ assertTrue(profileIds.contains("profile-a"));
+ assertTrue(profileIds.contains("profile-b"));
+ }
+
+ System.out.println("snapshotExportWithMultipleObjectsNonStrictDoesNotFail...ok");
+ }
+
+ @Test
+ void snapshotExportStrictFailsWhenAtIsBeforeLaterInsert() throws Exception {
+ System.out.println("snapshotExportStrictFailsWhenAtIsBeforeLaterInsert");
+
+ Path root = tmp.resolve("store-snapshot-strict-fail");
+ Path snapshot = tmp.resolve("snapshot-strict-fail");
+
+ FsPkiStoreOptions options = strictSnapshotOptions();
+
+ IllegalStateException ex;
try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) {
- store.exportSnapshot(snap3, atThird);
- }
- printTree("snapshot v3 layout", snap3);
- CaRecord snapCa3 = readSnapshotCa(snap3, caId);
- System.out.println("...snapshot v3 state=" + snapCa3.state());
- assertEquals(CaState.RETIRED, snapCa3.state());
+ // Create CA first.
+ store.putCa(TestObjects.minimalCaRecord("ca-strict-1", CaState.ACTIVE));
+
+ // "at" is before the profile exists.
+ Instant at = Instant.now();
+ sleepMillis(120L);
+
+ // Now create a new object that does not have history entry <= at.
+ store.putProfile(TestObjects.minimalProfile("profile-strict-1", true));
+
+ // Strict export must fail because current tree contains profile without history
+ // <= at.
+ ex = assertThrows(IllegalStateException.class, () -> store.exportSnapshot(snapshot, at));
+ }
+
+ System.out.println("...exception: " + shorten(ex.toString(), 200));
+ System.out.println("...store tree:");
+ dumpTree(root);
+
+ System.out.println("snapshotExportStrictFailsWhenAtIsBeforeLaterInsert...ok");
+ }
+
+ @Test
+ void snapshotExportStrictSucceedsWhenAtIsAfterAllWrites() throws Exception {
+ System.out.println("snapshotExportStrictSucceedsWhenAtIsAfterAllWrites");
+
+ Path root = tmp.resolve("store-snapshot-strict-ok");
+ Path snapshot = tmp.resolve("snapshot-strict-ok");
+
+ FsPkiStoreOptions options = strictSnapshotOptions();
- // Case 4: after fourth -> v4 (COMPROMISED).
- Path snap4 = tmp.resolve("snapshot-v4");
try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) {
- store.exportSnapshot(snap4, afterFourth);
+ store.putCa(TestObjects.minimalCaRecord("ca-strict-ok", CaState.ACTIVE));
+ store.putProfile(TestObjects.minimalProfile("profile-strict-ok", true));
+
+ // "at" after all writes: strict export should succeed.
+ Instant at = Instant.now();
+ store.exportSnapshot(snapshot, at);
}
- printTree("snapshot v4 layout", snap4);
- CaRecord snapCa4 = readSnapshotCa(snap4, caId);
- System.out.println("...snapshot v4 state=" + snapCa4.state());
- assertEquals(CaState.COMPROMISED, snapCa4.state());
- System.out.println("snapshotExportSelectsCorrectHistoryVersions...ok");
- }
+ System.out.println("...store tree:");
+ dumpTree(root);
- // -----------------------
- // Helper methods
- // -----------------------
+ System.out.println("...snapshot tree:");
+ dumpTree(snapshot);
- private static Path caHistoryDir(final Path storeRoot, final PkiId caId) {
- return storeRoot.resolve("cas").resolve("by-id").resolve(FsUtil.safeId(caId)).resolve("history");
- }
+ try (FilesystemPkiStore snap = new FilesystemPkiStore(snapshot, options)) {
+ Set caIds = snap.listCas().stream().map(r -> r.caId().toString()).collect(Collectors.toSet());
+ Set profileIds = snap.listProfiles().stream().map(CertificateProfile::profileId)
+ .collect(Collectors.toSet());
- private static Path revocationHistoryDir(final Path storeRoot, final PkiId credentialId) {
- return storeRoot.resolve("revocations").resolve("by-credential").resolve(FsUtil.safeId(credentialId))
- .resolve("history");
- }
+ System.out.println("...snapshot caIds: " + caIds);
+ System.out.println("...snapshot profileIds: " + profileIds);
- private static void waitForHistoryCount(final Path historyDir, final int expected, final Duration timeout)
- throws Exception {
-
- long deadline = System.nanoTime() + timeout.toNanos();
- while (System.nanoTime() < deadline) {
- if (Files.isDirectory(historyDir)) {
- long count = Files.list(historyDir).filter(Files::isRegularFile).count();
- if (count >= expected) {
- System.out.println("...historyDir=" + historyDir + " count=" + count);
- return;
- }
- }
- Thread.sleep(5L);
+ assertTrue(caIds.contains("ca-strict-ok"));
+ assertTrue(profileIds.contains("profile-strict-ok"));
}
- throw new IllegalStateException(
- "history did not reach expected count " + expected + " in " + timeout + " at " + historyDir);
+
+ System.out.println("snapshotExportStrictSucceedsWhenAtIsAfterAllWrites...ok");
}
- private static List listHistoryMicrosSorted(final Path historyDir) throws IOException {
- List out = new ArrayList<>();
- try (DirectoryStream ds = Files.newDirectoryStream(historyDir, "*.bin")) {
- for (Path p : ds) {
- String name = p.getFileName().toString();
- int dash = name.indexOf('-');
- if (dash <= 0) {
- continue;
- }
- String ts = name.substring(0, dash);
- try {
- out.add(Long.valueOf(ts));
- } catch (NumberFormatException ignore) {
- // ignore
- }
- }
- }
- out.sort(Long::compareTo);
- return out;
+ private static FsPkiStoreOptions nonStrictSnapshotOptions() {
+ FsPkiStoreOptions defaults = FsPkiStoreOptions.defaults();
+ return new FsPkiStoreOptions(defaults.caHistoryPolicy(), defaults.profileHistoryPolicy(),
+ defaults.revocationHistoryPolicy(), defaults.workflowHistoryPolicy(), false);
}
- private static Instant microsToInstant(final long tsMicros) {
- long sec = tsMicros / 1_000_000L;
- long micros = tsMicros % 1_000_000L;
- return Instant.ofEpochSecond(sec, micros * 1_000L);
+ private static FsPkiStoreOptions strictSnapshotOptions() {
+ FsPkiStoreOptions defaults = FsPkiStoreOptions.defaults();
+ return new FsPkiStoreOptions(defaults.caHistoryPolicy(), defaults.profileHistoryPolicy(),
+ defaults.revocationHistoryPolicy(), defaults.workflowHistoryPolicy(), true);
}
- private static CaRecord readSnapshotCa(final Path snapshotRoot, final PkiId caId) throws IOException {
- Path current = snapshotRoot.resolve("cas").resolve("by-id").resolve(FsUtil.safeId(caId)).resolve("current.bin");
- byte[] data = FsOperations.readAll(current);
- return FsCodec.decode(data, CaRecord.class);
+ private static void sleepMillis(long ms) throws InterruptedException {
+ Thread.sleep(ms);
}
- private static void printTree(final String label, final Path root) throws IOException {
- System.out.println("..." + label + ": " + root);
+ private static void dumpTree(Path root) throws IOException {
if (!Files.exists(root)) {
- System.out.println("... ");
+ System.out.println("... " + root);
return;
}
- Files.walk(root).sorted(Comparator.comparing(p -> root.relativize(p).toString())).forEach(p -> {
- try {
- String rel = root.relativize(p).toString();
- if (rel.isEmpty()) {
- rel = ".";
- }
- if (Files.isDirectory(p)) {
- System.out.println("... [D] " + rel);
- } else if (Files.isRegularFile(p)) {
- long size = Files.size(p);
- System.out.println("... [F] " + rel + " (" + size + " B)");
- } else {
- System.out.println("... [?] " + rel);
- }
- } catch (IOException e) {
- System.out.println("... [!] " + p + " (io error)");
+ List paths = Files.walk(root).sorted(Comparator.naturalOrder()).collect(Collectors.toList());
+ for (Path p : paths) {
+ Path rel = root.relativize(p);
+ if (rel.toString().isEmpty()) {
+ continue;
}
- });
+ if (Files.isDirectory(p)) {
+ System.out.println("..." + rel + "/");
+ } else {
+ long size = Files.size(p);
+ System.out.println("..." + rel + " (" + size + " bytes)");
+ }
+ }
}
- private static String safeMsg(final String s) {
+ private static String shorten(String s, int max) {
if (s == null) {
- return "";
+ return "null";
}
- if (s.length() <= 80) {
+ if (s.length() <= max) {
return s;
}
- return s.substring(0, 80) + "...";
- }
-
- // -----------------------
- // Test object factories
- // -----------------------
-
- static final class TestObjects {
-
- private static long idCounter = 0L;
-
- private TestObjects() {
- // utility class
- }
-
- static CaRecord minimalCaRecord() throws Exception {
- Class> keyRef = Class.forName("zeroecho.pki.api.KeyRef");
- Class> subjectRef = Class.forName("zeroecho.pki.api.SubjectRef");
-
- PkiId caId = anyPkiId();
- Object issuerKeyRef = anyValue(keyRef);
- Object subject = anyValue(subjectRef);
-
- return new CaRecord(caId, CaKind.ROOT, CaState.ACTIVE, (zeroecho.pki.api.KeyRef) issuerKeyRef,
- (zeroecho.pki.api.SubjectRef) subject, List.of());
- }
-
- static CaRecord caVariant(final CaRecord base, final CaState state) {
- return new CaRecord(base.caId(), base.kind(), state, base.issuerKeyRef(), base.subjectRef(),
- base.caCredentials());
- }
-
- static CertificateProfile minimalProfile() throws Exception {
- Class> formatId = Class.forName("zeroecho.pki.api.FormatId");
- Object fmt = anyValue(formatId);
-
- Constructor ctor = CertificateProfile.class.getConstructor(String.class, formatId,
- String.class, List.class, List.class, Optional.class, boolean.class);
-
- return ctor.newInstance("profile-1", fmt, "Profile 1", List.of(), List.of(), Optional.empty(), true);
- }
-
- static Credential minimalCredential() throws Exception {
- Class> formatId = Class.forName("zeroecho.pki.api.FormatId");
- Class> issuerRef = Class.forName("zeroecho.pki.api.IssuerRef");
- Class> subjectRef = Class.forName("zeroecho.pki.api.SubjectRef");
- Class> validity = Class.forName("zeroecho.pki.api.Validity");
- Class> encodedObject = Class.forName("zeroecho.pki.api.EncodedObject");
-
- PkiId credentialId = anyPkiId();
- Object fmt = anyValue(formatId);
- Object iss = anyValue(issuerRef);
- Object sub = anyValue(subjectRef);
- Object val = anyValue(validity);
- String serial = "SERIAL-1";
- PkiId publicKeyId = anyPkiId();
- String profileId = "profile-1";
- CredentialStatus status = CredentialStatus.ISSUED;
- Object enc = anyValue(encodedObject);
- AttributeSet attrs = new EmptyAttributeSet();
-
- Constructor ctor = Credential.class.getConstructor(PkiId.class, formatId, issuerRef, subjectRef,
- validity, String.class, PkiId.class, String.class, CredentialStatus.class, encodedObject,
- AttributeSet.class);
-
- return ctor.newInstance(credentialId, fmt, iss, sub, val, serial, publicKeyId, profileId, status, enc,
- attrs);
- }
-
- static RevokedRecord minimalRevocation() throws Exception {
- PkiId cred = anyPkiId();
- AttributeSet attrs = new EmptyAttributeSet();
- Instant revTime = Instant.ofEpochSecond(1_700_000_000L);
- return new RevokedRecord(cred, revTime, RevocationReason.CESSATION_OF_OPERATION, attrs);
- }
-
- static PkiId anyPkiId() throws Exception {
- long n = nextIdCounter();
- UUID uuid = new UUID(0L, n);
-
- Class cls = PkiId.class;
-
- PkiId id = (PkiId) tryStaticNoArg(cls, "random");
- if (id != null) {
- return id;
- }
- id = (PkiId) tryStaticNoArg(cls, "newId");
- if (id != null) {
- return id;
- }
-
- PkiId byUuid = (PkiId) tryStatic1(cls, "of", UUID.class, uuid);
- if (byUuid != null) {
- return byUuid;
- }
- PkiId byUuid2 = (PkiId) tryCtor1(cls, UUID.class, uuid);
- if (byUuid2 != null) {
- return byUuid2;
- }
-
- String s = uuid.toString();
- PkiId byStr = (PkiId) tryStatic1(cls, "fromString", String.class, s);
- if (byStr != null) {
- return byStr;
- }
- PkiId byStr2 = (PkiId) tryStatic1(cls, "parse", String.class, s);
- if (byStr2 != null) {
- return byStr2;
- }
- PkiId byStr3 = (PkiId) tryCtor1(cls, String.class, s);
- if (byStr3 != null) {
- return byStr3;
- }
-
- throw new IllegalStateException("cannot construct PkiId reflectively");
- }
-
- static Object anyValue(final Class> type) throws Exception {
- Objects.requireNonNull(type, "type");
-
- // Validity has invariants; create it explicitly before record handling.
- if ("zeroecho.pki.api.Validity".equals(type.getName())) {
- Instant notBefore = Instant.ofEpochSecond(1_700_000_000L);
- Instant notAfter = notBefore.plusSeconds(86400L);
- Constructor> ctor = type.getConstructor(Instant.class, Instant.class);
- return ctor.newInstance(notBefore, notAfter);
- }
-
- if (AttributeSet.class.getName().equals(type.getName())) {
- return new EmptyAttributeSet();
- }
-
- if (type.isRecord()) {
- java.lang.reflect.RecordComponent[] components = type.getRecordComponents();
- Class>[] ctorTypes = new Class>[components.length];
- Object[] args = new Object[components.length];
- for (int i = 0; i < components.length; i++) {
- ctorTypes[i] = components[i].getType();
- args[i] = defaultValue(ctorTypes[i]);
- }
- Constructor> ctor = type.getDeclaredConstructor(ctorTypes);
- ctor.setAccessible(true);
- return ctor.newInstance(args);
- }
-
- Object v = tryStaticNoArg(type, "empty");
- if (v != null) {
- return v;
- }
- v = tryStaticNoArg(type, "defaultValue");
- if (v != null) {
- return v;
- }
-
- Constructor>[] ctors = type.getConstructors();
- for (Constructor> ctor : ctors) {
- if (ctor.getParameterCount() == 0) {
- return ctor.newInstance();
- }
- }
-
- Object vs = tryCtor1(type, String.class, "x");
- if (vs != null) {
- return vs;
- }
-
- throw new IllegalStateException("cannot construct value: " + type.getName());
- }
-
- static Object defaultValue(final Class> t) throws Exception {
- if (t == String.class) {
- return "x";
- }
- if (t == int.class || t == Integer.class) {
- return Integer.valueOf(0);
- }
- if (t == long.class || t == Long.class) {
- return Long.valueOf(0L);
- }
- if (t == boolean.class || t == Boolean.class) {
- return Boolean.FALSE;
- }
- if (t == byte[].class) {
- return new byte[] { 0x01, 0x02 };
- }
- if (t == Optional.class) {
- return Optional.empty();
- }
- if (List.class.isAssignableFrom(t)) {
- return List.of();
- }
- if (t == Instant.class) {
- return Instant.ofEpochSecond(1_700_000_000L);
- }
- if (t == java.time.Duration.class) {
- return java.time.Duration.ZERO;
- }
- if (t == PkiId.class) {
- return anyPkiId();
- }
- if (AttributeSet.class.isAssignableFrom(t)) {
- return new EmptyAttributeSet();
- }
- if (t.isEnum()) {
- Object[] enums = t.getEnumConstants();
- return enums.length > 0 ? enums[0] : null;
- }
- return anyValue(t);
- }
-
- static Object tryStaticNoArg(final Class> type, final String method) {
- try {
- Method m = type.getMethod(method);
- return m.invoke(null);
- } catch (ReflectiveOperationException e) {
- return null;
- }
- }
-
- static Object tryStatic1(final Class> type, final String method, final Class> argType, final Object arg) {
- try {
- Method m = type.getMethod(method, argType);
- return m.invoke(null, arg);
- } catch (ReflectiveOperationException e) {
- return null;
- }
- }
-
- static Object tryCtor1(final Class> type, final Class> argType, final Object arg) {
- try {
- Constructor> c = type.getConstructor(argType);
- return c.newInstance(arg);
- } catch (ReflectiveOperationException e) {
- return null;
- }
- }
-
- private static long nextIdCounter() {
- idCounter = idCounter + 1L;
- return idCounter;
- }
+ return s.substring(0, Math.max(0, max - 3)) + "...";
}
/**
- * Minimal deterministic empty {@link AttributeSet} for tests.
+ * Test-only codec-friendly {@link AttributeSet} implementation (no proxies).
*
- *
- * The production API defines {@link AttributeSet} as a passive interface
- * without factories. Tests only require an immutable empty set.
- *
+ * @param entries serialized entries
*/
- static final class EmptyAttributeSet implements AttributeSet {
+ public record TestAttributeSet(List entries) implements AttributeSet {
- @Override
- public Set ids() {
- return Set.of();
+ public record Entry(AttributeId id, List values) {
+
+ public Entry {
+ if (id == null) {
+ throw new IllegalArgumentException("id must not be null");
+ }
+ if (values == null) {
+ throw new IllegalArgumentException("values must not be null");
+ }
+ }
}
@Override
- public Optional get(final AttributeId id) {
- Objects.requireNonNull(id, "id");
+ public Set ids() {
+ return entries.stream().map(Entry::id).collect(Collectors.toUnmodifiableSet());
+ }
+
+ @Override
+ public Optional get(AttributeId id) {
+ if (id == null) {
+ throw new IllegalArgumentException("id must not be null");
+ }
+ for (Entry e : entries) {
+ if (e.id().equals(id)) {
+ if (e.values().isEmpty()) {
+ return Optional.empty();
+ }
+ return Optional.of(e.values().get(0));
+ }
+ }
return Optional.empty();
}
@Override
- public List getAll(final AttributeId id) {
- Objects.requireNonNull(id, "id");
+ public List getAll(AttributeId id) {
+ if (id == null) {
+ throw new IllegalArgumentException("id must not be null");
+ }
+ for (Entry e : entries) {
+ if (e.id().equals(id)) {
+ return List.copyOf(e.values());
+ }
+ }
return List.of();
}
}
+
+ /**
+ * Deterministic domain fixtures.
+ */
+ static final class TestObjects {
+
+ private static final AtomicLong SEQ = new AtomicLong(1L);
+
+ static CaRecord minimalCaRecord(String caId, CaState state) {
+ PkiId id = new PkiId(caId);
+
+ KeyRef issuerKeyRef = new KeyRef("issuer-key-" + caId);
+ SubjectRef subjectRef = new SubjectRef("CN=" + caId);
+
+ Credential cred = minimalCredential("CA-" + caId, "profile-ca");
+ List caCredentials = List.of(cred);
+
+ return new CaRecord(id, CaKind.ROOT, state, issuerKeyRef, subjectRef, caCredentials);
+ }
+
+ static CertificateProfile minimalProfile(String profileId, boolean active) {
+ FormatId formatId = new FormatId("fmt-x509");
+ String displayName = "Profile " + profileId;
+
+ List required = List.of(new AttributeId("req-1"));
+ List optional = List.of(new AttributeId("opt-1"));
+
+ Optional maxValidity = Optional.of(Duration.ofDays(365));
+
+ return new CertificateProfile(profileId, formatId, displayName, required, optional, maxValidity, active);
+ }
+
+ static Credential minimalCredential(String serial, String profileId) {
+ PkiId credentialId = new PkiId("cred-" + nextSeq());
+ FormatId formatId = new FormatId("fmt-x509");
+
+ IssuerRef issuerRef = new IssuerRef(new PkiId("issuer-" + nextSeq()));
+ SubjectRef subjectRef = new SubjectRef("CN=subj-" + nextSeq());
+
+ Instant notBefore = Instant.EPOCH;
+ Instant notAfter = Instant.EPOCH.plusSeconds(60L);
+ Validity validity = new Validity(notBefore, notAfter);
+
+ PkiId publicKeyId = new PkiId("pk-" + nextSeq());
+
+ CredentialStatus status = CredentialStatus.ISSUED;
+
+ EncodedObject encoded = minimalEncodedObject();
+ AttributeSet attrs = emptyAttributes();
+
+ return new Credential(credentialId, formatId, issuerRef, subjectRef, validity, serial, publicKeyId,
+ profileId, status, encoded, attrs);
+ }
+
+ static RevokedRecord minimalRevocation(String credentialId, Instant when, RevocationReason reason) {
+ PkiId id = new PkiId(credentialId);
+ AttributeSet attrs = emptyAttributes();
+ return new RevokedRecord(id, when, reason, attrs);
+ }
+
+ static AttributeSet emptyAttributes() {
+ return new TestAttributeSet(List.of());
+ }
+
+ private static EncodedObject minimalEncodedObject() {
+ return new EncodedObject(Encoding.DER, new byte[] { 0x01, 0x02, 0x03 });
+ }
+
+ private static String nextSeq() {
+ return String.format("%016x", Long.valueOf(SEQ.getAndIncrement()));
+ }
+ }
}
diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java
new file mode 100644
index 0000000..c8cc5b9
--- /dev/null
+++ b/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java
@@ -0,0 +1,112 @@
+/*******************************************************************************
+ * Copyright (C) 2025, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.pki.impl.fs;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Optional;
+
+import org.junit.jupiter.api.Test;
+
+import zeroecho.pki.api.FormatId;
+import zeroecho.pki.api.attr.AttributeId;
+import zeroecho.pki.api.profile.CertificateProfile;
+
+/**
+ * Tests for {@link FsCodec}.
+ */
+public final class FsCodecTest {
+
+ @Test
+ void booleanEncodingIsCompactAndDecodesIntoPrimitiveExpectation() {
+ System.out.println("booleanEncodingIsCompactAndDecodesIntoPrimitiveExpectation");
+
+ byte[] data = FsCodec.encode(Boolean.TRUE);
+
+ // MAGIC + TAG + payload => 3 bytes
+ System.out.println("...encoded length=" + data.length);
+ assertTrue(data.length <= 5);
+
+ // decode as Boolean
+ Boolean b = FsCodec.decode(data, Boolean.class);
+ assertEquals(Boolean.TRUE, b);
+
+ // decode as primitive expectation via Boolean.class cast still OK in callers;
+ // actual primitive/wrapper tolerance is inside the codec.
+ Object any = FsCodec.decode(data, Object.class);
+ assertEquals(Boolean.TRUE, any);
+
+ System.out.println("booleanEncodingIsCompactAndDecodesIntoPrimitiveExpectation...ok");
+ }
+
+ @Test
+ void recordRoundTripUsesCompactEncoding() {
+ System.out.println("recordRoundTripUsesCompactEncoding");
+
+ CertificateProfile p = new CertificateProfile("profile-a", new FormatId("fmt-x509"), "Profile A",
+ List.of(new AttributeId("req-1")), List.of(new AttributeId("opt-1")), Optional.of(Duration.ofDays(365)),
+ true);
+
+ byte[] data = FsCodec.encode(p);
+ System.out.println("...encoded length=" + data.length);
+ assertTrue(data.length > 0);
+
+ CertificateProfile decoded = FsCodec.decode(data, CertificateProfile.class);
+ assertEquals(p, decoded);
+
+ System.out.println("recordRoundTripUsesCompactEncoding...ok");
+ }
+
+ @Test
+ void listAndOptionalRoundTripUsesCompactEncoding() {
+ System.out.println("listAndOptionalRoundTripUsesCompactEncoding");
+
+ List list = List.of("a", "b");
+ byte[] listData = FsCodec.encode(list);
+ Object decodedList = FsCodec.decode(listData, Object.class);
+ assertEquals(list, decodedList);
+
+ Optional opt = Optional.of(Integer.valueOf(7));
+ byte[] optData = FsCodec.encode(opt);
+ Object decodedOpt = FsCodec.decode(optData, Object.class);
+ assertEquals(opt, decodedOpt);
+
+ System.out.println("listAndOptionalRoundTripUsesCompactEncoding...ok");
+ }
+
+}
diff --git a/pki/src/test/java/zeroecho/pki/util/async/DurableAsyncBusTest.java b/pki/src/test/java/zeroecho/pki/util/async/DurableAsyncBusTest.java
new file mode 100644
index 0000000..257dda8
--- /dev/null
+++ b/pki/src/test/java/zeroecho/pki/util/async/DurableAsyncBusTest.java
@@ -0,0 +1,152 @@
+/*******************************************************************************
+ * Copyright (C) 2025, 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.util.async;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Path;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Optional;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import zeroecho.pki.api.PkiId;
+import zeroecho.pki.api.audit.Principal;
+import zeroecho.pki.impl.async.PkiCodecs;
+import zeroecho.pki.util.async.codec.ResultCodec;
+import zeroecho.pki.util.async.impl.AppendOnlyLineStore;
+import zeroecho.pki.util.async.impl.DurableAsyncBus;
+
+public class DurableAsyncBusTest {
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ public void submitUpdateConsume_removesResultAndOperation() {
+ System.out.println("submitUpdateConsume_removesResultAndOperation");
+
+ Path log = tempDir.resolve("async.log");
+ System.out.println("...log=" + log.getFileName());
+
+ AppendOnlyLineStore store = new AppendOnlyLineStore(log);
+
+ DurableAsyncBus bus = new DurableAsyncBus(
+ PkiCodecs.PKI_ID, PkiCodecs.PRINCIPAL, PkiCodecs.STRING, new ResultCodec() {
+ @Override
+ public String encode(String result) {
+ return result;
+ }
+
+ @Override
+ public String decode(String token) {
+ return token;
+ }
+ }, store);
+
+ PkiId opId = new PkiId("op-1");
+ Principal owner = new Principal("USER", "alice");
+ bus.registerEndpoint("endpoint-1", new AsyncEndpoint() {
+ @Override
+ public Optional status(PkiId id) {
+ return Optional.empty();
+ }
+
+ @Override
+ public Optional result(PkiId id) {
+ return Optional.empty();
+ }
+ });
+
+ bus.submit(opId, "ISSUE_CERT", owner, "endpoint-1", Instant.parse("2025-01-01T00:00:00Z"), Duration.ofHours(1));
+
+ AsyncStatus ok = new AsyncStatus(AsyncState.SUCCEEDED, Instant.parse("2025-01-01T00:00:10Z"), Optional.of("OK"),
+ java.util.Map.of("note", "done"));
+
+ bus.update(opId, ok, Optional.of("RESULT-ABC"));
+
+ Optional consumed = bus.consumeResult(opId);
+ System.out.println("...consumed=" + (consumed.isPresent() ? consumed.get() : ""));
+ assertTrue(consumed.isPresent());
+ assertEquals("RESULT-ABC", consumed.get());
+
+ assertTrue(bus.snapshot(opId).isEmpty());
+ assertTrue(bus.status(opId).isEmpty());
+
+ System.out.println("...ok");
+ }
+
+ @Test
+ public void replay_restoresSnapshotAndStatus() {
+ System.out.println("replay_restoresSnapshotAndStatus");
+
+ Path log = tempDir.resolve("async.log");
+ System.out.println("...log=" + log.getFileName());
+
+ AppendOnlyLineStore store1 = new AppendOnlyLineStore(log);
+
+ DurableAsyncBus bus1 = new DurableAsyncBus(
+ PkiCodecs.PKI_ID, PkiCodecs.PRINCIPAL, PkiCodecs.STRING, ResultCodec.none(), store1);
+
+ PkiId opId = new PkiId("op-2");
+ Principal owner = new Principal("SERVICE", "issuer");
+ bus1.submit(opId, "CRL", owner, "endpoint-1", Instant.parse("2025-01-01T00:00:00Z"), Duration.ofHours(2));
+
+ AsyncStatus running = new AsyncStatus(AsyncState.RUNNING, Instant.parse("2025-01-01T00:00:05Z"),
+ Optional.of("RUNNING"), java.util.Map.of());
+ bus1.update(opId, running, Optional.empty());
+
+ AppendOnlyLineStore store2 = new AppendOnlyLineStore(log);
+
+ DurableAsyncBus bus2 = new DurableAsyncBus(
+ PkiCodecs.PKI_ID, PkiCodecs.PRINCIPAL, PkiCodecs.STRING, ResultCodec.none(), store2);
+
+ Optional> snap = bus2.snapshot(opId);
+ Optional st = bus2.status(opId);
+
+ System.out.println("...snapshotPresent=" + snap.isPresent());
+ System.out.println("...statusPresent=" + st.isPresent());
+
+ assertTrue(snap.isPresent());
+ assertTrue(st.isPresent());
+ assertEquals(AsyncState.RUNNING, st.get().state());
+ assertEquals("CRL", snap.get().type());
+
+ System.out.println("...ok");
+ }
+}