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 <lg@hq.egothor.org>
This commit is contained in:
2025-12-29 22:41:11 +01:00
parent 0346c5b30f
commit d2ec77b8e3
10 changed files with 1478 additions and 23 deletions

View File

@@ -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}.
*
* <h2>Failure model</h2>
* <p>
* 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.
* </p>
*
* <h2>Security</h2>
* <ul>
* <li>Never logs {@link KeyRef} values.</li>
* <li>Never logs payload bytes or signature bytes.</li>
* <li>Never returns private key material; private keys are materialized only
* within this boundary.</li>
* </ul>
*/
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<PkiId, OperationStatus> statuses;
private final Map<PkiId, NotificationSink> 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<String> 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}.
*
* <p>
* 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.
* </p>
*
* <h2>Security</h2>
* <ul>
* <li>This type must never contain private key material; it only carries alias
* strings.</li>
* <li>Instances must not be logged in production, as aliases can be sensitive
* operational metadata.</li>
* </ul>
*
* <p>
* 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.
* </p>
*/
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.
*
* <p>
* 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}.
* </p>
*
* <h2>Design constraints</h2>
* <ul>
* <li>{@code detailCode} must be non-sensitive and stable, suitable for audit
* logs and transport-level APIs.</li>
* <li>The exception message is set to {@code detailCode} for diagnostics only;
* callers must not depend on it.</li>
* <li>{@code cause} may be attached for local diagnostics but must not be used
* to derive user-facing output.</li>
* </ul>
*/
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;
}
}
}

View 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.
******************************************************************************/
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}.
*
* <p>
* Configuration keys:
* </p>
* <ul>
* <li>{@code keyringPath} (required): filesystem path to the KeyringStore
* file</li>
* <li>{@code keyRefPrefix} (optional, default {@code "zeroecho-lib:"})</li>
* <li>{@code requireComponentSuffix} (optional, default {@code true})</li>
* </ul>
*
* <h2>Security</h2>
* <p>
* Configuration values may be sensitive and must not be logged. This provider
* performs no value logging.
* </p>
*/
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<String> 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);
}
}

View File

@@ -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}.
*
* <p>
* 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.
* </p>
*
* <h2>KeyRef mapping</h2>
* <p>
* This provider supports simple mappings intended for practical deployments:
* </p>
* <ul>
* <li>{@code zeroecho-lib:&lt;alias&gt;.prv} for signing</li>
* <li>{@code zeroecho-lib:&lt;alias&gt;.pub} for verification</li>
* </ul>
*
* <p>
* 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.
* </p>
*
* <h2>Configuration</h2>
* <ul>
* <li>{@code keyringPath} (required): path to the KeyringStore text file</li>
* <li>{@code keyRefPrefix} (optional, default {@code "zeroecho-lib:"})</li>
* <li>{@code requireComponentSuffix} (optional, default {@code true}): require
* {@code .prv}/{@code .pub}</li>
* </ul>
*/
package zeroecho.pki.impl.crypto.zeroecholib;

View File

@@ -44,6 +44,8 @@ import zeroecho.pki.spi.ConfigurableProvider;
import zeroecho.pki.spi.ProviderConfig; import zeroecho.pki.spi.ProviderConfig;
import zeroecho.pki.spi.audit.AuditSink; import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.spi.audit.AuditSinkProvider; 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.PkiStore;
import zeroecho.pki.spi.store.PkiStoreProvider; 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 * This class provides deterministic selection and instantiation rules for
* components discovered via {@link java.util.ServiceLoader}. It is designed to * components discovered via {@link java.util.ServiceLoader}. It is designed to
* scale as more SPIs are introduced (audit, publish, framework integrations, * scale as more SPIs are introduced (audit, publish, framework integrations,
* etc.). * crypto/workflows, etc.).
* </p> * </p>
* *
* <h2>System property conventions</h2> * <h2>System property conventions</h2>
*
* <ul> * <ul>
* <li>Select store provider: {@code -Dzeroecho.pki.store=&lt;id&gt;}</li> * <li>Select store provider: {@code -Dzeroecho.pki.store=&lt;id&gt;}</li>
* <li>Configure store provider: * <li>Configure store provider:
* {@code -Dzeroecho.pki.store.&lt;key&gt;=&lt;value&gt;}</li> * {@code -Dzeroecho.pki.store.&lt;key&gt;=&lt;value&gt;}</li>
* <li>Select audit provider: {@code -Dzeroecho.pki.audit=&lt;id&gt;}</li>
* <li>Configure audit provider:
* {@code -Dzeroecho.pki.audit.&lt;key&gt;=&lt;value&gt;}</li>
* <li>Select crypto workflow provider:
* {@code -Dzeroecho.pki.crypto.workflow=&lt;id&gt;}</li>
* <li>Configure crypto workflow provider:
* {@code -Dzeroecho.pki.crypto.workflow.&lt;key&gt;=&lt;value&gt;}</li>
* </ul> * </ul>
* *
* <p> * <p>
@@ -80,13 +88,16 @@ public final class PkiBootstrap {
private static final String PROP_AUDIT_BACKEND = "zeroecho.pki.audit"; 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_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() { private PkiBootstrap() {
throw new AssertionError("No instances."); throw new AssertionError("No instances.");
} }
/** /**
* Opens a {@link PkiStore} using {@link PkiStoreProvider} discovered via * Opens a {@link PkiStore} using {@link PkiStoreProvider} discovered via
* {@link java.util.ServiceLoader}. * ServiceLoader.
* *
* <p> * <p>
* Provider selection is deterministic and fail-fast: * Provider selection is deterministic and fail-fast:
@@ -112,19 +123,8 @@ public final class PkiBootstrap {
* {@code "pki-store"} relative to the working directory. * {@code "pki-store"} relative to the working directory.
* </p> * </p>
* *
* <p>
* 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.
* </p>
*
* @return opened store (never {@code null}) * @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() { public static PkiStore openStore() {
String requestedId = System.getProperty(PROP_STORE_BACKEND); String requestedId = System.getProperty(PROP_STORE_BACKEND);
@@ -169,7 +169,7 @@ public final class PkiBootstrap {
/** /**
* Opens an {@link AuditSink} using {@link AuditSinkProvider} discovered via * Opens an {@link AuditSink} using {@link AuditSinkProvider} discovered via
* {@link java.util.ServiceLoader}. * ServiceLoader.
* *
* <p> * <p>
* Selection and configuration follow the same conventions as * Selection and configuration follow the same conventions as
@@ -177,11 +177,14 @@ public final class PkiBootstrap {
* {@code zeroecho.pki.audit.} prefixed properties. * {@code zeroecho.pki.audit.} prefixed properties.
* </p> * </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 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() { public static AuditSink openAudit() {
String requestedId = System.getProperty(PROP_AUDIT_BACKEND); String requestedId = System.getProperty(PROP_AUDIT_BACKEND);
@@ -192,7 +195,6 @@ public final class PkiBootstrap {
AuditSinkProvider provider = SpiSelector.select(AuditSinkProvider.class, requestedId, AuditSinkProvider provider = SpiSelector.select(AuditSinkProvider.class, requestedId,
new SpiSelector.ProviderId<>() { new SpiSelector.ProviderId<>() {
@Override @Override
public String id(AuditSinkProvider p) { public String id(AuditSinkProvider p) {
return p.id(); return p.id();
@@ -202,16 +204,56 @@ public final class PkiBootstrap {
Map<String, String> props = SpiSystemProperties.readPrefixed(PROP_AUDIT_PREFIX); Map<String, String> props = SpiSystemProperties.readPrefixed(PROP_AUDIT_PREFIX);
if ("file".equals(provider.id()) && !props.containsKey("root")) { 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); ProviderConfig config = new ProviderConfig(provider.id(), props);
if (LOG.isLoggable(Level.INFO)) { 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); return provider.allocate(config);
}
/**
* Opens a {@link SignatureWorkflow} using {@link SignatureWorkflowProvider}
* discovered via ServiceLoader.
*
* <p>
* Conventions:
* </p>
* <ul>
* <li>Select provider: {@code -Dzeroecho.pki.crypto.workflow=&lt;id&gt;}</li>
* <li>Provider config:
* {@code -Dzeroecho.pki.crypto.workflow.&lt;key&gt;=&lt;value&gt;}</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})
*/
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<String, String> 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);
} }
} }

View File

@@ -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.
*
* <p>
* 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.
* </p>
*
* <h2>Trust boundary</h2>
* <ul>
* <li><strong>PKI (caller):</strong> works only with {@link KeyRef}, algorithm
* id and payload bytes; it must never parse {@code KeyRef} nor access private
* key bytes.</li>
* <li><strong>Provider (callee):</strong> resolves {@code KeyRef} into an
* internal runtime key handle, enforces policy (including multi-hop approvals),
* and performs cryptographic operations.</li>
* </ul>
*
* <h2>Failure model (normative - Variant 1)</h2>
* <p>
* This workflow is designed to be audit-friendly and transport-friendly (e.g.,
* future HTTP APIs). Implementations must follow these rules:
* </p>
* <ul>
* <li>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()}.</li>
* <li>{@link IllegalArgumentException} may be thrown only for programmer errors
* such as {@code request == null} or {@code operationId == null}. These are not
* business/policy failures.</li>
* <li>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.</li>
* </ul>
*/
public interface SignatureWorkflow extends Closeable {
/**
* Provider identifier for diagnostics and audit correlation.
*
* @return stable provider id (never blank)
*/
String id();
/**
* Submits a signing request.
*
* <p>
* Providers must never return private key material. The output is a signature
* (encoded) on success.
* </p>
*
* <h4>Failure model (normative)</h4>
* <ul>
* <li>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}.</li>
* <li>May throw {@link IllegalArgumentException} only for programmer errors
* (e.g., {@code request == null}).</li>
* </ul>
*
* @param request request (never {@code null})
* @return operation id (never {@code null})
*/
PkiId submitSign(SignRequest request);
/**
* Submits a verification request.
*
* <p>
* Verification typically uses public key material. For maximal portability
* across PQC/hybrid algorithms, verification requests can either:
* </p>
* <ul>
* <li>reference a public key via {@link KeyRef} (preferred), or</li>
* <li>provide encoded public key bytes (provider-defined format; may be
* unsupported by some providers).</li>
* </ul>
*
* <h4>Failure model (normative)</h4>
* <ul>
* <li>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}.</li>
* <li>May throw {@link IllegalArgumentException} only for programmer errors
* (e.g., {@code request == null}).</li>
* </ul>
*
* @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.
*
* <p>
* Implementations may invoke the sink from provider-owned threads. The sink
* must not throw.
* </p>
*
* @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<String> supportedAlgorithms();
@Override
void close();
/**
* Signing request.
*
* <p>
* 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)}.
* </p>
*
* @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<Encoding> preferredSignatureEncoding, Optional<Instant> 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.
*
* <p>
* 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)}.
* </p>
*
* @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<KeyRef> publicKeyRef, Optional<EncodedObject> publicKeyEncoded,
Optional<Instant> 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.
*
* <p>
* 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)}.
* </p>
*
* <h2>Failure and audit model</h2>
* <ul>
* <li>Operational, validation, or policy failures are reported via
* {@link State#FAILED}, not via uncaught exceptions.</li>
* <li>{@link #detailCode()} provides a stable, non-sensitive classification of
* the outcome and is intended for audit logs, diagnostics, and transport-level
* APIs.</li>
* <li>{@link #detailCode()} must never contain secrets, key identifiers,
* payload data, or provider-internal state.</li>
* </ul>
*
* <h2>Terminal states</h2>
* <p>
* Once an operation reaches a terminal state (see {@link #isTerminal()}), its
* {@code OperationStatus} must no longer change.
* </p>
*
* @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<String> detailCode,
Optional<OperationResult> 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.
*
* <p>
* Terminal states are final and must not transition to any other state.
* </p>
*
* @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.
*
* <p>
* The state machine is intentionally minimal to accommodate both local
* (in-process) and remote or multi-hop providers.
* </p>
*
* <ul>
* <li>{@link #PENDING} operation accepted but not yet actively processed</li>
* <li>{@link #RUNNING} operation is currently being processed</li>
* <li>{@link #WAITING_APPROVAL} operation is suspended awaiting external
* approval (e.g. human review, policy gate, HSM quorum)</li>
* <li>{@link #SUCCEEDED} operation completed successfully</li>
* <li>{@link #FAILED} operation permanently failed due to validation, policy,
* or execution error</li>
* <li>{@link #CANCELLED} operation was explicitly cancelled by the
* caller</li>
* <li>{@link #EXPIRED} operation expired before completion</li>
* </ul>
*
* <p>
* Providers must document which transitions they support and under which
* conditions a given state may be observed.
* </p>
*/
enum State {
PENDING, RUNNING, WAITING_APPROVAL, SUCCEEDED, FAILED, CANCELLED, EXPIRED
}
/**
* Terminal result of a signature workflow operation.
*
* <p>
* 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.
* </p>
*
* <ul>
* <li>For sign operations, {@link #signature()} contains the generated
* signature.</li>
* <li>For verify operations, {@link #verified()} indicates verification
* outcome.</li>
* </ul>
*
* <p>
* Exactly one of {@code signature} or {@code verified} is typically present,
* depending on the operation type.
* </p>
*
* @param signature optional signature artifact for successful sign operations
* @param verified optional verification result for successful verify
* operations
*/
record OperationResult(Optional<EncodedObject> signature, Optional<Boolean> verified) {
public OperationResult {
Objects.requireNonNull(signature, "signature");
Objects.requireNonNull(verified, "verified");
}
}
/**
* Callback interface for receiving asynchronous status updates.
*
* <p>
* 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.
* </p>
*
* <p>
* 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)}.
* </p>
*/
@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}.
*
* <p>
* Closing the registration unregisters the associated sink. The operation is
* idempotent and must not throw.
* </p>
*/
@SuppressWarnings("PMD.ImplicitFunctionalInterface")
interface Registration extends AutoCloseable {
/**
* Unregisters the associated {@link NotificationSink}.
*/
@Override
void close();
}
}

View File

@@ -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<SignatureWorkflow> {
// marker
}

View File

@@ -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.
*
* <p>
* 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.
* </p>
*
* <h2>Trust boundary</h2>
* <ul>
* <li><strong>PKI (caller):</strong> submits sign/verify requests using
* {@code KeyRef}, algorithm id, and payload bytes. It must not parse nor
* interpret {@code KeyRef}.</li>
* <li><strong>Crypto provider (callee):</strong> 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).</li>
* </ul>
*
* <h2>Security constraints</h2>
* <ul>
* <li>{@code KeyRef} is sensitive metadata and must not be logged.</li>
* <li>Payload bytes and signature bytes must not be logged.</li>
* <li>Implementations must avoid persisting secrets; audit evidence must
* contain only non-secret metadata.</li>
* </ul>
*/
package zeroecho.pki.spi.crypto;

View File

@@ -0,0 +1 @@
zeroecho.pki.impl.crypto.zeroecholib.ZeroEchoLibSignatureWorkflowProvider

View File

@@ -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("<none>"));
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("<none>"));
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("<none>"));
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");
}
}

View File

@@ -116,7 +116,6 @@ public final class PkiBootstrapTest {
// Per bootstrap: if no -Dzeroecho.pki.audit is set, default is "stdout". // Per bootstrap: if no -Dzeroecho.pki.audit is set, default is "stdout".
// This must work even if multiple audit providers exist. // This must work even if multiple audit providers exist.
// :contentReference[oaicite:1]{index=1}
AuditSink sink = PkiBootstrap.openAudit(); AuditSink sink = PkiBootstrap.openAudit();
assertNotNull(sink); assertNotNull(sink);