From d2ec77b8e36c7264bf701ecfc012d6e77f6a2e04 Mon Sep 17 00:00:00 2001
From: Leo Galambos
Date: Mon, 29 Dec 2025 22:41:11 +0100
Subject: [PATCH] feat: introduce SignatureWorkflow SPI and zeroecho-lib
implementation
- add SignatureWorkflow SPI for asynchronous sign/verify operations
- define audit-friendly, exception-free failure model
- introduce stable OperationStatus, State and OperationResult semantics
- document trust boundaries, lifecycle, and audit constraints in SPI
JavaDoc
- add ZeroEchoLibSignatureWorkflow backed by KeyringStore and ZeroEcho
lib
- enforce opaque KeyRef handling and provider-local parsing
- add deterministic detail codes and UNKNOWN_OPERATION handling
- integrate workflow provider into ServiceLoader bootstrap
- align PkiBootstrap logging and defaults with crypto workflow SPI
- add comprehensive JUnit tests for validation and status semantics
Signed-off-by: Leo Galambos
---
.../ZeroEchoLibSignatureWorkflow.java | 573 ++++++++++++++++++
.../ZeroEchoLibSignatureWorkflowProvider.java | 97 +++
.../impl/crypto/zeroecholib/package-info.java | 69 +++
.../pki/spi/bootstrap/PkiBootstrap.java | 86 ++-
.../pki/spi/crypto/SignatureWorkflow.java | 435 +++++++++++++
.../spi/crypto/SignatureWorkflowProvider.java | 44 ++
.../zeroecho/pki/spi/crypto/package-info.java | 64 ++
...o.pki.spi.crypto.SignatureWorkflowProvider | 1 +
.../ZeroEchoLibKeyRefParsingTest.java | 131 ++++
.../pki/spi/bootstrap/PkiBootstrapTest.java | 1 -
10 files changed, 1478 insertions(+), 23 deletions(-)
create mode 100644 pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflow.java
create mode 100644 pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowProvider.java
create mode 100644 pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/package-info.java
create mode 100644 pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflow.java
create mode 100644 pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflowProvider.java
create mode 100644 pki/src/main/java/zeroecho/pki/spi/crypto/package-info.java
create mode 100644 pki/src/main/resources/META-INF/services/zeroecho.pki.spi.crypto.SignatureWorkflowProvider
create mode 100644 pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibKeyRefParsingTest.java
diff --git a/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflow.java b/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflow.java
new file mode 100644
index 0000000..401ef45
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflow.java
@@ -0,0 +1,573 @@
+/*******************************************************************************
+ * 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.crypto.zeroecholib;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.security.GeneralSecurityException;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import zeroecho.core.CryptoAlgorithms;
+import zeroecho.core.KeyUsage;
+import zeroecho.core.context.SignatureContext;
+import zeroecho.core.io.TailStrippingInputStream;
+import zeroecho.core.storage.KeyringStore;
+import zeroecho.pki.api.EncodedObject;
+import zeroecho.pki.api.Encoding;
+import zeroecho.pki.api.KeyRef;
+import zeroecho.pki.api.PkiId;
+import zeroecho.pki.spi.crypto.SignatureWorkflow;
+
+/**
+ * ZeroEcho-lib backed {@link SignatureWorkflow}.
+ *
+ * Failure model
+ *
+ * Validation and policy failures must not surface as uncaught exceptions.
+ * Instead, this provider always returns an operation id and exposes failures
+ * via {@link #status(PkiId)} as {@link State#FAILED} with a stable detail code.
+ *
+ *
+ * Security
+ *
+ * - Never logs {@link KeyRef} values.
+ * - Never logs payload bytes or signature bytes.
+ * - Never returns private key material; private keys are materialized only
+ * within this boundary.
+ *
+ */
+public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { // NOPMD
+
+ private static final Logger LOG = Logger.getLogger(ZeroEchoLibSignatureWorkflow.class.getName());
+
+ // Stable detail codes (non-secret), intended for audit + transport mapping.
+ public static final String DC_SUBMITTED = "SUBMITTED";
+ public static final String DC_SIGNED = "SIGNED";
+ public static final String DC_VERIFIED = "VERIFIED";
+ public static final String DC_REJECTED = "REJECTED";
+ public static final String DC_CANCELLED = "CANCELLED";
+
+ public static final String DC_INVALID_KEYREF_SUFFIX = "INVALID_KEYREF_SUFFIX";
+ public static final String DC_INVALID_KEYREF_PREFIX = "INVALID_KEYREF_PREFIX";
+ public static final String DC_UNSUPPORTED_PUBLICKEY_FORM = "UNSUPPORTED_PUBLICKEY_FORM"; // NOPMD
+ public static final String DC_INVALID_ALGORITHM_ID = "INVALID_ALGORITHM_ID";
+ public static final String DC_ALGORITHM_MISMATCH = "ALGORITHM_MISMATCH";
+ public static final String DC_KEYRING_IO_ERROR = "KEYRING_IO_ERROR";
+ public static final String DC_KEY_NOT_FOUND = "KEY_NOT_FOUND";
+ public static final String DC_CRYPTO_FAILURE = "CRYPTO_FAILURE";
+ public static final String DC_UNSUPPORTED_SIGNATURE_ENCODING = "UNSUPPORTED_SIGNATURE_ENCODING"; // NOPMD
+ public static final String DC_UNKNOWN_OPERATION = "UNKNOWN_OPERATION";
+
+ private static final OperationStatus UNKNOWN_OPERATION_STATUS = new OperationStatus(State.FAILED, Instant.EPOCH,
+ Optional.of(DC_UNKNOWN_OPERATION), Optional.empty());
+
+ private final String id;
+ private final java.nio.file.Path keyringPath;
+ private final String keyRefPrefix;
+ private final boolean requireComponentSuffix;
+
+ private final Map statuses;
+ private final Map sinks;
+
+ private volatile KeyringStore keyringOrNull; // NOPMD
+
+ /* default */ ZeroEchoLibSignatureWorkflow(String id, java.nio.file.Path keyringPath, String keyRefPrefix,
+ boolean requireComponentSuffix) {
+ if (id == null || id.isBlank()) {
+ throw new IllegalArgumentException("id must not be blank");
+ }
+ if (keyringPath == null) {
+ throw new IllegalArgumentException("keyringPath must not be null");
+ }
+ if (keyRefPrefix == null) {
+ throw new IllegalArgumentException("keyRefPrefix must not be null");
+ }
+ this.id = id;
+ this.keyringPath = keyringPath;
+ this.keyRefPrefix = keyRefPrefix;
+ this.requireComponentSuffix = requireComponentSuffix;
+
+ this.statuses = Collections.synchronizedMap(new HashMap<>());
+ this.sinks = Collections.synchronizedMap(new HashMap<>());
+ }
+
+ @Override
+ public String id() {
+ return this.id;
+ }
+
+ @Override
+ public Set supportedAlgorithms() {
+ // Informational only; runtime policy/key availability may still reject.
+ return CryptoAlgorithms.available();
+ }
+
+ @Override
+ public PkiId submitSign(SignRequest request) {
+ if (request == null) {
+ throw new IllegalArgumentException("request must not be null");
+ }
+
+ PkiId opId = newOperationId();
+ putStatus(opId, new OperationStatus(State.RUNNING, Instant.now(), Optional.of(DC_SUBMITTED), Optional.empty()));
+
+ try {
+ KeyRefParts parts = parseKeyRefOrThrow(request.keyRef(), true);
+
+ if (request.algorithmId() == null || request.algorithmId().isBlank()) {
+ throw new InvalidRequestException(DC_INVALID_ALGORITHM_ID);
+ }
+
+ KeyringStore ks = requireKeyringOrThrow();
+
+ KeyringStore.PrivateWithId prv;
+ KeyringStore.PublicWithId pub;
+ try {
+ prv = ks.getPrivateWithId(parts.privateAlias);
+ pub = ks.getPublicWithId(parts.publicAlias);
+ } catch (GeneralSecurityException missing) {
+ throw new InvalidRequestException(DC_KEY_NOT_FOUND, missing);
+ }
+
+ enforceAlgorithmMatchOrThrow(request.algorithmId(), prv.algorithm());
+
+ byte[] signatureBytes = signStreaming(request.algorithmId(), prv.key(), pub.key(),
+ request.payload().bytes());
+
+ Encoding outEnc = request.preferredSignatureEncoding().orElse(Encoding.BINARY);
+ EncodedObject signature = encodeSignatureOrThrow(outEnc, signatureBytes);
+
+ OperationResult result = new OperationResult(Optional.of(signature), Optional.empty());
+ putStatus(opId,
+ new OperationStatus(State.SUCCEEDED, Instant.now(), Optional.of(DC_SIGNED), Optional.of(result)));
+ return opId;
+
+ } catch (InvalidRequestException inv) { // NOPMD
+ putStatus(opId,
+ new OperationStatus(State.FAILED, Instant.now(), Optional.of(inv.detailCode), Optional.empty()));
+ return opId;
+
+ } catch (IOException io) {
+ putStatus(opId, new OperationStatus(State.FAILED, Instant.now(), Optional.of(DC_KEYRING_IO_ERROR),
+ Optional.empty()));
+ if (LOG.isLoggable(Level.FINE)) {
+ LOG.log(Level.FINE, "Sign failed due to IO (details suppressed)", io);
+ }
+ return opId;
+
+ } catch (GeneralSecurityException sec) {
+ putStatus(opId,
+ new OperationStatus(State.FAILED, Instant.now(), Optional.of(DC_CRYPTO_FAILURE), Optional.empty()));
+ if (LOG.isLoggable(Level.FINE)) {
+ LOG.log(Level.FINE, "Sign failed due to security exception (details suppressed)", sec);
+ }
+ return opId;
+
+ } catch (RuntimeException ex) { // NOPMD
+ putStatus(opId,
+ new OperationStatus(State.FAILED, Instant.now(), Optional.of(DC_CRYPTO_FAILURE), Optional.empty()));
+ if (LOG.isLoggable(Level.FINE)) {
+ LOG.log(Level.FINE, "Sign failed due to runtime exception (details suppressed)", ex);
+ }
+ return opId;
+ }
+ }
+
+ @Override
+ public PkiId submitVerify(VerifyRequest request) {
+ if (request == null) {
+ throw new IllegalArgumentException("request must not be null");
+ }
+
+ PkiId opId = newOperationId();
+ putStatus(opId, new OperationStatus(State.RUNNING, Instant.now(), Optional.of(DC_SUBMITTED), Optional.empty()));
+
+ try {
+ if (request.algorithmId() == null || request.algorithmId().isBlank()) {
+ throw new InvalidRequestException(DC_INVALID_ALGORITHM_ID);
+ }
+
+ // Provider choice: full PQC/hybrid support requires KeyRef for public keys.
+ // Encoded public keys are not supported here; treat as FAILED (Variant 1).
+ if (request.publicKeyRef().isEmpty() && request.publicKeyEncoded().isPresent()) {
+ throw new InvalidRequestException(DC_UNSUPPORTED_PUBLICKEY_FORM);
+ }
+
+ PublicKey pub = resolvePublicKeyOrThrow(request);
+
+ byte[] sigBytes = decodeSignatureOrThrow(request.signature());
+ boolean ok = verifyStreaming(request.algorithmId(), pub, request.payload().bytes(), sigBytes);
+
+ OperationResult result = new OperationResult(Optional.empty(), Optional.of(ok));
+ String dc = ok ? DC_VERIFIED : DC_REJECTED;
+ putStatus(opId, new OperationStatus(State.SUCCEEDED, Instant.now(), Optional.of(dc), Optional.of(result)));
+ return opId;
+
+ } catch (InvalidRequestException inv) { // NOPMD
+ putStatus(opId,
+ new OperationStatus(State.FAILED, Instant.now(), Optional.of(inv.detailCode), Optional.empty()));
+ return opId;
+
+ } catch (IOException io) {
+ putStatus(opId, new OperationStatus(State.FAILED, Instant.now(), Optional.of(DC_KEYRING_IO_ERROR),
+ Optional.empty()));
+ if (LOG.isLoggable(Level.FINE)) {
+ LOG.log(Level.FINE, "Verify failed due to IO (details suppressed)", io);
+ }
+ return opId;
+
+ } catch (GeneralSecurityException sec) {
+ putStatus(opId,
+ new OperationStatus(State.FAILED, Instant.now(), Optional.of(DC_CRYPTO_FAILURE), Optional.empty()));
+ if (LOG.isLoggable(Level.FINE)) {
+ LOG.log(Level.FINE, "Verify failed due to security exception (details suppressed)", sec);
+ }
+ return opId;
+
+ } catch (RuntimeException ex) { // NOPMD
+ putStatus(opId,
+ new OperationStatus(State.FAILED, Instant.now(), Optional.of(DC_CRYPTO_FAILURE), Optional.empty()));
+ if (LOG.isLoggable(Level.FINE)) {
+ LOG.log(Level.FINE, "Verify failed due to runtime exception (details suppressed)", ex);
+ }
+ return opId;
+ }
+ }
+
+ @Override
+ public OperationStatus status(PkiId operationId) {
+ if (operationId == null) {
+ throw new IllegalArgumentException("operationId must not be null");
+ }
+ OperationStatus st = this.statuses.get(operationId);
+ if (st == null) {
+ return UNKNOWN_OPERATION_STATUS;
+ }
+ return st;
+ }
+
+ @Override
+ public boolean cancel(PkiId operationId, String reason) {
+ if (operationId == null) {
+ throw new IllegalArgumentException("operationId must not be null");
+ }
+ if (reason == null || reason.isBlank()) {
+ throw new IllegalArgumentException("reason must not be blank");
+ }
+ OperationStatus st = this.statuses.get(operationId);
+ if (st == null || st.isTerminal()) {
+ return false;
+ }
+ putStatus(operationId,
+ new OperationStatus(State.CANCELLED, Instant.now(), Optional.of(DC_CANCELLED), Optional.empty()));
+ return true;
+ }
+
+ @Override
+ public Registration register(NotificationSink sink) {
+ if (sink == null) {
+ throw new IllegalArgumentException("sink must not be null");
+ }
+ PkiId regId = newOperationId();
+ this.sinks.put(regId, sink);
+ return new Registration() {
+ @Override
+ public void close() {
+ sinks.remove(regId);
+ }
+ };
+ }
+
+ @Override
+ public void close() {
+ this.statuses.clear();
+ this.sinks.clear();
+ this.keyringOrNull = null;
+ }
+
+ private KeyringStore requireKeyringOrThrow() throws IOException {
+ KeyringStore ks = this.keyringOrNull;
+ if (ks != null) {
+ return ks;
+ }
+ KeyringStore loaded = KeyringStore.load(this.keyringPath);
+ this.keyringOrNull = loaded;
+ return loaded;
+ }
+
+ private static void enforceAlgorithmMatchOrThrow(String requested, String stored) throws InvalidRequestException {
+ if (!requested.equals(stored)) {
+ throw new InvalidRequestException(DC_ALGORITHM_MISMATCH);
+ }
+ }
+
+ private KeyRefParts parseKeyRefOrThrow(KeyRef keyRef, boolean forSigning) throws InvalidRequestException {
+ String raw = keyRef.value();
+ if (!raw.startsWith(this.keyRefPrefix)) {
+ throw new InvalidRequestException(DC_INVALID_KEYREF_PREFIX);
+ }
+
+ String v = raw.substring(this.keyRefPrefix.length());
+ boolean hasPrv = v.endsWith(".prv");
+ boolean hasPub = v.endsWith(".pub");
+
+ if (this.requireComponentSuffix) {
+ if (forSigning && !hasPrv) {
+ throw new InvalidRequestException(DC_INVALID_KEYREF_SUFFIX);
+ }
+ if (!forSigning && !hasPub) {
+ throw new InvalidRequestException(DC_INVALID_KEYREF_SUFFIX);
+ }
+ }
+
+ if (forSigning) {
+ String privateAlias = hasPrv ? v : (v + ".prv");
+ String base = hasPrv ? v.substring(0, v.length() - 4) : v;
+ String publicAlias = base + ".pub";
+ return new KeyRefParts(privateAlias, publicAlias);
+ }
+
+ String publicAlias = hasPub ? v : (v + ".pub");
+ return new KeyRefParts(publicAlias, publicAlias);
+ }
+
+ private PublicKey resolvePublicKeyOrThrow(VerifyRequest request)
+ throws InvalidRequestException, IOException, GeneralSecurityException {
+
+ if (request.publicKeyRef().isPresent()) {
+ KeyRefParts parts = parseKeyRefOrThrow(request.publicKeyRef().get(), false);
+
+ KeyringStore ks = requireKeyringOrThrow();
+
+ KeyringStore.PublicWithId pub;
+ try {
+ pub = ks.getPublicWithId(parts.publicAlias);
+ } catch (GeneralSecurityException missing) {
+ throw new InvalidRequestException(DC_KEY_NOT_FOUND, missing);
+ }
+
+ enforceAlgorithmMatchOrThrow(request.algorithmId(), pub.algorithm());
+ return pub.key();
+ }
+
+ throw new InvalidRequestException(DC_UNSUPPORTED_PUBLICKEY_FORM);
+ }
+
+ private static byte[] signStreaming(String algorithmId, PrivateKey prv, PublicKey pub, byte[] msg)
+ throws GeneralSecurityException, IOException {
+
+ int sigLen;
+ try (SignatureContext verifier = CryptoAlgorithms.create(algorithmId, KeyUsage.VERIFY, pub)) {
+ sigLen = verifier.tagLength();
+ }
+
+ try (SignatureContext signer = CryptoAlgorithms.create(algorithmId, KeyUsage.SIGN, prv)) {
+ final byte[][] sigHolder = new byte[1][];
+ try (InputStream in = new TailStrippingInputStream(signer.wrap(new ByteArrayInputStream(msg)), sigLen,
+ 8192) {
+ @Override
+ protected void processTail(byte[] tail) throws IOException {
+ sigHolder[0] = (tail == null) ? null : tail.clone();
+ }
+ }) {
+ in.transferTo(OutputStream.nullOutputStream());
+ }
+
+ byte[] sig = sigHolder[0];
+ if (sig == null || sig.length == 0) {
+ throw new GeneralSecurityException("Signature trailer missing.");
+ }
+ return sig;
+ }
+ }
+
+ private static boolean verifyStreaming(String algorithmId, PublicKey pub, byte[] msg, byte[] signature)
+ throws GeneralSecurityException, IOException {
+
+ try (SignatureContext verifier = CryptoAlgorithms.create(algorithmId, KeyUsage.VERIFY, pub)) {
+ verifier.setExpectedTag(signature);
+ try (InputStream in = verifier.wrap(new ByteArrayInputStream(msg))) {
+ in.transferTo(OutputStream.nullOutputStream());
+ }
+ return true;
+ } catch (Exception mismatch) {
+ return false;
+ }
+ }
+
+ private static EncodedObject encodeSignatureOrThrow(Encoding encoding, byte[] sigBytes)
+ throws InvalidRequestException {
+ if (encoding == Encoding.BINARY || encoding == Encoding.DER) {
+ return new EncodedObject(encoding, sigBytes);
+ }
+ if (encoding == Encoding.PEM) {
+ String pem = pemWrap("ZEROECHO SIGNATURE", sigBytes);
+ return new EncodedObject(Encoding.PEM, pem.getBytes(java.nio.charset.StandardCharsets.US_ASCII));
+ }
+ throw new InvalidRequestException(DC_UNSUPPORTED_SIGNATURE_ENCODING);
+ }
+
+ private static byte[] decodeSignatureOrThrow(EncodedObject signature) throws InvalidRequestException {
+ if (signature.encoding() == Encoding.BINARY || signature.encoding() == Encoding.DER) {
+ return signature.bytes().clone();
+ }
+ if (signature.encoding() == Encoding.PEM) {
+ String text = new String(signature.bytes(), java.nio.charset.StandardCharsets.US_ASCII);
+ return pemUnwrap(text);
+ }
+ throw new InvalidRequestException(DC_UNSUPPORTED_SIGNATURE_ENCODING);
+ }
+
+ private static String pemWrap(String label, byte[] data) {
+ String b64 = Base64.getMimeEncoder(64, new byte[] { '\n' }).encodeToString(data);
+ return "-----BEGIN " + label + "-----\n" + b64 + "\n-----END " + label + "-----\n";
+ }
+
+ private static byte[] pemUnwrap(String pem) {
+ String[] lines = pem.replace("\r", "").split("\n");
+ StringBuilder b64 = new StringBuilder();
+ for (String line : lines) {
+ if (line.startsWith("-----BEGIN ") || line.startsWith("-----END ")) {
+ continue;
+ }
+ String t = line.trim();
+ if (!t.isEmpty()) {
+ b64.append(t);
+ }
+ }
+ return Base64.getDecoder().decode(b64.toString());
+ }
+
+ private void putStatus(PkiId id, OperationStatus st) {
+ this.statuses.put(id, st);
+ for (NotificationSink sink : this.sinks.values()) {
+ try {
+ sink.onStatusChanged(id, st);
+ } catch (Throwable ignore) { // NOPMD
+ // sink must not break provider
+ LOG.log(Level.FINE, "cannot put status, ignoring", ignore);
+ }
+ }
+ }
+
+ private static PkiId newOperationId() {
+ return new PkiId(UUID.randomUUID().toString());
+ }
+
+ /**
+ * Parsed, provider-local representation of a {@link KeyRef}.
+ *
+ *
+ * This is an internal helper that translates an opaque {@link KeyRef} into
+ * concrete keyring aliases. The mapping is intentionally provider-specific: PKI
+ * must treat {@link KeyRef} as an opaque identifier and must never rely on
+ * parsing rules.
+ *
+ *
+ * Security
+ *
+ * - This type must never contain private key material; it only carries alias
+ * strings.
+ * - Instances must not be logged in production, as aliases can be sensitive
+ * operational metadata.
+ *
+ *
+ *
+ * For signing requests, {@link #privateAlias} is expected to address the
+ * private component (e.g. {@code *.prv}) and {@link #publicAlias} the
+ * corresponding public component (e.g. {@code *.pub}). For verification,
+ * {@link #privateAlias} is unused and {@link #publicAlias} addresses the public
+ * component.
+ *
+ */
+ private static final class KeyRefParts {
+ private final String privateAlias;
+ private final String publicAlias;
+
+ private KeyRefParts(String privateAlias, String publicAlias) {
+ this.privateAlias = privateAlias;
+ this.publicAlias = publicAlias;
+ }
+ }
+
+ /**
+ * Internal exception used to signal validation/policy failures that must be
+ * surfaced via operation status.
+ *
+ *
+ * This exception must never escape the public boundary of
+ * {@link ZeroEchoLibSignatureWorkflow}. It is caught by
+ * {@code submitSign}/{@code submitVerify} and converted to a deterministic
+ * status: {@link State#FAILED} with a stable {@code detailCode}.
+ *
+ *
+ * Design constraints
+ *
+ * - {@code detailCode} must be non-sensitive and stable, suitable for audit
+ * logs and transport-level APIs.
+ * - The exception message is set to {@code detailCode} for diagnostics only;
+ * callers must not depend on it.
+ * - {@code cause} may be attached for local diagnostics but must not be used
+ * to derive user-facing output.
+ *
+ */
+ private static final class InvalidRequestException extends Exception {
+ private static final long serialVersionUID = 2058759559902131220L;
+ private final String detailCode;
+
+ private InvalidRequestException(String detailCode) {
+ super(detailCode);
+ this.detailCode = detailCode;
+ }
+
+ private InvalidRequestException(String detailCode, Throwable cause) {
+ super(detailCode, cause);
+ this.detailCode = detailCode;
+ }
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowProvider.java b/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowProvider.java
new file mode 100644
index 0000000..2cebc9e
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowProvider.java
@@ -0,0 +1,97 @@
+/*******************************************************************************
+ * Copyright (C) 2025, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.pki.impl.crypto.zeroecholib;
+
+import java.nio.file.Path;
+import java.util.Set;
+
+import zeroecho.pki.spi.ProviderConfig;
+import zeroecho.pki.spi.crypto.SignatureWorkflow;
+import zeroecho.pki.spi.crypto.SignatureWorkflowProvider;
+
+/**
+ * Production provider bridging PKI signature workflow to ZeroEcho lib based on
+ * {@code KeyringStore}.
+ *
+ *
+ * Configuration keys:
+ *
+ *
+ * - {@code keyringPath} (required): filesystem path to the KeyringStore
+ * file
+ * - {@code keyRefPrefix} (optional, default {@code "zeroecho-lib:"})
+ * - {@code requireComponentSuffix} (optional, default {@code true})
+ *
+ *
+ * Security
+ *
+ * Configuration values may be sensitive and must not be logged. This provider
+ * performs no value logging.
+ *
+ */
+public final class ZeroEchoLibSignatureWorkflowProvider implements SignatureWorkflowProvider {
+
+ private static final String KEY_KEYRING_PATH = "keyringPath";
+ private static final String KEY_KEYREF_PREFIX = "keyRefPrefix";
+ private static final String KEY_REQUIRE_SUFFIX = "requireComponentSuffix";
+
+ @Override
+ public String id() {
+ return "zeroecho-lib";
+ }
+
+ @Override
+ public Set supportedKeys() {
+ return Set.of(KEY_KEYRING_PATH, KEY_KEYREF_PREFIX, KEY_REQUIRE_SUFFIX);
+ }
+
+ @Override
+ public SignatureWorkflow allocate(ProviderConfig config) {
+ if (config == null) {
+ throw new IllegalArgumentException("config must not be null");
+ }
+
+ // Defensive hardening: fail fast if miswired config reaches this provider.
+ if (!id().equals(config.backendId())) {
+ throw new IllegalArgumentException("ProviderConfig backendId mismatch.");
+ }
+
+ String keyringPath = config.require(KEY_KEYRING_PATH);
+ String prefix = config.get(KEY_KEYREF_PREFIX).orElse("zeroecho-lib:");
+ boolean requireSuffix = config.get(KEY_REQUIRE_SUFFIX).map(Boolean::parseBoolean).orElse(Boolean.TRUE);
+
+ return new ZeroEchoLibSignatureWorkflow(id(), Path.of(keyringPath), prefix, requireSuffix);
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/package-info.java b/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/package-info.java
new file mode 100644
index 0000000..781366e
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/package-info.java
@@ -0,0 +1,69 @@
+/*******************************************************************************
+ * 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 crypto boundary provider backed by ZeroEcho lib and
+ * {@code KeyringStore}.
+ *
+ *
+ * This provider resolves {@link zeroecho.pki.api.KeyRef} values to key aliases
+ * managed by {@code zeroecho.core.storage.KeyringStore}. Private key material
+ * is never returned to PKI; it is materialized only inside the provider
+ * implementation to perform signing.
+ *
+ *
+ * KeyRef mapping
+ *
+ * This provider supports simple mappings intended for practical deployments:
+ *
+ *
+ * - {@code zeroecho-lib:<alias>.prv} for signing
+ * - {@code zeroecho-lib:<alias>.pub} for verification
+ *
+ *
+ *
+ * The alias component may be a UUID string (recommended). The provider does not
+ * require UUIDs by default, but a stricter mode can be enabled by
+ * configuration.
+ *
+ *
+ * Configuration
+ *
+ * - {@code keyringPath} (required): path to the KeyringStore text file
+ * - {@code keyRefPrefix} (optional, default {@code "zeroecho-lib:"})
+ * - {@code requireComponentSuffix} (optional, default {@code true}): require
+ * {@code .prv}/{@code .pub}
+ *
+ */
+package zeroecho.pki.impl.crypto.zeroecholib;
diff --git a/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java b/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java
index 5cccaab..f4cac7c 100644
--- a/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java
+++ b/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java
@@ -44,6 +44,8 @@ import zeroecho.pki.spi.ConfigurableProvider;
import zeroecho.pki.spi.ProviderConfig;
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;
@@ -54,15 +56,21 @@ 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,
- * etc.).
+ * crypto/workflows, etc.).
*
*
* System property conventions
- *
*
* - Select store provider: {@code -Dzeroecho.pki.store=<id>}
* - Configure store provider:
* {@code -Dzeroecho.pki.store.<key>=<value>}
+ * - Select audit provider: {@code -Dzeroecho.pki.audit=<id>}
+ * - Configure audit provider:
+ * {@code -Dzeroecho.pki.audit.<key>=<value>}
+ * - Select crypto workflow provider:
+ * {@code -Dzeroecho.pki.crypto.workflow=<id>}
+ * - Configure crypto workflow provider:
+ * {@code -Dzeroecho.pki.crypto.workflow.<key>=<value>}
*
*
*
@@ -80,13 +88,16 @@ public final class PkiBootstrap {
private static final String PROP_AUDIT_BACKEND = "zeroecho.pki.audit";
private static final String PROP_AUDIT_PREFIX = "zeroecho.pki.audit.";
+ 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 PkiBootstrap() {
throw new AssertionError("No instances.");
}
/**
* Opens a {@link PkiStore} using {@link PkiStoreProvider} discovered via
- * {@link java.util.ServiceLoader}.
+ * ServiceLoader.
*
*
* Provider selection is deterministic and fail-fast:
@@ -112,19 +123,8 @@ public final class PkiBootstrap {
* {@code "pki-store"} relative to the working directory.
*
*
- *
- * This method is suitable for CLI and server deployments. In DI containers
- * (Spring, Micronaut, etc.), invoke it from managed components and close the
- * returned store using the container lifecycle.
- *
- *
* @return opened store (never {@code null})
- * @throws IllegalStateException if provider selection is ambiguous or no
- * provider is available
- * @throws IllegalArgumentException if required configuration is missing/invalid
- * @throws RuntimeException if allocation fails
*/
-
public static PkiStore openStore() {
String requestedId = System.getProperty(PROP_STORE_BACKEND);
@@ -169,7 +169,7 @@ public final class PkiBootstrap {
/**
* Opens an {@link AuditSink} using {@link AuditSinkProvider} discovered via
- * {@link java.util.ServiceLoader}.
+ * ServiceLoader.
*
*
* Selection and configuration follow the same conventions as
@@ -177,11 +177,14 @@ public final class PkiBootstrap {
* {@code zeroecho.pki.audit.} prefixed properties.
*
*
+ *
+ * Defaulting rules implemented by this bootstrap (policy, not SPI requirement):
+ * if no audit backend is specified, defaults to {@code stdout}. For
+ * {@code file} provider, if {@code root} is not specified, defaults to
+ * {@code "pki-audit"} relative to the working directory.
+ *
+ *
* @return opened audit sink (never {@code null})
- * @throws IllegalStateException if provider selection is ambiguous or no
- * provider is available
- * @throws IllegalArgumentException if required configuration is missing/invalid
- * @throws RuntimeException if allocation fails
*/
public static AuditSink openAudit() {
String requestedId = System.getProperty(PROP_AUDIT_BACKEND);
@@ -192,7 +195,6 @@ public final class PkiBootstrap {
AuditSinkProvider provider = SpiSelector.select(AuditSinkProvider.class, requestedId,
new SpiSelector.ProviderId<>() {
-
@Override
public String id(AuditSinkProvider p) {
return p.id();
@@ -202,16 +204,56 @@ public final class PkiBootstrap {
Map props = SpiSystemProperties.readPrefixed(PROP_AUDIT_PREFIX);
if ("file".equals(provider.id()) && !props.containsKey("root")) {
- props.put("root", Path.of("pki-store").toString());
+ props.put("root", Path.of("pki-audit").toString());
}
ProviderConfig config = new ProviderConfig(provider.id(), props);
if (LOG.isLoggable(Level.INFO)) {
- LOG.info("Selected store provider: " + provider.id() + " (keys: " + props.keySet() + ")");
+ LOG.info("Selected audit provider: " + provider.id() + " (keys: " + props.keySet() + ")");
}
return provider.allocate(config);
+ }
+ /**
+ * Opens a {@link SignatureWorkflow} using {@link SignatureWorkflowProvider}
+ * discovered via ServiceLoader.
+ *
+ *
+ * Conventions:
+ *
+ *
+ * - Select provider: {@code -Dzeroecho.pki.crypto.workflow=<id>}
+ * - Provider config:
+ * {@code -Dzeroecho.pki.crypto.workflow.<key>=<value>}
+ *
+ *
+ *
+ * Security note: configuration values may be sensitive and must not be logged.
+ * This bootstrap logs only provider ids and configuration keys.
+ *
+ *
+ * @return opened signature workflow (never {@code null})
+ */
+ public static SignatureWorkflow openSignatureWorkflow() {
+ String requestedId = System.getProperty(PROP_CRYPTO_WORKFLOW_BACKEND);
+
+ SignatureWorkflowProvider provider = SpiSelector.select(SignatureWorkflowProvider.class, requestedId,
+ new SpiSelector.ProviderId<>() {
+ @Override
+ public String id(SignatureWorkflowProvider p) {
+ return p.id();
+ }
+ });
+
+ Map props = SpiSystemProperties.readPrefixed(PROP_CRYPTO_WORKFLOW_PREFIX);
+ ProviderConfig config = new ProviderConfig(provider.id(), props);
+
+ if (LOG.isLoggable(Level.INFO)) {
+ LOG.info("Selected crypto workflow provider: " + provider.id() + " (keys: " + props.keySet() + ")");
+ }
+
+ return provider.allocate(config);
}
}
diff --git a/pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflow.java b/pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflow.java
new file mode 100644
index 0000000..380fb60
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflow.java
@@ -0,0 +1,435 @@
+/*******************************************************************************
+ * 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.crypto;
+
+import java.io.Closeable;
+import java.time.Instant;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+
+import zeroecho.pki.api.EncodedObject;
+import zeroecho.pki.api.Encoding;
+import zeroecho.pki.api.KeyRef;
+import zeroecho.pki.api.PkiId;
+import zeroecho.pki.api.audit.AccessContext;
+
+/**
+ * Asynchronous signature workflow boundary for PKI.
+ *
+ *
+ * This SPI is intentionally asynchronous to support ultra-secure deployments
+ * where signing can be routed across multiple hops and may require human
+ * approval. A local provider may complete operations immediately; a
+ * remote/hop-based provider may keep them pending for extended periods.
+ *
+ *
+ * Trust boundary
+ *
+ * - PKI (caller): works only with {@link KeyRef}, algorithm
+ * id and payload bytes; it must never parse {@code KeyRef} nor access private
+ * key bytes.
+ * - Provider (callee): resolves {@code KeyRef} into an
+ * internal runtime key handle, enforces policy (including multi-hop approvals),
+ * and performs cryptographic operations.
+ *
+ *
+ * Failure model (normative - Variant 1)
+ *
+ * This workflow is designed to be audit-friendly and transport-friendly (e.g.,
+ * future HTTP APIs). Implementations must follow these rules:
+ *
+ *
+ * - Validation and policy failures must not be surfaced as uncaught
+ * exceptions. Instead, the provider must return an operation id and expose the
+ * failure via {@link #status(PkiId)} using {@link OperationStatus#state()} ==
+ * {@link State#FAILED} and a stable {@link OperationStatus#detailCode()}.
+ * - {@link IllegalArgumentException} may be thrown only for programmer errors
+ * such as {@code request == null} or {@code operationId == null}. These are not
+ * business/policy failures.
+ * - Providers must ensure {@link OperationStatus#detailCode()} does not
+ * contain secrets, key material, or payload fragments; it must be safe for
+ * audit logs and API responses.
+ *
+ */
+public interface SignatureWorkflow extends Closeable {
+
+ /**
+ * Provider identifier for diagnostics and audit correlation.
+ *
+ * @return stable provider id (never blank)
+ */
+ String id();
+
+ /**
+ * Submits a signing request.
+ *
+ *
+ * Providers must never return private key material. The output is a signature
+ * (encoded) on success.
+ *
+ *
+ * Failure model (normative)
+ *
+ * - For validation and policy failures: do not throw; return operation id and
+ * expose failure via {@link #status(PkiId)} with {@code FAILED} and stable
+ * {@code detailCode}.
+ * - May throw {@link IllegalArgumentException} only for programmer errors
+ * (e.g., {@code request == null}).
+ *
+ *
+ * @param request request (never {@code null})
+ * @return operation id (never {@code null})
+ */
+ PkiId submitSign(SignRequest request);
+
+ /**
+ * Submits a verification request.
+ *
+ *
+ * Verification typically uses public key material. For maximal portability
+ * across PQC/hybrid algorithms, verification requests can either:
+ *
+ *
+ * - reference a public key via {@link KeyRef} (preferred), or
+ * - provide encoded public key bytes (provider-defined format; may be
+ * unsupported by some providers).
+ *
+ *
+ * Failure model (normative)
+ *
+ * - For validation and policy failures: do not throw; return operation id and
+ * expose failure via {@link #status(PkiId)} with {@code FAILED} and stable
+ * {@code detailCode}.
+ * - May throw {@link IllegalArgumentException} only for programmer errors
+ * (e.g., {@code request == null}).
+ *
+ *
+ * @param request request (never {@code null})
+ * @return operation id (never {@code null})
+ */
+ PkiId submitVerify(VerifyRequest request);
+
+ /**
+ * Reads current status of an operation.
+ *
+ * @param operationId operation id (never {@code null})
+ * @return status (never {@code null})
+ */
+ OperationStatus status(PkiId operationId);
+
+ /**
+ * Best-effort cancellation.
+ *
+ * @param operationId operation id (never {@code null})
+ * @param reason non-sensitive reason (never blank)
+ * @return true if cancellation was accepted; false if already terminal/unknown
+ */
+ boolean cancel(PkiId operationId, String reason);
+
+ /**
+ * Registers a notification sink for status changes.
+ *
+ *
+ * Implementations may invoke the sink from provider-owned threads. The sink
+ * must not throw.
+ *
+ *
+ * @param sink sink (never {@code null})
+ * @return registration handle (never {@code null})
+ */
+ Registration register(NotificationSink sink);
+
+ /**
+ * Returns supported algorithm identifiers.
+ *
+ * @return supported algorithm ids (never {@code null})
+ */
+ Set supportedAlgorithms();
+
+ @Override
+ void close();
+
+ /**
+ * Signing request.
+ *
+ *
+ * Note: this record performs basic structural validation and may throw
+ * {@link IllegalArgumentException} at construction time. Callers building
+ * requests for external transports are expected to catch such exceptions and
+ * convert them to an appropriate error state before invoking
+ * {@link #submitSign(SignRequest)}.
+ *
+ *
+ * @param accessContext audit/governance context (never
+ * {@code null})
+ * @param keyRef opaque reference to the private key (never
+ * {@code null})
+ * @param algorithmId requested signature algorithm id (never
+ * blank)
+ * @param payload payload bytes (never {@code null})
+ * @param preferredSignatureEncoding preferred signature encoding (optional)
+ * @param deadline optional absolute deadline
+ */
+ record SignRequest(AccessContext accessContext, KeyRef keyRef, String algorithmId, EncodedObject payload,
+ Optional preferredSignatureEncoding, Optional deadline) {
+
+ public SignRequest {
+ Objects.requireNonNull(accessContext, "accessContext");
+ Objects.requireNonNull(keyRef, "keyRef");
+ Objects.requireNonNull(algorithmId, "algorithmId");
+ Objects.requireNonNull(payload, "payload");
+ Objects.requireNonNull(preferredSignatureEncoding, "preferredSignatureEncoding");
+ Objects.requireNonNull(deadline, "deadline");
+ if (algorithmId.isBlank()) {
+ throw new IllegalArgumentException("algorithmId must not be blank");
+ }
+ }
+ }
+
+ /**
+ * Verification request.
+ *
+ *
+ * Note: this record performs basic structural validation and may throw
+ * {@link IllegalArgumentException} at construction time. Callers building
+ * requests for external transports are expected to catch such exceptions and
+ * convert them to an appropriate error state before invoking
+ * {@link #submitVerify(VerifyRequest)}.
+ *
+ *
+ * @param accessContext audit/governance context (never {@code null})
+ * @param algorithmId requested signature algorithm id (never blank)
+ * @param payload signed payload bytes (never {@code null})
+ * @param signature signature bytes (never {@code null})
+ * @param publicKeyRef optional public key reference (preferred)
+ * @param publicKeyEncoded optional encoded public key bytes (provider-specific;
+ * may be unsupported)
+ * @param deadline optional absolute deadline
+ */
+ record VerifyRequest(AccessContext accessContext, String algorithmId, EncodedObject payload,
+ EncodedObject signature, Optional publicKeyRef, Optional publicKeyEncoded,
+ Optional deadline) {
+
+ public VerifyRequest {
+ Objects.requireNonNull(accessContext, "accessContext");
+ Objects.requireNonNull(algorithmId, "algorithmId");
+ Objects.requireNonNull(payload, "payload");
+ Objects.requireNonNull(signature, "signature");
+ Objects.requireNonNull(publicKeyRef, "publicKeyRef");
+ Objects.requireNonNull(publicKeyEncoded, "publicKeyEncoded");
+ Objects.requireNonNull(deadline, "deadline");
+ if (algorithmId.isBlank()) {
+ throw new IllegalArgumentException("algorithmId must not be blank");
+ }
+ if (publicKeyRef.isEmpty() && publicKeyEncoded.isEmpty()) {
+ throw new IllegalArgumentException("Either publicKeyRef or publicKeyEncoded must be provided");
+ }
+ }
+ }
+
+ /**
+ * Status of a long-running or asynchronous signature workflow operation.
+ *
+ *
+ * An {@code OperationStatus} represents the authoritative, audit-relevant state
+ * of an operation previously submitted via {@code submitSign} or
+ * {@code submitVerify}. Providers must ensure that status transitions are
+ * monotonic and observable through repeated calls to
+ * {@link SignatureWorkflow#status(PkiId)}.
+ *
+ *
+ * Failure and audit model
+ *
+ * - Operational, validation, or policy failures are reported via
+ * {@link State#FAILED}, not via uncaught exceptions.
+ * - {@link #detailCode()} provides a stable, non-sensitive classification of
+ * the outcome and is intended for audit logs, diagnostics, and transport-level
+ * APIs.
+ * - {@link #detailCode()} must never contain secrets, key identifiers,
+ * payload data, or provider-internal state.
+ *
+ *
+ * Terminal states
+ *
+ * Once an operation reaches a terminal state (see {@link #isTerminal()}), its
+ * {@code OperationStatus} must no longer change.
+ *
+ *
+ * @param state current lifecycle state of the operation (never
+ * {@code null})
+ * @param updatedAt timestamp of the last state transition (never
+ * {@code null}); providers should use a deterministic value
+ * where possible for non-operational states (e.g. unknown
+ * operation)
+ * @param detailCode optional stable, non-sensitive code describing the outcome
+ * or reason for the current state
+ * @param result optional terminal result; present only for
+ * {@link State#SUCCEEDED} and never for non-terminal states
+ */
+ record OperationStatus(State state, Instant updatedAt, Optional detailCode,
+ Optional result) {
+
+ public OperationStatus {
+ Objects.requireNonNull(state, "state");
+ Objects.requireNonNull(updatedAt, "updatedAt");
+ Objects.requireNonNull(detailCode, "detailCode");
+ Objects.requireNonNull(result, "result");
+ }
+
+ /**
+ * Indicates whether this status represents a terminal lifecycle state.
+ *
+ *
+ * Terminal states are final and must not transition to any other state.
+ *
+ *
+ * @return {@code true} if the operation is terminal, {@code false} otherwise
+ */
+ public boolean isTerminal() {
+ return state == State.SUCCEEDED || state == State.FAILED || state == State.CANCELLED
+ || state == State.EXPIRED;
+ }
+ }
+
+ /**
+ * Lifecycle states of a signature workflow operation.
+ *
+ *
+ * The state machine is intentionally minimal to accommodate both local
+ * (in-process) and remote or multi-hop providers.
+ *
+ *
+ *
+ * - {@link #PENDING} – operation accepted but not yet actively processed
+ * - {@link #RUNNING} – operation is currently being processed
+ * - {@link #WAITING_APPROVAL} – operation is suspended awaiting external
+ * approval (e.g. human review, policy gate, HSM quorum)
+ * - {@link #SUCCEEDED} – operation completed successfully
+ * - {@link #FAILED} – operation permanently failed due to validation, policy,
+ * or execution error
+ * - {@link #CANCELLED} – operation was explicitly cancelled by the
+ * caller
+ * - {@link #EXPIRED} – operation expired before completion
+ *
+ *
+ *
+ * Providers must document which transitions they support and under which
+ * conditions a given state may be observed.
+ *
+ */
+ enum State {
+ PENDING, RUNNING, WAITING_APPROVAL, SUCCEEDED, FAILED, CANCELLED, EXPIRED
+ }
+
+ /**
+ * Terminal result of a signature workflow operation.
+ *
+ *
+ * An {@code OperationResult} is present only when an operation reaches
+ * {@link State#SUCCEEDED}. It represents the semantic outcome of the operation
+ * without exposing any sensitive key material.
+ *
+ *
+ *
+ * - For sign operations, {@link #signature()} contains the generated
+ * signature.
+ * - For verify operations, {@link #verified()} indicates verification
+ * outcome.
+ *
+ *
+ *
+ * Exactly one of {@code signature} or {@code verified} is typically present,
+ * depending on the operation type.
+ *
+ *
+ * @param signature optional signature artifact for successful sign operations
+ * @param verified optional verification result for successful verify
+ * operations
+ */
+ record OperationResult(Optional signature, Optional verified) {
+
+ public OperationResult {
+ Objects.requireNonNull(signature, "signature");
+ Objects.requireNonNull(verified, "verified");
+ }
+ }
+
+ /**
+ * Callback interface for receiving asynchronous status updates.
+ *
+ *
+ * Providers may invoke this callback from provider-managed threads.
+ * Implementations must therefore be thread-safe and must not throw exceptions.
+ * Any exception thrown by a {@code NotificationSink} must be ignored by the
+ * provider.
+ *
+ *
+ *
+ * The callback must be treated as a best-effort notification mechanism and must
+ * not be relied upon as the sole source of truth; callers should always be able
+ * to query the authoritative state via {@link SignatureWorkflow#status(PkiId)}.
+ *
+ */
+ @FunctionalInterface
+ interface NotificationSink {
+
+ /**
+ * Notifies about a change in operation status.
+ *
+ * @param operationId identifier of the affected operation (never {@code null})
+ * @param status new status of the operation (never {@code null})
+ */
+ void onStatusChanged(PkiId operationId, OperationStatus status);
+ }
+
+ /**
+ * Handle representing a registered {@link NotificationSink}.
+ *
+ *
+ * Closing the registration unregisters the associated sink. The operation is
+ * idempotent and must not throw.
+ *
+ */
+ @SuppressWarnings("PMD.ImplicitFunctionalInterface")
+ interface Registration extends AutoCloseable {
+
+ /**
+ * Unregisters the associated {@link NotificationSink}.
+ */
+ @Override
+ void close();
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflowProvider.java b/pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflowProvider.java
new file mode 100644
index 0000000..26694b9
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflowProvider.java
@@ -0,0 +1,44 @@
+/*******************************************************************************
+ * 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.crypto;
+
+import zeroecho.pki.spi.ConfigurableProvider;
+
+/**
+ * ServiceLoader provider for {@link SignatureWorkflow}.
+ */
+public interface SignatureWorkflowProvider extends ConfigurableProvider {
+ // marker
+}
\ No newline at end of file
diff --git a/pki/src/main/java/zeroecho/pki/spi/crypto/package-info.java b/pki/src/main/java/zeroecho/pki/spi/crypto/package-info.java
new file mode 100644
index 0000000..567bd11
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/spi/crypto/package-info.java
@@ -0,0 +1,64 @@
+/*******************************************************************************
+ * 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.
+ ******************************************************************************/
+/**
+ * Crypto boundary SPI for the PKI module.
+ *
+ *
+ * The PKI module must never store, expose, nor directly handle private key
+ * material. It operates exclusively on opaque {@link zeroecho.pki.api.KeyRef}
+ * values resolved at runtime by a selected
+ * {@link java.util.ServiceLoader}-driven provider.
+ *
+ *
+ * Trust boundary
+ *
+ * - PKI (caller): submits sign/verify requests using
+ * {@code KeyRef}, algorithm id, and payload bytes. It must not parse nor
+ * interpret {@code KeyRef}.
+ * - Crypto provider (callee): resolves {@code KeyRef} to an
+ * internal key handle, enforces policy (including multi-hop approvals in other
+ * implementations), performs crypto, and returns only non-secret outputs
+ * (signature bytes or verification decision).
+ *
+ *
+ * Security constraints
+ *
+ * - {@code KeyRef} is sensitive metadata and must not be logged.
+ * - Payload bytes and signature bytes must not be logged.
+ * - Implementations must avoid persisting secrets; audit evidence must
+ * contain only non-secret metadata.
+ *
+ */
+package zeroecho.pki.spi.crypto;
diff --git a/pki/src/main/resources/META-INF/services/zeroecho.pki.spi.crypto.SignatureWorkflowProvider b/pki/src/main/resources/META-INF/services/zeroecho.pki.spi.crypto.SignatureWorkflowProvider
new file mode 100644
index 0000000..5e3e6ce
--- /dev/null
+++ b/pki/src/main/resources/META-INF/services/zeroecho.pki.spi.crypto.SignatureWorkflowProvider
@@ -0,0 +1 @@
+zeroecho.pki.impl.crypto.zeroecholib.ZeroEchoLibSignatureWorkflowProvider
diff --git a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibKeyRefParsingTest.java b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibKeyRefParsingTest.java
new file mode 100644
index 0000000..2a1ae03
--- /dev/null
+++ b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibKeyRefParsingTest.java
@@ -0,0 +1,131 @@
+/*******************************************************************************
+ * 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.crypto.zeroecholib;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.Optional;
+
+import org.junit.jupiter.api.Test;
+
+import zeroecho.pki.api.EncodedObject;
+import zeroecho.pki.api.Encoding;
+import zeroecho.pki.api.KeyRef;
+import zeroecho.pki.api.PkiId;
+import zeroecho.pki.api.audit.AccessContext;
+import zeroecho.pki.api.audit.Principal;
+import zeroecho.pki.api.audit.Purpose;
+import zeroecho.pki.spi.crypto.SignatureWorkflow;
+
+public final class ZeroEchoLibKeyRefParsingTest {
+
+ @Test
+ void signing_requires_prv_suffix_in_strict_mode_ok() {
+ System.out.println("signing_requires_prv_suffix_in_strict_mode_ok");
+
+ try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("zeroecho-lib", Path.of("nonexistent"),
+ "zeroecho-lib:", true)) {
+
+ AccessContext ctx = new AccessContext(new Principal("TEST", "unit"), new Purpose("UNIT_TEST"),
+ Optional.empty(), Optional.empty());
+
+ SignatureWorkflow.SignRequest req = new SignatureWorkflow.SignRequest(ctx, new KeyRef("zeroecho-lib:abc"), // missing
+ // .prv
+ "ECDSA", new EncodedObject(Encoding.BINARY, new byte[] { 0x01 }), Optional.of(Encoding.BINARY),
+ Optional.of(Instant.now()));
+
+ PkiId opId = wf.submitSign(req);
+ SignatureWorkflow.OperationStatus st = wf.status(opId);
+
+ System.out.println("...state=" + st.state());
+ System.out.println("...detailCode=" + st.detailCode().orElse(""));
+
+ assertEquals(SignatureWorkflow.State.FAILED, st.state());
+ assertEquals(ZeroEchoLibSignatureWorkflow.DC_INVALID_KEYREF_SUFFIX, st.detailCode().orElse(""));
+ }
+ System.out.println("...ok");
+ }
+
+ @Test
+ void verify_with_publicKeyEncoded_is_rejected_ok() {
+ System.out.println("verify_with_publicKeyEncoded_is_rejected_ok");
+
+ try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("zeroecho-lib", Path.of("nonexistent"),
+ "zeroecho-lib:", true)) {
+
+ AccessContext ctx = new AccessContext(new Principal("TEST", "unit"), new Purpose("UNIT_TEST"),
+ Optional.empty(), Optional.empty());
+
+ SignatureWorkflow.VerifyRequest req = new SignatureWorkflow.VerifyRequest(ctx, "ECDSA",
+ new EncodedObject(Encoding.BINARY, new byte[] { 0x01 }),
+ new EncodedObject(Encoding.BINARY, new byte[] { 0x02 }), Optional.empty(),
+ Optional.of(new EncodedObject(Encoding.BINARY, new byte[] { 0x03 })), // unsupported form
+ Optional.of(Instant.now()));
+
+ PkiId opId = wf.submitVerify(req);
+ SignatureWorkflow.OperationStatus st = wf.status(opId);
+
+ System.out.println("...state=" + st.state());
+ System.out.println("...detailCode=" + st.detailCode().orElse(""));
+
+ assertEquals(SignatureWorkflow.State.FAILED, st.state());
+ assertEquals(ZeroEchoLibSignatureWorkflow.DC_UNSUPPORTED_PUBLICKEY_FORM, st.detailCode().orElse(""));
+ }
+ System.out.println("...ok");
+ }
+
+ @Test
+ void status_unknown_operation_is_deterministic_ok() {
+ System.out.println("status_unknown_operation_is_deterministic_ok");
+
+ try (ZeroEchoLibSignatureWorkflow wf = new ZeroEchoLibSignatureWorkflow("zeroecho-lib", Path.of("nonexistent"),
+ "zeroecho-lib:", true)) {
+
+ PkiId unknown = new PkiId("00000000-0000-0000-0000-000000000000");
+ SignatureWorkflow.OperationStatus st = wf.status(unknown);
+
+ System.out.println("...state=" + st.state());
+ System.out.println("...detailCode=" + st.detailCode().orElse(""));
+ System.out.println("...updatedAt=" + st.updatedAt());
+
+ assertEquals(SignatureWorkflow.State.FAILED, st.state());
+ assertEquals(ZeroEchoLibSignatureWorkflow.DC_UNKNOWN_OPERATION, st.detailCode().orElse(""));
+ assertEquals(Instant.EPOCH, st.updatedAt());
+ }
+ System.out.println("...ok");
+ }
+}
diff --git a/pki/src/test/java/zeroecho/pki/spi/bootstrap/PkiBootstrapTest.java b/pki/src/test/java/zeroecho/pki/spi/bootstrap/PkiBootstrapTest.java
index c3c1b2c..7366764 100644
--- a/pki/src/test/java/zeroecho/pki/spi/bootstrap/PkiBootstrapTest.java
+++ b/pki/src/test/java/zeroecho/pki/spi/bootstrap/PkiBootstrapTest.java
@@ -116,7 +116,6 @@ public final class PkiBootstrapTest {
// Per bootstrap: if no -Dzeroecho.pki.audit is set, default is "stdout".
// This must work even if multiple audit providers exist.
- // :contentReference[oaicite:1]{index=1}
AuditSink sink = PkiBootstrap.openAudit();
assertNotNull(sink);