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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user