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;