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