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