From aa89c09238efa32415ddaf637f02169758670034 Mon Sep 17 00:00:00 2001
From: Leo Galambos
Date: Sat, 1 Aug 2026 20:37:46 +0200
Subject: [PATCH] feat(pki): confine local private-key signing to lib
Move local key resolution and signing execution behind the lib-owned
KeyringSignatureExecutor boundary.
Ensure production PKI code operates only with KeyRef and never obtains,
stores, encodes, or exposes PrivateKey material. Preserve streaming,
cancellation, workflow persistence, and terminal outcome semantics.
---
.../storage/KeyringSignatureExecutor.java | 506 ++++++++++++++++++
.../storage/KeyringSignatureExecutorTest.java | 487 +++++++++++++++++
.../ZeroEchoLibSignatureWorkflow.java | 233 ++++----
...hoLibSignatureWorkflowPersistenceTest.java | 292 +++++++++-
4 files changed, 1404 insertions(+), 114 deletions(-)
create mode 100644 lib/src/main/java/zeroecho/core/storage/KeyringSignatureExecutor.java
create mode 100644 lib/src/test/java/zeroecho/core/storage/KeyringSignatureExecutorTest.java
diff --git a/lib/src/main/java/zeroecho/core/storage/KeyringSignatureExecutor.java b/lib/src/main/java/zeroecho/core/storage/KeyringSignatureExecutor.java
new file mode 100644
index 0000000..a133f81
--- /dev/null
+++ b/lib/src/main/java/zeroecho/core/storage/KeyringSignatureExecutor.java
@@ -0,0 +1,506 @@
+/*******************************************************************************
+ * Copyright (C) 2026, 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.core.storage;
+
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InterruptedIOException;
+import java.security.GeneralSecurityException;
+import java.security.PrivateKey;
+import java.security.ProviderException;
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.Optional;
+
+import zeroecho.core.KeyUsage;
+import zeroecho.core.alg.common.sig.SignatureInteropProfile;
+import zeroecho.core.alg.common.sig.SignatureInteropProfiles;
+import zeroecho.core.context.SignatureContext;
+import zeroecho.core.err.UnsupportedRoleException;
+import zeroecho.core.err.UnsupportedSpecException;
+import zeroecho.core.io.CancellationSignal;
+import zeroecho.core.io.RepeatableContent;
+import zeroecho.core.spec.AlgorithmIdentity;
+import zeroecho.sdk.ZeroEchoSession;
+
+/**
+ * Executes a streaming signature while confining private-key resolution to the
+ * keyring-owning library boundary.
+ *
+ *
+ * Instances retain only the owning {@link KeyringStore} and
+ * {@link ZeroEchoSession}; they never retain, expose, encode, or log private key
+ * material. Each invocation resolves its key entries independently and creates
+ * operation-local signature contexts. Instances are safe for concurrent use when
+ * callers provide independent content and cancellation objects.
+ *
+ *
+ *
+ * Signing runs in {@code O(n)} time for an {@code n}-byte payload and uses
+ * {@code O(1)} transfer memory plus storage bounded by the selected algorithm's
+ * signature length. The executor closes streams opened for the invocation but
+ * does not close the caller-owned {@link RepeatableContent}.
+ *
+ */
+public final class KeyringSignatureExecutor {
+ private static final int TRANSFER_BUFFER_BYTES = 16 * 1024;
+
+ private final KeyringStore keyring;
+ private final ZeroEchoSession session;
+
+ /**
+ * Stable, non-sensitive failure categories.
+ */
+ public enum FailureCode {
+ /** A requested key entry is absent or has the wrong entry kind. */
+ KEY_UNAVAILABLE,
+ /** The exact signature identity is incompatible with the stored keys. */
+ ALGORITHM_MISMATCH,
+ /** Cryptographic-provider execution failed. */
+ PROVIDER_FAILURE
+ }
+
+ /**
+ * Checked, redacted failure raised by key resolution or provider execution.
+ */
+ public static final class Failure extends GeneralSecurityException {
+ private static final long serialVersionUID = 1L;
+ private final FailureCode code;
+
+ private Failure(FailureCode code) {
+ super(Objects.requireNonNull(code, "code must not be null").name());
+ this.code = code;
+ }
+
+ /**
+ * Returns the stable failure category.
+ *
+ * @return non-sensitive failure code
+ */
+ public FailureCode code() {
+ return code;
+ }
+ }
+
+ /**
+ * Cause-free marker indicating cancellation observed at a documented signing
+ * checkpoint.
+ */
+ public static final class Cancellation extends InterruptedIOException {
+ private static final long serialVersionUID = 1L;
+
+ private Cancellation() {
+ super("SIGNING_CANCELLED");
+ }
+ }
+
+ /**
+ * Creates an executor bound to an open keyring and a session.
+ *
+ * @param keyring owning keyring used to resolve provider-local aliases
+ * @param session session whose policy and provider configuration govern signing
+ * @throws NullPointerException if either argument is {@code null}
+ */
+ public KeyringSignatureExecutor(KeyringStore keyring, ZeroEchoSession session) {
+ this.keyring = Objects.requireNonNull(keyring, "keyring must not be null");
+ this.session = Objects.requireNonNull(session, "session must not be null");
+ }
+
+ /**
+ * Signs repeatable content using a private key resolved within this executor.
+ *
+ *
+ * The algorithm identity must resolve to a registered interop profile through
+ * its canonical form. The stored private-key entry must carry that profile's
+ * exact key algorithm identifier. Cancellation is checked before key
+ * resolution, before opening the content, and between bounded reads.
+ *
+ *
+ * @param privateAlias provider-local private-key alias; must not be blank
+ * @param algorithmIdentity exact canonical signature identity
+ * @param content caller-owned repeatable content; never closed here
+ * @param cancellation cancellation signal consulted during the operation
+ * @return independently owned external signature bytes
+ * @throws IllegalArgumentException if an alias is blank or the identity is not
+ * a signature identity
+ * @throws NullPointerException if any non-alias argument is {@code null}
+ * @throws IOException if keyring resolution, authentication, or
+ * filesystem I/O fails; if content opening or
+ * streaming fails; or if a stream makes zero
+ * progress
+ * @throws Failure if a key is unavailable, the algorithm does
+ * not match, or provider execution fails
+ */
+ public byte[] sign(String privateAlias, AlgorithmIdentity algorithmIdentity, RepeatableContent content,
+ CancellationSignal cancellation) throws IOException, Failure {
+ requireNonBlank(privateAlias, "privateAlias");
+ Objects.requireNonNull(algorithmIdentity, "algorithmIdentity must not be null");
+ Objects.requireNonNull(content, "content must not be null");
+ Objects.requireNonNull(cancellation, "cancellation must not be null");
+ if (algorithmIdentity.kind() != AlgorithmIdentity.Kind.SIGNATURE) {
+ throw new IllegalArgumentException("algorithmIdentity must have SIGNATURE kind");
+ }
+
+ Optional resolvedProfile = SignatureInteropProfiles
+ .resolve(algorithmIdentity.canonicalForm());
+ if (resolvedProfile.isEmpty()) {
+ throw new Failure(FailureCode.ALGORITHM_MISMATCH);
+ }
+ SignatureInteropProfile profile = resolvedProfile.get();
+
+ checkCancellation(cancellation);
+ KeyringStore.PrivateWithId privateEntry = null;
+ FailureCode resolutionFailure = null;
+ try {
+ privateEntry = keyring.getPrivateWithId(privateAlias);
+ } catch (IllegalArgumentException unavailable) {
+ resolutionFailure = FailureCode.KEY_UNAVAILABLE;
+ } catch (GeneralSecurityException | IllegalStateException | SecurityException
+ | ProviderException providerFailure) {
+ resolutionFailure = FailureCode.PROVIDER_FAILURE;
+ }
+ if (resolutionFailure != null) {
+ throw new Failure(resolutionFailure);
+ }
+
+ String expectedKeyAlgorithm = profile.keyAlgorithmId();
+ if (!expectedKeyAlgorithm.equals(privateEntry.algorithm())) {
+ throw new Failure(FailureCode.ALGORITHM_MISMATCH);
+ }
+
+ PrivateKey privateKey = privateEntry.key();
+ SignatureContext signer = null;
+ boolean creationFailed = false;
+ try {
+ signer = session.createContext(profile.contextAlgorithmId(), KeyUsage.SIGN, privateKey,
+ profile.contextSpec());
+ } catch (UnsupportedRoleException | UnsupportedSpecException | IllegalArgumentException
+ | IllegalStateException | SecurityException | ProviderException providerFailure) {
+ creationFailed = true;
+ }
+ if (creationFailed) {
+ throw new Failure(FailureCode.PROVIDER_FAILURE);
+ }
+
+ ExecutionResult result = ExecutionResult.providerFailure();
+ try (SignatureContext ownedSigner = signer) {
+ result = executeOpenContent(ownedSigner, profile, content, cancellation);
+ } catch (IOException | IllegalArgumentException | IllegalStateException | SecurityException
+ | ProviderException providerCloseFailure) {
+ result = result.withProviderFailure();
+ }
+ return result.valueOrThrow();
+ }
+
+ private static ExecutionResult executeOpenContent(SignatureContext signer, SignatureInteropProfile profile,
+ RepeatableContent content, CancellationSignal cancellation) {
+ int signatureLength;
+ try {
+ signatureLength = signer.tagLength();
+ } catch (IllegalArgumentException | IllegalStateException | SecurityException
+ | ProviderException providerFailure) {
+ return ExecutionResult.providerFailure();
+ }
+ if (signatureLength <= 0) {
+ return ExecutionResult.providerFailure();
+ }
+ try {
+ checkCancellation(cancellation);
+ } catch (Cancellation cancelled) {
+ return ExecutionResult.cancelled();
+ }
+
+ ProgressCheckedInputStream source;
+ try {
+ source = new ProgressCheckedInputStream(content.openStream(), cancellation);
+ } catch (IOException contentFailure) {
+ return ExecutionResult.contentFailure(contentFailure);
+ }
+ ExecutionResult result = ExecutionResult.providerFailure();
+ try (source; InputStream input = signer.wrap(source)) {
+ result = readSignatureTail(input, signatureLength, cancellation);
+ if (result.kind == ExecutionKind.SUCCESS) {
+ source.disableCancellationChecks();
+ result = convertSignature(profile, result);
+ if (result.kind == ExecutionKind.PROVIDER_FAILURE) {
+ source.abortReads();
+ }
+ } else {
+ source.abortReads();
+ }
+ } catch (ContentBoundaryIOException contentFailure) {
+ result = result.withContentFailure(contentFailure.original);
+ } catch (Cancellation cancelled) {
+ result = ExecutionResult.cancelled();
+ } catch (IOException | IllegalArgumentException | IllegalStateException | SecurityException
+ | ProviderException providerFailure) {
+ result = result.withProviderFailure();
+ }
+ return result;
+ }
+
+ private static ExecutionResult convertSignature(SignatureInteropProfile profile, ExecutionResult internal) {
+ byte[] internalSignature = internal.signature;
+ try {
+ byte[] external = profile.internalToExternalSignature(internalSignature);
+ return ExecutionResult.success(external);
+ } catch (IOException | IllegalArgumentException | IllegalStateException | SecurityException
+ | ProviderException malformedResult) {
+ return ExecutionResult.providerFailure();
+ } finally {
+ Arrays.fill(internalSignature, (byte) 0);
+ }
+ }
+
+ private static ExecutionResult readSignatureTail(InputStream input, int signatureLength,
+ CancellationSignal cancellation) {
+ byte[] transfer = new byte[TRANSFER_BUFFER_BYTES];
+ byte[] tail = new byte[signatureLength];
+ int tailCount = 0;
+ int tailPosition = 0;
+ try {
+ while (true) {
+ try {
+ checkCancellation(cancellation);
+ } catch (Cancellation cancelled) {
+ return ExecutionResult.cancelled();
+ }
+ int count;
+ try {
+ count = input.read(transfer);
+ } catch (ContentBoundaryIOException contentFailure) {
+ return ExecutionResult.contentFailure(contentFailure.original);
+ } catch (Cancellation cancelled) {
+ return ExecutionResult.cancelled();
+ } catch (IOException | IllegalArgumentException | IllegalStateException | SecurityException
+ | ProviderException providerFailure) {
+ return ExecutionResult.providerFailure();
+ }
+ if (count < 0) {
+ break;
+ }
+ if (count == 0) {
+ return ExecutionResult.providerFailure();
+ }
+ if (count >= signatureLength) {
+ System.arraycopy(transfer, count - signatureLength, tail, 0, signatureLength);
+ tailCount = signatureLength;
+ tailPosition = 0;
+ } else {
+ int first = Math.min(count, signatureLength - tailPosition);
+ System.arraycopy(transfer, 0, tail, tailPosition, first);
+ int remaining = count - first;
+ if (remaining > 0) {
+ System.arraycopy(transfer, first, tail, 0, remaining);
+ }
+ tailPosition = (tailPosition + count) % signatureLength;
+ tailCount = Math.min(signatureLength, tailCount + count);
+ }
+ }
+ if (tailCount != signatureLength) {
+ return ExecutionResult.providerFailure();
+ }
+ byte[] signature = new byte[signatureLength];
+ int first = signatureLength - tailPosition;
+ System.arraycopy(tail, tailPosition, signature, 0, first);
+ if (tailPosition > 0) {
+ System.arraycopy(tail, 0, signature, first, tailPosition);
+ }
+ try {
+ checkCancellation(cancellation);
+ return ExecutionResult.success(signature);
+ } catch (Cancellation cancelled) {
+ Arrays.fill(signature, (byte) 0);
+ return ExecutionResult.cancelled();
+ }
+ } finally {
+ Arrays.fill(transfer, (byte) 0);
+ Arrays.fill(tail, (byte) 0);
+ }
+ }
+
+ private static void checkCancellation(CancellationSignal cancellation) throws Cancellation {
+ if (cancellation.isCancelled()) {
+ throw new Cancellation();
+ }
+ }
+
+ private static String requireNonBlank(String value, String parameterName) {
+ if (value == null || value.isBlank()) {
+ throw new IllegalArgumentException(parameterName + " must not be blank");
+ }
+ return value;
+ }
+
+ /** Content reader that marks caller-owned I/O and enforces read checkpoints. */
+ private static final class ProgressCheckedInputStream extends FilterInputStream {
+ private final CancellationSignal cancellation;
+ private boolean readsAborted;
+ private boolean cancellationChecksEnabled = true;
+ private boolean closed;
+
+ private ProgressCheckedInputStream(InputStream source, CancellationSignal cancellation) {
+ super(Objects.requireNonNull(source, "source must not be null"));
+ this.cancellation = cancellation;
+ }
+
+ @Override
+ public int read() throws IOException {
+ if (readsAborted) {
+ return -1;
+ }
+ checkCancellationWhenEnabled();
+ try {
+ return super.read();
+ } catch (IOException contentFailure) {
+ throw new ContentBoundaryIOException(contentFailure);
+ }
+ }
+
+ @Override
+ public int read(byte[] bytes, int offset, int length) throws IOException {
+ if (readsAborted) {
+ return length == 0 ? 0 : -1;
+ }
+ checkCancellationWhenEnabled();
+ int count;
+ try {
+ count = super.read(bytes, offset, length);
+ } catch (IOException contentFailure) {
+ throw new ContentBoundaryIOException(contentFailure);
+ }
+ if (length > 0 && count == 0) {
+ throw new ContentBoundaryIOException(new IOException("Content stream made no progress"));
+ }
+ return count;
+ }
+
+ private void checkCancellationWhenEnabled() throws Cancellation {
+ if (cancellationChecksEnabled) {
+ checkCancellation(cancellation);
+ }
+ }
+
+ private void disableCancellationChecks() {
+ cancellationChecksEnabled = false;
+ }
+
+ private void abortReads() {
+ readsAborted = true;
+ cancellationChecksEnabled = false;
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (!closed) {
+ closed = true;
+ try {
+ super.close();
+ } catch (IOException contentFailure) {
+ throw new ContentBoundaryIOException(contentFailure);
+ }
+ }
+ }
+ }
+
+ /** Internal marker retaining a caller-owned content failure until cleanup ends. */
+ private static final class ContentBoundaryIOException extends IOException {
+ private static final long serialVersionUID = 1L;
+ private final IOException original;
+
+ private ContentBoundaryIOException(IOException original) {
+ super("CONTENT_IO");
+ this.original = original;
+ }
+ }
+
+ /** Exhaustive outcomes of provider signing execution. */
+ private enum ExecutionKind {
+ SUCCESS, CONTENT_FAILURE, CANCELLED, PROVIDER_FAILURE
+ }
+
+ /** Phase result that carries either owned signature bytes or one safe failure. */
+ private static final class ExecutionResult {
+ private final ExecutionKind kind;
+ private final byte[] signature;
+ private final IOException contentFailure;
+
+ private ExecutionResult(ExecutionKind kind, byte[] signature, IOException contentFailure) {
+ this.kind = kind;
+ this.signature = signature;
+ this.contentFailure = contentFailure;
+ }
+
+ private static ExecutionResult success(byte[] signature) {
+ return new ExecutionResult(ExecutionKind.SUCCESS, signature, null);
+ }
+
+ private static ExecutionResult contentFailure(IOException failure) {
+ return new ExecutionResult(ExecutionKind.CONTENT_FAILURE, null, failure);
+ }
+
+ private static ExecutionResult cancelled() {
+ return new ExecutionResult(ExecutionKind.CANCELLED, null, null);
+ }
+
+ private static ExecutionResult providerFailure() {
+ return new ExecutionResult(ExecutionKind.PROVIDER_FAILURE, null, null);
+ }
+
+ private ExecutionResult withContentFailure(IOException failure) {
+ if (kind != ExecutionKind.SUCCESS) {
+ return this;
+ }
+ Arrays.fill(signature, (byte) 0);
+ return contentFailure(failure);
+ }
+
+ private ExecutionResult withProviderFailure() {
+ if (signature != null) {
+ Arrays.fill(signature, (byte) 0);
+ }
+ return kind == ExecutionKind.SUCCESS ? providerFailure() : this;
+ }
+
+ private byte[] valueOrThrow() throws IOException, Failure {
+ return switch (kind) {
+ case SUCCESS -> signature;
+ case CONTENT_FAILURE -> throw contentFailure;
+ case CANCELLED -> throw new Cancellation();
+ case PROVIDER_FAILURE -> throw new Failure(FailureCode.PROVIDER_FAILURE);
+ };
+ }
+ }
+}
diff --git a/lib/src/test/java/zeroecho/core/storage/KeyringSignatureExecutorTest.java b/lib/src/test/java/zeroecho/core/storage/KeyringSignatureExecutorTest.java
new file mode 100644
index 0000000..7fd17db
--- /dev/null
+++ b/lib/src/test/java/zeroecho/core/storage/KeyringSignatureExecutorTest.java
@@ -0,0 +1,487 @@
+/*******************************************************************************
+ * Copyright (C) 2026, 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.core.storage;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InterruptedIOException;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.nio.file.Path;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.PrivateKey;
+import java.security.ProviderException;
+import java.security.Signature;
+import java.util.Arrays;
+import java.util.OptionalLong;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import zeroecho.core.alg.BootstrapAlgorithmIdentities;
+import zeroecho.core.KeyUsage;
+import zeroecho.core.context.SignatureContext;
+import zeroecho.core.io.CancellationSignal;
+import zeroecho.core.io.RepeatableContent;
+import zeroecho.core.policy.CryptoPolicy;
+import zeroecho.core.spec.ContextSpec;
+import zeroecho.sdk.ZeroEchoSession;
+
+final class KeyringSignatureExecutorTest {
+ private static final char[] PASSWORD = { 'e', 'x', 'e', 'c', 'u', 't', 'o', 'r' };
+ private static final String PRIVATE_ALIAS = "signing.prv";
+
+ @TempDir
+ Path temporaryDirectory;
+
+ private KeyringStore keyring;
+ private KeyPair keyPair;
+
+ @BeforeEach
+ void createKeyring() throws Exception {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(2048);
+ keyPair = generator.generateKeyPair();
+ try (KeyringPassword password = password()) {
+ keyring = KeyringStore.create(temporaryDirectory.resolve("executor.zek"), password);
+ }
+ keyring.putPrivate(PRIVATE_ALIAS, "RSA", keyPair.getPrivate());
+ }
+
+ @AfterEach
+ void closeKeyring() {
+ keyring.close();
+ }
+
+ @Test
+ void signsMultipleBuffersAndReturnedSignatureVerifies() throws Exception {
+ System.out.println("signsMultipleBuffersAndReturnedSignatureVerifies");
+ byte[] payload = new byte[96 * 1024 + 37];
+ for (int index = 0; index < payload.length; index++) {
+ payload[index] = (byte) index;
+ }
+ AtomicInteger delegateCloseCount = new AtomicInteger();
+ TestContent content = new TestContent(() -> new ByteArrayInputStream(payload) {
+ @Override
+ public void close() throws IOException {
+ if (delegateCloseCount.incrementAndGet() > 1) {
+ throw new IOException("delegate closed more than once");
+ }
+ super.close();
+ }
+ });
+
+ byte[] signature = executor(new ZeroEchoSession()).sign(PRIVATE_ALIAS,
+ BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, content, CancellationSignal.NONE);
+
+ Signature verifier = Signature.getInstance("SHA256withRSA");
+ verifier.initVerify(keyPair.getPublic());
+ verifier.update(payload);
+ assertTrue(verifier.verify(signature));
+ assertEquals(1, content.openCount.get());
+ assertEquals(1, content.streamCloseCount.get());
+ assertEquals(1, delegateCloseCount.get());
+ assertFalse(content.closed.get());
+ System.out.println("...payload=" + payload.length + " bytes");
+ System.out.println("signsMultipleBuffersAndReturnedSignatureVerifies...ok");
+ }
+
+ @Test
+ void rejectsUnavailableKeysInvalidIdentitiesAndIncompatibleAlgorithms() throws Exception {
+ System.out.println("rejectsUnavailableKeysInvalidIdentitiesAndIncompatibleAlgorithms");
+ KeyringSignatureExecutor executor = executor(new ZeroEchoSession());
+ TestContent content = TestContent.bytes(new byte[] { 1, 2, 3 });
+
+ KeyringSignatureExecutor.Failure missing = assertThrows(KeyringSignatureExecutor.Failure.class,
+ () -> executor.sign("missing.prv", BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, content,
+ CancellationSignal.NONE));
+ assertEquals(KeyringSignatureExecutor.FailureCode.KEY_UNAVAILABLE, missing.code());
+ assertEquals("KEY_UNAVAILABLE", missing.getMessage());
+ assertRedacted(missing, "missing.prv");
+
+ assertThrows(IllegalArgumentException.class,
+ () -> executor.sign(PRIVATE_ALIAS, BootstrapAlgorithmIdentities.SHA256, content,
+ CancellationSignal.NONE));
+ assertThrows(IllegalArgumentException.class,
+ () -> executor.sign(" ", BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, content,
+ CancellationSignal.NONE));
+
+ KeyringSignatureExecutor.Failure incompatible = assertThrows(KeyringSignatureExecutor.Failure.class,
+ () -> executor.sign(PRIVATE_ALIAS, BootstrapAlgorithmIdentities.ECDSA_SHA256, content,
+ CancellationSignal.NONE));
+ assertEquals(KeyringSignatureExecutor.FailureCode.ALGORITHM_MISMATCH, incompatible.code());
+ assertEquals(0, content.openCount.get());
+ System.out.println("rejectsUnavailableKeysInvalidIdentitiesAndIncompatibleAlgorithms...ok");
+ }
+
+ @Test
+ void closedKeyringPreservesIoFailureAndPolicyDenialIsRedacted() throws Exception {
+ System.out.println("closedKeyringPreservesIoFailureAndPolicyDenialIsRedacted");
+ TestContent content = TestContent.bytes(new byte[] { 4, 5, 6 });
+ ZeroEchoSession deniedSession = new ZeroEchoSession().withPolicy(
+ (CryptoPolicy) (id, role, key, spec) -> {
+ throw new IllegalArgumentException("PROVIDER_SENTINEL");
+ });
+ KeyringSignatureExecutor.Failure denied = assertThrows(KeyringSignatureExecutor.Failure.class,
+ () -> executor(deniedSession).sign(PRIVATE_ALIAS, BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256,
+ content, CancellationSignal.NONE));
+ assertEquals(KeyringSignatureExecutor.FailureCode.PROVIDER_FAILURE, denied.code());
+ assertEquals("PROVIDER_FAILURE", denied.getMessage());
+ assertRedacted(denied, "PROVIDER_SENTINEL");
+
+ KeyringStore unsafeKeyring = mock(KeyringStore.class);
+ ProviderException importFailure = new ProviderException("KEY_IMPORT_SENTINEL",
+ new IllegalStateException("KEY_IMPORT_NESTED_SENTINEL"));
+ importFailure.addSuppressed(new IOException("KEY_IMPORT_SUPPRESSED_SENTINEL"));
+ when(unsafeKeyring.getPrivateWithId(PRIVATE_ALIAS)).thenThrow(importFailure);
+ KeyringSignatureExecutor.Failure keyImport = assertThrows(KeyringSignatureExecutor.Failure.class,
+ () -> new KeyringSignatureExecutor(unsafeKeyring, new ZeroEchoSession()).sign(PRIVATE_ALIAS,
+ BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, content, CancellationSignal.NONE));
+ assertEquals(KeyringSignatureExecutor.FailureCode.PROVIDER_FAILURE, keyImport.code());
+ assertRedacted(keyImport, "KEY_IMPORT_SENTINEL");
+ assertRedacted(keyImport, "KEY_IMPORT_NESTED_SENTINEL");
+ assertRedacted(keyImport, "KEY_IMPORT_SUPPRESSED_SENTINEL");
+
+ keyring.close();
+ assertThrows(IOException.class,
+ () -> executor(new ZeroEchoSession()).sign(PRIVATE_ALIAS,
+ BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, content, CancellationSignal.NONE));
+ assertEquals(0, content.openCount.get());
+ System.out.println("closedKeyringPreservesIoFailureAndPolicyDenialIsRedacted...ok");
+ }
+
+ @Test
+ void cancellationWinsBeforeResolutionAndStopsDuringStreaming() throws Exception {
+ System.out.println("cancellationWinsBeforeResolutionAndStopsDuringStreaming");
+ TestContent unopened = TestContent.bytes(new byte[] { 7 });
+ AtomicInteger cancellationChecks = new AtomicInteger();
+ CancellationSignal oneShotCancellation = () -> {
+ if (cancellationChecks.incrementAndGet() > 1) {
+ throw new IllegalStateException("CANCELLATION_RECHECK_SENTINEL");
+ }
+ return true;
+ };
+ assertThrows(InterruptedIOException.class,
+ () -> executor(new ZeroEchoSession()).sign("missing.prv",
+ BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, unopened, oneShotCancellation));
+ assertEquals(1, cancellationChecks.get());
+ assertEquals(0, unopened.openCount.get());
+
+ AtomicBoolean cancelled = new AtomicBoolean();
+ AtomicInteger streamingCancellationChecks = new AtomicInteger();
+ AtomicInteger streamingReadCalls = new AtomicInteger();
+ TestContent streaming = new TestContent(() -> new InputStream() {
+ private boolean first = true;
+
+ @Override
+ public int read() {
+ return -1;
+ }
+
+ @Override
+ public int read(byte[] bytes, int offset, int length) {
+ if (streamingReadCalls.incrementAndGet() > 1) {
+ throw new IllegalStateException("POST_CANCELLATION_DRAIN_SENTINEL");
+ }
+ if (!first) {
+ return -1;
+ }
+ first = false;
+ int count = Math.min(length, 4096);
+ Arrays.fill(bytes, offset, offset + count, (byte) 0x5a);
+ cancelled.set(true);
+ return count;
+ }
+ });
+ assertThrows(InterruptedIOException.class,
+ () -> executor(new ZeroEchoSession()).sign(PRIVATE_ALIAS,
+ BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, streaming, () -> {
+ if (streamingCancellationChecks.incrementAndGet() > 5) {
+ throw new IllegalStateException("POST_CANCELLATION_CHECK_SENTINEL");
+ }
+ return cancelled.get();
+ }));
+ assertEquals(5, streamingCancellationChecks.get());
+ assertEquals(1, streamingReadCalls.get());
+ assertEquals(1, streaming.streamCloseCount.get());
+ assertFalse(streaming.closed.get());
+ System.out.println("cancellationWinsBeforeResolutionAndStopsDuringStreaming...ok");
+ }
+
+ @Test
+ void zeroProgressAndFailingContentAreCheckedAndClosed() throws Exception {
+ System.out.println("zeroProgressAndFailingContentAreCheckedAndClosed");
+ TestContent zeroProgress = new TestContent(() -> new InputStream() {
+ @Override
+ public int read() {
+ return 0;
+ }
+
+ @Override
+ public int read(byte[] bytes, int offset, int length) {
+ return 0;
+ }
+ });
+ assertThrows(IOException.class,
+ () -> executor(new ZeroEchoSession()).sign(PRIVATE_ALIAS,
+ BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, zeroProgress, CancellationSignal.NONE));
+ assertEquals(1, zeroProgress.streamCloseCount.get());
+
+ IOException controlledFailure = new IOException("controlled read failure");
+ AtomicInteger failingReadCalls = new AtomicInteger();
+ TestContent failing = new TestContent(() -> new InputStream() {
+ @Override
+ public int read() throws IOException {
+ throw controlledFailure;
+ }
+
+ @Override
+ public int read(byte[] bytes, int offset, int length) throws IOException {
+ if (failingReadCalls.incrementAndGet() > 1) {
+ throw new IllegalStateException("POST_CONTENT_FAILURE_DRAIN_SENTINEL");
+ }
+ throw controlledFailure;
+ }
+ });
+ AtomicInteger contentCancellationChecks = new AtomicInteger();
+ IOException observedFailure = assertThrows(IOException.class,
+ () -> executor(new ZeroEchoSession()).sign(PRIVATE_ALIAS,
+ BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, failing, () -> {
+ if (contentCancellationChecks.incrementAndGet() > 4) {
+ throw new IllegalStateException("CONTENT_CANCELLATION_RECHECK_SENTINEL");
+ }
+ return false;
+ }));
+ assertSame(controlledFailure, observedFailure);
+ assertEquals(4, contentCancellationChecks.get());
+ assertEquals(1, failingReadCalls.get());
+ assertEquals(1, failing.streamCloseCount.get());
+ assertFalse(failing.closed.get());
+ System.out.println("zeroProgressAndFailingContentAreCheckedAndClosed...ok");
+ }
+
+ @Test
+ void providerIoAndMalformedResultsAreRedactedProviderFailures() throws Exception {
+ System.out.println("providerIoAndMalformedResultsAreRedactedProviderFailures");
+ TestContent content = TestContent.bytes(new byte[] { 1, 2, 3 });
+
+ SignatureContext tagLengthFailure = mock(SignatureContext.class);
+ when(tagLengthFailure.tagLength()).thenThrow(new ProviderException("PROVIDER_TAG_LENGTH_SENTINEL"));
+ KeyringSignatureExecutor.Failure tagLength = assertThrows(KeyringSignatureExecutor.Failure.class,
+ () -> executor(mockSession(tagLengthFailure)).sign(PRIVATE_ALIAS,
+ BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, content, CancellationSignal.NONE));
+ assertEquals(KeyringSignatureExecutor.FailureCode.PROVIDER_FAILURE, tagLength.code());
+ assertRedacted(tagLength, "PROVIDER_TAG_LENGTH_SENTINEL");
+
+ SignatureContext wrapFailure = mockContext(256);
+ when(wrapFailure.wrap(any(InputStream.class))).thenThrow(new IOException("PROVIDER_WRAP_SENTINEL"));
+ AtomicInteger providerCancellationChecks = new AtomicInteger();
+ KeyringSignatureExecutor.Failure wrap = assertThrows(KeyringSignatureExecutor.Failure.class,
+ () -> executor(mockSession(wrapFailure)).sign(PRIVATE_ALIAS,
+ BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, content, () -> {
+ if (providerCancellationChecks.incrementAndGet() > 2) {
+ throw new IllegalStateException("PROVIDER_CANCELLATION_RECHECK_SENTINEL");
+ }
+ return false;
+ }));
+ assertEquals(KeyringSignatureExecutor.FailureCode.PROVIDER_FAILURE, wrap.code());
+ assertRedacted(wrap, "PROVIDER_WRAP_SENTINEL");
+ assertEquals(2, providerCancellationChecks.get());
+
+ SignatureContext readFailure = mockContext(256);
+ when(readFailure.wrap(any(InputStream.class))).thenReturn(new InputStream() {
+ @Override
+ public int read() throws IOException {
+ throw new IOException("PROVIDER_READ_SENTINEL");
+ }
+ });
+ KeyringSignatureExecutor.Failure read = assertThrows(KeyringSignatureExecutor.Failure.class,
+ () -> executor(mockSession(readFailure)).sign(PRIVATE_ALIAS,
+ BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, content, CancellationSignal.NONE));
+ assertEquals(KeyringSignatureExecutor.FailureCode.PROVIDER_FAILURE, read.code());
+ assertRedacted(read, "PROVIDER_READ_SENTINEL");
+
+ SignatureContext closeFailure = mockContext(256);
+ when(closeFailure.wrap(any(InputStream.class))).thenReturn(new ByteArrayInputStream(new byte[256]));
+ doThrow(new IOException("PROVIDER_CLOSE_SENTINEL")).when(closeFailure).close();
+ KeyringSignatureExecutor.Failure close = assertThrows(KeyringSignatureExecutor.Failure.class,
+ () -> executor(mockSession(closeFailure)).sign(PRIVATE_ALIAS,
+ BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, content, CancellationSignal.NONE));
+ assertEquals(KeyringSignatureExecutor.FailureCode.PROVIDER_FAILURE, close.code());
+ assertRedacted(close, "PROVIDER_CLOSE_SENTINEL");
+
+ SignatureContext runtimeCloseFailure = mockContext(256);
+ when(runtimeCloseFailure.wrap(any(InputStream.class))).thenReturn(new ByteArrayInputStream(new byte[256]));
+ doThrow(new ProviderException("PROVIDER_RUNTIME_CLOSE_SENTINEL")).when(runtimeCloseFailure).close();
+ KeyringSignatureExecutor.Failure runtimeClose = assertThrows(KeyringSignatureExecutor.Failure.class,
+ () -> executor(mockSession(runtimeCloseFailure)).sign(PRIVATE_ALIAS,
+ BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, content, CancellationSignal.NONE));
+ assertEquals(KeyringSignatureExecutor.FailureCode.PROVIDER_FAILURE, runtimeClose.code());
+ assertRedacted(runtimeClose, "PROVIDER_RUNTIME_CLOSE_SENTINEL");
+
+ SignatureContext malformedContext = mockContext(256);
+ when(malformedContext.wrap(any(InputStream.class))).thenReturn(new ByteArrayInputStream(new byte[8]));
+ KeyringSignatureExecutor.Failure malformed = assertThrows(KeyringSignatureExecutor.Failure.class,
+ () -> executor(mockSession(malformedContext)).sign(PRIVATE_ALIAS,
+ BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, content, CancellationSignal.NONE));
+ assertEquals(KeyringSignatureExecutor.FailureCode.PROVIDER_FAILURE, malformed.code());
+ assertRedacted(malformed, "MALFORMED_RESULT_SENTINEL");
+ System.out.println("providerIoAndMalformedResultsAreRedactedProviderFailures...ok");
+ }
+
+ @Test
+ void publicSurfaceAndFieldsExposeNoPrivateKeyTypes() {
+ System.out.println("publicSurfaceAndFieldsExposeNoPrivateKeyTypes");
+ for (Field field : KeyringSignatureExecutor.class.getDeclaredFields()) {
+ assertFalse(PrivateKey.class.isAssignableFrom(field.getType()));
+ }
+ for (Method method : KeyringSignatureExecutor.class.getDeclaredMethods()) {
+ if (!Modifier.isPublic(method.getModifiers())) {
+ continue;
+ }
+ if ("sign".equals(method.getName())) {
+ assertEquals(1, Arrays.stream(method.getParameterTypes()).filter(String.class::equals).count());
+ }
+ assertFalse(PrivateKey.class.isAssignableFrom(method.getReturnType()));
+ assertFalse(KeyringStore.PrivateWithId.class.isAssignableFrom(method.getReturnType()));
+ for (Class> parameterType : method.getParameterTypes()) {
+ assertFalse(PrivateKey.class.isAssignableFrom(parameterType));
+ assertFalse(KeyringStore.PrivateWithId.class.isAssignableFrom(parameterType));
+ assertFalse(KeyPair.class.isAssignableFrom(parameterType));
+ }
+ }
+ System.out.println("publicSurfaceAndFieldsExposeNoPrivateKeyTypes...ok");
+ }
+
+ private KeyringSignatureExecutor executor(ZeroEchoSession session) {
+ return new KeyringSignatureExecutor(keyring, session);
+ }
+
+ private static ZeroEchoSession mockSession(SignatureContext context) {
+ ZeroEchoSession session = mock(ZeroEchoSession.class);
+ doReturn(context).when(session).createContext(anyString(), eq(KeyUsage.SIGN), any(PrivateKey.class),
+ any(ContextSpec.class));
+ return session;
+ }
+
+ private static SignatureContext mockContext(int signatureLength) {
+ SignatureContext context = mock(SignatureContext.class);
+ when(context.tagLength()).thenReturn(signatureLength);
+ return context;
+ }
+
+ private static void assertRedacted(KeyringSignatureExecutor.Failure failure, String sentinel) {
+ assertEquals(null, failure.getCause());
+ assertEquals(0, failure.getSuppressed().length);
+ assertFalse(failure.toString().contains(sentinel));
+ }
+
+ private static KeyringPassword password() {
+ return new KeyringPassword(PASSWORD);
+ }
+
+ @FunctionalInterface
+ private interface InputFactory {
+ InputStream open() throws IOException;
+ }
+
+ private static final class TestContent implements RepeatableContent {
+ private final InputFactory factory;
+ private final AtomicInteger openCount = new AtomicInteger();
+ private final AtomicInteger streamCloseCount = new AtomicInteger();
+ private final AtomicBoolean closed = new AtomicBoolean();
+
+ private TestContent(InputFactory factory) {
+ this.factory = factory;
+ }
+
+ private static TestContent bytes(byte[] bytes) {
+ return new TestContent(() -> new ByteArrayInputStream(bytes));
+ }
+
+ @Override
+ public InputStream openStream() throws IOException {
+ openCount.incrementAndGet();
+ InputStream delegate = factory.open();
+ return new java.io.FilterInputStream(delegate) {
+ private boolean streamClosed;
+
+ @Override
+ public void close() throws IOException {
+ if (streamClosed) {
+ return;
+ }
+ streamClosed = true;
+ streamCloseCount.incrementAndGet();
+ super.close();
+ }
+ };
+ }
+
+ @Override
+ public OptionalLong length() {
+ return OptionalLong.empty();
+ }
+
+ @Override
+ public String contentId() {
+ return "executor-test-content";
+ }
+
+ @Override
+ public void close() {
+ closed.set(true);
+ }
+ }
+}
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
index 76a0f81..79e13b6 100644
--- a/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflow.java
+++ b/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflow.java
@@ -51,7 +51,6 @@ import java.nio.file.attribute.PosixFilePermissions;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
-import java.security.PrivateKey;
import java.security.PublicKey;
import java.time.Clock;
import java.time.Duration;
@@ -75,6 +74,7 @@ import java.util.logging.Level;
import java.util.logging.Logger;
import zeroecho.core.KeyUsage;
+import zeroecho.core.alg.BootstrapAlgorithmIdentities;
import zeroecho.core.alg.common.sig.SignatureInteropProfile;
import zeroecho.core.alg.common.sig.SignatureInteropProfiles;
import zeroecho.core.alg.ecdsa.EcdsaPublicKeySpec;
@@ -87,11 +87,12 @@ import zeroecho.core.alg.sphincsplus.SphincsPlusPublicKeySpec;
import zeroecho.core.context.SignatureContext;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.RepeatableContent;
-import zeroecho.core.io.TailStrippingInputStream;
+import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.spec.AlgorithmKeySpec;
import zeroecho.core.spec.ContextSpec;
import zeroecho.core.spi.KeyringUnlockProvider;
import zeroecho.core.storage.KeyringPassword;
+import zeroecho.core.storage.KeyringSignatureExecutor;
import zeroecho.core.storage.KeyringStore;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding;
@@ -108,9 +109,10 @@ import zeroecho.sdk.ZeroEchoSession;
* This provider forms a cryptographic boundary between the PKI orchestration
* layer and ZeroEcho-lib based key material handling backed by
* {@link KeyringStore}. It resolves opaque {@link KeyRef} values to provider-
- * local keyring aliases, materializes the required key objects inside this
- * boundary, and performs signing or verification through the explicit
- * {@link ZeroEchoSession} and {@link SignatureContext}.
+ * local keyring aliases, delegates signing to the keyring-owned execution
+ * boundary, and performs verification through the explicit
+ * {@link ZeroEchoSession} and {@link SignatureContext}. Private key objects never
+ * enter this PKI module.
*
*
*
@@ -125,8 +127,8 @@ import zeroecho.sdk.ZeroEchoSession;
*
*
Supported operations
*
- * - {@link #submitSign(SignRequest)} resolves a private/public key pair from
- * the configured {@link KeyringStore}, validates the requested algorithm
+ *
- {@link #submitSign(SignRequest)} resolves a private key from the configured
+ * {@link KeyringStore}, validates the requested algorithm
* compatibility, produces a signature over the supplied payload, and stores the
* result as a terminal successful or failed operation status.
* - {@link #submitVerify(VerifyRequest)} verifies a signature either against
@@ -187,18 +189,12 @@ import zeroecho.sdk.ZeroEchoSession;
* {@code zeroecho-lib:<alias>.pub}.
*
*
- *
- * The provider may derive the corresponding public alias from a signing key
- * reference in order to obtain public-key metadata needed for algorithm checks
- * and interop processing.
- *
- *
* Algorithm handling
*
- * Requested signature algorithm identifiers are matched against the stored key
- * algorithm using {@link SignatureInteropProfiles} where available and by a
- * legacy normalization fallback for historical identifiers such as
- * {@code *withRSA} and {@code *withECDSA}. For interoperable algorithms, the
+ * Signing resolves requested identifiers through
+ * {@link BootstrapAlgorithmIdentities} and delegates the exact canonical identity
+ * to the keyring-owned executor. Verification retains compatibility normalization
+ * for supported public-key import forms. For interoperable algorithms, the
* provider transparently maps between external signature form and the internal
* ZeroEcho representation expected by {@link SignatureContext}.
*
@@ -224,8 +220,8 @@ import zeroecho.sdk.ZeroEchoSession;
*
* - Never logs {@link KeyRef} values.
* - Never logs payload bytes or signature bytes.
- * - Never returns private key material; private keys are materialized only
- * within this provider boundary.
+ * - Never returns private key material; signing key resolution occurs only in
+ * the keyring-owning library boundary.
* - Notification sinks must be treated as trusted local integration points,
* because they receive operation identifiers and status metadata for all
* operations observed by this provider instance.
@@ -313,9 +309,26 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
this.keyringOrNull = java.util.Objects.requireNonNull(keyring, "keyring must not be null");
}
+ /* default */ ZeroEchoLibSignatureWorkflow(String id, Path keyringPath, Path operationRoot, Clock clock,
+ Duration operationHorizon, String keyRefPrefix, boolean requireComponentSuffix,
+ KeyringUnlockProvider keyringUnlockProvider, SigningDependencies signingDependencies) {
+ this(id, keyringPath, operationRoot, clock, operationHorizon, keyRefPrefix, requireComponentSuffix,
+ keyringUnlockProvider, (category, cleared) -> {
+ }, signingDependencies.session());
+ this.keyringOrNull = signingDependencies.keyring();
+ }
+
/* default */ ZeroEchoLibSignatureWorkflow(String id, Path keyringPath, Path operationRoot, Clock clock,
Duration operationHorizon, String keyRefPrefix, boolean requireComponentSuffix,
KeyringUnlockProvider keyringUnlockProvider, BiConsumer cleanupObserver) {
+ this(id, keyringPath, operationRoot, clock, operationHorizon, keyRefPrefix, requireComponentSuffix,
+ keyringUnlockProvider, cleanupObserver, new ZeroEchoSession());
+ }
+
+ private ZeroEchoLibSignatureWorkflow(String id, Path keyringPath, Path operationRoot, Clock clock,
+ Duration operationHorizon, String keyRefPrefix, boolean requireComponentSuffix,
+ KeyringUnlockProvider keyringUnlockProvider, BiConsumer cleanupObserver,
+ ZeroEchoSession session) {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("id must not be blank");
}
@@ -349,7 +362,7 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
this.requireComponentSuffix = requireComponentSuffix;
this.cleanupObserver = cleanupObserver;
this.keyringUnlockProvider = keyringUnlockProvider;
- this.session = new ZeroEchoSession();
+ this.session = java.util.Objects.requireNonNull(session, "session must not be null");
this.statuses = new ConcurrentHashMap<>();
this.fingerprints = new ConcurrentHashMap<>();
@@ -451,10 +464,9 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
*
*
*
- * The method validates the key reference, loads the corresponding private and
- * public key entries from the configured {@link KeyringStore}, verifies that
- * the requested signature algorithm is compatible with the stored key
- * algorithm, performs streaming signature generation, encodes the resulting
+ * The method validates the key reference, resolves the requested algorithm to
+ * an exact canonical identity, delegates key resolution and streaming signature
+ * generation to {@link KeyringSignatureExecutor}, encodes the resulting
* signature according to the preferred output encoding, and stores the final
* outcome in the internal status registry.
*
@@ -468,9 +480,8 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
* @param request sign request; must not be {@code null}
* @return operation identifier that can be used to query the terminal result
* @throws IllegalArgumentException if {@code request} is {@code null}
- */
+ */
@Override
- @SuppressWarnings("PMD.CloseResource")
public PkiId submitSign(SignRequest request) {
if (request == null) {
throw new IllegalArgumentException("request must not be null");
@@ -488,6 +499,16 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
return opId;
}
+ SignExecutionResult execution = executeAcceptedSign(request);
+ try {
+ completeSign(request, execution.status);
+ return opId;
+ } finally {
+ clearOwned("sign-result-copy", execution.signatureBytes);
+ }
+ }
+
+ private SignExecutionResult executeAcceptedSign(SignRequest request) {
byte[] signatureBytes = null;
try {
KeyRefParts parts = parseKeyRefOrThrow(request.keyRef(), true);
@@ -495,26 +516,17 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
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);
+ Optional resolvedAlgorithm = BootstrapAlgorithmIdentities.resolve(request.algorithmId());
+ if (resolvedAlgorithm.isEmpty()
+ || resolvedAlgorithm.get().kind() != AlgorithmIdentity.Kind.SIGNATURE) {
+ throw new InvalidRequestException(DC_INVALID_ALGORITHM_ID);
}
- enforceAlgorithmMatchOrThrow(request.algorithmId(), prv.algorithm());
-
if (deadlineReached(request.deadline(), now())) {
- completeSign(request, expiredStatus());
- return opId;
+ return SignExecutionResult.terminal(expiredStatus());
}
- request.cancellation().throwIfCancelled();
- signatureBytes = signStreaming(request.algorithmId(), prv.key(), pub.key(), request.content(),
+ KeyringSignatureExecutor executor = requireSignatureExecutor();
+ signatureBytes = executor.sign(parts.privateAlias, resolvedAlgorithm.get(), request.content(),
request.cancellation());
Encoding outEnc = request.preferredSignatureEncoding().orElse(Encoding.BINARY);
@@ -522,41 +534,50 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
Instant completedAt = now();
if (deadlineReached(request.deadline(), completedAt)) {
- completeSign(request, expiredStatus(completedAt));
- return opId;
+ return SignExecutionResult.withSignature(expiredStatus(completedAt), signatureBytes);
}
OperationResult result = new OperationResult(Optional.of(signature), Optional.empty());
- completeSign(request,
- new OperationStatus(State.SUCCEEDED, completedAt, Optional.of(DC_SIGNED), Optional.of(result)));
- return opId;
+ OperationStatus status = new OperationStatus(State.SUCCEEDED, completedAt, Optional.of(DC_SIGNED),
+ Optional.of(result));
+ return SignExecutionResult.withSignature(status, signatureBytes);
+
+ } catch (KeyringSignatureExecutor.Failure failure) {
+ String detailCode = switch (failure.code()) {
+ case KEY_UNAVAILABLE -> DC_KEY_NOT_FOUND;
+ case ALGORITHM_MISMATCH -> DC_ALGORITHM_MISMATCH;
+ case PROVIDER_FAILURE -> DC_CRYPTO_FAILURE;
+ };
+ if (failure.code() == KeyringSignatureExecutor.FailureCode.PROVIDER_FAILURE) {
+ logSafeFailure("SIGN", detailCode, failure);
+ }
+ return SignExecutionResult.withSignature(failedStatus(detailCode), signatureBytes);
} catch (InvalidRequestException inv) { // NOPMD
- completeSign(request,
- new OperationStatus(State.FAILED, now(), Optional.of(inv.detailCode), Optional.empty()));
- return opId;
+ return SignExecutionResult.withSignature(failedStatus(inv.detailCode), signatureBytes);
+
+ } catch (KeyringSignatureExecutor.Cancellation cancelled) {
+ OperationStatus status = new OperationStatus(State.CANCELLED, now(), Optional.of(DC_CANCELLED),
+ Optional.empty());
+ return SignExecutionResult.withSignature(status, signatureBytes);
} catch (IOException io) {
- completeSign(request,
- new OperationStatus(State.FAILED, now(), Optional.of(DC_KEYRING_IO_ERROR), Optional.empty()));
logSafeFailure("SIGN", DC_KEYRING_IO_ERROR, io);
- return opId;
+ return SignExecutionResult.withSignature(failedStatus(DC_KEYRING_IO_ERROR), signatureBytes);
} catch (GeneralSecurityException sec) {
- completeSign(request,
- new OperationStatus(State.FAILED, now(), Optional.of(DC_CRYPTO_FAILURE), Optional.empty()));
logSafeFailure("SIGN", DC_CRYPTO_FAILURE, sec);
- return opId;
-
- } catch (RuntimeException ex) { // NOPMD
- completeSign(request,
- new OperationStatus(State.FAILED, now(), Optional.of(DC_CRYPTO_FAILURE), Optional.empty()));
- logSafeFailure("SIGN", DC_CRYPTO_FAILURE, ex);
- return opId;
- } finally {
- clearOwned("sign-result-copy", signatureBytes);
+ return SignExecutionResult.withSignature(failedStatus(DC_CRYPTO_FAILURE), signatureBytes);
}
}
+ private OperationStatus failedStatus(String detailCode) {
+ return new OperationStatus(State.FAILED, now(), Optional.of(detailCode), Optional.empty());
+ }
+
+ private KeyringSignatureExecutor requireSignatureExecutor() throws IOException, GeneralSecurityException {
+ return new KeyringSignatureExecutor(requireKeyringOrThrow(), session);
+ }
+
private boolean beginSign(SignRequest request) {
PkiId operationId = request.submissionId();
SignLockEntry entry = acquireOperationLock(operationId);
@@ -940,9 +961,7 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
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);
+ return new KeyRefParts(privateAlias, null);
}
String publicAlias = hasPub ? v : (v + ".pub");
@@ -1059,47 +1078,6 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
return a;
}
- private byte[] signStreaming(String algorithmId, PrivateKey prv, PublicKey pub, RepeatableContent content,
- CancellationSignal cancellation)
- throws GeneralSecurityException, IOException {
-
- Optional profile = SignatureInteropProfiles.resolve(algorithmId);
- String contextAlgorithmId = profile.map(SignatureInteropProfile::contextAlgorithmId).orElse(algorithmId);
- ContextSpec contextSpec = profile.map(SignatureInteropProfile::contextSpec).orElse(null);
-
- int sigLen;
- try (SignatureContext verifier = session.createContext(contextAlgorithmId, KeyUsage.VERIFY, pub, contextSpec)) {
- sigLen = verifier.tagLength();
- }
-
- try (SignatureContext signer = session.createContext(contextAlgorithmId, KeyUsage.SIGN, prv, contextSpec)) {
- final byte[][] sigHolder = new byte[1][];
- try (InputStream source = content.openStream();
- InputStream in = new TailStrippingInputStream(signer.wrap(source), sigLen, 8192) {
- @Override
- protected void processTail(byte[] tail) throws IOException {
- sigHolder[0] = (tail == null) ? null : tail.clone();
- }
- }) {
- consume(in, cancellation);
- }
-
- byte[] internalSignature = sigHolder[0];
- try {
- if (internalSignature == null || internalSignature.length == 0) {
- throw new GeneralSecurityException("Signature trailer missing.");
- }
- if (profile.isPresent()) {
- return profile.get().internalToExternalSignature(internalSignature);
- }
- sigHolder[0] = null;
- return internalSignature;
- } finally {
- clearOwned("sign-internal-signature", sigHolder[0]);
- }
- }
- }
-
private boolean verifyStreaming(String algorithmId, PublicKey pub, RepeatableContent content, byte[] signature,
CancellationSignal cancellation)
throws GeneralSecurityException, IOException {
@@ -1661,6 +1639,14 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
}
}
+ /** Package-local dependencies used to exercise provider failure boundaries. */
+ /* default */ record SigningDependencies(KeyringStore keyring, ZeroEchoSession session) {
+ SigningDependencies {
+ java.util.Objects.requireNonNull(keyring, "keyring must not be null");
+ java.util.Objects.requireNonNull(session, "session must not be null");
+ }
+ }
+
/**
* Parsed, provider-local representation of a {@link KeyRef}.
*
@@ -1680,11 +1666,10 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
*
*
*
- * 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.
+ * For signing requests, {@link #privateAlias} addresses the authoritative
+ * private component (e.g. {@code *.prv}) and {@link #publicAlias} is unused.
+ * For verification, {@link #privateAlias} is unused and {@link #publicAlias}
+ * addresses the public component.
*
*/
private static final class KeyRefParts {
@@ -1697,6 +1682,28 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
}
}
+ /**
+ * Terminal status and the executor-owned signature copy that must be cleared
+ * after the status is committed.
+ */
+ private static final class SignExecutionResult {
+ private final OperationStatus status;
+ private final byte[] signatureBytes;
+
+ private SignExecutionResult(OperationStatus status, byte[] signatureBytes) {
+ this.status = status;
+ this.signatureBytes = signatureBytes;
+ }
+
+ private static SignExecutionResult terminal(OperationStatus status) {
+ return new SignExecutionResult(status, null);
+ }
+
+ private static SignExecutionResult withSignature(OperationStatus status, byte[] signatureBytes) {
+ return new SignExecutionResult(status, signatureBytes);
+ }
+ }
+
/**
* Internal exception used to signal validation/policy failures that must be
* surfaced via operation status.
diff --git a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowPersistenceTest.java b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowPersistenceTest.java
index f035332..046bdcd 100644
--- a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowPersistenceTest.java
+++ b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowPersistenceTest.java
@@ -38,11 +38,17 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InterruptedIOException;
import java.nio.ByteBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.security.Key;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
+import java.security.ProviderException;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
@@ -50,8 +56,10 @@ import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.List;
import java.util.Optional;
+import java.util.OptionalLong;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@@ -67,9 +75,13 @@ import java.util.logging.Logger;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
+import zeroecho.core.alg.BootstrapAlgorithmIdentities;
import zeroecho.core.storage.KeyringStore;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.ImmutableByteContent;
+import zeroecho.core.io.RepeatableContent;
+import zeroecho.core.policy.CryptoPolicy;
+import zeroecho.core.spec.ContextSpec;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.KeyRef;
@@ -79,6 +91,7 @@ import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.audit.Purpose;
import zeroecho.pki.api.orch.SigningSubmissionId;
import zeroecho.pki.spi.crypto.SignatureWorkflow;
+import zeroecho.sdk.ZeroEchoSession;
final class ZeroEchoLibSignatureWorkflowPersistenceTest {
@@ -162,9 +175,199 @@ final class ZeroEchoLibSignatureWorkflowPersistenceTest {
logger.removeHandler(handler);
logger.setLevel(previousLevel);
}
+ byte[] privateEncoding = pair.getPrivate().getEncoded();
+ try (java.util.stream.Stream paths = Files.walk(root.resolve("cleanup-operations"))) {
+ for (Path path : paths.filter(Files::isRegularFile).toList()) {
+ byte[] persisted = Files.readAllBytes(path);
+ try {
+ assertFalse(contains(persisted, privateEncoding));
+ } finally {
+ Arrays.fill(persisted, (byte) 0);
+ }
+ }
+ } finally {
+ Arrays.fill(privateEncoding, (byte) 0);
+ }
System.out.println("signingVerificationPersistenceAndCallbackCopiesAreCleared...ok");
}
+ @Test
+ void signingMapsCanonicalInvalidMissingForeignAndMismatchedInputs(@TempDir Path root) throws Exception {
+ System.out.println("signingMapsCanonicalInvalidMissingForeignAndMismatchedInputs");
+ Instant now = Instant.parse("2026-02-03T04:05:06.789Z");
+ Clock clock = Clock.fixed(now, ZoneOffset.UTC);
+ Path keyring = root.resolve("mapping-keyring.zek");
+ KeyPair pair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
+ try (zeroecho.core.storage.KeyringPassword password = TestKeyringUnlocks.provider().acquire();
+ KeyringStore keyringStore = KeyringStore.create(keyring, password)) {
+ keyringStore.putPrivate("test.prv", "RSA", pair.getPrivate());
+ keyringStore.putPublic("test.pub", "RSA", pair.getPublic());
+ }
+
+ AtomicInteger terminalPublications = new AtomicInteger();
+ try (ZeroEchoLibSignatureWorkflow workflow = workflow(root, root.resolve("mapping-operations"), keyring,
+ clock);
+ SignatureWorkflow.Registration registration = workflow.register((operationId, status) -> {
+ if (status.state() != SignatureWorkflow.State.RUNNING) {
+ terminalPublications.incrementAndGet();
+ }
+ })) {
+ PkiId canonicalId = SigningSubmissionId.create(NAMESPACE, now, new SecureRandom()).id();
+ SignatureWorkflow.SignRequest canonical = request(canonicalId, 1L, new byte[] { 1, 2, 3 },
+ new KeyRef("zeroecho-lib:test.prv"), Optional.empty(),
+ BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256.canonicalForm());
+ workflow.submitSign(canonical);
+ assertEquals(SignatureWorkflow.State.SUCCEEDED, workflow.status(canonicalId).state());
+
+ assertSigningFailure(workflow, now, "SHA1withRSA", new KeyRef("zeroecho-lib:test.prv"),
+ ZeroEchoLibSignatureWorkflow.DC_INVALID_ALGORITHM_ID);
+ assertSigningFailure(workflow, now, BootstrapAlgorithmIdentities.SHA256.canonicalForm(),
+ new KeyRef("zeroecho-lib:test.prv"), ZeroEchoLibSignatureWorkflow.DC_INVALID_ALGORITHM_ID);
+ assertSigningFailure(workflow, now, "SHA256withRSA", new KeyRef("zeroecho-lib:missing.prv"),
+ ZeroEchoLibSignatureWorkflow.DC_KEY_NOT_FOUND);
+ assertSigningFailure(workflow, now, "SHA256withRSA", new KeyRef("foreign:test.prv"),
+ ZeroEchoLibSignatureWorkflow.DC_INVALID_KEYREF_PREFIX);
+ assertSigningFailure(workflow, now, "SHA256withECDSA", new KeyRef("zeroecho-lib:test.prv"),
+ ZeroEchoLibSignatureWorkflow.DC_ALGORITHM_MISMATCH);
+ assertEquals(6, terminalPublications.get());
+ }
+ System.out.println("signingMapsCanonicalInvalidMissingForeignAndMismatchedInputs...ok");
+ }
+
+ @Test
+ void signingCancellationIsTerminalOnlyForRequestedCancellation(@TempDir Path root) throws Exception {
+ System.out.println("signingCancellationIsTerminalOnlyForRequestedCancellation");
+ Instant now = Instant.parse("2026-02-03T04:05:06.789Z");
+ Clock clock = Clock.fixed(now, ZoneOffset.UTC);
+ Path keyring = root.resolve("cancellation-keyring.zek");
+ KeyPair pair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
+ try (zeroecho.core.storage.KeyringPassword password = TestKeyringUnlocks.provider().acquire();
+ KeyringStore keyringStore = KeyringStore.create(keyring, password)) {
+ keyringStore.putPrivate("test.prv", "RSA", pair.getPrivate());
+ }
+
+ AtomicInteger terminalPublications = new AtomicInteger();
+ try (ZeroEchoLibSignatureWorkflow workflow = workflow(root, root.resolve("cancellation-operations"), keyring,
+ clock);
+ SignatureWorkflow.Registration registration = workflow.register((operationId, status) -> {
+ if (status.state() != SignatureWorkflow.State.RUNNING) {
+ terminalPublications.incrementAndGet();
+ }
+ })) {
+ PkiId beforeId = SigningSubmissionId.create(NAMESPACE, now, new SecureRandom()).id();
+ SignatureWorkflow.SignRequest beforeBase = request(beforeId, 1L, new byte[] { 1 });
+ AtomicInteger cancellationChecks = new AtomicInteger();
+ workflow.submitSign(withCancellation(beforeBase, () -> {
+ if (cancellationChecks.incrementAndGet() > 1) {
+ throw new IllegalStateException("CANCELLATION_RECHECK_SENTINEL");
+ }
+ return true;
+ }));
+ assertEquals(1, cancellationChecks.get());
+ assertEquals(SignatureWorkflow.State.CANCELLED, workflow.status(beforeId).state());
+ assertEquals(Optional.of(ZeroEchoLibSignatureWorkflow.DC_CANCELLED),
+ workflow.status(beforeId).detailCode());
+
+ AtomicBoolean armed = new AtomicBoolean();
+ AtomicBoolean cancelled = new AtomicBoolean();
+ byte[] payload = new byte[32 * 1024];
+ RepeatableContent streaming = repeatableContent(payload, input -> {
+ if (armed.get() && input > 0) {
+ cancelled.set(true);
+ }
+ });
+ PkiId streamingId = SigningSubmissionId.create(NAMESPACE, now, new SecureRandom()).id();
+ SignatureWorkflow.SignRequest streamingRequest = request(streamingId, 1L, streaming,
+ new KeyRef("zeroecho-lib:test.prv"), "SHA256withRSA", cancelled::get);
+ armed.set(true);
+ workflow.submitSign(streamingRequest);
+ assertEquals(SignatureWorkflow.State.CANCELLED, workflow.status(streamingId).state());
+ assertEquals(Optional.of(ZeroEchoLibSignatureWorkflow.DC_CANCELLED),
+ workflow.status(streamingId).detailCode());
+
+ AtomicBoolean failReads = new AtomicBoolean();
+ RepeatableContent interruptedIo = new RepeatableContent() {
+ @Override
+ public InputStream openStream() {
+ if (failReads.get()) {
+ return new InputStream() {
+ @Override
+ public int read() throws IOException {
+ throw new InterruptedIOException("controlled interruption");
+ }
+ };
+ }
+ return new ByteArrayInputStream(new byte[] { 4, 5, 6 });
+ }
+
+ @Override
+ public OptionalLong length() {
+ return OptionalLong.of(3);
+ }
+
+ @Override
+ public String contentId() {
+ return "interrupted-io-content";
+ }
+
+ @Override
+ public void close() {
+ // Caller owns this test content.
+ }
+ };
+ PkiId ioId = SigningSubmissionId.create(NAMESPACE, now, new SecureRandom()).id();
+ SignatureWorkflow.SignRequest ioRequest = request(ioId, 1L, interruptedIo,
+ new KeyRef("zeroecho-lib:test.prv"), "SHA256withRSA", CancellationSignal.NONE);
+ failReads.set(true);
+ workflow.submitSign(ioRequest);
+ assertEquals(SignatureWorkflow.State.FAILED, workflow.status(ioId).state());
+ assertEquals(Optional.of(ZeroEchoLibSignatureWorkflow.DC_KEYRING_IO_ERROR),
+ workflow.status(ioId).detailCode());
+ assertEquals(3, terminalPublications.get());
+ }
+ System.out.println("signingCancellationIsTerminalOnlyForRequestedCancellation...ok");
+ }
+
+ @Test
+ void providerFailureTerminalizesExactlyOnce(@TempDir Path root) throws Exception {
+ System.out.println("providerFailureTerminalizesExactlyOnce");
+ Instant now = Instant.parse("2026-02-03T04:05:06.789Z");
+ Path keyring = root.resolve("provider-keyring.zek");
+ KeyPair pair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
+ try (zeroecho.core.storage.KeyringPassword password = TestKeyringUnlocks.provider().acquire();
+ KeyringStore created = KeyringStore.create(keyring, password)) {
+ created.putPrivate("test.prv", "RSA", pair.getPrivate());
+ }
+
+ KeyringStore opened;
+ try (zeroecho.core.storage.KeyringPassword password = TestKeyringUnlocks.provider().acquire()) {
+ opened = KeyringStore.open(keyring, password);
+ }
+ ZeroEchoSession denied = new ZeroEchoSession().withPolicy(
+ (CryptoPolicy) (algorithm, role, key, spec) -> {
+ throw new ProviderException("PROVIDER_POLICY_RUNTIME_SENTINEL");
+ });
+ AtomicInteger terminalPublications = new AtomicInteger();
+ try (ZeroEchoLibSignatureWorkflow workflow = new ZeroEchoLibSignatureWorkflow("zeroecho-lib", keyring,
+ root.resolve("provider-operations"), Clock.fixed(now, ZoneOffset.UTC), Duration.ofDays(90),
+ "zeroecho-lib:", true, TestKeyringUnlocks.provider(),
+ new ZeroEchoLibSignatureWorkflow.SigningDependencies(opened, denied));
+ SignatureWorkflow.Registration registration = workflow.register((operationId, status) -> {
+ if (status.state() != SignatureWorkflow.State.RUNNING) {
+ terminalPublications.incrementAndGet();
+ }
+ })) {
+ PkiId id = SigningSubmissionId.create(NAMESPACE, now, new SecureRandom()).id();
+ workflow.submitSign(request(id, 1L, new byte[] { 6, 7, 8 }, new KeyRef("zeroecho-lib:test.prv"),
+ Optional.empty()));
+ assertEquals(SignatureWorkflow.State.FAILED, workflow.status(id).state());
+ assertEquals(Optional.of(ZeroEchoLibSignatureWorkflow.DC_CRYPTO_FAILURE),
+ workflow.status(id).detailCode());
+ assertEquals(1, terminalPublications.get());
+ }
+ System.out.println("providerFailureTerminalizesExactlyOnce...ok");
+ }
+
@Test
void unsupportedProviderRecordVersionFailsClosedWithRedactedDiagnostic(@TempDir Path root) throws Exception {
Instant now = Instant.parse("2026-02-03T04:05:06.789Z");
@@ -369,12 +572,99 @@ final class ZeroEchoLibSignatureWorkflowPersistenceTest {
private static SignatureWorkflow.SignRequest request(PkiId id, long fence, byte[] payload, KeyRef keyRef,
Optional deadline) {
+ return request(id, fence, payload, keyRef, deadline, "SHA256withRSA");
+ }
+
+ private static SignatureWorkflow.SignRequest request(PkiId id, long fence, byte[] payload, KeyRef keyRef,
+ Optional deadline, String algorithmId) {
AccessContext access = new AccessContext(new Principal("TEST", "owner"), new Purpose("TEST"), Optional.empty(),
Optional.empty());
- return SignatureWorkflow.SignRequest.create(id, NAMESPACE, fence, access, keyRef, "SHA256withRSA",
+ return SignatureWorkflow.SignRequest.create(id, NAMESPACE, fence, access, keyRef, algorithmId,
new ImmutableByteContent(payload), Optional.of(Encoding.BINARY), deadline);
}
+ private static SignatureWorkflow.SignRequest request(PkiId id, long fence, RepeatableContent content,
+ KeyRef keyRef, String algorithmId, CancellationSignal cancellation) {
+ AccessContext access = new AccessContext(new Principal("TEST", "owner"), new Purpose("TEST"), Optional.empty(),
+ Optional.empty());
+ Optional encoding = Optional.of(Encoding.BINARY);
+ Optional deadline = Optional.empty();
+ String fingerprint = SignatureWorkflow.SignRequest.fingerprint(NAMESPACE, access, keyRef, algorithmId,
+ content, encoding, deadline);
+ return new SignatureWorkflow.SignRequest(id, NAMESPACE, fingerprint, fence, access, keyRef, algorithmId,
+ content, encoding, deadline, cancellation);
+ }
+
+ private static SignatureWorkflow.SignRequest withCancellation(SignatureWorkflow.SignRequest request,
+ CancellationSignal cancellation) {
+ return new SignatureWorkflow.SignRequest(request.submissionId(), request.namespace(),
+ request.semanticFingerprint(), request.fencingToken(), request.accessContext(), request.keyRef(),
+ request.algorithmId(), request.content(), request.preferredSignatureEncoding(), request.deadline(),
+ cancellation);
+ }
+
+ private static RepeatableContent repeatableContent(byte[] payload, java.util.function.IntConsumer readObserver) {
+ return new RepeatableContent() {
+ @Override
+ public InputStream openStream() {
+ return new ByteArrayInputStream(payload) {
+ @Override
+ public int read() {
+ int value = super.read();
+ readObserver.accept(value < 0 ? value : 1);
+ return value;
+ }
+
+ @Override
+ public int read(byte[] bytes, int offset, int length) {
+ int count = super.read(bytes, offset, length);
+ readObserver.accept(count);
+ return count;
+ }
+ };
+ }
+
+ @Override
+ public OptionalLong length() {
+ return OptionalLong.of(payload.length);
+ }
+
+ @Override
+ public String contentId() {
+ return "streaming-cancellation-content";
+ }
+
+ @Override
+ public void close() {
+ // Caller owns this test content.
+ }
+ };
+ }
+
+ private static void assertSigningFailure(ZeroEchoLibSignatureWorkflow workflow, Instant now, String algorithmId,
+ KeyRef keyRef, String expectedDetailCode) {
+ PkiId id = SigningSubmissionId.create(NAMESPACE, now, new SecureRandom()).id();
+ workflow.submitSign(request(id, 1L, new byte[] { 9 }, keyRef, Optional.empty(), algorithmId));
+ assertEquals(SignatureWorkflow.State.FAILED, workflow.status(id).state());
+ assertEquals(Optional.of(expectedDetailCode), workflow.status(id).detailCode());
+ }
+
+ private static boolean contains(byte[] haystack, byte[] needle) {
+ if (needle.length == 0 || needle.length > haystack.length) {
+ return false;
+ }
+ for (int offset = 0; offset <= haystack.length - needle.length; offset++) {
+ int index = 0;
+ while (index < needle.length && haystack[offset + index] == needle[index]) {
+ index++;
+ }
+ if (index == needle.length) {
+ return true;
+ }
+ }
+ return false;
+ }
+
private static void forcePersistedStateCode(Path operations, int from, int to) {
try {
Path record;