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.
This commit is contained in:
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* 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.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* 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}.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* 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.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @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<SignatureInteropProfile> 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ContextSpec, java.security.Key>) (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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,6 @@ import java.nio.file.attribute.PosixFilePermissions;
|
|||||||
import java.security.GeneralSecurityException;
|
import java.security.GeneralSecurityException;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.security.PrivateKey;
|
|
||||||
import java.security.PublicKey;
|
import java.security.PublicKey;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
@@ -75,6 +74,7 @@ import java.util.logging.Level;
|
|||||||
import java.util.logging.Logger;
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
import zeroecho.core.KeyUsage;
|
import zeroecho.core.KeyUsage;
|
||||||
|
import zeroecho.core.alg.BootstrapAlgorithmIdentities;
|
||||||
import zeroecho.core.alg.common.sig.SignatureInteropProfile;
|
import zeroecho.core.alg.common.sig.SignatureInteropProfile;
|
||||||
import zeroecho.core.alg.common.sig.SignatureInteropProfiles;
|
import zeroecho.core.alg.common.sig.SignatureInteropProfiles;
|
||||||
import zeroecho.core.alg.ecdsa.EcdsaPublicKeySpec;
|
import zeroecho.core.alg.ecdsa.EcdsaPublicKeySpec;
|
||||||
@@ -87,11 +87,12 @@ import zeroecho.core.alg.sphincsplus.SphincsPlusPublicKeySpec;
|
|||||||
import zeroecho.core.context.SignatureContext;
|
import zeroecho.core.context.SignatureContext;
|
||||||
import zeroecho.core.io.CancellationSignal;
|
import zeroecho.core.io.CancellationSignal;
|
||||||
import zeroecho.core.io.RepeatableContent;
|
import zeroecho.core.io.RepeatableContent;
|
||||||
import zeroecho.core.io.TailStrippingInputStream;
|
import zeroecho.core.spec.AlgorithmIdentity;
|
||||||
import zeroecho.core.spec.AlgorithmKeySpec;
|
import zeroecho.core.spec.AlgorithmKeySpec;
|
||||||
import zeroecho.core.spec.ContextSpec;
|
import zeroecho.core.spec.ContextSpec;
|
||||||
import zeroecho.core.spi.KeyringUnlockProvider;
|
import zeroecho.core.spi.KeyringUnlockProvider;
|
||||||
import zeroecho.core.storage.KeyringPassword;
|
import zeroecho.core.storage.KeyringPassword;
|
||||||
|
import zeroecho.core.storage.KeyringSignatureExecutor;
|
||||||
import zeroecho.core.storage.KeyringStore;
|
import zeroecho.core.storage.KeyringStore;
|
||||||
import zeroecho.pki.api.EncodedObject;
|
import zeroecho.pki.api.EncodedObject;
|
||||||
import zeroecho.pki.api.Encoding;
|
import zeroecho.pki.api.Encoding;
|
||||||
@@ -108,9 +109,10 @@ import zeroecho.sdk.ZeroEchoSession;
|
|||||||
* This provider forms a cryptographic boundary between the PKI orchestration
|
* This provider forms a cryptographic boundary between the PKI orchestration
|
||||||
* layer and ZeroEcho-lib based key material handling backed by
|
* layer and ZeroEcho-lib based key material handling backed by
|
||||||
* {@link KeyringStore}. It resolves opaque {@link KeyRef} values to provider-
|
* {@link KeyringStore}. It resolves opaque {@link KeyRef} values to provider-
|
||||||
* local keyring aliases, materializes the required key objects inside this
|
* local keyring aliases, delegates signing to the keyring-owned execution
|
||||||
* boundary, and performs signing or verification through the explicit
|
* boundary, and performs verification through the explicit
|
||||||
* {@link ZeroEchoSession} and {@link SignatureContext}.
|
* {@link ZeroEchoSession} and {@link SignatureContext}. Private key objects never
|
||||||
|
* enter this PKI module.
|
||||||
* </p>
|
* </p>
|
||||||
*
|
*
|
||||||
* <p>
|
* <p>
|
||||||
@@ -125,8 +127,8 @@ import zeroecho.sdk.ZeroEchoSession;
|
|||||||
*
|
*
|
||||||
* <h2>Supported operations</h2>
|
* <h2>Supported operations</h2>
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>{@link #submitSign(SignRequest)} resolves a private/public key pair from
|
* <li>{@link #submitSign(SignRequest)} resolves a private key from the configured
|
||||||
* the configured {@link KeyringStore}, validates the requested algorithm
|
* {@link KeyringStore}, validates the requested algorithm
|
||||||
* compatibility, produces a signature over the supplied payload, and stores the
|
* compatibility, produces a signature over the supplied payload, and stores the
|
||||||
* result as a terminal successful or failed operation status.</li>
|
* result as a terminal successful or failed operation status.</li>
|
||||||
* <li>{@link #submitVerify(VerifyRequest)} verifies a signature either against
|
* <li>{@link #submitVerify(VerifyRequest)} verifies a signature either against
|
||||||
@@ -187,18 +189,12 @@ import zeroecho.sdk.ZeroEchoSession;
|
|||||||
* {@code zeroecho-lib:<alias>.pub}.</li>
|
* {@code zeroecho-lib:<alias>.pub}.</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <p>
|
|
||||||
* 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.
|
|
||||||
* </p>
|
|
||||||
*
|
|
||||||
* <h2>Algorithm handling</h2>
|
* <h2>Algorithm handling</h2>
|
||||||
* <p>
|
* <p>
|
||||||
* Requested signature algorithm identifiers are matched against the stored key
|
* Signing resolves requested identifiers through
|
||||||
* algorithm using {@link SignatureInteropProfiles} where available and by a
|
* {@link BootstrapAlgorithmIdentities} and delegates the exact canonical identity
|
||||||
* legacy normalization fallback for historical identifiers such as
|
* to the keyring-owned executor. Verification retains compatibility normalization
|
||||||
* {@code *withRSA} and {@code *withECDSA}. For interoperable algorithms, the
|
* for supported public-key import forms. For interoperable algorithms, the
|
||||||
* provider transparently maps between external signature form and the internal
|
* provider transparently maps between external signature form and the internal
|
||||||
* ZeroEcho representation expected by {@link SignatureContext}.
|
* ZeroEcho representation expected by {@link SignatureContext}.
|
||||||
* </p>
|
* </p>
|
||||||
@@ -224,8 +220,8 @@ import zeroecho.sdk.ZeroEchoSession;
|
|||||||
* <ul>
|
* <ul>
|
||||||
* <li>Never logs {@link KeyRef} values.</li>
|
* <li>Never logs {@link KeyRef} values.</li>
|
||||||
* <li>Never logs payload bytes or signature bytes.</li>
|
* <li>Never logs payload bytes or signature bytes.</li>
|
||||||
* <li>Never returns private key material; private keys are materialized only
|
* <li>Never returns private key material; signing key resolution occurs only in
|
||||||
* within this provider boundary.</li>
|
* the keyring-owning library boundary.</li>
|
||||||
* <li>Notification sinks must be treated as trusted local integration points,
|
* <li>Notification sinks must be treated as trusted local integration points,
|
||||||
* because they receive operation identifiers and status metadata for all
|
* because they receive operation identifiers and status metadata for all
|
||||||
* operations observed by this provider instance.</li>
|
* operations observed by this provider instance.</li>
|
||||||
@@ -313,9 +309,26 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
|||||||
this.keyringOrNull = java.util.Objects.requireNonNull(keyring, "keyring must not be null");
|
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,
|
/* default */ ZeroEchoLibSignatureWorkflow(String id, Path keyringPath, Path operationRoot, Clock clock,
|
||||||
Duration operationHorizon, String keyRefPrefix, boolean requireComponentSuffix,
|
Duration operationHorizon, String keyRefPrefix, boolean requireComponentSuffix,
|
||||||
KeyringUnlockProvider keyringUnlockProvider, BiConsumer<String, byte[]> cleanupObserver) {
|
KeyringUnlockProvider keyringUnlockProvider, BiConsumer<String, byte[]> 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<String, byte[]> cleanupObserver,
|
||||||
|
ZeroEchoSession session) {
|
||||||
if (id == null || id.isBlank()) {
|
if (id == null || id.isBlank()) {
|
||||||
throw new IllegalArgumentException("id must not be blank");
|
throw new IllegalArgumentException("id must not be blank");
|
||||||
}
|
}
|
||||||
@@ -349,7 +362,7 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
|||||||
this.requireComponentSuffix = requireComponentSuffix;
|
this.requireComponentSuffix = requireComponentSuffix;
|
||||||
this.cleanupObserver = cleanupObserver;
|
this.cleanupObserver = cleanupObserver;
|
||||||
this.keyringUnlockProvider = keyringUnlockProvider;
|
this.keyringUnlockProvider = keyringUnlockProvider;
|
||||||
this.session = new ZeroEchoSession();
|
this.session = java.util.Objects.requireNonNull(session, "session must not be null");
|
||||||
|
|
||||||
this.statuses = new ConcurrentHashMap<>();
|
this.statuses = new ConcurrentHashMap<>();
|
||||||
this.fingerprints = new ConcurrentHashMap<>();
|
this.fingerprints = new ConcurrentHashMap<>();
|
||||||
@@ -451,10 +464,9 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
|||||||
* </p>
|
* </p>
|
||||||
*
|
*
|
||||||
* <p>
|
* <p>
|
||||||
* The method validates the key reference, loads the corresponding private and
|
* The method validates the key reference, resolves the requested algorithm to
|
||||||
* public key entries from the configured {@link KeyringStore}, verifies that
|
* an exact canonical identity, delegates key resolution and streaming signature
|
||||||
* the requested signature algorithm is compatible with the stored key
|
* generation to {@link KeyringSignatureExecutor}, encodes the resulting
|
||||||
* algorithm, performs streaming signature generation, encodes the resulting
|
|
||||||
* signature according to the preferred output encoding, and stores the final
|
* signature according to the preferred output encoding, and stores the final
|
||||||
* outcome in the internal status registry.
|
* outcome in the internal status registry.
|
||||||
* </p>
|
* </p>
|
||||||
@@ -468,9 +480,8 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
|||||||
* @param request sign request; must not be {@code null}
|
* @param request sign request; must not be {@code null}
|
||||||
* @return operation identifier that can be used to query the terminal result
|
* @return operation identifier that can be used to query the terminal result
|
||||||
* @throws IllegalArgumentException if {@code request} is {@code null}
|
* @throws IllegalArgumentException if {@code request} is {@code null}
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
@SuppressWarnings("PMD.CloseResource")
|
|
||||||
public PkiId submitSign(SignRequest request) {
|
public PkiId submitSign(SignRequest request) {
|
||||||
if (request == null) {
|
if (request == null) {
|
||||||
throw new IllegalArgumentException("request must not be null");
|
throw new IllegalArgumentException("request must not be null");
|
||||||
@@ -488,6 +499,16 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
|||||||
return opId;
|
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;
|
byte[] signatureBytes = null;
|
||||||
try {
|
try {
|
||||||
KeyRefParts parts = parseKeyRefOrThrow(request.keyRef(), true);
|
KeyRefParts parts = parseKeyRefOrThrow(request.keyRef(), true);
|
||||||
@@ -495,26 +516,17 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
|||||||
if (request.algorithmId() == null || request.algorithmId().isBlank()) {
|
if (request.algorithmId() == null || request.algorithmId().isBlank()) {
|
||||||
throw new InvalidRequestException(DC_INVALID_ALGORITHM_ID);
|
throw new InvalidRequestException(DC_INVALID_ALGORITHM_ID);
|
||||||
}
|
}
|
||||||
|
Optional<AlgorithmIdentity> resolvedAlgorithm = BootstrapAlgorithmIdentities.resolve(request.algorithmId());
|
||||||
KeyringStore ks = requireKeyringOrThrow();
|
if (resolvedAlgorithm.isEmpty()
|
||||||
|
|| resolvedAlgorithm.get().kind() != AlgorithmIdentity.Kind.SIGNATURE) {
|
||||||
KeyringStore.PrivateWithId prv;
|
throw new InvalidRequestException(DC_INVALID_ALGORITHM_ID);
|
||||||
KeyringStore.PublicWithId pub;
|
|
||||||
try {
|
|
||||||
prv = ks.getPrivateWithId(parts.privateAlias);
|
|
||||||
pub = ks.getPublicWithId(parts.publicAlias);
|
|
||||||
} catch (GeneralSecurityException missing) {
|
|
||||||
throw new InvalidRequestException(DC_KEY_NOT_FOUND, missing);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enforceAlgorithmMatchOrThrow(request.algorithmId(), prv.algorithm());
|
|
||||||
|
|
||||||
if (deadlineReached(request.deadline(), now())) {
|
if (deadlineReached(request.deadline(), now())) {
|
||||||
completeSign(request, expiredStatus());
|
return SignExecutionResult.terminal(expiredStatus());
|
||||||
return opId;
|
|
||||||
}
|
}
|
||||||
request.cancellation().throwIfCancelled();
|
KeyringSignatureExecutor executor = requireSignatureExecutor();
|
||||||
signatureBytes = signStreaming(request.algorithmId(), prv.key(), pub.key(), request.content(),
|
signatureBytes = executor.sign(parts.privateAlias, resolvedAlgorithm.get(), request.content(),
|
||||||
request.cancellation());
|
request.cancellation());
|
||||||
|
|
||||||
Encoding outEnc = request.preferredSignatureEncoding().orElse(Encoding.BINARY);
|
Encoding outEnc = request.preferredSignatureEncoding().orElse(Encoding.BINARY);
|
||||||
@@ -522,41 +534,50 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
|||||||
|
|
||||||
Instant completedAt = now();
|
Instant completedAt = now();
|
||||||
if (deadlineReached(request.deadline(), completedAt)) {
|
if (deadlineReached(request.deadline(), completedAt)) {
|
||||||
completeSign(request, expiredStatus(completedAt));
|
return SignExecutionResult.withSignature(expiredStatus(completedAt), signatureBytes);
|
||||||
return opId;
|
|
||||||
}
|
}
|
||||||
OperationResult result = new OperationResult(Optional.of(signature), Optional.empty());
|
OperationResult result = new OperationResult(Optional.of(signature), Optional.empty());
|
||||||
completeSign(request,
|
OperationStatus status = new OperationStatus(State.SUCCEEDED, completedAt, Optional.of(DC_SIGNED),
|
||||||
new OperationStatus(State.SUCCEEDED, completedAt, Optional.of(DC_SIGNED), Optional.of(result)));
|
Optional.of(result));
|
||||||
return opId;
|
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
|
} catch (InvalidRequestException inv) { // NOPMD
|
||||||
completeSign(request,
|
return SignExecutionResult.withSignature(failedStatus(inv.detailCode), signatureBytes);
|
||||||
new OperationStatus(State.FAILED, now(), Optional.of(inv.detailCode), Optional.empty()));
|
|
||||||
return opId;
|
} 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) {
|
} 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);
|
logSafeFailure("SIGN", DC_KEYRING_IO_ERROR, io);
|
||||||
return opId;
|
return SignExecutionResult.withSignature(failedStatus(DC_KEYRING_IO_ERROR), signatureBytes);
|
||||||
|
|
||||||
} catch (GeneralSecurityException sec) {
|
} catch (GeneralSecurityException sec) {
|
||||||
completeSign(request,
|
|
||||||
new OperationStatus(State.FAILED, now(), Optional.of(DC_CRYPTO_FAILURE), Optional.empty()));
|
|
||||||
logSafeFailure("SIGN", DC_CRYPTO_FAILURE, sec);
|
logSafeFailure("SIGN", DC_CRYPTO_FAILURE, sec);
|
||||||
return opId;
|
return SignExecutionResult.withSignature(failedStatus(DC_CRYPTO_FAILURE), signatureBytes);
|
||||||
|
|
||||||
} 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
private boolean beginSign(SignRequest request) {
|
||||||
PkiId operationId = request.submissionId();
|
PkiId operationId = request.submissionId();
|
||||||
SignLockEntry entry = acquireOperationLock(operationId);
|
SignLockEntry entry = acquireOperationLock(operationId);
|
||||||
@@ -940,9 +961,7 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
|||||||
|
|
||||||
if (forSigning) {
|
if (forSigning) {
|
||||||
String privateAlias = hasPrv ? v : (v + ".prv");
|
String privateAlias = hasPrv ? v : (v + ".prv");
|
||||||
String base = hasPrv ? v.substring(0, v.length() - 4) : v;
|
return new KeyRefParts(privateAlias, null);
|
||||||
String publicAlias = base + ".pub";
|
|
||||||
return new KeyRefParts(privateAlias, publicAlias);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String publicAlias = hasPub ? v : (v + ".pub");
|
String publicAlias = hasPub ? v : (v + ".pub");
|
||||||
@@ -1059,47 +1078,6 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
|
|
||||||
private byte[] signStreaming(String algorithmId, PrivateKey prv, PublicKey pub, RepeatableContent content,
|
|
||||||
CancellationSignal cancellation)
|
|
||||||
throws GeneralSecurityException, IOException {
|
|
||||||
|
|
||||||
Optional<SignatureInteropProfile> 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,
|
private boolean verifyStreaming(String algorithmId, PublicKey pub, RepeatableContent content, byte[] signature,
|
||||||
CancellationSignal cancellation)
|
CancellationSignal cancellation)
|
||||||
throws GeneralSecurityException, IOException {
|
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}.
|
* Parsed, provider-local representation of a {@link KeyRef}.
|
||||||
*
|
*
|
||||||
@@ -1680,11 +1666,10 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
|||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <p>
|
* <p>
|
||||||
* For signing requests, {@link #privateAlias} is expected to address the
|
* For signing requests, {@link #privateAlias} addresses the authoritative
|
||||||
* private component (e.g. {@code *.prv}) and {@link #publicAlias} the
|
* private component (e.g. {@code *.prv}) and {@link #publicAlias} is unused.
|
||||||
* corresponding public component (e.g. {@code *.pub}). For verification,
|
* For verification, {@link #privateAlias} is unused and {@link #publicAlias}
|
||||||
* {@link #privateAlias} is unused and {@link #publicAlias} addresses the public
|
* addresses the public component.
|
||||||
* component.
|
|
||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
private static final class KeyRefParts {
|
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
|
* Internal exception used to signal validation/policy failures that must be
|
||||||
* surfaced via operation status.
|
* surfaced via operation status.
|
||||||
|
|||||||
@@ -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.assertThrows;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
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.ByteBuffer;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.security.Key;
|
||||||
import java.security.KeyPair;
|
import java.security.KeyPair;
|
||||||
import java.security.KeyPairGenerator;
|
import java.security.KeyPairGenerator;
|
||||||
|
import java.security.ProviderException;
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
@@ -50,8 +56,10 @@ import java.time.Instant;
|
|||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.OptionalLong;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.CountDownLatch;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
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.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import zeroecho.core.alg.BootstrapAlgorithmIdentities;
|
||||||
import zeroecho.core.storage.KeyringStore;
|
import zeroecho.core.storage.KeyringStore;
|
||||||
import zeroecho.core.io.CancellationSignal;
|
import zeroecho.core.io.CancellationSignal;
|
||||||
import zeroecho.core.io.ImmutableByteContent;
|
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.EncodedObject;
|
||||||
import zeroecho.pki.api.Encoding;
|
import zeroecho.pki.api.Encoding;
|
||||||
import zeroecho.pki.api.KeyRef;
|
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.audit.Purpose;
|
||||||
import zeroecho.pki.api.orch.SigningSubmissionId;
|
import zeroecho.pki.api.orch.SigningSubmissionId;
|
||||||
import zeroecho.pki.spi.crypto.SignatureWorkflow;
|
import zeroecho.pki.spi.crypto.SignatureWorkflow;
|
||||||
|
import zeroecho.sdk.ZeroEchoSession;
|
||||||
|
|
||||||
final class ZeroEchoLibSignatureWorkflowPersistenceTest {
|
final class ZeroEchoLibSignatureWorkflowPersistenceTest {
|
||||||
|
|
||||||
@@ -162,9 +175,199 @@ final class ZeroEchoLibSignatureWorkflowPersistenceTest {
|
|||||||
logger.removeHandler(handler);
|
logger.removeHandler(handler);
|
||||||
logger.setLevel(previousLevel);
|
logger.setLevel(previousLevel);
|
||||||
}
|
}
|
||||||
|
byte[] privateEncoding = pair.getPrivate().getEncoded();
|
||||||
|
try (java.util.stream.Stream<Path> 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");
|
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<ContextSpec, Key>) (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
|
@Test
|
||||||
void unsupportedProviderRecordVersionFailsClosedWithRedactedDiagnostic(@TempDir Path root) throws Exception {
|
void unsupportedProviderRecordVersionFailsClosedWithRedactedDiagnostic(@TempDir Path root) throws Exception {
|
||||||
Instant now = Instant.parse("2026-02-03T04:05:06.789Z");
|
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,
|
private static SignatureWorkflow.SignRequest request(PkiId id, long fence, byte[] payload, KeyRef keyRef,
|
||||||
Optional<Instant> deadline) {
|
Optional<Instant> deadline) {
|
||||||
|
return request(id, fence, payload, keyRef, deadline, "SHA256withRSA");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SignatureWorkflow.SignRequest request(PkiId id, long fence, byte[] payload, KeyRef keyRef,
|
||||||
|
Optional<Instant> deadline, String algorithmId) {
|
||||||
AccessContext access = new AccessContext(new Principal("TEST", "owner"), new Purpose("TEST"), Optional.empty(),
|
AccessContext access = new AccessContext(new Principal("TEST", "owner"), new Purpose("TEST"), Optional.empty(),
|
||||||
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);
|
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> encoding = Optional.of(Encoding.BINARY);
|
||||||
|
Optional<Instant> 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) {
|
private static void forcePersistedStateCode(Path operations, int from, int to) {
|
||||||
try {
|
try {
|
||||||
Path record;
|
Path record;
|
||||||
|
|||||||
Reference in New Issue
Block a user