feat: add universal AsyncBus infrastructure
Introduce a generic asynchronous bus used for internal PKI workflows, with resilient sweep support and symmetric primitive/wrapper type compatibility for dispatch handling. Signed-off-by: Leo Galambos <lg@hq.egothor.org>
This commit is contained in:
@@ -34,32 +34,76 @@
|
||||
******************************************************************************/
|
||||
package zeroecho.pki;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
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.bootstrap.PkiBootstrap;
|
||||
import zeroecho.pki.util.async.AsyncBus;
|
||||
|
||||
/**
|
||||
* Minimal bootstrap entry point for the {@code pki} module.
|
||||
*
|
||||
* <p>
|
||||
* This class is intentionally limited to process bootstrap concerns only:
|
||||
* This class is intentionally limited to process bootstrap and hosting
|
||||
* concerns:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>initializes JUL logging conventions (without leaking secrets),</li>
|
||||
* <li>installs an uncaught-exception handler,</li>
|
||||
* <li>emits a minimal startup/shutdown signal.</li>
|
||||
* <li>composes PKI runtime components using {@link PkiBootstrap},</li>
|
||||
* <li>hosts the async-bus maintenance loop via periodic
|
||||
* {@code sweep(...)},</li>
|
||||
* <li>waits until process termination (Ctrl+C) and performs orderly
|
||||
* shutdown.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Async bus sweep</h2>
|
||||
* <p>
|
||||
* No cryptography, persistence, or domain/business logic is performed here. The
|
||||
* public PKI API resides under {@code zeroecho.pki.api.*} and is not modified
|
||||
* by this bootstrap.
|
||||
* The async bus requires periodic {@link AsyncBus#sweep(Instant)} calls to:
|
||||
* expire operations past their deadline and re-synchronize open operations
|
||||
* after restart (depending on the bus implementation).
|
||||
* </p>
|
||||
*
|
||||
* <h2>Security</h2>
|
||||
* <p>
|
||||
* Command-line arguments and configuration values are never logged because they
|
||||
* can contain sensitive material (paths, tokens, passphrases).
|
||||
* </p>
|
||||
*/
|
||||
@SuppressWarnings("PMD.DoNotUseThreads")
|
||||
public final class PkiApplication {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(PkiApplication.class.getName());
|
||||
|
||||
/**
|
||||
* System property controlling the async sweep interval in milliseconds.
|
||||
*
|
||||
* <p>
|
||||
* If missing or invalid, a safe default is used.
|
||||
* </p>
|
||||
*/
|
||||
private static final String PROP_ASYNC_SWEEP_INTERVAL_MS = "zeroecho.pki.async.sweepIntervalMs";
|
||||
|
||||
/**
|
||||
* Default async sweep interval used when not configured.
|
||||
*/
|
||||
private static final Duration DEFAULT_ASYNC_SWEEP_INTERVAL = Duration.ofSeconds(2L);
|
||||
|
||||
/**
|
||||
* Shutdown grace time for the sweep executor.
|
||||
*/
|
||||
private static final Duration SWEEP_SHUTDOWN_GRACE = Duration.ofSeconds(10L);
|
||||
|
||||
private PkiApplication() {
|
||||
throw new AssertionError("No instances.");
|
||||
}
|
||||
@@ -82,18 +126,184 @@ public final class PkiApplication {
|
||||
|
||||
LOG.info("ZeroEcho PKI starting.");
|
||||
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> { // NOPMD
|
||||
Logger shutdownLogger = Logger.getLogger(PkiApplication.class.getName());
|
||||
PkiLogging.emitShutdownMessage(shutdownLogger, "ZeroEcho PKI stopping.");
|
||||
}, "zeroecho-pki-shutdown"));
|
||||
CountDownLatch shutdownLatch = new CountDownLatch(1);
|
||||
|
||||
// closed in the shutdown routine
|
||||
ScheduledExecutorService sweepExecutor = Executors.newSingleThreadScheduledExecutor(new SweepThreadFactory()); // NOPMD
|
||||
|
||||
Runtime.getRuntime()
|
||||
.addShutdownHook(new Thread(new ShutdownHook(sweepExecutor, shutdownLatch), "zeroecho-pki-shutdown"));
|
||||
|
||||
try {
|
||||
// Intentionally no business logic yet. Bootstrap only.
|
||||
LOG.info("ZeroEcho PKI started (bootstrap only).");
|
||||
AsyncBus<PkiId, Principal, String, Object> asyncBus = PkiBootstrap.openAsyncBus();
|
||||
|
||||
Duration sweepInterval = readSweepInterval(DEFAULT_ASYNC_SWEEP_INTERVAL);
|
||||
|
||||
if (LOG.isLoggable(Level.INFO)) {
|
||||
LOG.log(Level.INFO, "Async bus sweep enabled; intervalMs={0}", sweepInterval.toMillis());
|
||||
}
|
||||
|
||||
sweepExecutor.scheduleWithFixedDelay(new SweepTask(asyncBus), 0L, sweepInterval.toMillis(),
|
||||
TimeUnit.MILLISECONDS);
|
||||
|
||||
LOG.info("ZeroEcho PKI started.");
|
||||
|
||||
// Keep process alive until Ctrl+C (or other shutdown signal).
|
||||
awaitShutdown(shutdownLatch);
|
||||
} catch (RuntimeException ex) { // NOPMD
|
||||
// Do not include user-provided inputs in the message; log the exception object.
|
||||
LOG.log(Level.SEVERE, "Fatal error during PKI bootstrap.", ex);
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
private static void awaitShutdown(CountDownLatch latch) {
|
||||
try {
|
||||
latch.await();
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
if (LOG.isLoggable(Level.WARNING)) {
|
||||
LOG.log(Level.WARNING, "Interrupted while awaiting shutdown.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration readSweepInterval(Duration defaultValue) {
|
||||
String raw = System.getProperty(PROP_ASYNC_SWEEP_INTERVAL_MS);
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return defaultValue;
|
||||
}
|
||||
try {
|
||||
long ms = Long.parseLong(raw);
|
||||
if (ms <= 0L) { // NOPMD
|
||||
return defaultValue;
|
||||
}
|
||||
return Duration.ofMillis(ms);
|
||||
} catch (NumberFormatException ex) {
|
||||
if (LOG.isLoggable(Level.WARNING)) {
|
||||
LOG.log(Level.WARNING, "Invalid async sweep interval system property; using default.", ex);
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodic maintenance task for asynchronous PKI infrastructure.
|
||||
*
|
||||
* <p>
|
||||
* {@code SweepTask} represents a resilient, repeatable unit of work that
|
||||
* invokes time-based maintenance logic on an {@link AsyncBus} instance. It is
|
||||
* intended to be scheduled at a fixed rate by a background executor and must
|
||||
* tolerate partial failures without disrupting the surrounding runtime
|
||||
* environment.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* The task uses the current wall-clock time as a reference for sweep operations
|
||||
* and deliberately suppresses runtime failures, logging them for diagnostic
|
||||
* purposes while allowing future executions to proceed.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* This class is internal to the PKI bootstrap and lifecycle management logic
|
||||
* and is not part of the public API surface.
|
||||
* </p>
|
||||
*/
|
||||
private static final class SweepTask implements Runnable {
|
||||
|
||||
private final AsyncBus<PkiId, Principal, String, Object> asyncBus;
|
||||
|
||||
private SweepTask(AsyncBus<PkiId, Principal, String, Object> 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.
|
||||
*
|
||||
* <p>
|
||||
* {@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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* The hook guarantees that shutdown coordination always completes by
|
||||
* decrementing the associated {@link CountDownLatch}, regardless of whether
|
||||
* shutdown is graceful, forced, or interrupted.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* This class must never throw exceptions or prevent JVM termination. All
|
||||
* failure modes are handled internally.
|
||||
* </p>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* <p>
|
||||
* {@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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* The factory performs no additional customization such as priority changes or
|
||||
* uncaught-exception handlers, relying instead on executor-level policies.
|
||||
* </p>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,4 +62,9 @@ public record PkiId(String value) {
|
||||
throw new IllegalArgumentException("value must not be null/blank");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Examples</h2>
|
||||
* <ul>
|
||||
* <li>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.</li>
|
||||
* <li>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.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum OrchestrationDurabilityPolicy {
|
||||
|
||||
/**
|
||||
* Fail-closed behavior.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*/
|
||||
STRICT_ABORT_ON_RESTART,
|
||||
|
||||
/**
|
||||
* Durable operation using minimal non-sensitive state.
|
||||
*
|
||||
* <p>
|
||||
* 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).
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*/
|
||||
DURABLE_MIN_STATE,
|
||||
|
||||
/**
|
||||
* Durable operation with encrypted continuation state.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*/
|
||||
DURABLE_ENCRYPTED_STATE
|
||||
}
|
||||
120
pki/src/main/java/zeroecho/pki/api/orch/WorkflowStateRecord.java
Normal file
120
pki/src/main/java/zeroecho/pki/api/orch/WorkflowStateRecord.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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}.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Storage model</h2>
|
||||
* <p>
|
||||
* 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}.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Security</h2>
|
||||
* <ul>
|
||||
* <li>{@link #payload()} may contain sensitive information. It must not be
|
||||
* logged.</li>
|
||||
* <li>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.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @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<EncodedObject> 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);
|
||||
}
|
||||
}
|
||||
114
pki/src/main/java/zeroecho/pki/api/orch/package-info.java
Normal file
114
pki/src/main/java/zeroecho/pki/api/orch/package-info.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* This package defines stable domain abstractions related to <em>long-running
|
||||
* PKI workflows</em> and their durability requirements. It is concerned with
|
||||
* describing <strong>what must be persisted and how durable such persistence
|
||||
* must be</strong>, not with <em>how</em> the persistence is implemented.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Scope and responsibilities</h2>
|
||||
* <p>
|
||||
* The orchestration layer in ZeroEcho PKI coordinates multi-step operations
|
||||
* such as certificate issuance, revocation processing, key rollover, or
|
||||
* publication workflows. These operations:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>may span multiple logical steps,</li>
|
||||
* <li>may involve external systems or asynchronous callbacks,</li>
|
||||
* <li>must tolerate restarts, crashes, or redeployments.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* This package provides the minimal API necessary to:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>describe the required durability guarantees for workflow state,</li>
|
||||
* <li>represent persisted workflow state in a storage-agnostic form,</li>
|
||||
* <li>enable deterministic recovery and audit of workflow progression.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Key abstractions</h2>
|
||||
* <ul>
|
||||
* <li>{@link zeroecho.pki.api.orch.OrchestrationDurabilityPolicy
|
||||
* OrchestrationDurabilityPolicy} defines <em>how durable</em> orchestration
|
||||
* state must be (e.g. write-through, buffered, best-effort), allowing different
|
||||
* operational trade-offs without changing orchestration logic.</li>
|
||||
* <li>{@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.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Immutability and history</h2>
|
||||
* <p>
|
||||
* Workflow state records are designed to be immutable once persisted.
|
||||
* Implementations are expected to append new state records rather than
|
||||
* overwrite existing ones, enabling:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>full audit trails,</li>
|
||||
* <li>temporal queries ("state at time T"),</li>
|
||||
* <li>post-mortem analysis of failed or aborted workflows.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>API stability and integration</h2>
|
||||
* <p>
|
||||
* The types in this package are part of the public PKI API surface. They are
|
||||
* intended to be usable across different runtime environments, including:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>standalone CLI applications,</li>
|
||||
* <li>long-running server processes,</li>
|
||||
* <li>container-managed frameworks such as Spring or Micronaut.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* No assumptions are made about the underlying persistence mechanism
|
||||
* (filesystem, database, distributed log, etc.). Such concerns are handled by
|
||||
* SPI and implementation layers.
|
||||
* </p>
|
||||
*
|
||||
* @since 1.0
|
||||
*/
|
||||
package zeroecho.pki.api.orch;
|
||||
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Configuration keys</h2>
|
||||
* <ul>
|
||||
* <li>{@code logPath} (optional): append-only log file path. Default:
|
||||
* {@code pki-async/async.log} relative to the working directory.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Security</h2>
|
||||
* <p>
|
||||
* The log contains operation metadata and must be protected. The underlying
|
||||
* store applies best-effort restrictive permissions (POSIX when available).
|
||||
* </p>
|
||||
*/
|
||||
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<String> supportedKeys() {
|
||||
return Set.of(KEY_LOG_PATH);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AsyncBus<PkiId, Principal, String, Object> allocate(ProviderConfig config) {
|
||||
Objects.requireNonNull(config, "config");
|
||||
|
||||
if (!id().equals(config.backendId())) {
|
||||
throw new IllegalArgumentException("ProviderConfig backendId mismatch.");
|
||||
}
|
||||
|
||||
Map<String, String> 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<Object> resultCodec = ResultCodec.none();
|
||||
|
||||
DurableAsyncBus<PkiId, Principal, String, Object> bus = new DurableAsyncBus<>(PkiCodecs.PKI_ID,
|
||||
PkiCodecs.PRINCIPAL, PkiCodecs.STRING, resultCodec, store);
|
||||
|
||||
return bus;
|
||||
}
|
||||
}
|
||||
109
pki/src/main/java/zeroecho/pki/impl/async/PkiAsyncTypes.java
Normal file
109
pki/src/main/java/zeroecho/pki/impl/async/PkiAsyncTypes.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* This class exists solely to centralize the chosen type parameters used to
|
||||
* instantiate the generic async bus core.
|
||||
* </p>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*/
|
||||
public static final class Result {
|
||||
private Result() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the operation id type.
|
||||
*
|
||||
* @return class token
|
||||
*/
|
||||
public static Class<PkiId> opIdType() {
|
||||
return PkiId.class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the owner type.
|
||||
*
|
||||
* @return class token
|
||||
*/
|
||||
public static Class<Principal> ownerType() {
|
||||
return Principal.class;
|
||||
}
|
||||
}
|
||||
117
pki/src/main/java/zeroecho/pki/impl/async/PkiCodecs.java
Normal file
117
pki/src/main/java/zeroecho/pki/impl/async/PkiCodecs.java
Normal file
@@ -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<PkiId> 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}.
|
||||
*
|
||||
* <p>
|
||||
* Encoded form: {@code type:name}. Both parts are treated as non-secret
|
||||
* identifiers.
|
||||
* </p>
|
||||
*/
|
||||
public static final IdCodec<Principal> 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> 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -33,6 +33,19 @@
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
/**
|
||||
*
|
||||
* Default implementations for the PKI async bus SPI.
|
||||
*
|
||||
* <p>
|
||||
* 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}.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Security</h2>
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*/
|
||||
package zeroecho.pki.internal.x509;
|
||||
package zeroecho.pki.impl.async;
|
||||
@@ -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<WorkflowStateRecord> 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<WorkflowStateRecord> listWorkflowStates() {
|
||||
// List by operation directory (workflows/by-op/<opId>/current.bin)
|
||||
return listCurrentRecords(this.paths.workflowRoot(), WorkflowStateRecord.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
synchronized (this.lockChannel) { // NOPMD
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <h2>Encoding format</h2>
|
||||
* <p>
|
||||
* This codec is designed for:
|
||||
* The codec supports two on-wire formats:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>Deterministic serialization across JVM runs.</li>
|
||||
* <li>No external dependencies.</li>
|
||||
* <li>Support for records (the dominant pattern in the PKI API).</li>
|
||||
* <li><b>Compact format (current)</b>: begins with a 1-byte MAGIC marker
|
||||
* {@code 0xFF}, followed by a 1-byte TAG and tag-specific payload.</li>
|
||||
* <li><b>Legacy format (backward-compatible)</b>: 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.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* Supported types:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>primitive wrappers, {@link String}, {@code byte[]}</li>
|
||||
* <li>{@link Instant}, {@link Duration}, {@link UUID}</li>
|
||||
* <li>{@link Optional}, {@link List}</li>
|
||||
* <li>{@link Enum}</li>
|
||||
* <li>Java {@code record} types, recursively</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Type compatibility</h2>
|
||||
* <p>
|
||||
* 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}.
|
||||
* </p>
|
||||
*/
|
||||
@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<?>, 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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*/
|
||||
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 <T> 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<Object> 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<Object> ca = (Comparable<Object>) 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<Object> 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<String> 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<Object> 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<Object> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,8 +43,16 @@ import zeroecho.pki.api.PkiId;
|
||||
* Deterministic path mapping for the filesystem PKI store.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* <p>
|
||||
* 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/}.
|
||||
* </p>
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,38 +39,57 @@ import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Configuration for {@link FilesystemPkiStore}.
|
||||
* Options for {@link FilesystemPkiStore}.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>History tracking</h2>
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @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).
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* Type parameters used by PKI:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>Operation id: {@link PkiId}</li>
|
||||
* <li>Owner: {@link Principal}</li>
|
||||
* <li>Endpoint id: {@link String}</li>
|
||||
* <li>Result: {@link Object} (intentionally not persisted by the default
|
||||
* provider)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*/
|
||||
public interface AsyncBusProvider extends ConfigurableProvider<AsyncBus<PkiId, Principal, String, Object>> {
|
||||
// marker
|
||||
}
|
||||
57
pki/src/main/java/zeroecho/pki/spi/async/package-info.java
Normal file
57
pki/src/main/java/zeroecho/pki/spi/async/package-info.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Selection</h2>
|
||||
* <ul>
|
||||
* <li>{@code -Dzeroecho.pki.async=<id>}</li>
|
||||
* <li>{@code -Dzeroecho.pki.async.<key>=<value>}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Security</h2>
|
||||
* <p>
|
||||
* Provider configuration values may be sensitive and must not be logged. Only
|
||||
* provider ids and configuration keys are suitable for diagnostics.
|
||||
* </p>
|
||||
*/
|
||||
package zeroecho.pki.spi.async;
|
||||
@@ -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.).
|
||||
* </p>
|
||||
*
|
||||
* <h2>System property conventions</h2>
|
||||
@@ -71,6 +75,9 @@ import zeroecho.pki.spi.store.PkiStoreProvider;
|
||||
* {@code -Dzeroecho.pki.crypto.workflow=<id>}</li>
|
||||
* <li>Configure crypto workflow provider:
|
||||
* {@code -Dzeroecho.pki.crypto.workflow.<key>=<value>}</li>
|
||||
* <li>Select async bus provider: {@code -Dzeroecho.pki.async=<id>}</li>
|
||||
* <li>Configure async bus provider:
|
||||
* {@code -Dzeroecho.pki.async.<key>=<value>}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
@@ -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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* This method is safe: it does not log configuration values.
|
||||
* </p>
|
||||
*
|
||||
* @param provider provider (never {@code null})
|
||||
*/
|
||||
public static <T> void logSupportedKeys(ConfigurableProvider<T> 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.
|
||||
*
|
||||
* <p>
|
||||
* Selection and configuration follow the same conventions as
|
||||
* {@link #openStore()}, using {@code -Dzeroecho.pki.audit=<id>} and
|
||||
* {@code zeroecho.pki.audit.} prefixed properties.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* Conventions:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>Select provider: {@code -Dzeroecho.pki.crypto.workflow=<id>}</li>
|
||||
* <li>Provider config:
|
||||
* {@code -Dzeroecho.pki.crypto.workflow.<key>=<value>}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* Security note: configuration values may be sensitive and must not be logged.
|
||||
* This bootstrap logs only provider ids and configuration keys.
|
||||
* </p>
|
||||
*
|
||||
* @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<String, String> 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.
|
||||
*
|
||||
* <p>
|
||||
* Defaulting rule: if no backend id is specified, defaults to {@code file}.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @return async bus (never {@code null})
|
||||
*/
|
||||
public static AsyncBus<PkiId, Principal, String, Object> 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<String, String> 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.
|
||||
*
|
||||
* <p>
|
||||
* This method is safe: it does not log configuration values.
|
||||
* </p>
|
||||
*
|
||||
* @param provider provider (never {@code null})
|
||||
*/
|
||||
public static <T> void logSupportedKeys(ConfigurableProvider<T> provider) {
|
||||
Objects.requireNonNull(provider, "provider");
|
||||
if (LOG.isLoggable(Level.FINE)) {
|
||||
LOG.fine("Provider '" + provider.id() + "' supports keys: " + provider.supportedKeys());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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).
|
||||
* </p>
|
||||
*
|
||||
* <h2>Dependency boundary</h2>
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*/
|
||||
public interface CredentialFrameworkProvider extends ConfigurableProvider<CredentialFramework> {
|
||||
|
||||
/**
|
||||
* Allocates a credential framework instance using the provided configuration.
|
||||
*
|
||||
* <p>
|
||||
* Implementations must validate that {@link ProviderConfig#backendId()} matches
|
||||
* {@link #id()}. A mismatch must be reported as
|
||||
* {@link IllegalArgumentException}.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* This helper is intended for defensive checks inside provider implementations.
|
||||
* </p>
|
||||
*
|
||||
* @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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Security</h2>
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Error handling</h2>
|
||||
* <p>
|
||||
* <strong>Security requirements:</strong>
|
||||
* This SPI uses unchecked failures. Implementations should throw
|
||||
* {@link IllegalStateException} when an operation cannot be completed safely.
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>Private key material MUST NOT be persisted.</li>
|
||||
* <li>Secrets must never be stored in cleartext.</li>
|
||||
* <li>Stored objects must be treated as immutable unless explicitly
|
||||
* replaced.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public interface PkiStore {
|
||||
|
||||
@@ -80,62 +77,56 @@ public interface PkiStore {
|
||||
* Persists or updates a Certificate Authority (CA) record.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @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<CaRecord> getCa(PkiId caId);
|
||||
|
||||
/**
|
||||
* Lists all CA records known to the store.
|
||||
* Lists all stored CA records.
|
||||
*
|
||||
* <p>
|
||||
* No filtering is applied at this level; higher layers are expected to perform
|
||||
* query-based filtering.
|
||||
* </p>
|
||||
*
|
||||
* @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<CaRecord> listCas();
|
||||
|
||||
/**
|
||||
* Persists an issued credential.
|
||||
* Persists a credential record.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @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<Credential> getCredential(PkiId credentialId);
|
||||
|
||||
@@ -143,162 +134,233 @@ public interface PkiStore {
|
||||
* Persists a parsed certification request.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @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<ParsedCertificationRequest> getRequest(PkiId requestId);
|
||||
|
||||
/**
|
||||
* Persists a revocation record.
|
||||
* Persists or updates a revocation record.
|
||||
*
|
||||
* <p>
|
||||
* Revocation records are authoritative inputs for generating revocation status
|
||||
* objects (CRLs, OCSP, etc.).
|
||||
* </p>
|
||||
*
|
||||
* @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<RevokedRecord> 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<RevokedRecord> listRevocations();
|
||||
|
||||
/**
|
||||
* Persists a generated status object.
|
||||
* Persists a status object.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @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<StatusObject> 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
|
||||
* <p>
|
||||
* 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).
|
||||
* </p>
|
||||
*
|
||||
* @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<StatusObject> listStatusObjects(PkiId issuerCaId);
|
||||
|
||||
/**
|
||||
* Persists a publication record.
|
||||
* Persists or updates a publication record.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @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<PublicationRecord> listPublicationRecords();
|
||||
|
||||
/**
|
||||
* Persists or updates a certificate profile.
|
||||
*
|
||||
* @param profile certificate profile
|
||||
* @throws IllegalArgumentException if {@code profile} is null
|
||||
* @throws RuntimeException if persistence fails
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @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
|
||||
* <p>
|
||||
* 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"}).
|
||||
* </p>
|
||||
*
|
||||
* @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<CertificateProfile> 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<CertificateProfile> listProfiles();
|
||||
|
||||
/**
|
||||
* Persists a policy evaluation trace.
|
||||
* Persists a policy trace.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @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<PolicyTrace> getPolicyTrace(PkiId decisionId);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Workflow continuation state (orchestration)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Persists or updates an orchestrator workflow continuation record.
|
||||
*
|
||||
* <p>
|
||||
* The workflow payload may be encrypted. Implementations must treat it as
|
||||
* sensitive and must not log it.
|
||||
* </p>
|
||||
*
|
||||
* @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<WorkflowStateRecord> getWorkflowState(PkiId opId);
|
||||
|
||||
/**
|
||||
* Deletes workflow continuation state for a given operation.
|
||||
*
|
||||
* <p>
|
||||
* Implementations may retain historical snapshots according to their history
|
||||
* policy. Deletion guarantees only removal of the current record.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* This method is intended for administrative reconciliation and recovery.
|
||||
* Higher layers should apply access control and filtering as appropriate.
|
||||
* </p>
|
||||
*
|
||||
* @return list of workflow state records (never {@code null})
|
||||
* @throws IllegalStateException if listing fails
|
||||
*/
|
||||
List<WorkflowStateRecord> listWorkflowStates();
|
||||
}
|
||||
|
||||
88
pki/src/main/java/zeroecho/pki/util/PkiIds.java
Normal file
88
pki/src/main/java/zeroecho/pki/util/PkiIds.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Security</h2>
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*/
|
||||
public final class PkiIds {
|
||||
|
||||
private PkiIds() {
|
||||
throw new AssertionError("No instances.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new random PKI identifier.
|
||||
*
|
||||
* <p>
|
||||
* The returned identifier is based on {@link UUID#randomUUID()}.
|
||||
* </p>
|
||||
*
|
||||
* @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");
|
||||
}
|
||||
}
|
||||
156
pki/src/main/java/zeroecho/pki/util/async/AsyncBus.java
Normal file
156
pki/src/main/java/zeroecho/pki/util/async/AsyncBus.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* The bus is responsible for:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>tracking operation metadata and status transitions,</li>
|
||||
* <li>optional durability (implementation-defined),</li>
|
||||
* <li>dispatching events to registered handlers,</li>
|
||||
* <li>expiring operations that exceeded their deadline,</li>
|
||||
* <li>forgetting completed results once retrieved (to avoid unbounded
|
||||
* growth).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Result retention</h2>
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @param <OpId> operation id type
|
||||
* @param <Owner> owner type
|
||||
* @param <EndpointId> endpoint id type
|
||||
* @param <Result> result type
|
||||
*/
|
||||
public interface AsyncBus<OpId, Owner, EndpointId, Result> {
|
||||
|
||||
/**
|
||||
* Registers an endpoint instance under an identifier.
|
||||
*
|
||||
* <p>
|
||||
* Registration must be performed by the endpoint itself during construction or
|
||||
* activation, not by bootstrap code, to avoid tight coupling.
|
||||
* </p>
|
||||
*
|
||||
* @param endpointId endpoint id (never {@code null})
|
||||
* @param endpoint endpoint instance (never {@code null})
|
||||
*/
|
||||
void registerEndpoint(EndpointId endpointId, AsyncEndpoint<OpId, Result> endpoint);
|
||||
|
||||
/**
|
||||
* Subscribes a handler to status change events.
|
||||
*
|
||||
* @param handler handler (never {@code null})
|
||||
* @return registration handle (never {@code null})
|
||||
*/
|
||||
AsyncRegistration subscribe(AsyncHandler<OpId, Owner, EndpointId, Result> 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<OpId, Owner, EndpointId> submit(OpId opId, String type, Owner owner, EndpointId endpointId,
|
||||
Instant createdAt, Duration ttl);
|
||||
|
||||
/**
|
||||
* Updates status and optional result for an operation.
|
||||
*
|
||||
* <p>
|
||||
* Implementations must persist the transition before dispatching events.
|
||||
* </p>
|
||||
*
|
||||
* @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> result);
|
||||
|
||||
/**
|
||||
* Retrieves the latest known status for an operation.
|
||||
*
|
||||
* @param opId operation id (never {@code null})
|
||||
* @return status if known
|
||||
*/
|
||||
Optional<AsyncStatus> status(OpId opId);
|
||||
|
||||
/**
|
||||
* Returns immutable operation metadata if present.
|
||||
*
|
||||
* @param opId operation id (never {@code null})
|
||||
* @return snapshot if known
|
||||
*/
|
||||
Optional<AsyncOperationSnapshot<OpId, Owner, EndpointId>> 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<Result> consumeResult(OpId opId);
|
||||
|
||||
/**
|
||||
* Performs periodic maintenance:
|
||||
*
|
||||
* <ul>
|
||||
* <li>expires operations past their deadline,</li>
|
||||
* <li>polls endpoints for open operations (restart recovery),</li>
|
||||
* <li>dispatches any discovered transitions.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param now current time (never {@code null})
|
||||
*/
|
||||
void sweep(Instant now);
|
||||
}
|
||||
85
pki/src/main/java/zeroecho/pki/util/async/AsyncEndpoint.java
Normal file
85
pki/src/main/java/zeroecho/pki/util/async/AsyncEndpoint.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* The bus uses endpoints to:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>poll open operations on {@link AsyncBus#sweep(Instant)} (restart
|
||||
* recovery),</li>
|
||||
* <li>deliver status/result updates via
|
||||
* {@link AsyncBus#update(Object, AsyncStatus, Optional)}.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param <OpId> operation id type
|
||||
* @param <Res> result type
|
||||
*/
|
||||
public interface AsyncEndpoint<OpId, Res> {
|
||||
|
||||
/**
|
||||
* Returns a best-effort current status for a known operation.
|
||||
*
|
||||
* <p>
|
||||
* Endpoints should return a terminal status if the operation is complete. If
|
||||
* unknown, endpoints may return an empty optional.
|
||||
* </p>
|
||||
*
|
||||
* @param opId operation id (never {@code null})
|
||||
* @return status if known
|
||||
*/
|
||||
Optional<AsyncStatus> status(OpId opId);
|
||||
|
||||
/**
|
||||
* Returns a best-effort result for a successful operation.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @param opId operation id (never {@code null})
|
||||
* @return result if available
|
||||
*/
|
||||
Optional<Res> result(OpId opId);
|
||||
}
|
||||
65
pki/src/main/java/zeroecho/pki/util/async/AsyncEvent.java
Normal file
65
pki/src/main/java/zeroecho/pki/util/async/AsyncEvent.java
Normal file
@@ -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 <OpId> operation identifier type
|
||||
* @param <Owner> operation owner type
|
||||
* @param <Eid> endpoint identifier type
|
||||
* @param <Result> 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<OpId, Owner, Eid, Result>(AsyncOperationSnapshot<OpId, Owner, Eid> snapshot,
|
||||
AsyncStatus status, Optional<Result> 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");
|
||||
}
|
||||
}
|
||||
60
pki/src/main/java/zeroecho/pki/util/async/AsyncHandler.java
Normal file
60
pki/src/main/java/zeroecho/pki/util/async/AsyncHandler.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* Implementations must be exception-safe: the bus will treat any handler
|
||||
* exception as a handler failure and will continue dispatching to other
|
||||
* handlers.
|
||||
* </p>
|
||||
*
|
||||
* @param <OpId> operation id type
|
||||
* @param <Owner> owner type
|
||||
* @param <Eid> endpoint id type
|
||||
* @param <Result> result type
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface AsyncHandler<OpId, Owner, Eid, Result> {
|
||||
|
||||
/**
|
||||
* Receives an event notification.
|
||||
*
|
||||
* @param event event (never {@code null})
|
||||
*/
|
||||
void onEvent(AsyncEvent<OpId, Owner, Eid, Result> event);
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* This object intentionally does not contain secrets. Payloads should be stored
|
||||
* outside the bus (or referenced indirectly).
|
||||
* </p>
|
||||
*
|
||||
* @param <OpId> operation identifier type
|
||||
* @param <Owner> operation owner type
|
||||
* @param <EndpointId> 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, Owner, EndpointId>(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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* Closing the handle unregisters the handler. Implementations must be
|
||||
* idempotent.
|
||||
* </p>
|
||||
*/
|
||||
package zeroecho.pki.internal;
|
||||
@SuppressWarnings("PMD.ImplicitFunctionalInterface")
|
||||
public interface AsyncRegistration extends Closeable {
|
||||
|
||||
/**
|
||||
* Unregisters the handler.
|
||||
*/
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
109
pki/src/main/java/zeroecho/pki/util/async/AsyncState.java
Normal file
109
pki/src/main/java/zeroecho/pki/util/async/AsyncState.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* The state machine is intentionally simple and audit-friendly. Implementations
|
||||
* may attach additional non-sensitive detail through
|
||||
* {@link AsyncStatus#detailCode()} and {@link AsyncStatus#details()}.
|
||||
* </p>
|
||||
*/
|
||||
public enum AsyncState {
|
||||
|
||||
/**
|
||||
* The operation has been created and is awaiting processing.
|
||||
*
|
||||
* <p>
|
||||
* Example: a certificate issuance request has been accepted, but is waiting for
|
||||
* an operator approval step.
|
||||
* </p>
|
||||
*/
|
||||
SUBMITTED,
|
||||
|
||||
/**
|
||||
* The operation is currently being processed.
|
||||
*
|
||||
* <p>
|
||||
* Example: an issuance request is being built, proof-of-possession verified, or
|
||||
* a signing workflow is executing.
|
||||
* </p>
|
||||
*/
|
||||
RUNNING,
|
||||
|
||||
/**
|
||||
* The operation completed successfully and the result is available for
|
||||
* retrieval.
|
||||
*
|
||||
* <p>
|
||||
* Example: a certificate (or bundle) has been issued and is ready to be
|
||||
* fetched.
|
||||
* </p>
|
||||
*/
|
||||
SUCCEEDED,
|
||||
|
||||
/**
|
||||
* The operation completed unsuccessfully (terminal failure).
|
||||
*
|
||||
* <p>
|
||||
* 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()}.
|
||||
* </p>
|
||||
*/
|
||||
FAILED,
|
||||
|
||||
/**
|
||||
* The operation was cancelled by a caller or operator.
|
||||
*
|
||||
* <p>
|
||||
* Example: an administrator cancelled a long-running operation before it was
|
||||
* approved.
|
||||
* </p>
|
||||
*/
|
||||
CANCELLED,
|
||||
|
||||
/**
|
||||
* The operation exceeded its declared deadline and was expired by the bus.
|
||||
*
|
||||
* <p>
|
||||
* Example: a request had a 24h approval window; after that it is no longer
|
||||
* eligible for completion and is treated as expired.
|
||||
* </p>
|
||||
*/
|
||||
EXPIRED
|
||||
}
|
||||
80
pki/src/main/java/zeroecho/pki/util/async/AsyncStatus.java
Normal file
80
pki/src/main/java/zeroecho/pki/util/async/AsyncStatus.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* This object is safe to expose via APIs and logs (assuming the producer
|
||||
* respects the no-secrets constraint for detail fields).
|
||||
* </p>
|
||||
*
|
||||
* @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<String> detailCode,
|
||||
Map<String, String> 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;
|
||||
}
|
||||
}
|
||||
63
pki/src/main/java/zeroecho/pki/util/async/codec/IdCodec.java
Normal file
63
pki/src/main/java/zeroecho/pki/util/async/codec/IdCodec.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* The encoded form MUST NOT embed secrets. It is intended for identifiers only.
|
||||
* </p>
|
||||
*
|
||||
* @param <T> identifier type
|
||||
*/
|
||||
public interface IdCodec<T> {
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
114
pki/src/main/java/zeroecho/pki/util/async/codec/ResultCodec.java
Normal file
114
pki/src/main/java/zeroecho/pki/util/async/codec/ResultCodec.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @param <R> result type
|
||||
*/
|
||||
public interface ResultCodec<R> {
|
||||
|
||||
/**
|
||||
* 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 <R> result type
|
||||
* @return codec that rejects encode/decode and signals non-persistence
|
||||
*/
|
||||
static <R> ResultCodec<R> 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<R> decodeOptional(String token) {
|
||||
if (token == null || token.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(decode(token));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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()}.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Security</h2>
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*/
|
||||
package zeroecho.pki.util.async.codec;
|
||||
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* This is a minimal durability helper for the async bus. It does not interpret
|
||||
* content; it only appends and replays lines.
|
||||
* </p>
|
||||
*/
|
||||
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<String> 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<PosixFilePermission> 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<PosixFilePermission> 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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* Log format is internal and versioned by a leading token. Corrupted lines are
|
||||
* ignored with a warning (without logging line content).
|
||||
* </p>
|
||||
*
|
||||
* <h2>No-secret logging</h2>
|
||||
* <p>
|
||||
* This implementation logs only operation ids (encoded) and types, never
|
||||
* payloads or serialized results.
|
||||
* </p>
|
||||
*
|
||||
* @param <OpId> operation id type
|
||||
* @param <Owner> owner type
|
||||
* @param <EndpointId> endpoint id type
|
||||
* @param <Result> result type
|
||||
*/
|
||||
public final class DurableAsyncBus<OpId, Owner, EndpointId, Result> // NOPMD
|
||||
implements AsyncBus<OpId, Owner, EndpointId, Result> {
|
||||
|
||||
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<OpId> opIdCodec;
|
||||
private final IdCodec<Owner> ownerCodec;
|
||||
private final IdCodec<EndpointId> endpointCodec;
|
||||
private final ResultCodec<Result> resultCodec;
|
||||
private final AppendOnlyLineStore store;
|
||||
|
||||
private final Map<OpId, AsyncOperationSnapshot<OpId, Owner, EndpointId>> active;
|
||||
private final Map<OpId, AsyncStatus> lastStatus;
|
||||
private final Map<OpId, Result> results; // removed on consume
|
||||
private final Map<EndpointId, AsyncEndpoint<OpId, Result>> endpointsById;
|
||||
private final List<AsyncHandler<OpId, Owner, EndpointId, Result>> 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<OpId> opIdCodec, IdCodec<Owner> ownerCodec, IdCodec<EndpointId> endpointCodec,
|
||||
ResultCodec<Result> 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<OpId, Result> 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<OpId, Owner, EndpointId, Result> 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<OpId, Owner, EndpointId> 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<OpId, Owner, EndpointId> 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> result) {
|
||||
Objects.requireNonNull(opId, "opId");
|
||||
Objects.requireNonNull(status, "status");
|
||||
Objects.requireNonNull(result, "result");
|
||||
|
||||
AsyncOperationSnapshot<OpId, Owner, EndpointId> 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<AsyncStatus> status(OpId opId) {
|
||||
Objects.requireNonNull(opId, "opId");
|
||||
return Optional.ofNullable(lastStatus.get(opId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AsyncOperationSnapshot<OpId, Owner, EndpointId>> snapshot(OpId opId) {
|
||||
Objects.requireNonNull(opId, "opId");
|
||||
return Optional.ofNullable(active.get(opId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Result> 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<AsyncOperationSnapshot<OpId, Owner, EndpointId>> open = List.copyOf(active.values());
|
||||
for (AsyncOperationSnapshot<OpId, Owner, EndpointId> snap : open) {
|
||||
OpId opId = snap.opId();
|
||||
AsyncStatus st = lastStatus.get(opId);
|
||||
if (st != null && st.isTerminal()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
AsyncEndpoint<OpId, Result> endpoint = endpointsById.get(snap.endpointId());
|
||||
if (endpoint == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Optional<AsyncStatus> 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<Result> 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<Map.Entry<OpId, AsyncOperationSnapshot<OpId, Owner, EndpointId>>> entries = List.copyOf(active.entrySet());
|
||||
for (Map.Entry<OpId, AsyncOperationSnapshot<OpId, Owner, EndpointId>> e : entries) {
|
||||
OpId opId = e.getKey();
|
||||
AsyncOperationSnapshot<OpId, Owner, EndpointId> 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<OpId, Owner, EndpointId> snap, AsyncStatus status,
|
||||
Optional<Result> result) {
|
||||
|
||||
AsyncEvent<OpId, Owner, EndpointId, Result> event = new AsyncEvent<>(snap, status, result);
|
||||
|
||||
List<AsyncHandler<OpId, Owner, EndpointId, Result>> copy = List.copyOf(handlers);
|
||||
for (AsyncHandler<OpId, Owner, EndpointId, Result> 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<String> 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<OpId, Owner, EndpointId> 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<String> dc = parts[4].isBlank() ? Optional.empty() : Optional.of(parts[4]);
|
||||
Map<String, String> 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<OpId, Owner, EndpointId> 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<String, String> details) {
|
||||
if (details == null || details.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder(64);
|
||||
boolean first = true;
|
||||
for (Map.Entry<String, String> 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<String, String> decodeDetails(String token) {
|
||||
if (token == null || token.isBlank()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, String> 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 "<opId>";
|
||||
}
|
||||
}
|
||||
|
||||
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 "<endpoint>";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* This package contains durability and recovery helpers (append-only line
|
||||
* store) and a generic durable bus implementation.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Operational model</h2>
|
||||
* <ul>
|
||||
* <li>State is held in memory for speed.</li>
|
||||
* <li>Transitions are appended to an on-disk log for restart recovery.</li>
|
||||
* <li>Restart recovery replays the log and then relies on {@code sweep(...)} to
|
||||
* synchronize open operations with their endpoints.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Security</h2>
|
||||
* <p>
|
||||
* Implementations must never log secrets, payloads, private keys, or internal
|
||||
* cryptographic primitive state. Persisted content must contain only
|
||||
* identifiers and non-sensitive status metadata.
|
||||
* </p>
|
||||
*/
|
||||
package zeroecho.pki.util.async.impl;
|
||||
70
pki/src/main/java/zeroecho/pki/util/async/package-info.java
Normal file
70
pki/src/main/java/zeroecho/pki/util/async/package-info.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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:
|
||||
* </p>
|
||||
*
|
||||
* <ul>
|
||||
* <li>operation identifier type (e.g., {@code PkiId}),</li>
|
||||
* <li>operation owner type (e.g., {@code Principal}),</li>
|
||||
* <li>endpoint identifier type (e.g., {@code String}),</li>
|
||||
* <li>result type (application-specific, may be empty).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Durability model</h2>
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Security</h2>
|
||||
* <ul>
|
||||
* <li>No secrets must be logged.</li>
|
||||
* <li>Persistence encodings should avoid plaintext secrets. If sensitive result
|
||||
* data exists, store it encrypted outside of this package or provide a redacted
|
||||
* {@code ResultCodec}.</li>
|
||||
* </ul>
|
||||
*/
|
||||
package zeroecho.pki.util.async;
|
||||
97
pki/src/main/java/zeroecho/pki/util/package-info.java
Normal file
97
pki/src/main/java/zeroecho/pki/util/package-info.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>deterministic and side-effect free,</li>
|
||||
* <li>safe to use across different runtime environments,</li>
|
||||
* <li>independent of persistence, networking, or cryptographic providers.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Package structure</h2>
|
||||
* <p>
|
||||
* The package is intentionally shallow at its top level and consists of:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>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.</li>
|
||||
* <li>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.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Design principles</h2>
|
||||
* <p>
|
||||
* Utilities in this package follow strict design rules:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>no global mutable state,</li>
|
||||
* <li>no hidden caching with externally visible effects,</li>
|
||||
* <li>no dependency on environment-specific configuration,</li>
|
||||
* <li>no leakage of sensitive material (keys, secrets, plaintext).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* Where randomness or uniqueness is required (e.g. identifier generation), the
|
||||
* semantics are explicitly documented and predictable within the guarantees of
|
||||
* the underlying platform.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Relation to other layers</h2>
|
||||
* <p>
|
||||
* This package is deliberately orthogonal to:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>API domain packages (such as {@code zeroecho.pki.api.*}),</li>
|
||||
* <li>SPI and implementation layers,</li>
|
||||
* <li>application bootstrap and orchestration code.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* As such, utilities defined here may be freely reused by API, SPI, and
|
||||
* implementation code without creating circular dependencies or architectural
|
||||
* coupling.
|
||||
* </p>
|
||||
*
|
||||
* @since 1.0
|
||||
*/
|
||||
package zeroecho.pki.util;
|
||||
@@ -0,0 +1 @@
|
||||
zeroecho.pki.impl.async.FileBackedAsyncBusProvider
|
||||
@@ -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}.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* Every test routine prints its own name and prints {@code ...ok} on success.
|
||||
* Important intermediate values are printed with {@code "..."} prefix.
|
||||
* Output conventions:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>Each test prints its own name at the start.</li>
|
||||
* <li>Each test prints {@code ...ok} on success.</li>
|
||||
* <li>Important intermediate state is printed with {@code ...} prefix.</li>
|
||||
* </ul>
|
||||
*/
|
||||
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<CaRecord> loaded = store.getCa(caId);
|
||||
Optional<CaRecord> 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<RevokedRecord> 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<Long> 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<CaRecord> cas = s2.listCas();
|
||||
List<CertificateProfile> profiles = s2.listProfiles();
|
||||
|
||||
Set<String> caIds = cas.stream().map(r -> r.caId().toString()).collect(Collectors.toSet());
|
||||
Set<String> 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<String> caIds = snap.listCas().stream().map(r -> r.caId().toString()).collect(Collectors.toSet());
|
||||
Set<String> 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<Long> listHistoryMicrosSorted(final Path historyDir) throws IOException {
|
||||
List<Long> out = new ArrayList<>();
|
||||
try (DirectoryStream<Path> 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("... <missing>");
|
||||
System.out.println("...<missing> " + 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<Path> 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 "<null>";
|
||||
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<CertificateProfile> 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<Credential> 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<PkiId> 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).
|
||||
*
|
||||
* <p>
|
||||
* The production API defines {@link AttributeSet} as a passive interface
|
||||
* without factories. Tests only require an immutable empty set.
|
||||
* </p>
|
||||
* @param entries serialized entries
|
||||
*/
|
||||
static final class EmptyAttributeSet implements AttributeSet {
|
||||
public record TestAttributeSet(List<Entry> entries) implements AttributeSet {
|
||||
|
||||
@Override
|
||||
public Set<AttributeId> ids() {
|
||||
return Set.of();
|
||||
public record Entry(AttributeId id, List<AttributeValue> 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<AttributeValue> get(final AttributeId id) {
|
||||
Objects.requireNonNull(id, "id");
|
||||
public Set<AttributeId> ids() {
|
||||
return entries.stream().map(Entry::id).collect(Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AttributeValue> 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<AttributeValue> getAll(final AttributeId id) {
|
||||
Objects.requireNonNull(id, "id");
|
||||
public List<AttributeValue> 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<Credential> 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<AttributeId> required = List.of(new AttributeId("req-1"));
|
||||
List<AttributeId> optional = List.of(new AttributeId("opt-1"));
|
||||
|
||||
Optional<Duration> 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
112
pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java
Normal file
112
pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java
Normal file
@@ -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<String> list = List.of("a", "b");
|
||||
byte[] listData = FsCodec.encode(list);
|
||||
Object decodedList = FsCodec.decode(listData, Object.class);
|
||||
assertEquals(list, decodedList);
|
||||
|
||||
Optional<Integer> 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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<PkiId, Principal, String, String> bus = new DurableAsyncBus<PkiId, Principal, String, String>(
|
||||
PkiCodecs.PKI_ID, PkiCodecs.PRINCIPAL, PkiCodecs.STRING, new ResultCodec<String>() {
|
||||
@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<PkiId, String>() {
|
||||
@Override
|
||||
public Optional<AsyncStatus> status(PkiId id) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> 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<String> consumed = bus.consumeResult(opId);
|
||||
System.out.println("...consumed=" + (consumed.isPresent() ? consumed.get() : "<empty>"));
|
||||
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<PkiId, Principal, String, String> bus1 = new DurableAsyncBus<PkiId, Principal, String, String>(
|
||||
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<PkiId, Principal, String, String> bus2 = new DurableAsyncBus<PkiId, Principal, String, String>(
|
||||
PkiCodecs.PKI_ID, PkiCodecs.PRINCIPAL, PkiCodecs.STRING, ResultCodec.none(), store2);
|
||||
|
||||
Optional<AsyncOperationSnapshot<PkiId, Principal, String>> snap = bus2.snapshot(opId);
|
||||
Optional<AsyncStatus> 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user