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

+ * + */ +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);