refactor!: consolidate crypto architecture and security model

* make ZeroEchoSession the sole policy, audit, and runtime boundary
* replace combined key builders with operation-specific SPI and typed metadata
* remove obsolete pre-release compatibility APIs and global crypto operations
* finalize JCA agreement contexts and replace inheritance with composition
* harden secret lifecycle, key destruction, hybrid KEX, PBKDF2, and audit handling
* standardize PairSeq I/O and introduce immutable validated value types
* migrate app, ext, samples, and required pki integration points
* expand correctness, security, concurrency, and malformed-input coverage

BREAKING CHANGE: removes deprecated pre-release global configuration, legacy
context factories, combined key-builder contracts, String-based password APIs,
unchecked PairSeq writing, BlockGeometry public fields, and other compatibility
facades.
This commit is contained in:
2026-07-28 19:20:30 +02:00
parent 7319aca0db
commit 49dc080c65
298 changed files with 12802 additions and 8763 deletions

View File

@@ -0,0 +1,128 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.core;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import zeroecho.core.context.DigestContext;
import zeroecho.core.alg.AbstractCryptoAlgorithm;
import zeroecho.core.spec.ContextSpec;
/**
* Verifies stable value semantics for capability metadata defaults.
*/
class CapabilityValueSemanticsTest {
@Test
void semanticallyEqualCapabilitiesHaveEqualHashes() {
System.out.println("semanticallyEqualCapabilitiesHaveEqualHashes");
Capability first = capability(() -> new TestSpec("SHA-256"));
Capability second = capability(() -> new TestSpec("SHA-256"));
assertEquals(first, second);
assertEquals(first.hashCode(), second.hashCode());
System.out.println("...hash=" + first.hashCode());
System.out.println("semanticallyEqualCapabilitiesHaveEqualHashes...ok");
}
@Test
void defaultSupplierIsResolvedOnceAndRepeatedAccessIsStable() {
System.out.println("defaultSupplierIsResolvedOnceAndRepeatedAccessIsStable");
AtomicInteger evaluations = new AtomicInteger();
Capability capability = capability(() -> {
evaluations.incrementAndGet();
return new TestSpec("SHA-256");
});
ContextSpec first = capability.defaultSpec();
ContextSpec second = capability.defaultSpec();
assertEquals(1, evaluations.get());
assertSame(first, second);
System.out.println("...evaluations=" + evaluations.get());
System.out.println("defaultSupplierIsResolvedOnceAndRepeatedAccessIsStable...ok");
}
@Test
void semanticallyDifferentCapabilitiesAreNotEqual() {
System.out.println("semanticallyDifferentCapabilitiesAreNotEqual");
Capability first = capability(() -> new TestSpec("SHA-256"));
Capability second = capability(() -> new TestSpec("SHA-512"));
assertNotEquals(first, second);
System.out.println("...firstDefault=" + first.defaultSpec());
System.out.println("...secondDefault=" + second.defaultSpec());
System.out.println("semanticallyDifferentCapabilitiesAreNotEqual...ok");
}
@Test
void nullAndIncompatibleDefaultsAreRejectedAtConstruction() {
System.out.println("nullAndIncompatibleDefaultsAreRejectedAtConstruction");
assertThrows(NullPointerException.class, () -> capability(() -> null));
assertThrows(IllegalArgumentException.class,
() -> new Capability("DIGEST", AlgorithmFamily.DIGEST, KeyUsage.DIGEST, DigestContext.class,
NullKey.class, TestSpec.class, new OtherSpec()));
System.out.println("...invalidDefaultsRejected=true");
System.out.println("nullAndIncompatibleDefaultsAreRejectedAtConstruction...ok");
}
@Test
void metadataMemoizationDoesNotChangeRuntimeSupplierLifecycle() throws IOException {
System.out.println("metadataMemoizationDoesNotChangeRuntimeSupplierLifecycle");
AtomicInteger evaluations = new AtomicInteger();
List<TestSpec> runtimeSpecs = new ArrayList<>();
TestAlgorithm algorithm = new TestAlgorithm(evaluations, runtimeSpecs);
Capability capability = algorithm.listCapabilities().get(0);
ContextSpec metadataFirst = capability.defaultSpec();
ContextSpec metadataSecond = capability.defaultSpec();
algorithm.createContext(KeyUsage.DIGEST, NullKey.INSTANCE, null);
algorithm.createContext(KeyUsage.DIGEST, NullKey.INSTANCE, null);
assertSame(metadataFirst, metadataSecond);
assertEquals(3, evaluations.get());
assertNotEquals(runtimeSpecs.get(0), runtimeSpecs.get(1));
System.out.println("...evaluations=" + evaluations.get());
System.out.println("...runtimeDefaults=" + runtimeSpecs.size());
System.out.println("metadataMemoizationDoesNotChangeRuntimeSupplierLifecycle...ok");
}
private static Capability capability(java.util.function.Supplier<? extends ContextSpec> defaultSpec) {
return new Capability("DIGEST", AlgorithmFamily.DIGEST, KeyUsage.DIGEST, DigestContext.class, NullKey.class,
TestSpec.class, defaultSpec.get());
}
private record TestSpec(String name) implements ContextSpec {
}
private static final class OtherSpec implements ContextSpec {
}
private static final class TestAlgorithm extends AbstractCryptoAlgorithm {
private TestAlgorithm(AtomicInteger evaluations, List<TestSpec> runtimeSpecs) {
super("TEST", "Test", "test");
capability(AlgorithmFamily.DIGEST, KeyUsage.DIGEST, DigestContext.class, NullKey.class, TestSpec.class,
(key, spec) -> {
runtimeSpecs.add(spec);
return mock(DigestContext.class);
},
() -> new TestSpec(Integer.toString(evaluations.incrementAndGet())));
}
}
}

View File

@@ -71,11 +71,16 @@ import zeroecho.core.context.MacContext;
import zeroecho.core.context.SignatureContext;
import zeroecho.core.io.TailStrippingInputStream;
import zeroecho.core.spec.AlgorithmKeySpec;
import zeroecho.core.spi.AsymmetricKeyBuilder;
import zeroecho.core.spi.SymmetricKeyBuilder;
import zeroecho.core.KeyOperation;
import zeroecho.core.KeyOperationInfo;
import zeroecho.core.spi.AsymmetricKeyPairGenerator;
import zeroecho.core.spi.SymmetricKeyGenerator;
import zeroecho.core.audit.AuditMode;
import zeroecho.sdk.ZeroEchoSession;
import zeroecho.sdk.util.BouncyCastleActivator;
public class CatalogContractTest {
private static ZeroEchoSession session;
static Level effectiveLevel(Logger lg) {
for (Logger x = lg; x != null; x = x.getParent()) {
@@ -102,9 +107,9 @@ public class CatalogContractTest {
Logger jul = Logger.getLogger("zeroecho.audit");
jul.setLevel(Level.FINE); // see PROGRESS at FINE
CryptoAlgorithms.setAuditListener(JulAuditListenerStd.builder().logger(jul).infoLevel(Level.INFO)
.warnLevel(Level.WARNING).progressLevel(Level.FINE).includeStackTraces(true).build());
CryptoAlgorithms.setAuditMode(CryptoAlgorithms.AuditMode.WRAP);
session = new ZeroEchoSession().withAuditListener(JulAuditListenerStd.builder().logger(jul)
.infoLevel(Level.INFO).warnLevel(Level.WARNING).progressLevel(Level.FINE)
.includeStackTraces(true).build()).withAuditMode(AuditMode.WRAP);
dump("");
dump("zeroecho.core.audit");
@@ -167,21 +172,21 @@ public class CatalogContractTest {
void genericRoundTrips() throws Exception {
logBegin();
byte[] msg = "roundtrip".getBytes();
CryptoAlgorithms.setAuditMode(CryptoAlgorithms.AuditMode.WRAP);
for (String id : CryptoAlgorithms.available()) {
CryptoAlgorithm alg = CryptoAlgorithms.require(id);
boolean hasAsym = alg.keyOperations().stream()
.anyMatch(info -> info.operation() == KeyOperation.ASYMMETRIC_KEY_PAIR_GENERATE);
boolean hasSym = alg.keyOperations().stream()
.anyMatch(info -> info.operation() == KeyOperation.SYMMETRIC_GENERATE);
// SIGN/VERIFY (asymmetric)
if (alg.roles().contains(KeyUsage.SIGN) && alg.roles().contains(KeyUsage.VERIFY)
&& !alg.asymmetricBuildersInfo().isEmpty()) {
&& hasAsym) {
trySignVerify(id, msg);
System.out.println();
}
// ENCRYPT/DECRYPT
boolean hasSym = !alg.symmetricBuildersInfo().isEmpty();
boolean hasAsym = !alg.asymmetricBuildersInfo().isEmpty();
if (alg.roles().contains(KeyUsage.ENCRYPT) && alg.roles().contains(KeyUsage.DECRYPT)) {
if (hasSym || hasAsym) {
tryEncryptDecrypt(id, msg, hasSym, hasAsym);
@@ -191,7 +196,7 @@ public class CatalogContractTest {
// KEM
if (alg.roles().contains(KeyUsage.ENCAPSULATE) && alg.roles().contains(KeyUsage.DECAPSULATE)
&& !alg.asymmetricBuildersInfo().isEmpty()) {
&& hasAsym) {
tryKem(id, msg);
System.out.println();
}
@@ -203,7 +208,7 @@ public class CatalogContractTest {
}
// MAC (single role now; verification via setExpectedTag)
if (alg.roles().contains(KeyUsage.MAC) && !alg.symmetricBuildersInfo().isEmpty()) {
if (alg.roles().contains(KeyUsage.MAC) && hasSym) {
tryMac(id, msg);
System.out.println();
}
@@ -222,7 +227,7 @@ public class CatalogContractTest {
}
// SIGN: produce [body][signature] and capture trailer
SignatureContext signer = CryptoAlgorithms.create(id, KeyUsage.SIGN, kp.getPrivate(), null);
SignatureContext signer = session.createContext(id, KeyUsage.SIGN, kp.getPrivate(), null);
final byte[][] sigHolder = new byte[1][];
final int sigLen = signer.tagLength();
try (InputStream in = new TailStrippingInputStream(signer.wrap(new ByteArrayInputStream(msg)), sigLen, 8192) {
@@ -240,7 +245,7 @@ public class CatalogContractTest {
assertTrue(sig.length > 0, "signature empty");
// VERIFY: supply signature via setExpectedTag and drain (throws on mismatch)
SignatureContext verifier = CryptoAlgorithms.create(id, KeyUsage.VERIFY, kp.getPublic(), null);
SignatureContext verifier = session.createContext(id, KeyUsage.VERIFY, kp.getPublic(), null);
verifier.setExpectedTag(sig);
try (InputStream verIn = verifier.wrap(new ByteArrayInputStream(msg))) {
readAll(verIn);
@@ -261,14 +266,14 @@ public class CatalogContractTest {
if (sk != null) {
conflux.CtxInterface session = conflux.Ctx.INSTANCE.getContext("encdec-" + System.nanoTime());
EncryptionContext enc = CryptoAlgorithms.create(id, KeyUsage.ENCRYPT, sk, null);
EncryptionContext enc = CatalogContractTest.session.createContext(id, KeyUsage.ENCRYPT, sk, null);
if (enc instanceof zeroecho.core.spi.ContextAware ca) {
ca.setContext(session);
}
byte[] ct = readAll(enc.attach(new ByteArrayInputStream(msg)));
enc.close();
EncryptionContext dec = CryptoAlgorithms.create(id, KeyUsage.DECRYPT, sk, null);
EncryptionContext dec = CatalogContractTest.session.createContext(id, KeyUsage.DECRYPT, sk, null);
if (dec instanceof zeroecho.core.spi.ContextAware ca2) {
ca2.setContext(session);
}
@@ -284,11 +289,11 @@ public class CatalogContractTest {
if (hasAsym) {
KeyPair kp = tryKeyPairWithDefaultSpec(alg);
if (kp != null) {
EncryptionContext enc = CryptoAlgorithms.create(id, KeyUsage.ENCRYPT, kp.getPublic(), null);
EncryptionContext enc = session.createContext(id, KeyUsage.ENCRYPT, kp.getPublic(), null);
byte[] ct = readAll(enc.attach(new ByteArrayInputStream(msg)));
enc.close();
EncryptionContext dec = CryptoAlgorithms.create(id, KeyUsage.DECRYPT, kp.getPrivate(), null);
EncryptionContext dec = session.createContext(id, KeyUsage.DECRYPT, kp.getPrivate(), null);
byte[] pt = readAll(dec.attach(new ByteArrayInputStream(ct)));
dec.close();
@@ -308,8 +313,8 @@ public class CatalogContractTest {
return;
}
KemContext pub = CryptoAlgorithms.create(id, KeyUsage.ENCAPSULATE, kp.getPublic(), null);
KemContext prv = CryptoAlgorithms.create(id, KeyUsage.DECAPSULATE, kp.getPrivate(), null);
KemContext pub = session.createContext(id, KeyUsage.ENCAPSULATE, kp.getPublic(), null);
KemContext prv = session.createContext(id, KeyUsage.DECAPSULATE, kp.getPrivate(), null);
KemContext.KemResult res = pub.encapsulate();
byte[] ss = prv.decapsulate(res.ciphertext());
@@ -326,7 +331,7 @@ public class CatalogContractTest {
private void tryDigest(String id, byte[] msg) throws Exception {
logBegin(id, Integer.valueOf(msg.length));
DigestContext dctx = CryptoAlgorithms.create(id, KeyUsage.DIGEST, NullKey.INSTANCE, null);
DigestContext dctx = session.createContext(id, KeyUsage.DIGEST, NullKey.INSTANCE, null);
final byte[][] digestHolder = new byte[1][];
final int tagLen = dctx.tagLength();
@@ -360,7 +365,7 @@ public class CatalogContractTest {
}
// Produce tag: [body][tag], capture trailer
MacContext mac = CryptoAlgorithms.create(id, KeyUsage.MAC, sk, null);
MacContext mac = session.createContext(id, KeyUsage.MAC, sk, null);
final byte[][] tagHolder = new byte[1][];
final int tagLen = mac.tagLength();
try (InputStream in = new TailStrippingInputStream(mac.wrap(new ByteArrayInputStream(msg)), tagLen, 8192) {
@@ -378,7 +383,7 @@ public class CatalogContractTest {
assertTrue(tag.length > 0);
// Verify: provide expected tag and drain (throws on mismatch)
MacContext ver = CryptoAlgorithms.create(id, KeyUsage.MAC, sk, null);
MacContext ver = session.createContext(id, KeyUsage.MAC, sk, null);
ver.setExpectedTag(tag);
try (InputStream in = ver.wrap(new ByteArrayInputStream(msg))) {
readAll(in);
@@ -406,33 +411,32 @@ public class CatalogContractTest {
private KeyPair tryKeyPairWithDefaultSpec(CryptoAlgorithm alg) {
logBegin(alg.id());
try {
List<CryptoAlgorithm.AsymBuilderInfo> infos = alg.asymmetricBuildersInfo();
List<KeyOperationInfo> infos = alg.keyOperations().stream()
.filter(info -> info.operation() == KeyOperation.ASYMMETRIC_KEY_PAIR_GENERATE).toList();
if (infos.isEmpty()) {
System.out.println("no asymmetric builder info");
logEnd();
return null;
}
for (CryptoAlgorithm.AsymBuilderInfo bi : infos) {
if (bi.defaultKeySpec == null) {
for (KeyOperationInfo bi : infos) {
if (bi.defaultSpec() == null) {
continue;
}
try {
@SuppressWarnings("unchecked")
Class<AlgorithmKeySpec> specType = (Class<AlgorithmKeySpec>) bi.specType;
AlgorithmKeySpec spec = (AlgorithmKeySpec) bi.defaultKeySpec;
Class<AlgorithmKeySpec> specType = (Class<AlgorithmKeySpec>) bi.specType();
AlgorithmKeySpec spec = bi.defaultSpec();
AsymmetricKeyBuilder<AlgorithmKeySpec> builder = alg.asymmetricKeyBuilder(specType);
AsymmetricKeyPairGenerator<AlgorithmKeySpec> builder = alg.asymmetricKeyPairGenerator(specType);
System.out.println("...building with " + specType.getName());
KeyPair kp = builder.generateKeyPair(spec);
if (kp != null) {
logEnd();
return kp;
}
} catch (UnsupportedOperationException e) {
// import-only
} catch (Throwable t) {
System.out.println("builder " + bi.specType.getSimpleName() + " failed to generate keypair: "
System.out.println("builder " + bi.specType().getSimpleName() + " failed to generate keypair: "
+ t.getClass().getSimpleName() + ": " + t.getMessage());
}
}
@@ -449,30 +453,29 @@ public class CatalogContractTest {
private SecretKey tryGenerateSecretWithDefaultSpec(CryptoAlgorithm alg) {
logBegin(alg.id());
try {
List<CryptoAlgorithm.SymBuilderInfo> infos = alg.symmetricBuildersInfo();
List<KeyOperationInfo> infos = alg.keyOperations().stream()
.filter(info -> info.operation() == KeyOperation.SYMMETRIC_GENERATE).toList();
if (infos.isEmpty()) {
System.out.println("no symmetric builder info");
logEnd();
return null;
}
for (CryptoAlgorithm.SymBuilderInfo bi : infos) {
if (bi.defaultKeySpec() == null) {
for (KeyOperationInfo bi : infos) {
if (bi.defaultSpec() == null) {
continue;
}
try {
@SuppressWarnings("unchecked")
Class<AlgorithmKeySpec> specType = (Class<AlgorithmKeySpec>) bi.specType();
AlgorithmKeySpec spec = (AlgorithmKeySpec) bi.defaultKeySpec();
AlgorithmKeySpec spec = bi.defaultSpec();
SymmetricKeyBuilder<AlgorithmKeySpec> builder = alg.symmetricKeyBuilder(specType);
SymmetricKeyGenerator<AlgorithmKeySpec> builder = alg.symmetricKeyGenerator(specType);
SecretKey sk = builder.generateSecret(spec);
if (sk != null) {
logEnd();
return sk;
}
} catch (UnsupportedOperationException e) {
// import-only
} catch (Throwable t) {
System.out.println("symmetric builder " + bi.specType().getSimpleName()
+ " failed to generate secret: " + t.getClass().getSimpleName() + ": " + t.getMessage());

View File

@@ -34,20 +34,41 @@
******************************************************************************/
package zeroecho.core;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Proxy;
import java.security.Key;
import java.security.PublicKey;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import zeroecho.core.CryptoAlgorithms.AuditMode;
import zeroecho.core.audit.AuditListener;
import zeroecho.core.audit.AuditedContexts;
import zeroecho.core.context.AgreementContext;
import zeroecho.core.context.CryptoContext;
import zeroecho.core.context.DigestContext;
import zeroecho.core.context.EncryptionContext;
import zeroecho.core.context.KemContext;
import zeroecho.core.context.KemContext.KemResult;
import zeroecho.core.context.MacContext;
import zeroecho.core.context.MessageAgreementContext;
import zeroecho.core.context.SignatureContext;
import zeroecho.core.spec.ContextSpec;
import zeroecho.core.tag.TagEngine;
/**
* Verifies audited proxy wrapping for representative {@link CryptoContext}
@@ -55,7 +76,7 @@ import zeroecho.core.context.DigestContext;
*
* <p>
* These tests focus on the internal audit wrapping path used by
* {@link CryptoAlgorithms#wrapForAudit(CryptoContext, zeroecho.core.audit.AuditListener, KeyUsage)}.
* {@link AuditedContexts#wrap(CryptoContext, AuditListener, KeyUsage)}.
* They verify that representative context types are wrapped as audited JDK
* proxies and that the resulting wrapper preserves the expected basic
* delegation behavior.
@@ -63,18 +84,12 @@ import zeroecho.core.context.DigestContext;
*/
class CryptoAlgorithmsAuditWrapTest {
@AfterEach
void restoreAuditConfiguration() {
CryptoAlgorithms.setAuditListener(AuditListener.noop());
CryptoAlgorithms.setAuditMode(AuditMode.OFF);
}
@Test
void wrapForAuditDigestContextReturnsProxy() {
System.out.println("wrapForAuditDigestContextReturnsProxy");
DigestContext context = mock(DigestContext.class);
DigestContext wrapped = CryptoAlgorithms.wrapForAudit(context, AuditListener.noop(), KeyUsage.DIGEST);
DigestContext wrapped = (DigestContext) AuditedContexts.wrap(context, AuditListener.noop(), KeyUsage.DIGEST);
System.out.println("...ctxClass=" + wrapped.getClass().getName());
assertTrue(Proxy.isProxyClass(wrapped.getClass()), "Digest context should be wrapped as JDK proxy");
@@ -87,7 +102,8 @@ class CryptoAlgorithmsAuditWrapTest {
System.out.println("wrapForAuditAgreementContextReturnsProxy");
AgreementContext context = mock(AgreementContext.class);
AgreementContext wrapped = CryptoAlgorithms.wrapForAudit(context, AuditListener.noop(), KeyUsage.AGREEMENT);
AgreementContext wrapped = (AgreementContext) AuditedContexts.wrap(context, AuditListener.noop(),
KeyUsage.AGREEMENT);
System.out.println("...ctxClass=" + wrapped.getClass().getName());
assertTrue(Proxy.isProxyClass(wrapped.getClass()), "Agreement context should be wrapped as JDK proxy");
@@ -100,7 +116,7 @@ class CryptoAlgorithmsAuditWrapTest {
System.out.println("wrapForAuditDigestContextCloseDelegatesToWrappedContext");
DigestContext context = mock(DigestContext.class);
DigestContext wrapped = CryptoAlgorithms.wrapForAudit(context, AuditListener.noop(), KeyUsage.DIGEST);
DigestContext wrapped = (DigestContext) AuditedContexts.wrap(context, AuditListener.noop(), KeyUsage.DIGEST);
wrapped.close();
@@ -108,4 +124,182 @@ class CryptoAlgorithmsAuditWrapTest {
System.out.println("...wrappedCloseDelegated=true");
System.out.println("wrapForAuditDigestContextCloseDelegatesToWrappedContext...ok");
}
}
@Test
void wrapAllFamilies() throws Exception {
System.out.println("wrapAllFamilies");
byte[] body = { 1, 2, 3 };
RecordingListener encryptionEvents = new RecordingListener();
EncryptionContext encryption = mock(EncryptionContext.class);
when(encryption.attach(any(InputStream.class))).thenAnswer(invocation -> invocation.getArgument(0));
EncryptionContext auditedEncryption = wrap(encryption, encryptionEvents, KeyUsage.ENCRYPT);
assertArrayEquals(body, auditedEncryption.attach(new ByteArrayInputStream(body)).readAllBytes());
encryptionEvents.assertCreationBefore("progress");
assertTagFamily(mockTag(SignatureContext.class), KeyUsage.SIGN, body);
assertTagFamily(mockTag(MacContext.class), KeyUsage.MAC, body);
assertTagFamily(mockTag(DigestContext.class), KeyUsage.DIGEST, body);
RecordingListener agreementEvents = new RecordingListener();
AgreementContext agreement = mock(AgreementContext.class);
PublicKey peer = mock(PublicKey.class);
when(agreement.deriveSecret()).thenReturn(new byte[] { 4, 5 });
AgreementContext auditedAgreement = wrap(agreement, agreementEvents, KeyUsage.AGREEMENT);
auditedAgreement.setPeerPublic(peer);
assertArrayEquals(new byte[] { 4, 5 }, auditedAgreement.deriveSecret());
agreementEvents.assertCreationBefore("peer");
agreementEvents.assertBefore("peer", "derived");
RecordingListener messageEvents = new RecordingListener();
MessageAgreementContext messageAgreement = mock(MessageAgreementContext.class);
when(messageAgreement.getPeerMessage()).thenReturn(new byte[] { 6, 7, 8 });
when(messageAgreement.deriveSecret()).thenReturn(new byte[] { 9 });
MessageAgreementContext auditedMessage = wrap(messageAgreement, messageEvents, KeyUsage.AGREEMENT);
auditedMessage.setPeerMessage(new byte[] { 6 });
assertArrayEquals(new byte[] { 6, 7, 8 }, auditedMessage.getPeerMessage());
assertArrayEquals(new byte[] { 9 }, auditedMessage.deriveSecret());
messageEvents.assertCreationBefore("message-set");
messageEvents.assertBefore("message-set", "message-get");
messageEvents.assertBefore("message-get", "derived");
RecordingListener kemEvents = new RecordingListener();
KemContext kem = mock(KemContext.class);
KemResult result = new KemResult(new byte[] { 10, 11 }, new byte[] { 12 });
when(kem.encapsulate()).thenReturn(result);
when(kem.decapsulate(any(byte[].class))).thenReturn(new byte[] { 12 });
KemContext auditedKem = wrap(kem, kemEvents, KeyUsage.ENCAPSULATE);
assertSame(result, auditedKem.encapsulate());
assertArrayEquals(new byte[] { 12 }, auditedKem.decapsulate(new byte[] { 10, 11 }));
kemEvents.assertCreationBefore("encapsulated");
kemEvents.assertBefore("encapsulated", "decapsulated");
System.out.println("...families=7");
System.out.println("wrapAllFamilies...ok");
}
@Test
void wrapFailureAndIdempotency() throws Exception {
System.out.println("wrapFailureAndIdempotency");
RecordingListener listener = new RecordingListener();
IOException expected = new IOException("controlled read failure");
EncryptionContext target = mock(EncryptionContext.class);
when(target.attach(any(InputStream.class))).thenReturn(new InputStream() {
@Override
public int read() throws IOException {
throw expected;
}
});
EncryptionContext wrapped = wrap(target, listener, KeyUsage.DECRYPT);
int eventsAfterCreation = listener.events.size();
assertSame(wrapped, AuditedContexts.wrap(wrapped, listener, KeyUsage.DECRYPT));
assertEquals(eventsAfterCreation, listener.events.size());
IOException actual = assertThrows(IOException.class,
() -> wrapped.attach(new ByteArrayInputStream(new byte[0])).readAllBytes());
assertSame(expected, actual);
assertEquals(1, listener.count("failure"));
listener.assertCreationBefore("failure");
System.out.println("...failureEvents=" + listener.count("failure"));
System.out.println("wrapFailureAndIdempotency...ok");
}
private static <T extends CryptoContext> T wrap(T context, RecordingListener listener, KeyUsage usage) {
@SuppressWarnings("unchecked")
T wrapped = (T) AuditedContexts.wrap(context, listener, usage);
assertTrue(Proxy.isProxyClass(wrapped.getClass()));
return wrapped;
}
private static <T extends CryptoContext & TagEngine<?>> T mockTag(Class<T> type) throws IOException {
T context = mock(type);
when(context.tagLength()).thenReturn(2);
when(context.wrap(any(InputStream.class))).thenAnswer(invocation -> {
InputStream upstream = invocation.getArgument(0);
return new java.io.SequenceInputStream(upstream, new ByteArrayInputStream(new byte[] { 0, 0 }));
});
return context;
}
private static void assertTagFamily(CryptoContext context, KeyUsage usage, byte[] body) throws IOException {
RecordingListener listener = new RecordingListener();
CryptoContext wrapped = wrap(context, listener, usage);
TagEngine<?> engine = (TagEngine<?>) wrapped;
byte[] output = engine.wrap(new ByteArrayInputStream(body)).readAllBytes();
assertArrayEquals(new byte[] { 1, 2, 3, 0, 0 }, output);
assertEquals(1, listener.count("tag"));
listener.assertCreationBefore("tag");
}
private static final class RecordingListener implements AuditListener {
private final List<String> events = new ArrayList<>();
@Override
public void onContextCreatedMeta(String contextId, String algorithmId, String provider, KeyUsage role,
String keyFingerprint, Map<String, Object> specMeta) {
events.add("meta");
}
@Override
public void onProgress(String contextId, long bodyBytes, long trailerBytes) {
events.add("progress");
}
@Override
public void onTagProduced(String contextId, int tagLength, String policy) {
events.add("tag");
}
@Override
public void onAgreementPeerSet(String contextId, String peerFingerprint) {
events.add("peer");
}
@Override
public void onAgreementDerived(String contextId, int secretLength) {
events.add("derived");
}
@Override
public void onAgreementPeerMessageSet(String contextId, int messageLength) {
events.add("message-set");
}
@Override
public void onAgreementPeerMessageGet(String contextId, int messageLength) {
events.add("message-get");
}
@Override
public void onKemEncapsulated(int ciphertextLength, int sharedSecretLength) {
events.add("encapsulated");
}
@Override
public void onKemDecapsulated(int sharedSecretLength) {
events.add("decapsulated");
}
@Override
public void onFailure(String contextId, String stage, String operation, Throwable cause) {
events.add("failure");
}
private int count(String event) {
return (int) events.stream().filter(event::equals).count();
}
private void assertCreationBefore(String event) {
assertEquals("meta", events.get(0));
assertEquals(1, count("meta"));
assertTrue(events.indexOf(event) > 0);
}
private void assertBefore(String first, String second) {
assertTrue(events.indexOf(first) < events.indexOf(second));
}
}
}

View File

@@ -0,0 +1,205 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.core;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
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 java.lang.reflect.Proxy;
import java.security.Key;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import zeroecho.core.audit.AuditMode;
import zeroecho.core.audit.AuditListener;
import zeroecho.core.context.DigestContext;
import zeroecho.core.policy.CryptoPolicy;
import zeroecho.core.spec.ContextSpec;
import zeroecho.sdk.ZeroEchoSession;
/**
* Verifies authoritative registry ownership and explicitly scoped runtime state.
*/
class CryptoArchitectureTest {
@Test
void authoritativeRegistry() {
System.out.println("authoritativeRegistry");
CryptoCatalog catalog = CryptoCatalog.load();
assertSame(CryptoAlgorithms.registry(), catalog.algorithms());
assertEquals(CryptoAlgorithms.available(), catalog.algorithms().keySet());
for (String id : CryptoAlgorithms.available()) {
assertSame(CryptoAlgorithms.require(id), catalog.algorithms().get(id));
}
System.out.println("...providerCount=" + CryptoAlgorithms.available().size());
System.out.println("authoritativeRegistry...ok");
}
@Test
void explicitPolicyOrder() throws Exception {
System.out.println("explicitPolicyOrder");
List<String> events = new ArrayList<>();
AuditListener listener = policyOrderListener(events);
ZeroEchoSession allowed = new ZeroEchoSession().withAuditListener(listener).withPolicy(
(id, role, key, spec) -> events.add("policy"));
try (DigestContext context = allowed.createContext("DIGEST", KeyUsage.DIGEST, NullKey.INSTANCE)) {
assertSame(CryptoAlgorithms.require("DIGEST"), context.algorithm());
}
assertEquals(List.of("policy", "audit"), events);
events.clear();
IllegalArgumentException denial = new IllegalArgumentException("controlled denial");
ZeroEchoSession denied = new ZeroEchoSession().withAuditListener(listener).withPolicy(
(id, role, key, spec) -> {
events.add("policy");
throw denial;
});
assertSame(denial, assertThrows(IllegalArgumentException.class,
() -> denied.createContext("DIGEST", KeyUsage.DIGEST, NullKey.INSTANCE)));
assertEquals(List.of("policy"), events);
events.clear();
IllegalStateException failure = new IllegalStateException("controlled policy failure");
ZeroEchoSession failing = new ZeroEchoSession().withAuditListener(listener).withPolicy(
(id, role, key, spec) -> {
events.add("policy");
throw failure;
});
assertSame(failure, assertThrows(IllegalStateException.class,
() -> failing.createContext("DIGEST", KeyUsage.DIGEST, NullKey.INSTANCE)));
assertEquals(List.of("policy"), events);
System.out.println("...allow=1...deny=1...failure=1");
System.out.println("explicitPolicyOrder...ok");
}
@Test
void isolatedAuditConfiguration() throws Exception {
System.out.println("isolatedAuditConfiguration");
AtomicInteger firstEvents = new AtomicInteger();
AtomicInteger secondEvents = new AtomicInteger();
AuditListener firstListener = contextListener(firstEvents);
AuditListener secondListener = contextListener(secondEvents);
ZeroEchoSession wrapped = new ZeroEchoSession().withAuditListener(firstListener)
.withAuditMode(AuditMode.WRAP);
ZeroEchoSession direct = new ZeroEchoSession().withAuditListener(secondListener);
try (DigestContext wrappedContext = wrapped.createContext("DIGEST", KeyUsage.DIGEST, NullKey.INSTANCE)) {
assertTrue(Proxy.isProxyClass(wrappedContext.getClass()));
}
int wrappedEvents = firstEvents.get();
assertTrue(wrappedEvents > 0);
assertEquals(0, secondEvents.get());
try (DigestContext directContext = direct.createContext("DIGEST", KeyUsage.DIGEST, NullKey.INSTANCE)) {
assertTrue(!Proxy.isProxyClass(directContext.getClass()));
}
assertEquals(wrappedEvents, firstEvents.get());
assertEquals(1, secondEvents.get());
assertEquals(AuditMode.WRAP, wrapped.auditMode());
assertEquals(AuditMode.OFF, direct.auditMode());
System.out.println("...firstEvents=" + firstEvents.get());
System.out.println("...secondEvents=" + secondEvents.get());
System.out.println("isolatedAuditConfiguration...ok");
}
@Test
void immutableSessionChanges() {
System.out.println("immutableSessionChanges");
ZeroEchoSession base = new ZeroEchoSession();
ZeroEchoSession changed = base.withAuditMode(AuditMode.WRAP)
.withAuditListener(contextListener(new AtomicInteger()));
assertNotSame(base, changed);
assertEquals(AuditMode.OFF, base.auditMode());
assertEquals(AuditMode.WRAP, changed.auditMode());
assertNotSame(base.auditListener(), changed.auditListener());
System.out.println("...baseMode=" + base.auditMode());
System.out.println("...changedMode=" + changed.auditMode());
System.out.println("immutableSessionChanges...ok");
}
@Test
void concurrentSessionReads() throws Exception {
System.out.println("concurrentSessionReads");
ZeroEchoSession session = new ZeroEchoSession().withAuditMode(AuditMode.MANUAL);
int taskCount = 16;
CountDownLatch start = new CountDownLatch(1);
ExecutorService executor = Executors.newFixedThreadPool(8);
try {
List<Future<Boolean>> futures = new ArrayList<>();
for (int index = 0; index < taskCount; index++) {
futures.add(executor.submit(() -> {
start.await();
for (int iteration = 0; iteration < 500; iteration++) {
if (session.auditMode() != AuditMode.MANUAL || session.available().isEmpty()
|| session.require("DIGEST") != CryptoAlgorithms.require("DIGEST")) {
return Boolean.FALSE;
}
}
return Boolean.TRUE;
}));
}
start.countDown();
for (Future<Boolean> future : futures) {
assertTrue(future.get().booleanValue());
}
} finally {
executor.shutdownNow();
}
System.out.println("...completedTasks=" + taskCount);
System.out.println("concurrentSessionReads...ok");
}
@Test
void manualModeSilent() throws Exception {
System.out.println("manualModeSilent");
AtomicInteger events = new AtomicInteger();
ZeroEchoSession session = new ZeroEchoSession().withAuditListener(contextListener(events))
.withAuditMode(AuditMode.MANUAL);
try (DigestContext context = session.createContext("DIGEST", KeyUsage.DIGEST, NullKey.INSTANCE)) {
assertTrue(!Proxy.isProxyClass(context.getClass()));
}
assertEquals(0, events.get());
System.out.println("...events=" + events.get());
System.out.println("manualModeSilent...ok");
}
private static AuditListener contextListener(AtomicInteger count) {
return new AuditListener() {
@Override
public void onContextCreatedMeta(String contextId, String algorithmId, String provider, KeyUsage role,
String keyFingerprint, java.util.Map<String, Object> specMeta) {
count.incrementAndGet();
}
};
}
private static AuditListener policyOrderListener(List<String> events) {
return new AuditListener() {
@Override
public void onContextCreatedMeta(String contextId, String algorithmId, String provider, KeyUsage role,
String keyFingerprint, java.util.Map<String, Object> specMeta) {
events.add("audit");
}
};
}
}

View File

@@ -0,0 +1,244 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.core;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import javax.security.auth.Destroyable;
import javax.crypto.SecretKey;
import org.junit.jupiter.api.Test;
import zeroecho.core.alg.aes.AesKeyImportSpec;
import zeroecho.core.alg.aes.AesAlgorithm;
import zeroecho.core.alg.chacha.ChaChaKeyImportSpec;
import zeroecho.core.alg.hmac.HmacKeyImportSpec;
import zeroecho.core.alg.mldsa.MldsaPrivateKeySpec;
import zeroecho.core.alg.rsa.RsaPrivateKeySpec;
import zeroecho.core.alg.slhdsa.SlhDsaPrivateKeySpec;
import zeroecho.core.alg.sphincsplus.SphincsPlusPrivateKeySpec;
import zeroecho.core.marshal.PairSeq;
class SecretSpecLifecycleTest {
private static final List<SpecCase> SPECS = List.of(
new SpecCase("zeroecho.core.alg.aes.AesKeyImportSpec", "key", 16, Factory.STATIC_RAW),
new SpecCase("zeroecho.core.alg.bike.BikePrivateKeySpec", "pkcs8", 8, Factory.CONSTRUCTOR),
new SpecCase("zeroecho.core.alg.chacha.ChaChaKeyImportSpec", "key", 32, Factory.STATIC_RAW),
new SpecCase("zeroecho.core.alg.cmce.CmcePrivateKeySpec", "pkcs8", 8, Factory.CONSTRUCTOR),
new SpecCase("zeroecho.core.alg.dh.DhPrivateKeySpec", "encoded", 8, Factory.CONSTRUCTOR),
new SpecCase("zeroecho.core.alg.ecdsa.EcdsaPrivateKeySpec", "encoded", 8, Factory.CONSTRUCTOR),
new SpecCase("zeroecho.core.alg.ed25519.Ed25519PrivateKeySpec", "encoded", 8, Factory.CONSTRUCTOR),
new SpecCase("zeroecho.core.alg.ed448.Ed448PrivateKeySpec", "encoded", 8, Factory.CONSTRUCTOR),
new SpecCase("zeroecho.core.alg.elgamal.ElgamalPrivateKeySpec", "encoded", 8, Factory.CONSTRUCTOR),
new SpecCase("zeroecho.core.alg.frodo.FrodoPrivateKeySpec", "pkcs8", 8, Factory.CONSTRUCTOR),
new SpecCase("zeroecho.core.alg.hmac.HmacKeyImportSpec", "key", 8, Factory.HMAC),
new SpecCase("zeroecho.core.alg.hqc.HqcPrivateKeySpec", "pkcs8", 8, Factory.CONSTRUCTOR),
new SpecCase("zeroecho.core.alg.kyber.KyberPrivateKeySpec", "pkcs8", 8, Factory.CONSTRUCTOR),
new SpecCase("zeroecho.core.alg.mldsa.MldsaPrivateKeySpec", "encoded", 8, Factory.CONSTRUCTOR),
new SpecCase("zeroecho.core.alg.ntru.NtruPrivateKeySpec", "pkcs8", 8, Factory.CONSTRUCTOR),
new SpecCase("zeroecho.core.alg.ntruprime.NtrulPrimePrivateKeySpec", "pkcs8", 8,
Factory.CONSTRUCTOR),
new SpecCase("zeroecho.core.alg.ntruprime.SntruPrimePrivateKeySpec", "pkcs8", 8,
Factory.CONSTRUCTOR),
new SpecCase("zeroecho.core.alg.rsa.RsaPrivateKeySpec", "encoded", 8, Factory.CONSTRUCTOR),
new SpecCase("zeroecho.core.alg.saber.SaberPrivateKeySpec", "pkcs8", 8, Factory.CONSTRUCTOR),
new SpecCase("zeroecho.core.alg.slhdsa.SlhDsaPrivateKeySpec", "encoded", 8, Factory.CONSTRUCTOR),
new SpecCase("zeroecho.core.alg.sphincsplus.SphincsPlusPrivateKeySpec", "encoded", 8,
Factory.CONSTRUCTOR),
new SpecCase("zeroecho.core.alg.xdh.XdhPrivateKeySpec", "encoded", 8, Factory.CONSTRUCTOR));
@Test
void allSecretSpecificationsOwnAndDestroyTheirByteArrays() throws Exception {
System.out.print("SecretSpec/lifecycle...");
for (SpecCase specCase : SPECS) {
byte[] input = sequence(specCase.length());
byte[] expected = input.clone();
Object spec = specCase.create(input);
Arrays.fill(input, (byte) 0);
Method accessor = spec.getClass().getMethod(specCase.accessor());
byte[] first = (byte[]) accessor.invoke(spec);
assertArrayEquals(expected, first, specCase.className());
first[0] ^= 0x7f;
assertArrayEquals(expected, (byte[]) accessor.invoke(spec), specCase.className());
Method marshal = spec.getClass().getMethod("marshal", spec.getClass());
marshal.invoke(null, spec);
Destroyable destroyable = assertInstanceOf(Destroyable.class, spec);
assertFalse(destroyable.isDestroyed());
destroyable.destroy();
destroyable.destroy();
assertTrue(destroyable.isDestroyed());
assertAllSecretFieldsZero(spec);
InvocationTargetException accessFailure = assertThrows(InvocationTargetException.class,
() -> accessor.invoke(spec), specCase.className());
assertInstanceOf(IllegalStateException.class, accessFailure.getCause());
InvocationTargetException marshalFailure = assertThrows(InvocationTargetException.class,
() -> marshal.invoke(null, spec), specCase.className());
assertInstanceOf(IllegalStateException.class, marshalFailure.getCause());
}
System.out.println("ok");
}
@Test
void validatesNullAndBoundaryKeyMaterial() {
System.out.print("SecretSpec/boundaries...");
assertThrows(NullPointerException.class, () -> AesKeyImportSpec.fromRaw(null));
assertThrows(IllegalArgumentException.class, () -> AesKeyImportSpec.fromRaw(new byte[0]));
assertThrows(NullPointerException.class, () -> new HmacKeyImportSpec("HmacSHA256", null));
assertThrows(NullPointerException.class, () -> new RsaPrivateKeySpec(null));
HmacKeyImportSpec emptyHmac = new HmacKeyImportSpec("HmacSHA256", new byte[0]);
RsaPrivateKeySpec emptyRsa = new RsaPrivateKeySpec(new byte[0]);
assertArrayEquals(new byte[0], emptyHmac.key());
assertArrayEquals(new byte[0], emptyRsa.encoded());
emptyHmac.destroy();
emptyRsa.destroy();
System.out.println("ok");
}
@Test
void unmarshalCleansUpWhenMalformedDataFollowsValidSecretMaterial() {
System.out.println("unmarshalCleansUpWhenMalformedDataFollowsValidSecretMaterial");
String aesKey = Base64.getEncoder().encodeToString(sequence(16));
String chachaKey = Base64.getEncoder().encodeToString(sequence(32));
String encodedPrivateKey = Base64.getEncoder().encodeToString(sequence(8));
assertThrows(IllegalArgumentException.class,
() -> AesKeyImportSpec.unmarshal(PairSeq.of("k.b64", aesKey, "k.b64", "%")));
assertThrows(IllegalArgumentException.class,
() -> ChaChaKeyImportSpec.unmarshal(PairSeq.of("k.b64", chachaKey, "k.b64", "%")));
assertThrows(IllegalArgumentException.class, () -> HmacKeyImportSpec.unmarshal(
PairSeq.of("mac", "HmacSHA256", "k.b64", encodedPrivateKey, "k.b64", "%")));
assertThrows(IllegalArgumentException.class, () -> MldsaPrivateKeySpec.unmarshal(
PairSeq.of("pkcs8.b64", encodedPrivateKey, "pkcs8.b64", "%")));
assertThrows(IllegalArgumentException.class, () -> SlhDsaPrivateKeySpec.unmarshal(
PairSeq.of("pkcs8.b64", encodedPrivateKey, "pkcs8.b64", "%")));
assertThrows(IllegalArgumentException.class, () -> SphincsPlusPrivateKeySpec.unmarshal(
PairSeq.of("pkcs8.b64", encodedPrivateKey, "pkcs8.b64", "%")));
System.out.println("...cases=6");
System.out.println("unmarshalCleansUpWhenMalformedDataFollowsValidSecretMaterial...ok");
}
@Test
void accessAndDestroyAreLinearizable() throws Exception {
System.out.print("SecretSpec/concurrent...");
byte[] expected = sequence(16);
AesKeyImportSpec spec = AesKeyImportSpec.fromRaw(expected);
CountDownLatch start = new CountDownLatch(1);
ExecutorService executor = Executors.newFixedThreadPool(8);
try {
List<Future<?>> futures = new ArrayList<>();
for (int i = 0; i < 32; i++) {
futures.add(executor.submit(() -> {
start.await();
try {
assertArrayEquals(expected, spec.key());
} catch (IllegalStateException destroyed) {
assertTrue(spec.isDestroyed());
}
return null;
}));
}
futures.add(executor.submit(() -> {
start.await();
spec.destroy();
return null;
}));
start.countDown();
for (Future<?> future : futures) {
future.get();
}
} finally {
executor.shutdownNow();
}
assertTrue(spec.isDestroyed());
assertThrows(IllegalStateException.class, spec::key);
System.out.println("ok");
}
@Test
void importersDoNotDestroyCallerOwnedSpecifications() throws Exception {
System.out.print("SecretSpec/import...");
byte[] aesBytes = sequence(16);
AesKeyImportSpec aesSpec = AesKeyImportSpec.fromRaw(aesBytes);
AesAlgorithm aes = new AesAlgorithm();
SecretKey secretKey = aes.symmetricKeyImporter(AesKeyImportSpec.class).importSecret(aesSpec);
assertArrayEquals(aesBytes, secretKey.getEncoded());
assertFalse(aesSpec.isDestroyed());
assertArrayEquals(aesBytes, aesSpec.key());
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
KeyPair pair = generator.generateKeyPair();
RsaPrivateKeySpec rsaSpec = new RsaPrivateKeySpec(pair.getPrivate().getEncoded());
PrivateKey imported = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().importPrivate("RSA", rsaSpec);
assertArrayEquals(pair.getPrivate().getEncoded(), imported.getEncoded());
assertFalse(rsaSpec.isDestroyed());
assertArrayEquals(pair.getPrivate().getEncoded(), rsaSpec.encoded());
System.out.println("ok");
}
private static byte[] sequence(int length) {
byte[] bytes = new byte[length];
for (int i = 0; i < length; i++) {
bytes[i] = (byte) (i + 1);
}
return bytes;
}
private static void assertAllSecretFieldsZero(Object spec) throws IllegalAccessException {
for (Field field : spec.getClass().getDeclaredFields()) {
if (field.getType() == byte[].class) {
field.setAccessible(true);
byte[] bytes = (byte[]) field.get(spec);
assertTrue(Arrays.equals(new byte[bytes.length], bytes), spec.getClass().getName());
}
}
}
private enum Factory {
CONSTRUCTOR,
STATIC_RAW,
HMAC
}
private record SpecCase(String className, String accessor, int length, Factory factory) {
private Object create(byte[] input) throws ReflectiveOperationException {
Class<?> type = Class.forName(className);
if (factory == Factory.STATIC_RAW) {
Method method = type.getMethod("fromRaw", byte[].class);
return method.invoke(null, (Object) input);
}
if (factory == Factory.HMAC) {
Constructor<?> constructor = type.getConstructor(String.class, byte[].class);
return constructor.newInstance("HmacSHA256", input);
}
Constructor<?> constructor = type.getConstructor(byte[].class);
return constructor.newInstance((Object) input);
}
}
}

View File

@@ -0,0 +1,154 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.core;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.EnumMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
import zeroecho.core.alg.common.agreement.GenericJcaAgreementContext;
import zeroecho.core.audit.AuditListener;
import zeroecho.core.audit.AuditMode;
import zeroecho.core.context.DigestContext;
import zeroecho.core.spec.AlgorithmKeySpec;
import zeroecho.sdk.ZeroEchoSession;
/**
* Guards the consolidated pre-release architecture against legacy API drift.
*/
class TargetArchitectureTest {
@Test
void exactRegisteredKeyOperationMatrix() {
String name = start("exactRegisteredKeyOperationMatrix");
Map<KeyOperation, Integer> counts = new EnumMap<>(KeyOperation.class);
Set<String> registrations = new HashSet<>();
for (String algorithmId : CryptoAlgorithms.available()) {
CryptoAlgorithm algorithm = CryptoAlgorithms.require(algorithmId);
for (KeyOperationInfo info : algorithm.keyOperations()) {
String registration = algorithmId + "|" + info.operation() + "|" + info.specType().getName();
assertTrue(registrations.add(registration), registration);
assertLookupSucceeds(algorithm, info);
counts.merge(info.operation(), 1, Integer::sum);
}
}
assertEquals(20, counts.getOrDefault(KeyOperation.ASYMMETRIC_KEY_PAIR_GENERATE, 0));
assertEquals(20, counts.getOrDefault(KeyOperation.ASYMMETRIC_PUBLIC_IMPORT, 0));
assertEquals(20, counts.getOrDefault(KeyOperation.ASYMMETRIC_PRIVATE_IMPORT, 0));
assertEquals(4, counts.getOrDefault(KeyOperation.SYMMETRIC_GENERATE, 0));
assertEquals(4, counts.getOrDefault(KeyOperation.SYMMETRIC_IMPORT, 0));
progress("registrations=" + registrations.size());
ok(name);
}
@Test
void obsoleteArchitectureTypesAreAbsent() {
String name = start("obsoleteArchitectureTypesAreAbsent");
assertThrows(ClassNotFoundException.class,
() -> Class.forName("zeroecho.core.spi.ContextConstructorKS"));
assertThrows(ClassNotFoundException.class,
() -> Class.forName("zeroecho.core.spi.SymmetricKeyBuilder"));
assertThrows(ClassNotFoundException.class,
() -> Class.forName("zeroecho.core.spi.AsymmetricKeyBuilder"));
for (Method method : CryptoAlgorithms.class.getDeclaredMethods()) {
if (Modifier.isPublic(method.getModifiers()) && Modifier.isStatic(method.getModifiers())) {
assertTrue(method.getName().equals("available") || method.getName().equals("require"),
method.toString());
}
}
for (Field field : CryptoAlgorithms.class.getDeclaredFields()) {
assertTrue(Modifier.isFinal(field.getModifiers()), field.toString());
}
Set<String> removedConveniences = Set.of("generateSecret", "importSecret", "generateKeyPair",
"importPublic", "importPrivate");
for (Method method : CryptoAlgorithm.class.getDeclaredMethods()) {
assertFalse(removedConveniences.contains(method.getName()), method.toString());
}
progress("registryStatics=readOnly");
ok(name);
}
@Test
void agreementImplementationIsFinal() {
String name = start("agreementImplementationIsFinal");
assertTrue(Modifier.isFinal(GenericJcaAgreementContext.class.getModifiers()));
progress("final=true");
ok(name);
}
@Test
void auditListenerFailureCannotChangeOperationOutcome() throws Exception {
String name = start("auditListenerFailureCannotChangeOperationOutcome");
AuditListener failing = new AuditListener() {
@Override
public void onContextCreatedMeta(String contextId, String algorithmId, String provider,
KeyUsage role, String keyFingerprint, Map<String, Object> metadata) {
throw new AssertionError("controlled listener failure");
}
@Override
public void onContextClosed(String contextId, long bodyBytes, long trailerBytes,
long durationMillis) {
throw new AssertionError("controlled listener failure");
}
};
for (AuditMode mode : new AuditMode[] { AuditMode.OFF, AuditMode.WRAP }) {
ZeroEchoSession session = new ZeroEchoSession().withAuditListener(failing).withAuditMode(mode);
try (DigestContext context = assertDoesNotThrow(
() -> session.createContext("DIGEST", KeyUsage.DIGEST, NullKey.INSTANCE))) {
assertFalse(context.algorithm().id().isBlank());
}
}
progress("modes=OFF,WRAP");
ok(name);
}
@SuppressWarnings("unchecked")
private static void assertLookupSucceeds(CryptoAlgorithm algorithm, KeyOperationInfo info) {
Class<AlgorithmKeySpec> specType = (Class<AlgorithmKeySpec>) info.specType();
switch (info.operation()) {
case ASYMMETRIC_KEY_PAIR_GENERATE ->
assertDoesNotThrow(() -> algorithm.asymmetricKeyPairGenerator(specType));
case ASYMMETRIC_PUBLIC_IMPORT ->
assertDoesNotThrow(() -> algorithm.publicKeyImporter(specType));
case ASYMMETRIC_PRIVATE_IMPORT ->
assertDoesNotThrow(() -> algorithm.privateKeyImporter(specType));
case SYMMETRIC_GENERATE ->
assertDoesNotThrow(() -> algorithm.symmetricKeyGenerator(specType));
case SYMMETRIC_IMPORT ->
assertDoesNotThrow(() -> algorithm.symmetricKeyImporter(specType));
}
}
private static String start(String routine) {
String label = routine.length() <= 30 ? routine : routine.substring(0, 27) + "...";
System.out.println(label);
return label;
}
private static void progress(String detail) {
System.out.println("..." + detail);
}
private static void ok(String name) {
System.out.println(name + "...ok");
}
}

View File

@@ -0,0 +1,55 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.core;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.security.Key;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import zeroecho.core.audit.AuditMode;
import zeroecho.core.audit.AuditListener;
import zeroecho.core.err.UnsupportedSpecException;
import zeroecho.core.spec.ContextSpec;
import zeroecho.sdk.ZeroEchoSession;
class WrongKeySecurityTest {
@Test
void wrongKeyMatrix() {
System.out.println("wrongKeyMatrix");
AtomicInteger auditEvents = new AtomicInteger();
ZeroEchoSession session = new ZeroEchoSession().withAuditMode(AuditMode.WRAP)
.withAuditListener(new AuditListener() {
@Override
public void onContextCreatedMeta(String contextId, String algorithmId, String provider,
KeyUsage role, String keyFingerprint, java.util.Map<String, Object> specMeta) {
auditEvents.incrementAndGet();
}
});
assertWrongKey(session, "AES", KeyUsage.ENCRYPT);
assertWrongKey(session, "ECDSA", KeyUsage.SIGN);
assertWrongKey(session, "Xdh", KeyUsage.AGREEMENT);
assertWrongKey(session, "ML-KEM", KeyUsage.ENCAPSULATE);
assertEquals(0, auditEvents.get(), "rejected keys must not create or process contexts");
System.out.println("...families=4");
System.out.println("wrongKeyMatrix...ok");
}
private static void assertWrongKey(ZeroEchoSession session, String algorithm, KeyUsage role) {
UnsupportedSpecException failure = assertThrows(UnsupportedSpecException.class,
() -> session.createContext(algorithm, role, NullKey.INSTANCE));
assertTrue(failure.getMessage().contains(algorithm));
assertTrue(failure.getMessage().contains(role.name()));
assertNull(failure.getCause());
}
}

View File

@@ -0,0 +1,275 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.core;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Proxy;
import java.security.KeyPair;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import conflux.Ctx;
import conflux.CtxInterface;
import zeroecho.core.audit.AuditMode;
import zeroecho.core.alg.aes.AesSpec;
import zeroecho.core.alg.common.agreement.KeyPairKey;
import zeroecho.core.alg.ed25519.Ed25519KeyGenSpec;
import zeroecho.core.alg.hmac.HmacSpec;
import zeroecho.core.alg.kyber.KyberKeyGenSpec;
import zeroecho.core.alg.xdh.XdhSpec;
import zeroecho.core.audit.AuditListener;
import zeroecho.core.context.AgreementContext;
import zeroecho.core.context.CryptoContext;
import zeroecho.core.context.DigestContext;
import zeroecho.core.context.EncryptionContext;
import zeroecho.core.context.KemContext;
import zeroecho.core.context.MacContext;
import zeroecho.core.context.MessageAgreementContext;
import zeroecho.core.context.SignatureContext;
import zeroecho.core.io.TailStrippingInputStream;
import zeroecho.core.spec.VoidSpec;
import zeroecho.core.spi.ContextAware;
import zeroecho.core.tag.TagEngine;
import zeroecho.sdk.ZeroEchoSession;
import zeroecho.sdk.util.BouncyCastleActivator;
class ZeroEchoSessionWrapIntegrationTest {
private static final AtomicInteger CONTEXT_IDS = new AtomicInteger();
@BeforeAll
static void initializeProvider() {
BouncyCastleActivator.init();
}
@Test
void registeredStreamContexts() throws Exception {
System.out.println("registeredStreamContexts");
RecordingListener listener = new RecordingListener();
ZeroEchoSession session = wrappedSession(listener);
byte[] message = { 1, 2, 3, 4, 5 };
SecretKey aesKey = new SecretKeySpec(new byte[16], "AES");
CtxInterface aesContext = Ctx.INSTANCE.getContext("wrap-integration-" + CONTEXT_IDS.incrementAndGet());
byte[] ciphertext;
try (EncryptionContext encryption = session.createContext(
"AES", KeyUsage.ENCRYPT, aesKey, AesSpec.gcm128(null))) {
assertProxy(encryption);
((ContextAware) encryption).setContext(aesContext);
try (InputStream input = encryption.attach(new ByteArrayInputStream(message))) {
ciphertext = input.readAllBytes();
}
}
try (EncryptionContext decryption = session.createContext(
"AES", KeyUsage.DECRYPT, aesKey, AesSpec.gcm128(null))) {
assertProxy(decryption);
((ContextAware) decryption).setContext(aesContext);
try (InputStream input = decryption.attach(new ByteArrayInputStream(ciphertext))) {
assertArrayEquals(message, input.readAllBytes());
}
}
KeyPair signingKeys = session.keyBuilders().asymmetric()
.generateKeyPair("Ed25519", Ed25519KeyGenSpec.defaultSpec());
TaggedBody signature;
try (SignatureContext signer = session.createContext(
"Ed25519", KeyUsage.SIGN, signingKeys.getPrivate())) {
assertProxy(signer);
signature = produceTag(signer, message);
}
try (SignatureContext verifier = session.createContext(
"Ed25519", KeyUsage.VERIFY, signingKeys.getPublic())) {
assertProxy(verifier);
verifier.setExpectedTag(signature.tag());
try (InputStream input = verifier.wrap(new ByteArrayInputStream(message))) {
assertArrayEquals(message, input.readAllBytes());
}
}
SecretKey hmacKey = new SecretKeySpec(new byte[32], "HmacSHA256");
try (MacContext mac = session.createContext("HMAC", KeyUsage.MAC, hmacKey, HmacSpec.sha256())) {
assertProxy(mac);
assertArrayEquals(message, produceTag(mac, message).body());
}
try (DigestContext digest = session.createContext("DIGEST", KeyUsage.DIGEST, NullKey.INSTANCE)) {
assertProxy(digest);
assertArrayEquals(message, produceTag(digest, message).body());
}
assertEquals(List.of(
"create:ENCRYPT", "create:DECRYPT",
"create:SIGN", "tag", "create:VERIFY", "verify:true",
"create:MAC", "tag", "create:DIGEST", "tag"), listener.events);
System.out.println("...contexts=6...events=" + listener.events.size());
System.out.println("registeredStreamContexts...ok");
}
@Test
void registeredAgreementContexts() throws Exception {
System.out.println("registeredAgreementContexts");
RecordingListener listener = new RecordingListener();
ZeroEchoSession session = wrappedSession(listener);
KeyPair aliceKeys = session.keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobKeys = session.keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
try (AgreementContext alice = session.createContext(
"Xdh", KeyUsage.AGREEMENT, aliceKeys.getPrivate(), XdhSpec.X25519);
AgreementContext bob = session.createContext(
"Xdh", KeyUsage.AGREEMENT, bobKeys.getPrivate(), XdhSpec.X25519)) {
assertProxy(alice);
assertProxy(bob);
alice.setPeerPublic(bobKeys.getPublic());
bob.setPeerPublic(aliceKeys.getPublic());
assertArrayEquals(alice.deriveSecret(), bob.deriveSecret());
}
try (MessageAgreementContext alice = session.createContext(
"Xdh", KeyUsage.AGREEMENT, new KeyPairKey(aliceKeys), XdhSpec.X25519);
MessageAgreementContext bob = session.createContext(
"Xdh", KeyUsage.AGREEMENT, new KeyPairKey(bobKeys), XdhSpec.X25519)) {
assertProxy(alice);
assertProxy(bob);
byte[] aliceMessage = alice.getPeerMessage();
byte[] bobMessage = bob.getPeerMessage();
alice.setPeerMessage(bobMessage);
bob.setPeerMessage(aliceMessage);
assertArrayEquals(alice.deriveSecret(), bob.deriveSecret());
}
assertEquals(List.of(
"create:AGREEMENT", "create:AGREEMENT", "peer", "peer", "derived", "derived",
"create:AGREEMENT", "create:AGREEMENT", "message-get", "message-get",
"message-set", "message-set", "derived", "derived"), listener.events);
System.out.println("...contexts=4...events=" + listener.events.size());
System.out.println("registeredAgreementContexts...ok");
}
@Test
void registeredKemContexts() throws Exception {
System.out.println("registeredKemContexts");
RecordingListener listener = new RecordingListener();
ZeroEchoSession session = wrappedSession(listener);
KeyPair recipient = session.keyBuilders().asymmetric()
.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber512());
KemContext.KemResult encapsulated;
try (KemContext encapsulator = session.createContext(
"ML-KEM", KeyUsage.ENCAPSULATE, recipient.getPublic(), VoidSpec.INSTANCE);
KemContext decapsulator = session.createContext(
"ML-KEM", KeyUsage.DECAPSULATE, recipient.getPrivate(), VoidSpec.INSTANCE)) {
assertProxy(encapsulator);
assertProxy(decapsulator);
encapsulated = encapsulator.encapsulate();
assertArrayEquals(encapsulated.sharedSecret(), decapsulator.decapsulate(encapsulated.ciphertext()));
}
try (MessageAgreementContext initiator = session.createContext(
"ML-KEM", KeyUsage.AGREEMENT, recipient.getPublic(), VoidSpec.INSTANCE);
MessageAgreementContext responder = session.createContext(
"ML-KEM", KeyUsage.AGREEMENT, recipient.getPrivate(), VoidSpec.INSTANCE)) {
assertProxy(initiator);
assertProxy(responder);
responder.setPeerMessage(initiator.getPeerMessage());
assertArrayEquals(initiator.deriveSecret(), responder.deriveSecret());
}
assertEquals(List.of(
"create:ENCAPSULATE", "create:DECAPSULATE", "encapsulated", "decapsulated",
"create:AGREEMENT", "create:AGREEMENT", "message-get", "message-set", "derived", "derived"),
listener.events);
System.out.println("...contexts=4...events=" + listener.events.size());
System.out.println("registeredKemContexts...ok");
}
private static ZeroEchoSession wrappedSession(RecordingListener listener) {
return new ZeroEchoSession().withAuditListener(listener).withAuditMode(AuditMode.WRAP);
}
private static TaggedBody produceTag(TagEngine<?> engine, byte[] body) throws IOException {
byte[][] tag = new byte[1][];
int tagLength = engine.tagLength();
byte[] emittedBody;
try (InputStream input = new TailStrippingInputStream(
engine.wrap(new ByteArrayInputStream(body)), tagLength, 128) {
@Override
protected void processTail(byte[] tail) {
tag[0] = tail.clone();
}
}) {
emittedBody = input.readAllBytes();
}
return new TaggedBody(emittedBody, tag[0]);
}
private static void assertProxy(CryptoContext context) {
assertTrue(Proxy.isProxyClass(context.getClass()));
}
private record TaggedBody(byte[] body, byte[] tag) {}
private static final class RecordingListener implements AuditListener {
private final List<String> events = new ArrayList<>();
@Override
public void onContextCreatedMeta(String contextId, String algorithmId, String provider, KeyUsage role,
String keyFingerprint, Map<String, Object> specMeta) {
events.add("create:" + role);
}
@Override
public void onTagProduced(String contextId, int tagLength, String policy) {
events.add("tag");
}
@Override
public void onVerifyResult(String contextId, boolean success, String policy, String expectedSource,
int tagLength) {
events.add("verify:" + success);
}
@Override
public void onAgreementPeerSet(String contextId, String peerFingerprint) {
events.add("peer");
}
@Override
public void onAgreementDerived(String contextId, int secretLength) {
events.add("derived");
}
@Override
public void onAgreementPeerMessageSet(String contextId, int messageLength) {
events.add("message-set");
}
@Override
public void onAgreementPeerMessageGet(String contextId, int messageLength) {
events.add("message-get");
}
@Override
public void onKemEncapsulated(int ciphertextLength, int sharedSecretLength) {
events.add("encapsulated");
}
@Override
public void onKemDecapsulated(int sharedSecretLength) {
events.add("decapsulated");
}
}
}

View File

@@ -0,0 +1,233 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.core.alg.aes;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import javax.crypto.BadPaddingException;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.junit.jupiter.api.Test;
import conflux.Ctx;
import conflux.CtxInterface;
import zeroecho.core.ConfluxKeys;
import zeroecho.core.CryptoAlgorithms;
import zeroecho.core.KeyUsage;
import zeroecho.core.context.EncryptionContext;
import zeroecho.core.spi.ContextAware;
class AesDecryptionSecurityTest {
private static final SecretKey KEY = new SecretKeySpec(repeated((byte) 0x11, 16), "AES");
private static final SecretKey WRONG_KEY = new SecretKeySpec(repeated((byte) 0x22, 16), "AES");
private static final AtomicInteger CONTEXT_IDS = new AtomicInteger();
@Test
void gcmTamperMatrix() throws Exception {
System.out.println("gcmTamperMatrix");
byte[] plaintext = repeated((byte) 0x5a, 96);
Encrypted encrypted = encrypt(AesSpec.gcm128(null), plaintext);
FailureResult modifiedBody = failedDecryption(
encrypted.spec, KEY, encrypted.iv, changed(encrypted.ciphertext, 0));
FailureResult modifiedTag = failedDecryption(encrypted.spec, KEY, encrypted.iv,
changed(encrypted.ciphertext, encrypted.ciphertext.length - 1));
FailureResult truncatedBody = failedDecryption(encrypted.spec, KEY, encrypted.iv,
removed(encrypted.ciphertext, encrypted.ciphertext.length - 17));
FailureResult truncatedTag = failedDecryption(encrypted.spec, KEY, encrypted.iv,
Arrays.copyOf(encrypted.ciphertext, encrypted.ciphertext.length - 1));
FailureResult trailing = failedDecryption(encrypted.spec, KEY, encrypted.iv,
Arrays.copyOf(encrypted.ciphertext, encrypted.ciphertext.length + 1));
FailureResult wrongKey = failedDecryption(encrypted.spec, WRONG_KEY, encrypted.iv, encrypted.ciphertext);
FailureResult wrongIv = failedDecryption(
encrypted.spec, KEY, changed(encrypted.iv, 0), encrypted.ciphertext);
FailureResult empty = failedDecryption(encrypted.spec, KEY, encrypted.iv, new byte[0]);
for (FailureResult result : List.of(
modifiedBody, modifiedTag, truncatedBody, truncatedTag, trailing, wrongKey, wrongIv, empty)) {
assertEquals(0, result.outputBytes());
assertAuthenticationFailure(result.failure());
}
assertThrows(IOException.class, () -> decrypt(encrypted.spec, KEY,
Arrays.copyOf(encrypted.iv, encrypted.iv.length - 1), encrypted.ciphertext));
assertThrows(IOException.class,
() -> decrypt(encrypted.spec, KEY, null, encrypted.ciphertext));
System.out.println("...cases=10");
System.out.println("gcmTamperMatrix...ok");
}
@Test
void ctrTamperMatrix() throws Exception {
System.out.println("ctrTamperMatrix");
byte[] plaintext = repeated((byte) 0x33, 64);
Encrypted encrypted = encrypt(AesSpec.ctr(null), plaintext);
byte[] wrongIv = decrypt(encrypted.spec, KEY, changed(encrypted.iv, 0), encrypted.ciphertext);
byte[] wrongKey = decrypt(encrypted.spec, WRONG_KEY, encrypted.iv, encrypted.ciphertext);
byte[] modified = decrypt(encrypted.spec, KEY, encrypted.iv, changed(encrypted.ciphertext, 0));
byte[] truncated = decrypt(encrypted.spec, KEY, encrypted.iv,
Arrays.copyOf(encrypted.ciphertext, encrypted.ciphertext.length - 1));
byte[] trailing = decrypt(encrypted.spec, KEY, encrypted.iv,
Arrays.copyOf(encrypted.ciphertext, encrypted.ciphertext.length + 1));
assertFalse(Arrays.equals(plaintext, wrongIv));
assertFalse(Arrays.equals(plaintext, wrongKey));
assertFalse(Arrays.equals(plaintext, modified));
assertEquals(plaintext.length - 1, truncated.length);
assertEquals(plaintext.length + 1, trailing.length);
assertThrows(IOException.class, () -> decrypt(encrypted.spec, KEY,
Arrays.copyOf(encrypted.iv, encrypted.iv.length - 1), encrypted.ciphertext));
assertThrows(IOException.class, () -> decrypt(encrypted.spec, KEY, null, encrypted.ciphertext));
System.out.println("...cases=7");
System.out.println("ctrTamperMatrix...ok");
}
@Test
void cbcNoPaddingMatrix() throws Exception {
System.out.println("cbcNoPaddingMatrix");
AesSpec spec = AesSpec.builder().mode(AesSpec.Mode.CBC).padding(AesSpec.Padding.NOPADDING).build();
byte[] plaintext = repeated((byte) 0x44, 32);
Encrypted encrypted = encrypt(spec, plaintext);
assertFalse(Arrays.equals(plaintext,
decrypt(spec, KEY, changed(encrypted.iv, 0), encrypted.ciphertext)));
assertFalse(Arrays.equals(plaintext,
decrypt(spec, WRONG_KEY, encrypted.iv, encrypted.ciphertext)));
assertFalse(Arrays.equals(plaintext,
decrypt(spec, KEY, encrypted.iv, changed(encrypted.ciphertext, 0))));
assertThrows(IOException.class, () -> decrypt(spec, KEY, encrypted.iv,
Arrays.copyOf(encrypted.ciphertext, encrypted.ciphertext.length - 1)));
assertThrows(IOException.class, () -> decrypt(spec, KEY, encrypted.iv,
Arrays.copyOf(encrypted.ciphertext, encrypted.ciphertext.length + 1)));
assertThrows(IOException.class, () -> decrypt(spec, KEY,
Arrays.copyOf(encrypted.iv, encrypted.iv.length - 1), encrypted.ciphertext));
assertThrows(IOException.class, () -> decrypt(spec, KEY, null, encrypted.ciphertext));
System.out.println("...cases=7");
System.out.println("cbcNoPaddingMatrix...ok");
}
@Test
void cbcPkcsPaddingMatrix() throws Exception {
System.out.println("cbcPkcsPaddingMatrix");
AesSpec spec = AesSpec.cbcPkcs7(null);
Encrypted encrypted = encrypt(spec, repeated((byte) 0x66, 31));
assertThrows(IOException.class, () -> decrypt(spec, KEY, encrypted.iv,
Arrays.copyOf(encrypted.ciphertext, encrypted.ciphertext.length - 1)));
// CBC is malleable: flipping this bit changes the final 0x01 padding byte to
// 0x00 deterministically. The assertion covers padding rejection, not
// authentication.
assertThrows(IOException.class, () -> decrypt(spec, KEY, encrypted.iv,
changed(encrypted.ciphertext, encrypted.ciphertext.length - 17)));
assertThrows(IOException.class, () -> decrypt(spec, KEY,
Arrays.copyOf(encrypted.iv, encrypted.iv.length - 1), encrypted.ciphertext));
assertThrows(IOException.class, () -> decrypt(spec, KEY, null, encrypted.ciphertext));
System.out.println("...cases=4");
System.out.println("cbcPkcsPaddingMatrix...ok");
}
private static Encrypted encrypt(AesSpec spec, byte[] plaintext) throws Exception {
CtxInterface context = newContext("aes-security-enc-");
EncryptionContext encryption = new zeroecho.sdk.ZeroEchoSession().createContext("AES", KeyUsage.ENCRYPT, KEY, spec);
((ContextAware) encryption).setContext(context);
byte[] ciphertext;
try (InputStream stream = encryption.attach(new ByteArrayInputStream(plaintext))) {
ciphertext = stream.readAllBytes();
} finally {
encryption.close();
}
return new Encrypted(spec, context.get(ConfluxKeys.iv("AES")).clone(), ciphertext);
}
private static byte[] decrypt(AesSpec spec, SecretKey key, byte[] iv, byte[] ciphertext) throws Exception {
CtxInterface context = newContext("aes-security-dec-");
if (iv != null) {
context.put(ConfluxKeys.iv("AES"), iv);
}
EncryptionContext decryption = new zeroecho.sdk.ZeroEchoSession().createContext("AES", KeyUsage.DECRYPT, key, spec);
((ContextAware) decryption).setContext(context);
try (InputStream stream = decryption.attach(new ByteArrayInputStream(ciphertext))) {
return stream.readAllBytes();
} finally {
decryption.close();
}
}
private static FailureResult failedDecryption(AesSpec spec, SecretKey key, byte[] iv, byte[] ciphertext)
throws Exception {
CtxInterface context = newContext("aes-security-fail-");
context.put(ConfluxKeys.iv("AES"), iv);
EncryptionContext decryption = new zeroecho.sdk.ZeroEchoSession().createContext("AES", KeyUsage.DECRYPT, key, spec);
((ContextAware) decryption).setContext(context);
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (InputStream stream = decryption.attach(new ByteArrayInputStream(ciphertext))) {
byte[] buffer = new byte[17];
IOException failure = assertThrows(IOException.class, () -> {
int count;
while ((count = stream.read(buffer)) >= 0) {
if (count > 0) {
output.write(buffer, 0, count);
}
}
});
return new FailureResult(output.size(), failure);
} finally {
decryption.close();
}
}
private static void assertAuthenticationFailure(IOException failure) {
assertTrue(hasCause(failure, BadPaddingException.class),
"GCM failure must retain an authentication-related provider cause");
}
private static boolean hasCause(Throwable failure, Class<? extends Throwable> expectedType) {
for (Throwable cause = failure; cause != null; cause = cause.getCause()) {
if (expectedType.isInstance(cause)) {
return true;
}
}
return false;
}
private static CtxInterface newContext(String prefix) {
return Ctx.INSTANCE.getContext(prefix + CONTEXT_IDS.incrementAndGet());
}
private static byte[] changed(byte[] input, int index) {
byte[] result = input.clone();
result[index] ^= 0x01;
return result;
}
private static byte[] removed(byte[] input, int index) {
byte[] result = new byte[input.length - 1];
System.arraycopy(input, 0, result, 0, index);
System.arraycopy(input, index + 1, result, index, input.length - index - 1);
return result;
}
private static byte[] repeated(byte value, int length) {
byte[] result = new byte[length];
Arrays.fill(result, value);
return result;
}
private record Encrypted(AesSpec spec, byte[] iv, byte[] ciphertext) {}
private record FailureResult(int outputBytes, IOException failure) {}
}

View File

@@ -133,7 +133,7 @@ public class AesGcmCrossCheckTest {
// --- key (either via your builder or direct JCA; both fine) ---
CryptoAlgorithm aesAlg = CryptoAlgorithms.require("AES");
SecretKey key = aesAlg.symmetricKeyBuilder(AesKeyGenSpec.class).generateSecret(AesKeyGenSpec.aes256());
SecretKey key = aesAlg.symmetricKeyGenerator(AesKeyGenSpec.class).generateSecret(AesKeyGenSpec.aes256());
// or:
// KeyGenerator kg = KeyGenerator.getInstance("AES");
// kg.init(256);
@@ -148,7 +148,7 @@ public class AesGcmCrossCheckTest {
AesSpec spec = AesSpec.gcm128(null);
// === STREAM ENCRYPT ===
EncryptionContext enc = CryptoAlgorithms.create("AES", KeyUsage.ENCRYPT, key, spec);
EncryptionContext enc = new zeroecho.sdk.ZeroEchoSession().createContext("AES", KeyUsage.ENCRYPT, key, spec);
((ContextAware) enc).setContext(session);
byte[] ct_stream = readAll(enc.attach(new ByteArrayInputStream(msg)));
enc.close();
@@ -160,7 +160,7 @@ public class AesGcmCrossCheckTest {
assertArrayEquals(ct_jca, ct_stream, "STREAM ciphertext != JCA ciphertext (IV/AAD/msg must match)");
// === STREAM DECRYPT of JCA ciphertext ===
EncryptionContext dec1 = CryptoAlgorithms.create("AES", KeyUsage.DECRYPT, key, spec);
EncryptionContext dec1 = new zeroecho.sdk.ZeroEchoSession().createContext("AES", KeyUsage.DECRYPT, key, spec);
((ContextAware) dec1).setContext(session); // same IV/AAD in ctx
byte[] pt1 = readAll(dec1.attach(new ByteArrayInputStream(ct_jca)));
dec1.close();

View File

@@ -109,18 +109,18 @@ public class AesLargeDataTest {
byte[] msg = randomBytes(SIZE);
CryptoAlgorithm aes = CryptoAlgorithms.require("AES");
SecretKey key = aes.symmetricKeyBuilder(AesKeyGenSpec.class).generateSecret(AesKeyGenSpec.aes256());
SecretKey key = aes.symmetricKeyGenerator(AesKeyGenSpec.class).generateSecret(AesKeyGenSpec.aes256());
CtxInterface session = Ctx.INSTANCE.getContext("aes-ctx-" + System.nanoTime());
AesSpec spec = AesSpec.gcm128(null);
EncryptionContext enc = CryptoAlgorithms.create("AES", KeyUsage.ENCRYPT, key, spec);
EncryptionContext enc = new zeroecho.sdk.ZeroEchoSession().createContext("AES", KeyUsage.ENCRYPT, key, spec);
((ContextAware) enc).setContext(session);
byte[] ct = readAll(enc.attach(new ByteArrayInputStream(msg)));
enc.close();
EncryptionContext dec = CryptoAlgorithms.create("AES", KeyUsage.DECRYPT, key, spec);
EncryptionContext dec = new zeroecho.sdk.ZeroEchoSession().createContext("AES", KeyUsage.DECRYPT, key, spec);
((ContextAware) dec).setContext(session);
byte[] pt = readAll(dec.attach(new ByteArrayInputStream(ct)));
dec.close();
@@ -139,18 +139,18 @@ public class AesLargeDataTest {
System.out.printf("...input: %d bytes%n", msg.length);
CryptoAlgorithm aes = CryptoAlgorithms.require("AES");
SecretKey key = aes.symmetricKeyBuilder(AesKeyGenSpec.class).generateSecret(AesKeyGenSpec.aes256());
SecretKey key = aes.symmetricKeyGenerator(AesKeyGenSpec.class).generateSecret(AesKeyGenSpec.aes256());
CtxInterface session = Ctx.INSTANCE.getContext("aes-hdr-" + System.nanoTime());
AesSpec spec = AesSpec.gcm128(new AesHeaderCodec());
EncryptionContext enc = CryptoAlgorithms.create("AES", KeyUsage.ENCRYPT, key, spec);
EncryptionContext enc = new zeroecho.sdk.ZeroEchoSession().createContext("AES", KeyUsage.ENCRYPT, key, spec);
((ContextAware) enc).setContext(session);
byte[] ct = readAll(enc.attach(new ByteArrayInputStream(msg)));
enc.close();
System.out.printf("...encrypted: %d bytes%n", ct.length);
EncryptionContext dec = CryptoAlgorithms.create("AES", KeyUsage.DECRYPT, key, spec);
EncryptionContext dec = new zeroecho.sdk.ZeroEchoSession().createContext("AES", KeyUsage.DECRYPT, key, spec);
((ContextAware) dec).setContext(session);
byte[] pt = readAll(dec.attach(new ByteArrayInputStream(ct)));
dec.close();
@@ -169,18 +169,18 @@ public class AesLargeDataTest {
byte[] msg = randomBytes(SIZE);
CryptoAlgorithm aes = CryptoAlgorithms.require("AES");
SecretKey key = aes.symmetricKeyBuilder(AesKeyGenSpec.class).generateSecret(AesKeyGenSpec.aes256());
SecretKey key = aes.symmetricKeyGenerator(AesKeyGenSpec.class).generateSecret(AesKeyGenSpec.aes256());
CtxInterface session = Ctx.INSTANCE.getContext("aes-cbc-" + System.nanoTime());
AesSpec spec = AesSpec.cbcPkcs7(null);
EncryptionContext enc = CryptoAlgorithms.create("AES", KeyUsage.ENCRYPT, key, spec);
EncryptionContext enc = new zeroecho.sdk.ZeroEchoSession().createContext("AES", KeyUsage.ENCRYPT, key, spec);
((ContextAware) enc).setContext(session);
byte[] ct = readAll(enc.attach(new ByteArrayInputStream(msg)));
enc.close();
EncryptionContext dec = CryptoAlgorithms.create("AES", KeyUsage.DECRYPT, key, spec);
EncryptionContext dec = new zeroecho.sdk.ZeroEchoSession().createContext("AES", KeyUsage.DECRYPT, key, spec);
((ContextAware) dec).setContext(session);
byte[] pt = readAll(dec.attach(new ByteArrayInputStream(ct)));
dec.close();

View File

@@ -0,0 +1,149 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.core.alg.aes;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import java.io.ByteArrayInputStream;
import java.lang.reflect.Field;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.junit.jupiter.api.Test;
import conflux.Ctx;
import conflux.CtxInterface;
import zeroecho.core.ConfluxKeys;
import zeroecho.core.KeyUsage;
import zeroecho.core.context.EncryptionContext;
import zeroecho.core.spec.VoidSpec;
import zeroecho.core.spi.ContextAware;
import zeroecho.sdk.util.RandomSupport;
class AesRandomSupportTest {
private static final SecretKey KEY = new SecretKeySpec(new byte[16], "AES");
@Test
void allFactoriesUseSharedRandomSource() throws Exception {
System.out.print("AesRandomSupport/allFactoriesUseSharedRandomSource...");
AesAlgorithm algorithm = new AesAlgorithm();
assertSharedRandom(algorithm.createContext(KeyUsage.ENCRYPT, KEY, AesSpec.gcm128(null)));
assertSharedRandom(algorithm.createContext(KeyUsage.DECRYPT, KEY, AesSpec.gcm128(null)));
assertSharedRandom(algorithm.createContext(KeyUsage.ENCRYPT, KEY, VoidSpec.INSTANCE));
assertSharedRandom(algorithm.createContext(KeyUsage.DECRYPT, KEY, VoidSpec.INSTANCE));
System.out.println("ok");
}
@Test
void nullFallbackUsesSharedRandomSource() throws Exception {
System.out.print("AesRandomSupport/nullFallbackUsesSharedRandomSource...");
AesCipherContext context = new AesCipherContext(new AesAlgorithm(), KEY, true, AesSpec.gcm128(null), null);
assertSharedRandom(context);
System.out.println("ok");
}
@Test
void keyGenerationUsesEstablishedRandomSourcePath() throws Exception {
System.out.print("AesRandomSupport/keyGenerationUsesEstablishedRandomSourcePath...");
AesAlgorithm algorithm = new AesAlgorithm();
SecretKey generated = algorithm.symmetricKeyGenerator(AesKeyGenSpec.class)
.generateSecret(AesKeyGenSpec.aes128());
assertNotNull(generated);
assertEquals("AES", generated.getAlgorithm());
assertEquals(16, generated.getEncoded().length);
System.out.println("...keyBytes=" + generated.getEncoded().length);
System.out.println("ok");
}
@Test
void controlledRandomProducesFreshIvForEachDirectContext() throws Exception {
System.out.print("AesRandomSupport/controlledRandomProducesFreshIvForEachDirectContext...");
CountingSecureRandom random = new CountingSecureRandom();
AesAlgorithm algorithm = new AesAlgorithm();
CtxInterface firstSession = Ctx.INSTANCE.getContext("aes-controlled-first");
CtxInterface secondSession = Ctx.INSTANCE.getContext("aes-controlled-second");
attachEmpty(new AesCipherContext(algorithm, KEY, true, AesSpec.gcm128(null), random), firstSession);
attachEmpty(new AesCipherContext(algorithm, KEY, true, AesSpec.gcm128(null), random), secondSession);
byte[] firstIv = firstSession.get(ConfluxKeys.iv("AES"));
byte[] secondIv = secondSession.get(ConfluxKeys.iv("AES"));
assertArrayEquals(repeated((byte) 1, 12), firstIv);
assertArrayEquals(repeated((byte) 2, 12), secondIv);
assertNotSame(firstIv, secondIv);
System.out.println("...randomCalls=" + random.calls);
System.out.println("ok");
}
@Test
void concurrentFactoryConstructionUsesEstablishedSharedSource() throws Exception {
System.out.print("AesRandomSupport/concurrentFactoryConstructionUsesEstablishedSharedSource...");
AesAlgorithm algorithm = new AesAlgorithm();
ExecutorService executor = Executors.newFixedThreadPool(8);
try {
List<Callable<SecureRandom>> tasks = new ArrayList<>();
for (int index = 0; index < 64; index++) {
tasks.add(() -> randomOf(algorithm.createContext(KeyUsage.ENCRYPT, KEY, AesSpec.gcm128(null))));
}
for (java.util.concurrent.Future<SecureRandom> result : executor.invokeAll(tasks)) {
assertSame(RandomSupport.getRandom(), result.get());
}
} finally {
executor.close();
}
System.out.println("ok");
}
private static void assertSharedRandom(EncryptionContext context) throws Exception {
assertSame(RandomSupport.getRandom(), randomOf(context));
}
private static SecureRandom randomOf(Object context) throws Exception {
Field field = AesCipherContext.class.getDeclaredField("rnd");
field.setAccessible(true);
return (SecureRandom) field.get(context);
}
private static void attachEmpty(AesCipherContext context, CtxInterface session) throws Exception {
((ContextAware) context).setContext(session);
try (java.io.InputStream stream = context.attach(new ByteArrayInputStream(new byte[0]))) {
stream.readAllBytes();
}
}
private static byte[] repeated(byte value, int length) {
byte[] result = new byte[length];
java.util.Arrays.fill(result, value);
return result;
}
private static final class CountingSecureRandom extends SecureRandom {
private static final long serialVersionUID = 1L;
private int calls;
@Override
public void nextBytes(byte[] bytes) {
calls++;
java.util.Arrays.fill(bytes, (byte) calls);
}
}
}

View File

@@ -116,17 +116,17 @@ public class ChaChaLargeDataTest {
byte[] msg = randomBytes(SIZE);
CryptoAlgorithm chacha = CryptoAlgorithms.require("CHACHA20");
SecretKey key = chacha.symmetricKeyBuilder(ChaChaKeyGenSpec.class).generateSecret(ChaChaKeyGenSpec.chacha256());
SecretKey key = chacha.symmetricKeyGenerator(ChaChaKeyGenSpec.class).generateSecret(ChaChaKeyGenSpec.chacha256());
CtxInterface session = Ctx.INSTANCE.getContext("chacha-ctx-" + System.nanoTime());
ChaChaSpec spec = ChaChaSpec.builder().initialCounter(1).header(null).build();
EncryptionContext enc = CryptoAlgorithms.create("CHACHA20", KeyUsage.ENCRYPT, key, spec);
EncryptionContext enc = new zeroecho.sdk.ZeroEchoSession().createContext("CHACHA20", KeyUsage.ENCRYPT, key, spec);
((ContextAware) enc).setContext(session);
byte[] ct = readAll(enc.attach(new ByteArrayInputStream(msg)));
enc.close();
EncryptionContext dec = CryptoAlgorithms.create("CHACHA20", KeyUsage.DECRYPT, key, spec);
EncryptionContext dec = new zeroecho.sdk.ZeroEchoSession().createContext("CHACHA20", KeyUsage.DECRYPT, key, spec);
((ContextAware) dec).setContext(session);
byte[] pt = readAll(dec.attach(new ByteArrayInputStream(ct)));
dec.close();
@@ -143,17 +143,17 @@ public class ChaChaLargeDataTest {
byte[] msg = randomBytes(SIZE);
CryptoAlgorithm chacha = CryptoAlgorithms.require("CHACHA20");
SecretKey key = chacha.symmetricKeyBuilder(ChaChaKeyGenSpec.class).generateSecret(ChaChaKeyGenSpec.chacha256());
SecretKey key = chacha.symmetricKeyGenerator(ChaChaKeyGenSpec.class).generateSecret(ChaChaKeyGenSpec.chacha256());
CtxInterface session = Ctx.INSTANCE.getContext("chacha-hdr-" + System.nanoTime());
ChaChaSpec spec = ChaChaSpec.builder().initialCounter(1).header(new ChaChaHeaderCodec()).build();
EncryptionContext enc = CryptoAlgorithms.create("CHACHA20", KeyUsage.ENCRYPT, key, spec);
EncryptionContext enc = new zeroecho.sdk.ZeroEchoSession().createContext("CHACHA20", KeyUsage.ENCRYPT, key, spec);
((ContextAware) enc).setContext(session);
byte[] ct = readAll(enc.attach(new ByteArrayInputStream(msg)));
enc.close();
EncryptionContext dec = CryptoAlgorithms.create("CHACHA20", KeyUsage.DECRYPT, key, spec);
EncryptionContext dec = new zeroecho.sdk.ZeroEchoSession().createContext("CHACHA20", KeyUsage.DECRYPT, key, spec);
((ContextAware) dec).setContext(session);
byte[] pt = readAll(dec.attach(new ByteArrayInputStream(ct)));
dec.close();
@@ -174,14 +174,14 @@ public class ChaChaLargeDataTest {
byte[] msg = randomBytes(SIZE);
CryptoAlgorithm chacha = CryptoAlgorithms.require("CHACHA20");
SecretKey key = chacha.symmetricKeyBuilder(ChaChaKeyGenSpec.class).generateSecret(ChaChaKeyGenSpec.chacha256());
SecretKey key = chacha.symmetricKeyGenerator(ChaChaKeyGenSpec.class).generateSecret(ChaChaKeyGenSpec.chacha256());
// Encrypt with explicit counter=7 (in ctx), headerless (ctx-only).
CtxInterface encCtx = Ctx.INSTANCE.getContext("chacha-ctr-enc-" + System.nanoTime());
encCtx.put(ConfluxKeys.tagBits("CHACHA20"), 7);
ChaChaSpec spec = ChaChaSpec.builder().initialCounter(1).header(null).build();
EncryptionContext enc = CryptoAlgorithms.create("CHACHA20", KeyUsage.ENCRYPT, key, spec);
EncryptionContext enc = new zeroecho.sdk.ZeroEchoSession().createContext("CHACHA20", KeyUsage.ENCRYPT, key, spec);
((ContextAware) enc).setContext(encCtx);
byte[] ct = readAll(enc.attach(new ByteArrayInputStream(msg)));
enc.close();
@@ -194,7 +194,7 @@ public class ChaChaLargeDataTest {
decCtxOk.put(ConfluxKeys.iv("CHACHA20"), nonce);
decCtxOk.put(ConfluxKeys.tagBits("CHACHA20"), 7);
EncryptionContext decOk = CryptoAlgorithms.create("CHACHA20", KeyUsage.DECRYPT, key, spec);
EncryptionContext decOk = new zeroecho.sdk.ZeroEchoSession().createContext("CHACHA20", KeyUsage.DECRYPT, key, spec);
((ContextAware) decOk).setContext(decCtxOk);
byte[] ptOk = readAll(decOk.attach(new ByteArrayInputStream(ct)));
decOk.close();
@@ -205,7 +205,7 @@ public class ChaChaLargeDataTest {
decCtxBad.put(ConfluxKeys.iv("CHACHA20"), nonce);
decCtxBad.put(ConfluxKeys.tagBits("CHACHA20"), 8);
EncryptionContext decBad = CryptoAlgorithms.create("CHACHA20", KeyUsage.DECRYPT, key, spec);
EncryptionContext decBad = new zeroecho.sdk.ZeroEchoSession().createContext("CHACHA20", KeyUsage.DECRYPT, key, spec);
((ContextAware) decBad).setContext(decCtxBad);
byte[] ptBad = readAll(decBad.attach(new ByteArrayInputStream(ct)));
decBad.close();
@@ -228,19 +228,19 @@ public class ChaChaLargeDataTest {
byte[] aad = "associated-data-ctx-only".getBytes();
CryptoAlgorithm aead = CryptoAlgorithms.require("CHACHA20-POLY1305");
SecretKey key = aead.symmetricKeyBuilder(ChaChaKeyGenSpec.class).generateSecret(ChaChaKeyGenSpec.chacha256());
SecretKey key = aead.symmetricKeyGenerator(ChaChaKeyGenSpec.class).generateSecret(ChaChaKeyGenSpec.chacha256());
CtxInterface session = Ctx.INSTANCE.getContext("chacha-aead-ctx-" + System.nanoTime());
session.put(ConfluxKeys.aad("CHACHA20-POLY1305"), aad);
ChaCha20Poly1305Spec spec = ChaCha20Poly1305Spec.builder().header(null).build();
EncryptionContext enc = CryptoAlgorithms.create("CHACHA20-POLY1305", KeyUsage.ENCRYPT, key, spec);
EncryptionContext enc = new zeroecho.sdk.ZeroEchoSession().createContext("CHACHA20-POLY1305", KeyUsage.ENCRYPT, key, spec);
((ContextAware) enc).setContext(session);
byte[] ct = readAll(enc.attach(new ByteArrayInputStream(msg)));
enc.close();
EncryptionContext dec = CryptoAlgorithms.create("CHACHA20-POLY1305", KeyUsage.DECRYPT, key, spec);
EncryptionContext dec = new zeroecho.sdk.ZeroEchoSession().createContext("CHACHA20-POLY1305", KeyUsage.DECRYPT, key, spec);
((ContextAware) dec).setContext(session);
byte[] pt = readAll(dec.attach(new ByteArrayInputStream(ct)));
dec.close();
@@ -259,19 +259,19 @@ public class ChaChaLargeDataTest {
byte[] aad = "associated-data-header".getBytes();
CryptoAlgorithm aead = CryptoAlgorithms.require("CHACHA20-POLY1305");
SecretKey key = aead.symmetricKeyBuilder(ChaChaKeyGenSpec.class).generateSecret(ChaChaKeyGenSpec.chacha256());
SecretKey key = aead.symmetricKeyGenerator(ChaChaKeyGenSpec.class).generateSecret(ChaChaKeyGenSpec.chacha256());
CtxInterface session = Ctx.INSTANCE.getContext("chacha-aead-hdr-" + System.nanoTime());
session.put(ConfluxKeys.aad("CHACHA20-POLY1305"), aad);
ChaCha20Poly1305Spec spec = ChaCha20Poly1305Spec.builder().header(new ChaCha20Poly1305HeaderCodec()).build();
EncryptionContext enc = CryptoAlgorithms.create("CHACHA20-POLY1305", KeyUsage.ENCRYPT, key, spec);
EncryptionContext enc = new zeroecho.sdk.ZeroEchoSession().createContext("CHACHA20-POLY1305", KeyUsage.ENCRYPT, key, spec);
((ContextAware) enc).setContext(session);
byte[] ct = readAll(enc.attach(new ByteArrayInputStream(msg)));
enc.close();
EncryptionContext dec = CryptoAlgorithms.create("CHACHA20-POLY1305", KeyUsage.DECRYPT, key, spec);
EncryptionContext dec = new zeroecho.sdk.ZeroEchoSession().createContext("CHACHA20-POLY1305", KeyUsage.DECRYPT, key, spec);
((ContextAware) dec).setContext(session);
byte[] pt = readAll(dec.attach(new ByteArrayInputStream(ct)));
dec.close();
@@ -294,7 +294,7 @@ public class ChaChaLargeDataTest {
byte[] aadDec = "aad-dec-different".getBytes();
CryptoAlgorithm aead = CryptoAlgorithms.require("CHACHA20-POLY1305");
SecretKey key = aead.symmetricKeyBuilder(ChaChaKeyGenSpec.class).generateSecret(ChaChaKeyGenSpec.chacha256());
SecretKey key = aead.symmetricKeyGenerator(ChaChaKeyGenSpec.class).generateSecret(ChaChaKeyGenSpec.chacha256());
// Encrypt with AAD = aadEnc (ctx-only, no header)
CtxInterface encCtx = Ctx.INSTANCE.getContext("chacha-aead-enc-" + System.nanoTime());
@@ -302,7 +302,7 @@ public class ChaChaLargeDataTest {
ChaCha20Poly1305Spec spec = ChaCha20Poly1305Spec.builder().header(null).build();
EncryptionContext enc = CryptoAlgorithms.create("CHACHA20-POLY1305", KeyUsage.ENCRYPT, key, spec);
EncryptionContext enc = new zeroecho.sdk.ZeroEchoSession().createContext("CHACHA20-POLY1305", KeyUsage.ENCRYPT, key, spec);
((ContextAware) enc).setContext(encCtx);
byte[] ct = readAll(enc.attach(new ByteArrayInputStream(msg)));
enc.close();
@@ -314,7 +314,7 @@ public class ChaChaLargeDataTest {
byte[] nonce = encCtx.get(ConfluxKeys.iv("CHACHA20-POLY1305"));
decCtx.put(ConfluxKeys.iv("CHACHA20-POLY1305"), nonce);
EncryptionContext dec = CryptoAlgorithms.create("CHACHA20-POLY1305", KeyUsage.DECRYPT, key, spec);
EncryptionContext dec = new zeroecho.sdk.ZeroEchoSession().createContext("CHACHA20-POLY1305", KeyUsage.DECRYPT, key, spec);
((ContextAware) dec).setContext(decCtx);
assertThrows(IOException.class, () -> {

View File

@@ -62,7 +62,9 @@ import zeroecho.core.context.MessageAgreementContext;
import zeroecho.core.spec.AlgorithmKeySpec;
import zeroecho.core.spec.ContextSpec;
import zeroecho.core.spec.VoidSpec;
import zeroecho.core.spi.AsymmetricKeyBuilder;
import zeroecho.core.KeyOperation;
import zeroecho.core.KeyOperationInfo;
import zeroecho.core.spi.AsymmetricKeyPairGenerator;
import zeroecho.sdk.util.BouncyCastleActivator;
public class AgreementAlgorithmsRoundTripTest {
@@ -174,7 +176,7 @@ public class AgreementAlgorithmsRoundTripTest {
ContextSpec spec = null;
try {
spec = cap.defaultSpec().get();
spec = cap.defaultSpec();
} catch (Throwable ignore) {
spec = tryExtractContextSpec(alg);
}
@@ -204,8 +206,8 @@ public class AgreementAlgorithmsRoundTripTest {
MessageAgreementContext bCtx = null;
try {
aCtx = CryptoAlgorithms.create(alg.id(), KeyUsage.AGREEMENT, aliceKey, spec);
bCtx = CryptoAlgorithms.create(alg.id(), KeyUsage.AGREEMENT, bobKey, spec);
aCtx = new zeroecho.sdk.ZeroEchoSession().createContext(alg.id(), KeyUsage.AGREEMENT, aliceKey, spec);
bCtx = new zeroecho.sdk.ZeroEchoSession().createContext(alg.id(), KeyUsage.AGREEMENT, bobKey, spec);
byte[] aMsg = aCtx.getPeerMessage();
byte[] bMsg = bCtx.getPeerMessage();
@@ -272,11 +274,11 @@ public class AgreementAlgorithmsRoundTripTest {
try {
// Alice (initiator): has Bob's public key
aliceCtx = CryptoAlgorithms.create(alg.id(), KeyUsage.AGREEMENT, bob.getPublic(),
aliceCtx = new zeroecho.sdk.ZeroEchoSession().createContext(alg.id(), KeyUsage.AGREEMENT, bob.getPublic(),
VoidSpec.INSTANCE);
// Bob (responder): has his private key
bobCtx = CryptoAlgorithms.create(alg.id(), KeyUsage.AGREEMENT, bob.getPrivate(),
bobCtx = new zeroecho.sdk.ZeroEchoSession().createContext(alg.id(), KeyUsage.AGREEMENT, bob.getPrivate(),
VoidSpec.INSTANCE);
// Initiator produces encapsulation message (ciphertext) to send
@@ -324,7 +326,7 @@ public class AgreementAlgorithmsRoundTripTest {
ContextSpec spec = null;
try {
spec = cap.defaultSpec().get();
spec = cap.defaultSpec();
} catch (Throwable ignore) {
spec = tryExtractContextSpec(alg);
}
@@ -360,8 +362,8 @@ public class AgreementAlgorithmsRoundTripTest {
AgreementContext bCtx = null;
try {
aCtx = CryptoAlgorithms.create(alg.id(), KeyUsage.AGREEMENT, alice.getPrivate(), spec);
bCtx = CryptoAlgorithms.create(alg.id(), KeyUsage.AGREEMENT, bob.getPrivate(), spec);
aCtx = new zeroecho.sdk.ZeroEchoSession().createContext(alg.id(), KeyUsage.AGREEMENT, alice.getPrivate(), spec);
bCtx = new zeroecho.sdk.ZeroEchoSession().createContext(alg.id(), KeyUsage.AGREEMENT, bob.getPrivate(), spec);
aCtx.setPeerPublic(bob.getPublic());
bCtx.setPeerPublic(alice.getPublic());
@@ -402,14 +404,14 @@ public class AgreementAlgorithmsRoundTripTest {
// ----- helpers -----
private static KeyPair generateKeyPair(CryptoAlgorithm alg) {
try {
for (CryptoAlgorithm.AsymBuilderInfo bi : alg.asymmetricBuildersInfo()) {
if (bi.defaultKeySpec == null) {
for (KeyOperationInfo bi : alg.keyOperations()) {
if (bi.operation() != KeyOperation.ASYMMETRIC_KEY_PAIR_GENERATE || bi.defaultSpec() == null) {
continue;
}
@SuppressWarnings("unchecked")
Class<AlgorithmKeySpec> specType = (Class<AlgorithmKeySpec>) bi.specType;
AsymmetricKeyBuilder<AlgorithmKeySpec> builder = alg.asymmetricKeyBuilder(specType);
AlgorithmKeySpec spec = (AlgorithmKeySpec) bi.defaultKeySpec;
Class<AlgorithmKeySpec> specType = (Class<AlgorithmKeySpec>) bi.specType();
AsymmetricKeyPairGenerator<AlgorithmKeySpec> builder = alg.asymmetricKeyPairGenerator(specType);
AlgorithmKeySpec spec = bi.defaultSpec();
return builder.generateKeyPair(spec);
}
} catch (Throwable ignore) {
@@ -423,7 +425,7 @@ public class AgreementAlgorithmsRoundTripTest {
for (Capability c : alg.listCapabilities()) {
if (c.role() == KeyUsage.AGREEMENT && ContextSpec.class.isAssignableFrom(c.specType())) {
try {
return c.defaultSpec().get();
return c.defaultSpec();
} catch (Throwable ignore) {
// continue searching
}

View File

@@ -132,10 +132,10 @@ public class EcdsaLargeDataTest {
System.out.println("...msg=" + msg.length + " bytes");
// Key pair via your unified ECDSA algorithm and enum spec
KeyPair kp = CryptoAlgorithms.keyPair("ECDSA", spec);
KeyPair kp = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ECDSA", spec);
// SIGN (streaming): emits [body][signature]; capture trailer
SignatureContext signer = CryptoAlgorithms.create("ECDSA", KeyUsage.SIGN, kp.getPrivate(), spec);
SignatureContext signer = new zeroecho.sdk.ZeroEchoSession().createContext("ECDSA", KeyUsage.SIGN, kp.getPrivate(), spec);
final byte[][] sigHolder = new byte[1][];
final int sigLen = signer.tagLength(); // should equal spec.signFixedLength()
@@ -160,7 +160,7 @@ public class EcdsaLargeDataTest {
System.out.println("...signature size: " + ourSig.length + " (expected " + spec.signFixedLength() + ")");
// VERIFY with our streaming verifier
SignatureContext verifier = CryptoAlgorithms.create("ECDSA", KeyUsage.VERIFY, kp.getPublic(), spec);
SignatureContext verifier = new zeroecho.sdk.ZeroEchoSession().createContext("ECDSA", KeyUsage.VERIFY, kp.getPublic(), spec);
verifier.setExpectedTag(ourSig);
byte[] sink2;
try (InputStream verIn = verifier.wrap(new ByteArrayInputStream(msg))) {
@@ -181,7 +181,7 @@ public class EcdsaLargeDataTest {
// Extra symmetry check (optional): our verifier must accept a JCA signature
// (different bytes)
byte[] jcaSig = jcaEcdsaSign(spec.jcaFactory(), kp.getPrivate(), msg);
SignatureContext verifier2 = CryptoAlgorithms.create("ECDSA", KeyUsage.VERIFY, kp.getPublic(), spec);
SignatureContext verifier2 = new zeroecho.sdk.ZeroEchoSession().createContext("ECDSA", KeyUsage.VERIFY, kp.getPublic(), spec);
verifier2.setExpectedTag(jcaSig);
try (InputStream verIn2 = verifier2.wrap(new ByteArrayInputStream(msg))) {
byte[] passthrough = readAll(verIn2);

View File

@@ -132,11 +132,11 @@ public class Ed25519LargeDataTest {
return;
}
KeyPair kp = CryptoAlgorithms.keyPair("Ed25519", Ed25519KeyGenSpec.defaultSpec());
KeyPair kp = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("Ed25519", Ed25519KeyGenSpec.defaultSpec());
// SIGN: context emits [body][signature] — capture trailer via
// TailStrippingInputStream
SignatureContext signer = CryptoAlgorithms.create("Ed25519", KeyUsage.SIGN, kp.getPrivate(), null);
SignatureContext signer = new zeroecho.sdk.ZeroEchoSession().createContext("Ed25519", KeyUsage.SIGN, kp.getPrivate(), null);
final byte[][] sigHolder = new byte[1][];
final int sigLen = signer.tagLength();
@@ -166,7 +166,7 @@ public class Ed25519LargeDataTest {
assertArrayEquals(refSig, ourSig, "signature mismatch vs JCA reference");
// VERIFY: supply expected tag and drain (throws on mismatch)
SignatureContext verifier = CryptoAlgorithms.create("Ed25519", KeyUsage.VERIFY, kp.getPublic(), null);
SignatureContext verifier = new zeroecho.sdk.ZeroEchoSession().createContext("Ed25519", KeyUsage.VERIFY, kp.getPublic(), null);
verifier.setExpectedTag(ourSig);
byte[] sink2;

View File

@@ -132,11 +132,11 @@ public class Ed448LargeDataTest {
return;
}
KeyPair kp = CryptoAlgorithms.keyPair("Ed448", Ed448KeyGenSpec.defaultSpec());
KeyPair kp = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("Ed448", Ed448KeyGenSpec.defaultSpec());
// SIGN: context emits [body][signature] — capture trailer via
// TailStrippingInputStream
SignatureContext signer = CryptoAlgorithms.create("Ed448", KeyUsage.SIGN, kp.getPrivate(), null);
SignatureContext signer = new zeroecho.sdk.ZeroEchoSession().createContext("Ed448", KeyUsage.SIGN, kp.getPrivate(), null);
final byte[][] sigHolder = new byte[1][];
final int sigLen = signer.tagLength();
@@ -166,7 +166,7 @@ public class Ed448LargeDataTest {
assertArrayEquals(refSig, ourSig, "signature mismatch vs JCA reference");
// VERIFY: supply expected tag and drain (throws on mismatch)
SignatureContext verifier = CryptoAlgorithms.create("Ed448", KeyUsage.VERIFY, kp.getPublic(), null);
SignatureContext verifier = new zeroecho.sdk.ZeroEchoSession().createContext("Ed448", KeyUsage.VERIFY, kp.getPublic(), null);
verifier.setExpectedTag(ourSig);
byte[] sink2;

View File

@@ -93,16 +93,16 @@ public class ElgamalLargeDataTest {
byte[] msg = randomBytes(SIZE);
System.out.printf("...input: %d bytes%n", msg.length);
KeyPair kp = CryptoAlgorithms.keyPair("ElGamal", ElgamalParamSpec.ffdhe2048());
KeyPair kp = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ElGamal", ElgamalParamSpec.ffdhe2048());
ElgamalEncSpec spec = ElgamalEncSpec.pkcs1();
EncryptionContext enc = CryptoAlgorithms.create("ElGamal", KeyUsage.ENCRYPT, kp.getPublic(), spec);
EncryptionContext enc = new zeroecho.sdk.ZeroEchoSession().createContext("ElGamal", KeyUsage.ENCRYPT, kp.getPublic(), spec);
InputStream ctIn = enc.attach(new ByteArrayInputStream(msg));
byte[] ct = ctIn.readAllBytes();
enc.close();
System.out.printf("...encrypted: %d bytes%n", ct.length);
EncryptionContext dec = CryptoAlgorithms.create("ElGamal", KeyUsage.DECRYPT, kp.getPrivate(), spec);
EncryptionContext dec = new zeroecho.sdk.ZeroEchoSession().createContext("ElGamal", KeyUsage.DECRYPT, kp.getPrivate(), spec);
InputStream ptIn = dec.attach(new ByteArrayInputStream(ct));
byte[] pt = ptIn.readAllBytes();
dec.close();
@@ -121,16 +121,16 @@ public class ElgamalLargeDataTest {
byte[] msg = randomBytes(SIZE);
System.out.printf("...input: %d bytes%n", msg.length);
KeyPair kp = CryptoAlgorithms.keyPair("ElGamal", ElgamalParamSpec.ffdhe2048());
KeyPair kp = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ElGamal", ElgamalParamSpec.ffdhe2048());
ElgamalEncSpec spec = ElgamalEncSpec.noPadding();
EncryptionContext enc = CryptoAlgorithms.create("ElGamal", KeyUsage.ENCRYPT, kp.getPublic(), spec);
EncryptionContext enc = new zeroecho.sdk.ZeroEchoSession().createContext("ElGamal", KeyUsage.ENCRYPT, kp.getPublic(), spec);
InputStream ctIn = enc.attach(new ByteArrayInputStream(msg));
byte[] ct = ctIn.readAllBytes();
enc.close();
System.out.printf("...encrypted: %d bytes%n", ct.length);
EncryptionContext dec = CryptoAlgorithms.create("ElGamal", KeyUsage.DECRYPT, kp.getPrivate(), spec);
EncryptionContext dec = new zeroecho.sdk.ZeroEchoSession().createContext("ElGamal", KeyUsage.DECRYPT, kp.getPrivate(), spec);
InputStream ptIn = dec.attach(new ByteArrayInputStream(ct));
byte[] pt = ptIn.readAllBytes();
dec.close();
@@ -149,10 +149,10 @@ public class ElgamalLargeDataTest {
byte[] msg = randomBytes(SIZE);
System.out.printf("...input: %d bytes%n", msg.length);
KeyPair kp = CryptoAlgorithms.keyPair("ElGamal", ElgamalParamSpec.ffdhe2048());
KeyPair kp = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ElGamal", ElgamalParamSpec.ffdhe2048());
ElgamalEncSpec spec = ElgamalEncSpec.noPadding();
EncryptionContext enc = CryptoAlgorithms.create("ElGamal", KeyUsage.ENCRYPT, kp.getPublic(), spec);
EncryptionContext enc = new zeroecho.sdk.ZeroEchoSession().createContext("ElGamal", KeyUsage.ENCRYPT, kp.getPublic(), spec);
InputStream ctIn = enc.attach(new ByteArrayInputStream(msg));
assertThrowsExactly(IllegalStateException.class, () -> ctIn.readAllBytes(),
"No-padding cipher streams cannot processes incomplete blocks: 3 instead of 255");

View File

@@ -127,11 +127,11 @@ public class HmacLargeDataTest {
CryptoAlgorithm algo = CryptoAlgorithms.require(ALG_ID);
// Generate a key (macName must match)
SecretKey key = algo.symmetricKeyBuilder(HmacKeyGenSpec.class).generateSecret(new HmacKeyGenSpec(JCA_MAC, 256));
SecretKey key = algo.symmetricKeyGenerator(HmacKeyGenSpec.class).generateSecret(new HmacKeyGenSpec(JCA_MAC, 256));
// --- MAC (produce): engine emits [body][tag]; capture trailer ---
HmacSpec spec = HmacSpec.sha256();
MacContext mac = CryptoAlgorithms.create(ALG_ID, KeyUsage.MAC, key, spec);
MacContext mac = new zeroecho.sdk.ZeroEchoSession().createContext(ALG_ID, KeyUsage.MAC, key, spec);
final byte[][] tagHolder = new byte[1][];
final int tagLen = mac.tagLength();
@@ -161,7 +161,7 @@ public class HmacLargeDataTest {
assertArrayEquals(ref, tag, "HMAC tag mismatch vs JCA reference");
// --- VERIFY (consume): provide expected tag and drain (throws on mismatch) ---
MacContext ver = CryptoAlgorithms.create(ALG_ID, KeyUsage.MAC, key, spec);
MacContext ver = new zeroecho.sdk.ZeroEchoSession().createContext(ALG_ID, KeyUsage.MAC, key, spec);
ver.setExpectedTag(tag);
byte[] pass2;
try (InputStream in = ver.wrap(new ByteArrayInputStream(msg))) {

View File

@@ -117,13 +117,13 @@ public final class MldsaLargeDataTest {
String caseId = "ML-DSA " + ps.name() + " preHash=" + preHash.name();
System.out.println(INDENT + " case=" + safeText(caseId));
KeyPair kp = CryptoAlgorithms.keyPair("ML-DSA", spec);
KeyPair kp = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ML-DSA", spec);
SignatureContext mldsaVerifier = CryptoAlgorithms.create("ML-DSA", KeyUsage.VERIFY, kp.getPublic());
SignatureContext mldsaVerifier = new zeroecho.sdk.ZeroEchoSession().createContext("ML-DSA", KeyUsage.VERIFY, kp.getPublic());
int expectedSigLen = mldsaVerifier.tagLength();
System.out.println(INDENT + " expectedSigLen=" + expectedSigLen);
SignatureContext signer = CryptoAlgorithms.create("ML-DSA", KeyUsage.SIGN, kp.getPrivate());
SignatureContext signer = new zeroecho.sdk.ZeroEchoSession().createContext("ML-DSA", KeyUsage.SIGN, kp.getPrivate());
final byte[][] sigHolder = new byte[1][];
byte[] passthrough;
@@ -178,7 +178,7 @@ public final class MldsaLargeDataTest {
byte[] badSig = Arrays.copyOf(signature, signature.length);
badSig[0] = (byte) (badSig[0] ^ 0x01);
SignatureContext badVerifier = CryptoAlgorithms.create("ML-DSA", KeyUsage.VERIFY, kp.getPublic());
SignatureContext badVerifier = new zeroecho.sdk.ZeroEchoSession().createContext("ML-DSA", KeyUsage.VERIFY, kp.getPublic());
try {
badVerifier.setExpectedTag(badSig);

View File

@@ -0,0 +1,79 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.core.alg.rsa;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.security.KeyPair;
import org.junit.jupiter.api.Test;
import zeroecho.core.CryptoAlgorithms;
import zeroecho.core.KeyUsage;
import zeroecho.core.context.EncryptionContext;
class BlockGeometryTest {
@Test
void geometryValidation() {
System.out.println("geometryValidation");
assertThrows(IllegalArgumentException.class, () -> new BlockGeometry(1, 1, 0));
assertThrows(IllegalArgumentException.class, () -> new BlockGeometry(2, 0, 0));
assertThrows(IllegalArgumentException.class, () -> new BlockGeometry(3, 2, 0));
assertThrows(IllegalArgumentException.class, () -> new BlockGeometry(2, 2, -1));
assertThrows(IllegalArgumentException.class, () -> new BlockGeometry(2, 2, 1));
BlockGeometry maximum = new BlockGeometry(Integer.MAX_VALUE, Integer.MAX_VALUE, 0);
assertEquals(Integer.MAX_VALUE, maximum.inChunkSize());
assertEquals(Integer.MAX_VALUE, maximum.outChunkSize());
System.out.println("...maximum=" + maximum.inChunkSize());
System.out.println("geometryValidation...ok");
}
@Test
void valueSemantics() {
System.out.println("valueSemantics");
BlockGeometry first = new BlockGeometry(2, 3, 0);
BlockGeometry equal = new BlockGeometry(2, 3, 0);
BlockGeometry different = new BlockGeometry(2, 4, 0);
assertEquals(first, equal);
assertEquals(first.hashCode(), equal.hashCode());
assertNotEquals(first, different);
assertEquals(2, first.inChunkSize());
assertEquals(3, first.outChunkSize());
assertEquals(0, first.finalizationOutputChunks());
assertEquals("BlockGeometry[inChunkSize=2, outChunkSize=3, finalizationOutputChunks=0]",
first.toString());
System.out.println("...legacyFields=true");
System.out.println("valueSemantics...ok");
}
@Test
void actualRsaPath() throws Exception {
System.out.println("actualRsaPath");
KeyPair keyPair = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("RSA", RsaKeyGenSpec.rsa2048());
RsaEncSpec spec = RsaEncSpec.oaep(RsaEncSpec.Hash.SHA256);
BlockGeometry encryptGeometry = BlockGeometry.forRsa(spec, keyPair.getPublic(), true);
BlockGeometry decryptGeometry = BlockGeometry.forRsa(spec, keyPair.getPrivate(), false);
assertEquals(190, encryptGeometry.inChunkSize());
assertEquals(256, encryptGeometry.outChunkSize());
assertEquals(256, decryptGeometry.inChunkSize());
assertEquals(256, decryptGeometry.outChunkSize());
EncryptionContext context = new zeroecho.sdk.ZeroEchoSession().createContext("RSA", KeyUsage.ENCRYPT, keyPair.getPublic(), spec);
try (InputStream stream = context.attach(new ByteArrayInputStream(new byte[] { 1, 2, 3 }))) {
assertEquals(256, stream.readAllBytes().length);
} finally {
context.close();
}
System.out.println("...ciphertextBytes=256");
System.out.println("actualRsaPath...ok");
}
}

View File

@@ -89,16 +89,16 @@ public class RsaLargeDataTest {
byte[] msg = randomBytes(SIZE);
System.out.printf("...input: %d bytes%n", msg.length);
KeyPair kp = CryptoAlgorithms.keyPair("RSA", RsaKeyGenSpec.rsa2048());
KeyPair kp = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("RSA", RsaKeyGenSpec.rsa2048());
RsaEncSpec spec = RsaEncSpec.oaep(RsaEncSpec.Hash.SHA256);
EncryptionContext enc = CryptoAlgorithms.create("RSA", KeyUsage.ENCRYPT, kp.getPublic(), spec);
EncryptionContext enc = new zeroecho.sdk.ZeroEchoSession().createContext("RSA", KeyUsage.ENCRYPT, kp.getPublic(), spec);
InputStream ctIn = enc.attach(new ByteArrayInputStream(msg));
byte[] ct = ctIn.readAllBytes();
enc.close();
System.out.printf("...encrypted: %d bytes%n", ct.length);
EncryptionContext dec = CryptoAlgorithms.create("RSA", KeyUsage.DECRYPT, kp.getPrivate(), spec);
EncryptionContext dec = new zeroecho.sdk.ZeroEchoSession().createContext("RSA", KeyUsage.DECRYPT, kp.getPrivate(), spec);
InputStream ptIn = dec.attach(new ByteArrayInputStream(ct));
byte[] pt = ptIn.readAllBytes();
dec.close();
@@ -117,16 +117,16 @@ public class RsaLargeDataTest {
byte[] msg = randomBytes(SIZE);
System.out.printf("...input: %d bytes%n", msg.length);
KeyPair kp = CryptoAlgorithms.keyPair("RSA", RsaKeyGenSpec.rsa2048());
KeyPair kp = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("RSA", RsaKeyGenSpec.rsa2048());
RsaEncSpec spec = RsaEncSpec.pkcs1v15();
EncryptionContext enc = CryptoAlgorithms.create("RSA", KeyUsage.ENCRYPT, kp.getPublic(), spec);
EncryptionContext enc = new zeroecho.sdk.ZeroEchoSession().createContext("RSA", KeyUsage.ENCRYPT, kp.getPublic(), spec);
InputStream ctIn = enc.attach(new ByteArrayInputStream(msg));
byte[] ct = ctIn.readAllBytes();
enc.close();
System.out.printf("...encrypted: %d bytes%n", ct.length);
EncryptionContext dec = CryptoAlgorithms.create("RSA", KeyUsage.DECRYPT, kp.getPrivate(), spec);
EncryptionContext dec = new zeroecho.sdk.ZeroEchoSession().createContext("RSA", KeyUsage.DECRYPT, kp.getPrivate(), spec);
InputStream ptIn = dec.attach(new ByteArrayInputStream(ct));
byte[] pt = ptIn.readAllBytes();
dec.close();

View File

@@ -122,18 +122,18 @@ public final class SlhDsaLargeDataTest {
String caseId = "SLH-DSA " + hash.name() + " " + sec.name() + " " + variant.name();
System.out.println(INDENT + " case=" + safeText(caseId));
KeyPair kp = CryptoAlgorithms.keyPair("SLH-DSA", spec);
KeyPair kp = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("SLH-DSA", spec);
// Create verifier FIRST to obtain tag length via
// SlhDsaSignatureContext.sigLenFromPublicKey.
SignatureContext verifierCtx = CryptoAlgorithms.create("SLH-DSA", KeyUsage.VERIFY, kp.getPublic());
SignatureContext verifierCtx = new zeroecho.sdk.ZeroEchoSession().createContext("SLH-DSA", KeyUsage.VERIFY, kp.getPublic());
int expectedSigLen = verifierCtx.tagLength();
System.out.println(INDENT + " expectedSigLen=" + expectedSigLen);
// Now sign and strip trailer using the expected length from verifier (not from
// signer).
SignatureContext signer = CryptoAlgorithms.create("SLH-DSA", KeyUsage.SIGN, kp.getPrivate());
SignatureContext signer = new zeroecho.sdk.ZeroEchoSession().createContext("SLH-DSA", KeyUsage.SIGN, kp.getPrivate());
final byte[][] sigHolder = new byte[1][];
byte[] passthrough;
@@ -188,7 +188,7 @@ public final class SlhDsaLargeDataTest {
byte[] badSig = Arrays.copyOf(signature, signature.length);
badSig[0] = (byte) (badSig[0] ^ 0x01);
SignatureContext badVerifier = CryptoAlgorithms.create("SLH-DSA", KeyUsage.VERIFY, kp.getPublic());
SignatureContext badVerifier = new zeroecho.sdk.ZeroEchoSession().createContext("SLH-DSA", KeyUsage.VERIFY, kp.getPublic());
try {
badVerifier.setExpectedTag(badSig);

View File

@@ -0,0 +1,272 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.core.audit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.security.Key;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import zeroecho.core.CryptoAlgorithm;
import zeroecho.core.KeyUsage;
import zeroecho.core.NullKey;
import zeroecho.core.alg.aes.AesAlgorithm;
import zeroecho.core.context.CryptoContext;
import zeroecho.core.context.DigestContext;
import zeroecho.core.context.EncryptionContext;
import zeroecho.core.spec.ContextSpec;
import zeroecho.core.tag.ThrowingBiPredicate.VerificationBiPredicate;
public class AuditedContextsAccessorTest {
@Test
void specAccessorBinding() {
System.out.println("specAccessorBinding");
MetadataListener firstListener = new MetadataListener();
MetadataListener secondListener = new MetadataListener();
MetadataListener missingListener = new MetadataListener();
MetadataListener incompatibleListener = new MetadataListener();
AuditedContexts.wrap(new FirstSpecDigest(), firstListener, KeyUsage.DIGEST);
AuditedContexts.wrap(new SecondSpecDigest(), secondListener, KeyUsage.DIGEST);
AuditedContexts.wrap(new BaseDigest(), missingListener, KeyUsage.DIGEST);
AuditedContexts.wrap(new IncompatibleAccessorsContext(), incompatibleListener, KeyUsage.ENCRYPT);
assertNotNull(firstListener.specMeta);
assertNotNull(secondListener.specMeta);
assertNull(missingListener.specMeta);
assertNull(incompatibleListener.specMeta);
assertEquals("default", firstListener.provider);
assertEquals("default", secondListener.provider);
System.out.println("...runtimeClasses=4");
System.out.println("specAccessorBinding...ok");
}
@Test
void tagLengthAccessorBinding() throws Exception {
System.out.println("tagLengthAccessorBinding");
BaseDigest explicitTarget = new BaseDigest();
DigestContext explicit = (DigestContext) AuditedContexts.wrap(explicitTarget, AuditListener.noop(),
KeyUsage.DIGEST);
assertEquals(4, explicit.tagLength());
assertEquals(4, explicit.tagLength());
explicit.wrap(new ByteArrayInputStream(new byte[] { 1 })).readAllBytes();
assertEquals(2, explicitTarget.tagCalls.get());
BaseDigest lazyTarget = new BaseDigest();
DigestContext lazy = (DigestContext) AuditedContexts.wrap(lazyTarget, AuditListener.noop(), KeyUsage.DIGEST);
lazy.wrap(new ByteArrayInputStream(new byte[] { 1 })).readAllBytes();
lazy.wrap(new ByteArrayInputStream(new byte[] { 2 })).readAllBytes();
assertEquals(1, lazyTarget.tagCalls.get());
BaseDigest concurrentTarget = new BaseDigest();
DigestContext concurrent = (DigestContext) AuditedContexts.wrap(concurrentTarget, AuditListener.noop(),
KeyUsage.DIGEST);
ExecutorService executor = Executors.newFixedThreadPool(8);
try {
List<java.util.concurrent.Callable<Integer>> calls = new ArrayList<>();
for (int index = 0; index < 64; index++) {
calls.add(concurrent::tagLength);
}
for (java.util.concurrent.Future<Integer> result : executor.invokeAll(calls)) {
assertEquals(4, result.get());
}
} finally {
executor.close();
}
assertEquals(64, concurrentTarget.tagCalls.get());
System.out.println("...concurrentCalls=" + concurrentTarget.tagCalls.get());
System.out.println("tagLengthAccessorBinding...ok");
}
@Test
void accessorFailureOrder() throws Exception {
System.out.println("accessorFailureOrder");
RecordingListener listener = new RecordingListener();
IllegalStateException expected = new IllegalStateException("controlled tag failure");
DigestContext failing = (DigestContext) AuditedContexts.wrap(new FailingDigest(expected), listener,
KeyUsage.DIGEST);
IllegalStateException actual = assertThrows(IllegalStateException.class, failing::tagLength);
assertSame(expected, actual);
assertSame(expected, listener.failure);
assertEquals(List.of("meta", "failure"), listener.events);
RecordingListener successListener = new RecordingListener();
DigestContext successful = (DigestContext) AuditedContexts.wrap(new BaseDigest(), successListener,
KeyUsage.DIGEST);
successful.wrap(new ByteArrayInputStream(new byte[] { 1, 2, 3 })).readAllBytes();
assertEquals("meta", successListener.events.get(0));
assertEquals("tag", successListener.events.get(successListener.events.size() - 1));
System.out.println("...failureEvents=" + listener.events.size());
System.out.println("accessorFailureOrder...ok");
}
public interface SpecAccessor {
ContextSpec spec();
}
public interface IncompatibleAccessors {
InputStream wrap(InputStream input);
String spec();
String tagLength();
}
public record TestSpec(String name) implements ContextSpec {
}
public static class BaseDigest implements DigestContext {
private final AtomicInteger tagCalls = new AtomicInteger();
@Override
public InputStream wrap(InputStream upstream) {
return new java.io.SequenceInputStream(upstream, new ByteArrayInputStream(new byte[4]));
}
@Override
public int tagLength() {
tagCalls.incrementAndGet();
return 4;
}
@Override
public void setVerificationApproach(VerificationBiPredicate<byte[]> strategy) {
// Not needed by this test context.
}
@Override
public VerificationBiPredicate<byte[]> getVerificationCore() {
return null;
}
@Override
public CryptoAlgorithm algorithm() {
return new AesAlgorithm();
}
@Override
public Key key() {
return NullKey.INSTANCE;
}
@Override
public void close() {
// No resources.
}
}
public static final class FirstSpecDigest extends BaseDigest implements SpecAccessor {
@Override
public ContextSpec spec() {
return new TestSpec("first");
}
}
public static final class SecondSpecDigest extends BaseDigest implements SpecAccessor {
@Override
public ContextSpec spec() {
return new TestSpec("second");
}
}
public static final class FailingDigest extends BaseDigest {
private final IllegalStateException failure;
private FailingDigest(IllegalStateException failure) {
this.failure = failure;
}
@Override
public int tagLength() {
throw failure;
}
}
public static final class IncompatibleAccessorsContext implements EncryptionContext, IncompatibleAccessors {
@Override
public InputStream attach(InputStream upstream) {
return upstream;
}
@Override
public InputStream wrap(InputStream input) {
return input;
}
@Override
public String spec() {
return "not a context spec";
}
@Override
public String tagLength() {
return "not an integer";
}
@Override
public CryptoAlgorithm algorithm() {
return new AesAlgorithm();
}
@Override
public Key key() {
return NullKey.INSTANCE;
}
@Override
public void close() {
// No resources.
}
}
private static class MetadataListener implements AuditListener {
private String provider;
private Map<String, Object> specMeta;
@Override
public void onContextCreatedMeta(String ctxId, String algoId, String providerName, KeyUsage role,
String keyFingerprint, Map<String, Object> metadata) {
provider = providerName;
specMeta = metadata;
}
}
private static final class RecordingListener extends MetadataListener {
private final List<String> events = new ArrayList<>();
private Throwable failure;
@Override
public void onContextCreatedMeta(String ctxId, String algoId, String providerName, KeyUsage role,
String keyFingerprint, Map<String, Object> metadata) {
super.onContextCreatedMeta(ctxId, algoId, providerName, role, keyFingerprint, metadata);
events.add("meta");
}
@Override
public void onFailure(String ctxId, String stage, String operation, Throwable cause) {
failure = cause;
events.add("failure");
}
@Override
public void onTagProduced(String ctxId, int tagLength, String policy) {
events.add("tag");
}
}
}

View File

@@ -0,0 +1,261 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.core.audit;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotSame;
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.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Proxy;
import java.security.Key;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import javax.crypto.SecretKey;
import org.junit.jupiter.api.Test;
import zeroecho.core.KeyUsage;
import zeroecho.core.context.AgreementContext;
import zeroecho.core.context.CryptoContext;
import zeroecho.core.context.DigestContext;
import zeroecho.core.context.EncryptionContext;
import zeroecho.core.context.MessageAgreementContext;
import zeroecho.core.spec.ContextSpec;
class AuditedContextsRegressionTest {
@Test
void unrelatedProxyGetsWrapped() throws Exception {
System.out.println("unrelatedProxyGetsWrapped");
DigestContext target = mock(DigestContext.class);
when(target.tagLength()).thenReturn(2);
when(target.wrap(any(InputStream.class))).thenAnswer(invocation -> invocation.getArgument(0));
DigestContext unrelated = (DigestContext) Proxy.newProxyInstance(
DigestContext.class.getClassLoader(), new Class<?>[] { DigestContext.class },
(proxy, method, arguments) -> {
try {
return method.invoke(target, arguments);
} catch (InvocationTargetException exception) {
throw exception.getCause();
}
});
DigestContext wrapped = (DigestContext) AuditedContexts.wrap(unrelated, AuditListener.noop(), KeyUsage.DIGEST);
assertNotSame(unrelated, wrapped);
assertTrue(Proxy.isProxyClass(wrapped.getClass()));
assertArrayEquals(new byte[] { 1, 2 }, wrapped.wrap(new ByteArrayInputStream(new byte[] { 1, 2 })).readAllBytes());
assertSame(wrapped, AuditedContexts.wrap(wrapped, AuditListener.noop(), KeyUsage.DIGEST));
System.out.println("...nestedProxy=true");
System.out.println("unrelatedProxyGetsWrapped...ok");
}
@Test
void verifyEofKeepsBodyAccounting() throws Exception {
System.out.println("verifyEofKeepsBodyAccounting");
RecordingListener listener = new RecordingListener();
DigestContext target = mock(DigestContext.class);
when(target.tagLength()).thenReturn(8);
when(target.wrap(any(InputStream.class))).thenAnswer(invocation -> invocation.getArgument(0));
DigestContext wrapped = (DigestContext) AuditedContexts.wrap(target, listener, KeyUsage.DIGEST);
wrapped.setExpectedTag(new byte[8]);
assertArrayEquals(new byte[] { 1, 2, 3 },
wrapped.wrap(new ByteArrayInputStream(new byte[] { 1, 2, 3 })).readAllBytes());
assertEquals(List.of(Boolean.TRUE), listener.verificationResults);
assertEquals(3, listener.bodyBytes);
assertEquals(0, listener.trailerBytes);
assertEquals(0, listener.tagEvents);
System.out.println("...body=3...trailer=0");
System.out.println("verifyEofKeepsBodyAccounting...ok");
}
@Test
void verifyFailureSingleEvent() throws Exception {
System.out.println("verifyFailureSingleEvent");
RecordingListener listener = new RecordingListener();
IOException expected = new IOException("controlled verification failure");
DigestContext target = mock(DigestContext.class);
when(target.tagLength()).thenReturn(4);
when(target.wrap(any(InputStream.class))).thenReturn(new InputStream() {
@Override
public int read() throws IOException {
throw expected;
}
});
DigestContext wrapped = (DigestContext) AuditedContexts.wrap(target, listener, KeyUsage.DIGEST);
wrapped.setExpectedTag(new byte[4]);
IOException actual = assertThrows(IOException.class, () -> {
try (InputStream input = wrapped.wrap(new ByteArrayInputStream(new byte[0]))) {
input.readAllBytes();
}
});
assertSame(expected, actual);
assertEquals(List.of(expected), listener.failures);
assertEquals(List.of(Boolean.FALSE), listener.verificationResults);
System.out.println("...failures=1...verifyFalse=1");
System.out.println("verifyFailureSingleEvent...ok");
}
@Test
void fingerprintsAvoidSecretBytes() {
System.out.println("fingerprintsAvoidSecretBytes");
RecordingListener listener = new RecordingListener();
SecretKey secretKey = mock(SecretKey.class);
when(secretKey.getAlgorithm()).thenReturn("CONTROLLED-SECRET");
PrivateKey privateKey = mock(PrivateKey.class);
when(privateKey.getAlgorithm()).thenReturn("CONTROLLED-PRIVATE");
EncryptionContext secretContext = mock(EncryptionContext.class);
when(secretContext.key()).thenReturn(secretKey);
EncryptionContext privateContext = mock(EncryptionContext.class);
when(privateContext.key()).thenReturn(privateKey);
AuditedContexts.wrap(secretContext, listener, KeyUsage.ENCRYPT);
AuditedContexts.wrap(privateContext, listener, KeyUsage.DECRYPT);
verify(secretKey, never()).getEncoded();
verify(privateKey, never()).getEncoded();
assertEquals(2, listener.fingerprints.size());
assertTrue(listener.fingerprints.get(0).startsWith("CONTROLLED-SECRET:"));
assertTrue(listener.fingerprints.get(1).startsWith("CONTROLLED-PRIVATE:"));
assertFalse(listener.fingerprints.toString().contains("sensitive-key-bytes"));
byte[] publicEncoding = { 1, 2, 3, 4 };
PublicKey publicKey = mock(PublicKey.class);
when(publicKey.getAlgorithm()).thenReturn("CONTROLLED-PUBLIC");
when(publicKey.getEncoded()).thenReturn(publicEncoding);
EncryptionContext publicContext = mock(EncryptionContext.class);
when(publicContext.key()).thenReturn(publicKey);
AuditedContexts.wrap(publicContext, listener, KeyUsage.ENCRYPT);
assertArrayEquals(new byte[4], publicEncoding);
assertFalse(listener.fingerprints.get(2).contains(Arrays.toString(new byte[] { 1, 2, 3, 4 })));
System.out.println("...privateEncodedCalls=0...secretEncodedCalls=0...publicEncodingCleared=true");
System.out.println("fingerprintsAvoidSecretBytes...ok");
}
@Test
void proxyStringRepresentationDoesNotDelegate() {
System.out.println("proxyStringRepresentationDoesNotDelegate");
SensitiveStringDigest target = new SensitiveStringDigest();
DigestContext wrapped = (DigestContext) AuditedContexts.wrap(target, AuditListener.noop(), KeyUsage.DIGEST);
String description = wrapped.toString();
assertTrue(description.startsWith("AuditedCryptoContext["));
assertFalse(description.contains("SENSITIVE-TARGET-STATE"));
assertEquals(0, target.toStringCalls);
System.out.println("...description=" + description.substring(0, Math.min(30, description.length())) + "...");
System.out.println("proxyStringRepresentationDoesNotDelegate...ok");
}
@Test
void agreementFailuresSingleEvent() {
System.out.println("agreementFailuresSingleEvent");
RecordingListener listener = new RecordingListener();
IllegalArgumentException peerFailure = new IllegalArgumentException("peer failure");
IllegalStateException deriveFailure = new IllegalStateException("derive failure");
AgreementContext agreement = mock(AgreementContext.class);
doThrow(peerFailure).when(agreement).setPeerPublic(any(PublicKey.class));
when(agreement.deriveSecret()).thenThrow(deriveFailure);
AgreementContext wrappedAgreement = (AgreementContext) AuditedContexts.wrap(
agreement, listener, KeyUsage.AGREEMENT);
assertSame(peerFailure, assertThrows(IllegalArgumentException.class,
() -> wrappedAgreement.setPeerPublic(mock(PublicKey.class))));
assertSame(deriveFailure, assertThrows(IllegalStateException.class, wrappedAgreement::deriveSecret));
IllegalArgumentException setMessageFailure = new IllegalArgumentException("set message failure");
IllegalStateException getMessageFailure = new IllegalStateException("get message failure");
IllegalStateException messageDeriveFailure = new IllegalStateException("message derive failure");
MessageAgreementContext messageAgreement = mock(MessageAgreementContext.class);
doThrow(setMessageFailure).when(messageAgreement).setPeerMessage(any(byte[].class));
when(messageAgreement.getPeerMessage()).thenThrow(getMessageFailure);
when(messageAgreement.deriveSecret()).thenThrow(messageDeriveFailure);
MessageAgreementContext wrappedMessage = (MessageAgreementContext) AuditedContexts.wrap(
messageAgreement, listener, KeyUsage.AGREEMENT);
assertSame(setMessageFailure, assertThrows(IllegalArgumentException.class,
() -> wrappedMessage.setPeerMessage(new byte[] { 1 })));
assertSame(getMessageFailure, assertThrows(IllegalStateException.class, wrappedMessage::getPeerMessage));
assertSame(messageDeriveFailure,
assertThrows(IllegalStateException.class, wrappedMessage::deriveSecret));
assertEquals(List.of(peerFailure, deriveFailure, setMessageFailure, getMessageFailure, messageDeriveFailure),
listener.failures);
System.out.println("...operations=5...failures=5");
System.out.println("agreementFailuresSingleEvent...ok");
}
private static final class RecordingListener implements AuditListener {
private final List<String> fingerprints = new ArrayList<>();
private final List<Throwable> failures = new ArrayList<>();
private final List<Boolean> verificationResults = new ArrayList<>();
private long bodyBytes;
private long trailerBytes;
private int tagEvents;
@Override
public void onContextCreatedMeta(String contextId, String algorithmId, String provider, KeyUsage role,
String keyFingerprint, Map<String, Object> specMeta) {
fingerprints.add(keyFingerprint);
}
@Override
public void onProgress(String contextId, long bodyByteCount, long trailerByteCount) {
bodyBytes = bodyByteCount;
trailerBytes = trailerByteCount;
}
@Override
public void onTagProduced(String contextId, int tagLength, String policy) {
tagEvents++;
}
@Override
public void onVerifyResult(String contextId, boolean success, String policy, String expectedSource,
int tagLength) {
verificationResults.add(Boolean.valueOf(success));
}
@Override
public void onFailure(String contextId, String stage, String operation, Throwable cause) {
failures.add(cause);
}
}
private static final class SensitiveStringDigest extends AuditedContextsAccessorTest.BaseDigest {
private int toStringCalls;
@Override
public String toString() {
toStringCalls++;
return "SENSITIVE-TARGET-STATE";
}
}
}

View File

@@ -0,0 +1,172 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.core.audit;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
import javax.crypto.SecretKey;
import org.junit.jupiter.api.Test;
class JulAuditListenerStdSecurityTest {
private static final String SENSITIVE_TEXT = "controlled-sensitive-message";
private static final AtomicInteger LOGGER_IDS = new AtomicInteger();
@Test
void keyLoggingUsesOnlySafeMetadataAndClearsPublicEncoding() {
System.out.println("keyLoggingUsesOnlySafeMetadataAndClearsPublicEncoding");
RecordingHandler handler = new RecordingHandler();
JulAuditListenerStd listener = listener(handler);
CountingSecretKey secretKey = new CountingSecretKey();
CountingPrivateKey privateKey = new CountingPrivateKey();
byte[] publicEncoding = { 1, 2, 3, 4 };
PublicKey publicKey = new ControlledPublicKey(publicEncoding);
listener.onKeyBuilt("secret", "provider", null, secretKey);
listener.onKeyBuilt("private", "provider", null, privateKey);
listener.onKeyBuilt("public", "provider", null, publicKey);
assertEquals(0, secretKey.encodedCalls);
assertEquals(0, privateKey.encodedCalls);
assertArrayEquals(new byte[publicEncoding.length], publicEncoding);
assertFalse(handler.rendered().contains(Arrays.toString(new byte[] { 1, 2, 3, 4 })));
System.out.println("...records=" + handler.records.size());
System.out.println("keyLoggingUsesOnlySafeMetadataAndClearsPublicEncoding...ok");
}
@Test
void defaultFailureLoggingOmitsExceptionMessageAndThrowable() {
System.out.println("defaultFailureLoggingOmitsExceptionMessageAndThrowable");
RecordingHandler handler = new RecordingHandler();
JulAuditListenerStd listener = listener(handler);
listener.onFailure("context", "read", "attach", new IllegalStateException(SENSITIVE_TEXT));
assertEquals(1, handler.records.size());
assertFalse(handler.rendered().contains(SENSITIVE_TEXT));
assertEquals(null, handler.records.get(0).getThrown());
System.out.println("...failureRecords=1");
System.out.println("defaultFailureLoggingOmitsExceptionMessageAndThrowable...ok");
}
private static JulAuditListenerStd listener(RecordingHandler handler) {
Logger logger = Logger.getLogger(
JulAuditListenerStdSecurityTest.class.getName() + "." + LOGGER_IDS.incrementAndGet());
logger.setUseParentHandlers(false);
logger.setLevel(Level.ALL);
handler.setLevel(Level.ALL);
logger.addHandler(handler);
return JulAuditListenerStd.builder().logger(logger).build();
}
private static final class RecordingHandler extends Handler {
private final List<LogRecord> records = new ArrayList<>();
@Override
public void publish(LogRecord record) {
records.add(record);
}
@Override
public void flush() {
// No buffered state.
}
@Override
public void close() {
records.clear();
}
private String rendered() {
StringBuilder result = new StringBuilder();
for (LogRecord record : records) {
result.append(record.getMessage());
if (record.getParameters() != null) {
result.append(Arrays.toString(record.getParameters()));
}
}
return result.toString();
}
}
private static final class CountingSecretKey implements SecretKey {
private static final long serialVersionUID = 1L;
private int encodedCalls;
@Override
public String getAlgorithm() {
return "CONTROLLED-SECRET";
}
@Override
public String getFormat() {
return "RAW";
}
@Override
public byte[] getEncoded() {
encodedCalls++;
return SENSITIVE_TEXT.getBytes(java.nio.charset.StandardCharsets.UTF_8);
}
}
private static final class CountingPrivateKey implements PrivateKey {
private static final long serialVersionUID = 1L;
private int encodedCalls;
@Override
public String getAlgorithm() {
return "CONTROLLED-PRIVATE";
}
@Override
public String getFormat() {
return "PKCS#8";
}
@Override
public byte[] getEncoded() {
encodedCalls++;
return SENSITIVE_TEXT.getBytes(java.nio.charset.StandardCharsets.UTF_8);
}
}
private static final class ControlledPublicKey implements PublicKey {
private static final long serialVersionUID = 1L;
private final byte[] encoding;
private ControlledPublicKey(byte[] encoding) {
this.encoding = encoding;
}
@Override
public String getAlgorithm() {
return "CONTROLLED-PUBLIC";
}
@Override
public String getFormat() {
return "X.509";
}
@Override
public byte[] getEncoded() {
return encoding;
}
}
}

View File

@@ -0,0 +1,107 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.core.io;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import javax.crypto.Cipher;
import org.junit.jupiter.api.Test;
class CipherTransformInputStreamBuilderTest {
@Test
void validatesEveryGeometryBoundaryWithoutAssertions() {
System.out.print("ChunkTransform/geometry...");
assertThrows(IllegalArgumentException.class, () -> new TestStream(1, 1, 1, 0));
assertThrows(IllegalArgumentException.class, () -> new TestStream(0, 1, 1, 0));
assertThrows(IllegalArgumentException.class, () -> new TestStream(2, 0, 1, 0));
assertThrows(IllegalArgumentException.class, () -> new TestStream(2, 1, 0, 0));
assertThrows(IllegalArgumentException.class, () -> new TestStream(2, 1, 1, -1));
assertThrows(IllegalArgumentException.class, () -> new TestStream(Integer.MAX_VALUE, 1, 2, 0));
assertThrows(IllegalArgumentException.class,
() -> new TestStream(2, 1, Integer.MAX_VALUE, 1));
assertThrows(IllegalArgumentException.class,
() -> new TestStream(2, Integer.MAX_VALUE, 1, 1));
new TestStream(2, 1, 1, 0);
new TestStream(2, 1, 1, 1);
System.out.println("ok");
}
@Test
void rejectsInvalidTransformOutputCounts() {
System.out.print("ChunkTransform/counts...");
TestStream negative = new TestStream(2, 2, 1, 0);
negative.transformCount = -1;
assertThrows(IllegalStateException.class, negative::read);
TestStream excessive = new TestStream(2, 2, 1, 0);
excessive.transformCount = 3;
assertThrows(IllegalStateException.class, excessive::read);
TestStream finalExcessive = new TestStream(2, 2, 1, 0, new byte[0]);
finalExcessive.finalCount = 3;
assertThrows(IllegalStateException.class, finalExcessive::read);
System.out.println("ok");
}
@Test
void rejectsAesIndependentBlocksBeforeReadingUpstream() throws Exception {
System.out.print("CipherBuilder/algorithm-guard...");
CountingInputStream gcmInput = new CountingInputStream();
Cipher gcm = Cipher.getInstance("AES/GCM/NoPadding");
assertThrows(IllegalArgumentException.class,
() -> CipherTransformInputStreamBuilder.builder().withUpstream(gcmInput).withCipher(gcm)
.withIndependentBlocks().build());
assertEquals(0, gcmInput.reads);
CountingInputStream cbcInput = new CountingInputStream();
Cipher cbc = Cipher.getInstance("AES/CBC/PKCS5Padding");
assertThrows(IllegalArgumentException.class,
() -> CipherTransformInputStreamBuilder.builder().withUpstream(cbcInput).withCipher(cbc)
.withLeftZeroPadding(true).withIndependentBlocks().build());
assertEquals(0, cbcInput.reads);
System.out.println("ok");
}
private static final class TestStream extends AbstractChunkTransformInputStream {
private int transformCount;
private int finalCount;
private TestStream(int inputChunk, int outputChunk, int chunks, int finalChunks) {
this(inputChunk, outputChunk, chunks, finalChunks, new byte[] { 1, 2 });
}
private TestStream(int inputChunk, int outputChunk, int chunks, int finalChunks, byte[] input) {
super(new ByteArrayInputStream(input), inputChunk, outputChunk, chunks, finalChunks);
transformCount = outputChunk;
}
@Override
protected int transform(byte[] input, int inputOffset, int inputChunks, byte[] output) {
return transformCount;
}
@Override
protected int doFinal(byte[] input, int inputOffset, int length, byte[] output, int outputOffset) {
return finalCount;
}
}
private static final class CountingInputStream extends InputStream {
private int reads;
@Override
public int read() throws IOException {
reads++;
return -1;
}
}
}

View File

@@ -0,0 +1,383 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.core.marshal;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
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 java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.junit.jupiter.api.Test;
/**
* Verifies cached accessor plans for {@link PairSeqCodec}.
*/
class PairSeqCodecTest {
@Test
void marshalAndStaticFactoryUnmarshalSucceed() {
System.out.println("marshalAndStaticFactoryUnmarshalSucceed");
PairSeqCodec<FactoryValue> codec = new PairSeqCodec<>(FactoryValue.class);
PairSeq representation = codec.marshal(new FactoryValue("alpha"));
FactoryValue decoded = codec.unmarshal(representation);
assertEquals("alpha", representation.valAt(0));
assertEquals("alpha", decoded.value);
System.out.println("...decoded=" + decoded.value);
System.out.println("marshalAndStaticFactoryUnmarshalSucceed...ok");
}
@Test
void repeatedOperationsReuseCachedPlans() {
System.out.println("repeatedOperationsReuseCachedPlans");
PairSeqCodec<FactoryValue> codec = new PairSeqCodec<>(FactoryValue.class);
Object marshalPlan = PairSeqCodec.cachedMarshalPlan(FactoryValue.class);
Object unmarshalPlan = PairSeqCodec.cachedUnmarshalPlan(FactoryValue.class);
for (int index = 0; index < 20; index++) {
PairSeq representation = codec.marshal(new FactoryValue(Integer.toString(index)));
assertEquals(Integer.toString(index), codec.unmarshal(representation).value);
}
assertSame(marshalPlan, PairSeqCodec.cachedMarshalPlan(FactoryValue.class));
assertSame(unmarshalPlan, PairSeqCodec.cachedUnmarshalPlan(FactoryValue.class));
assertEquals(1, PairSeqCodec.marshalResolutionCount(FactoryValue.class));
assertEquals(1, PairSeqCodec.unmarshalResolutionCount(FactoryValue.class));
System.out.println("...iterations=20");
System.out.println("repeatedOperationsReuseCachedPlans...ok");
}
@Test
void separateRuntimeClassesUseSeparateMarshalPlans() {
System.out.println("separateRuntimeClassesUseSeparateMarshalPlans");
Object first = PairSeqCodec.cachedMarshalPlan(FactoryValue.class);
Object second = PairSeqCodec.cachedMarshalPlan(ConstructorValue.class);
Object firstUnmarshal = PairSeqCodec.cachedUnmarshalPlan(FactoryValue.class);
Object secondUnmarshal = PairSeqCodec.cachedUnmarshalPlan(ConstructorValue.class);
assertNotSame(first, second);
assertNotSame(firstUnmarshal, secondUnmarshal);
System.out.println("...separatePlans=true");
System.out.println("separateRuntimeClassesUseSeparateMarshalPlans...ok");
}
@Test
void constructorFallbackUnmarshalsSuccessfully() {
System.out.println("constructorFallbackUnmarshalsSuccessfully");
PairSeqCodec<ConstructorValue> codec = new PairSeqCodec<>(ConstructorValue.class);
ConstructorValue decoded = codec.unmarshal(PairSeq.of("value", "beta"));
assertEquals("beta", decoded.value);
System.out.println("...decoded=" + decoded.value);
System.out.println("constructorFallbackUnmarshalsSuccessfully...ok");
}
@Test
void broadlyDeclaredFactoryReturnRemainsCompatible() {
System.out.println("broadlyDeclaredFactoryReturnRemainsCompatible");
PairSeqCodec<BroadFactoryValue> codec = new PairSeqCodec<>(BroadFactoryValue.class);
BroadFactoryValue decoded = codec.unmarshal(PairSeq.of("value", "broad"));
assertEquals("broad", decoded.value);
System.out.println("...decoded=" + decoded.value);
System.out.println("broadlyDeclaredFactoryReturnRemainsCompatible...ok");
}
@Test
void missingAndIncompatibleMethodsReportContext() {
System.out.println("missingAndIncompatibleMethodsReportContext");
PairSeqCodec<MissingValue> missingCodec = new PairSeqCodec<>(MissingValue.class);
PairSeqCodec<WrongMarshalValue> wrongMarshalCodec = new PairSeqCodec<>(WrongMarshalValue.class);
PairSeqCodec<WrongFactoryValue> wrongFactoryCodec = new PairSeqCodec<>(WrongFactoryValue.class);
IllegalStateException missingMarshal = assertThrows(IllegalStateException.class,
() -> missingCodec.marshal(new MissingValue()));
IllegalStateException missingUnmarshal = assertThrows(IllegalStateException.class,
() -> missingCodec.unmarshal(PairSeq.of()));
IllegalStateException wrongMarshal = assertThrows(IllegalStateException.class,
() -> wrongMarshalCodec.marshal(new WrongMarshalValue()));
IllegalStateException wrongFactory = assertThrows(IllegalStateException.class,
() -> wrongFactoryCodec.unmarshal(PairSeq.of()));
assertTrue(missingMarshal.getMessage().contains("marshal"));
assertTrue(missingUnmarshal.getMessage().contains("unmarshal"));
assertTrue(wrongMarshal.getMessage().contains("must return PairSeq"));
assertTrue(wrongFactory.getMessage().contains("must return"));
System.out.println("...negativeCases=4");
System.out.println("missingAndIncompatibleMethodsReportContext...ok");
}
@Test
void targetFailuresRetainInvocationContext() {
System.out.println("targetFailuresRetainInvocationContext");
PairSeqCodec<ThrowingValue> codec = new PairSeqCodec<>(ThrowingValue.class);
IllegalStateException marshalFailure = assertThrows(IllegalStateException.class,
() -> codec.marshal(new ThrowingValue()));
IllegalStateException unmarshalFailure = assertThrows(IllegalStateException.class,
() -> codec.unmarshal(PairSeq.of()));
assertTrue(marshalFailure.getMessage().contains("marshal() failed"));
assertTrue(unmarshalFailure.getMessage().contains("unmarshal(PairSeq) failed"));
assertTrue(marshalFailure.getCause() instanceof java.lang.reflect.InvocationTargetException);
assertTrue(unmarshalFailure.getCause() instanceof java.lang.reflect.InvocationTargetException);
System.out.println("...causesPreserved=true");
System.out.println("targetFailuresRetainInvocationContext...ok");
}
@Test
void nullInputsAreRejected() {
System.out.println("nullInputsAreRejected");
PairSeqCodec<FactoryValue> codec = new PairSeqCodec<>(FactoryValue.class);
assertThrows(NullPointerException.class, () -> new PairSeqCodec<FactoryValue>(null));
assertThrows(NullPointerException.class, () -> codec.marshal(null));
assertThrows(NullPointerException.class, () -> codec.unmarshal(null));
System.out.println("...nullCases=3");
System.out.println("nullInputsAreRejected...ok");
}
@Test
void concurrentFirstAccessUsesOneCachedPlan() throws Exception {
System.out.println("concurrentFirstAccessUsesOneCachedPlan");
int taskCount = 24;
CountDownLatch start = new CountDownLatch(1);
ExecutorService executor = Executors.newFixedThreadPool(8);
try {
List<Future<PlanPair>> futures = new ArrayList<>();
for (int index = 0; index < taskCount; index++) {
futures.add(executor.submit(() -> {
start.await();
PairSeqCodec<ConcurrentValue> codec = new PairSeqCodec<>(ConcurrentValue.class);
ConcurrentValue value = codec.unmarshal(codec.marshal(new ConcurrentValue("gamma")));
assertEquals("gamma", value.value);
return new PlanPair(PairSeqCodec.cachedMarshalPlan(ConcurrentValue.class),
PairSeqCodec.cachedUnmarshalPlan(ConcurrentValue.class));
}));
}
start.countDown();
PlanPair expectedPlans = futures.get(0).get();
for (Future<PlanPair> future : futures) {
PlanPair actualPlans = future.get();
assertSame(expectedPlans.marshal(), actualPlans.marshal());
assertSame(expectedPlans.unmarshal(), actualPlans.unmarshal());
}
assertEquals(1, PairSeqCodec.marshalResolutionCount(ConcurrentValue.class));
assertEquals(1, PairSeqCodec.unmarshalResolutionCount(ConcurrentValue.class));
} finally {
executor.shutdownNow();
}
System.out.println("...completedTasks=" + taskCount);
System.out.println("concurrentFirstAccessUsesOneCachedPlan...ok");
}
/**
* Test value exposing marshal and static-factory conventions.
*/
public static final class FactoryValue {
private final String value;
/**
* Creates a test value.
*
* @param value stored value
*/
public FactoryValue(String value) {
this.value = value;
}
/**
* Marshals this test value.
*
* @return pair representation
*/
public PairSeq marshal() {
return PairSeq.of("value", value);
}
/**
* Reconstructs a test value.
*
* @param representation pair representation
* @return reconstructed value
*/
public static FactoryValue unmarshal(PairSeq representation) {
return new FactoryValue(representation.valAt(0));
}
}
/**
* Test value exposing marshal and constructor conventions.
*/
public static final class ConstructorValue {
private final String value;
/**
* Creates a test value.
*
* @param value stored value
*/
public ConstructorValue(String value) {
this.value = value;
}
/**
* Reconstructs a test value.
*
* @param representation pair representation
*/
public ConstructorValue(PairSeq representation) {
this(representation.valAt(0));
}
/**
* Marshals this test value.
*
* @return pair representation
*/
public PairSeq marshal() {
return PairSeq.of("value", value);
}
}
/**
* Test value with no codec conventions.
*/
public static final class MissingValue {
}
/**
* Test value with an incompatible marshal return.
*/
public static final class WrongMarshalValue {
/**
* Returns an intentionally incompatible representation.
*
* @return incompatible value
*/
public String marshal() {
return "wrong";
}
}
/**
* Test value with an incompatible factory return.
*/
public static final class WrongFactoryValue {
/**
* Returns an intentionally incompatible value.
*
* @param representation ignored representation
* @return incompatible value
*/
public static String unmarshal(PairSeq representation) {
return representation.valAt(0);
}
}
/**
* Test value whose codec operations fail.
*/
public static final class ThrowingValue {
/**
* Fails during marshalling.
*
* @return no value
* @throws IllegalArgumentException always
*/
public PairSeq marshal() {
throw new IllegalArgumentException("marshal target");
}
/**
* Fails during unmarshalling.
*
* @param representation ignored representation
* @return no value
* @throws IllegalArgumentException always
*/
public static ThrowingValue unmarshal(PairSeq representation) {
throw new IllegalArgumentException("unmarshal target");
}
}
/**
* Test value whose factory declares a compatible broad return type.
*/
public static final class BroadFactoryValue {
private final String value;
/**
* Creates a test value.
*
* @param value stored value
*/
public BroadFactoryValue(String value) {
this.value = value;
}
/**
* Reconstructs a test value through a broadly declared return type.
*
* @param representation pair representation
* @return reconstructed value as {@link Object}
*/
public static Object unmarshal(PairSeq representation) {
return new BroadFactoryValue(representation.valAt(0));
}
}
/**
* Test value reserved for concurrent cold-cache access.
*/
public static final class ConcurrentValue {
private final String value;
/**
* Creates a test value.
*
* @param value stored value
*/
public ConcurrentValue(String value) {
this.value = value;
}
/**
* Marshals this test value.
*
* @return pair representation
*/
public PairSeq marshal() {
return PairSeq.of("value", value);
}
/**
* Reconstructs a test value.
*
* @param representation pair representation
* @return reconstructed value
*/
public static ConcurrentValue unmarshal(PairSeq representation) {
return new ConcurrentValue(representation.valAt(0));
}
}
private record PlanPair(Object marshal, Object unmarshal) {
}
}

View File

@@ -0,0 +1,138 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.core.marshal;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.IOException;
import java.io.StringReader;
import java.io.UncheckedIOException;
import org.junit.jupiter.api.Test;
class PairSeqTest {
@Test
void checkedRoundTrip() throws Exception {
System.out.println("checkedRoundTrip");
PairSeq empty = PairSeq.of();
assertEquals(0, empty.size());
PairSeq original = PairSeq.of("a", "1", "b", "");
StringBuilder output = new StringBuilder();
original.writeTo(output);
PairSeq decoded = PairSeq.readFrom(new StringReader(output.toString()));
assertEquals(2, decoded.size());
assertEquals("a", decoded.keyAt(0));
assertEquals("1", decoded.valAt(0));
assertEquals("", decoded.valAt(1));
System.out.println("...pairs=" + decoded.size());
System.out.println("checkedRoundTrip...ok");
}
@Test
void validationAndOwnership() {
System.out.println("validationAndOwnership");
assertThrows(IllegalArgumentException.class, () -> PairSeq.of((String[]) null));
assertThrows(IllegalArgumentException.class, () -> PairSeq.of("key"));
assertEquals("pair 0 key must not be null",
assertThrows(IllegalArgumentException.class, () -> PairSeq.of(null, "value")).getMessage());
assertEquals("pair 0 value must not be null",
assertThrows(IllegalArgumentException.class, () -> PairSeq.of("key", null)).getMessage());
assertEquals("pair 1 key must not be null",
assertThrows(IllegalArgumentException.class,
() -> PairSeq.of("first", "value", null, "other")).getMessage());
String[] source = { "key", "value" };
PairSeq sequence = PairSeq.of(source);
source[0] = "changed";
assertEquals("key", sequence.keyAt(0));
assertNotSame(source, sequence);
System.out.println("...owned=true");
System.out.println("validationAndOwnership...ok");
}
@Test
void failurePropagation() {
System.out.println("failurePropagation");
IOException immediate = new IOException("immediate");
IOException checked = assertThrows(IOException.class,
() -> PairSeq.of("a", "b").writeTo(new FailingAppendable(0, immediate)));
assertSame(immediate, checked);
IOException partial = new IOException("partial");
FailingAppendable partialOutput = new FailingAppendable(2, partial);
IOException partialActual = assertThrows(IOException.class,
() -> PairSeq.of("a", "b").writeTo(partialOutput));
assertSame(partial, partialActual);
assertEquals("a=", partialOutput.output.toString());
IllegalStateException runtime = new IllegalStateException("runtime");
IllegalStateException runtimeActual = assertThrows(IllegalStateException.class,
() -> PairSeq.of("a", "b").writeTo(new RuntimeFailingAppendable(runtime)));
assertSame(runtime, runtimeActual);
System.out.println("...partialChars=" + partialOutput.output.length());
System.out.println("failurePropagation...ok");
}
private static final class FailingAppendable implements Appendable {
private final int acceptedCharacters;
private final IOException failure;
private final StringBuilder output = new StringBuilder();
private FailingAppendable(int acceptedCharacters, IOException failure) {
this.acceptedCharacters = acceptedCharacters;
this.failure = failure;
}
@Override
public Appendable append(CharSequence sequence) throws IOException {
for (int index = 0; index < sequence.length(); index++) {
append(sequence.charAt(index));
}
return this;
}
@Override
public Appendable append(CharSequence sequence, int start, int end) throws IOException {
return append(sequence.subSequence(start, end));
}
@Override
public Appendable append(char character) throws IOException {
if (output.length() == acceptedCharacters) {
throw failure;
}
output.append(character);
return this;
}
}
private static final class RuntimeFailingAppendable implements Appendable {
private final IllegalStateException failure;
private RuntimeFailingAppendable(IllegalStateException failure) {
this.failure = failure;
}
@Override
public Appendable append(CharSequence sequence) {
throw failure;
}
@Override
public Appendable append(CharSequence sequence, int start, int end) {
throw failure;
}
@Override
public Appendable append(char character) {
throw failure;
}
}
}

View File

@@ -62,8 +62,10 @@ import zeroecho.core.alg.rsa.RsaKeyGenSpec;
import zeroecho.core.alg.rsa.RsaPrivateKeySpec;
import zeroecho.core.alg.rsa.RsaPublicKeySpec;
import zeroecho.core.spec.AlgorithmKeySpec;
import zeroecho.core.spi.AsymmetricKeyBuilder;
import zeroecho.core.spi.SymmetricKeyBuilder;
import zeroecho.core.KeyOperation;
import zeroecho.core.KeyOperationInfo;
import zeroecho.core.spi.AsymmetricKeyPairGenerator;
import zeroecho.core.spi.SymmetricKeyGenerator;
import zeroecho.sdk.util.BouncyCastleActivator;
public class KeyringStoreDynamicTest {
@@ -183,19 +185,19 @@ public class KeyringStoreDynamicTest {
logBegin();
Path keyringPath = tempDir.resolve("keyring-" + System.nanoTime() + ".txt");
KeyringStore store = new KeyringStore();
KeyringStore store = new KeyringStore(new zeroecho.sdk.ZeroEchoSession());
CryptoAlgorithm algo = CryptoAlgorithms.require("RSA");
KeyPair kp = algo.generateKeyPair(RsaKeyGenSpec.rsa4096());
zeroecho.sdk.ZeroEchoSession session = new zeroecho.sdk.ZeroEchoSession();
KeyPair kp = session.keyBuilders().asymmetric().generateKeyPair("RSA", RsaKeyGenSpec.rsa4096());
store.putPrivate("alice.priv", "RSA", new RsaPrivateKeySpec(kp.getPrivate().getEncoded()));
store.putPublic("alice.pub", "RSA", new RsaPublicKeySpec(kp.getPublic().getEncoded()));
kp = algo.generateKeyPair(RsaKeyGenSpec.rsa4096());
kp = session.keyBuilders().asymmetric().generateKeyPair("RSA", RsaKeyGenSpec.rsa4096());
store.putPrivate("bob.priv", "RSA", new RsaPrivateKeySpec(kp.getPrivate().getEncoded()));
store.putPublic("bob.pub", "RSA", new RsaPublicKeySpec(kp.getPublic().getEncoded()));
store.save(keyringPath);
store = KeyringStore.load(keyringPath);
store = KeyringStore.load(new zeroecho.sdk.ZeroEchoSession(), keyringPath);
String s = store.exportText(Collections.singleton("alice.pub"));
assertTrue(s.contains("# KeyringStore v1\n"));
@@ -214,7 +216,7 @@ public class KeyringStoreDynamicTest {
void keyring_dynamic_population_roundtrip_and_dump(@TempDir Path tempDir) throws Exception {
logBegin();
KeyringStore store = new KeyringStore();
KeyringStore store = new KeyringStore(new zeroecho.sdk.ZeroEchoSession());
Set<String> ids = CryptoAlgorithms.available();
System.out.println("...algorithms discovered: " + ids);
@@ -225,34 +227,35 @@ public class KeyringStoreDynamicTest {
CryptoAlgorithm alg = CryptoAlgorithms.require(id);
System.out.println("\n-- " + id + " --");
if (!alg.asymmetricBuildersInfo().isEmpty()) {
if (alg.keyOperations().stream()
.anyMatch(info -> info.operation() == KeyOperation.ASYMMETRIC_KEY_PAIR_GENERATE)) {
int perAlg = 0;
for (CryptoAlgorithm.AsymBuilderInfo bi : alg.asymmetricBuildersInfo()) {
if (bi.defaultKeySpec == null) {
for (KeyOperationInfo bi : alg.keyOperations()) {
if (bi.operation() != KeyOperation.ASYMMETRIC_KEY_PAIR_GENERATE || bi.defaultSpec() == null) {
continue;
}
try {
@SuppressWarnings("unchecked")
Class<AlgorithmKeySpec> genSpecType = (Class<AlgorithmKeySpec>) bi.specType;
AsymmetricKeyBuilder<AlgorithmKeySpec> b = alg.asymmetricKeyBuilder(genSpecType);
Class<AlgorithmKeySpec> genSpecType = (Class<AlgorithmKeySpec>) bi.specType();
AsymmetricKeyPairGenerator<AlgorithmKeySpec> b = alg.asymmetricKeyPairGenerator(genSpecType);
AlgorithmKeySpec genSpec = (AlgorithmKeySpec) bi.defaultKeySpec;
AlgorithmKeySpec genSpec = bi.defaultSpec();
KeyPair kp = b.generateKeyPair(genSpec);
PublicKey pub = kp.getPublic();
PrivateKey prv = kp.getPrivate();
Class<?> pubImpType = null;
Class<?> prvImpType = null;
for (CryptoAlgorithm.AsymBuilderInfo x : alg.asymmetricBuildersInfo()) {
if (looksLikeImportSpecForPublic(x.specType)) {
pubImpType = x.specType;
} else if (looksLikeImportSpecForPrivate(x.specType)) {
prvImpType = x.specType;
for (KeyOperationInfo x : alg.keyOperations()) {
if (x.operation() == KeyOperation.ASYMMETRIC_PUBLIC_IMPORT) {
pubImpType = x.specType();
} else if (x.operation() == KeyOperation.ASYMMETRIC_PRIVATE_IMPORT) {
prvImpType = x.specType();
}
}
if (pubImpType != null) {
AlgorithmKeySpec pubSpec = makeImportSpec(pubImpType, pub.getEncoded(), id,
bi.defaultKeySpec);
bi.defaultSpec());
String alias = id.toLowerCase() + "-pub-" + perAlg;
store.putPublic(alias, id, pubSpec);
System.out.println("..." + alias + " saved, len=" + encLen(pub.getEncoded()));
@@ -262,7 +265,7 @@ public class KeyringStoreDynamicTest {
}
if (prvImpType != null) {
AlgorithmKeySpec prvSpec = makeImportSpec(prvImpType, prv.getEncoded(), id,
bi.defaultKeySpec);
bi.defaultSpec());
String alias = id.toLowerCase() + "-prv-" + perAlg;
store.putPrivate(alias, id, prvSpec);
System.out.println("..." + alias + " saved, len=" + encLen(prv.getEncoded()));
@@ -282,46 +285,37 @@ public class KeyringStoreDynamicTest {
}
}
if (!alg.symmetricBuildersInfo().isEmpty()) {
if (alg.keyOperations().stream()
.anyMatch(info -> info.operation() == KeyOperation.SYMMETRIC_GENERATE)) {
int perAlg = 0;
for (CryptoAlgorithm.SymBuilderInfo bi : alg.symmetricBuildersInfo()) {
if (bi.defaultKeySpec() == null) {
for (KeyOperationInfo bi : alg.keyOperations()) {
if (bi.operation() != KeyOperation.SYMMETRIC_GENERATE || bi.defaultSpec() == null) {
continue;
}
try {
@SuppressWarnings("unchecked")
Class<AlgorithmKeySpec> genSpecType = (Class<AlgorithmKeySpec>) bi.specType();
SymmetricKeyBuilder<AlgorithmKeySpec> b = alg.symmetricKeyBuilder(genSpecType);
SymmetricKeyGenerator<AlgorithmKeySpec> b = alg.symmetricKeyGenerator(genSpecType);
AlgorithmKeySpec genSpec = (AlgorithmKeySpec) bi.defaultKeySpec();
AlgorithmKeySpec genSpec = bi.defaultSpec();
SecretKey sk = b.generateSecret(genSpec);
Class<?> impType = null;
for (CryptoAlgorithm.SymBuilderInfo x : alg.symmetricBuildersInfo()) {
if (looksLikeImportSpecForSecret(x.specType())) {
for (KeyOperationInfo x : alg.keyOperations()) {
if (x.operation() == KeyOperation.SYMMETRIC_IMPORT
&& looksLikeImportSpecForSecret(x.specType())) {
impType = x.specType();
}
}
if (impType == null) {
for (CryptoAlgorithm.SymBuilderInfo x : alg.symmetricBuildersInfo()) {
try {
x.specType().getConstructor(byte[].class);
impType = x.specType();
break;
} catch (NoSuchMethodException ignored) {
}
}
}
if (impType != null) {
byte[] raw = sk.getEncoded();
if (raw == null) {
raw = randomBytes(32);
}
AlgorithmKeySpec imp = makeImportSpec(impType, raw, id, bi.defaultKeySpec());
AlgorithmKeySpec imp = makeImportSpec(impType, raw, id, bi.defaultSpec());
String alias = id.toLowerCase() + "-sec-" + perAlg;
store.putSecret(alias, id, imp);
System.out.println("..." + alias + " saved, len=" + (raw == null ? 0 : raw.length) + " "
+ Base64.getEncoder().withoutPadding().encodeToString(raw));
System.out.println("..." + alias + " saved, len=" + raw.length);
totalAdded++;
} else {
System.out.println("...*** SKIP *** no symmetric import spec for " + id);
@@ -344,7 +338,7 @@ public class KeyringStoreDynamicTest {
System.out.println("\n...saved keyring: " + keyringPath.getFileName());
System.out.println("...entries stored: " + totalAdded);
KeyringStore loaded = KeyringStore.load(keyringPath);
KeyringStore loaded = KeyringStore.load(new zeroecho.sdk.ZeroEchoSession(), keyringPath);
assertTrue(loaded.aliases().size() >= Math.min(totalAdded, 1), "no entries reloaded");
int ok = 0;

View File

@@ -0,0 +1,138 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
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 java.security.GeneralSecurityException;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.security.auth.DestroyFailedException;
import javax.security.auth.Destroyable;
import org.junit.jupiter.api.Test;
import zeroecho.core.spec.AlgorithmKeySpec;
import zeroecho.sdk.ZeroEchoSession;
class KeyringStoreSecurityTest {
private static final AtomicBoolean UNREGISTERED_INITIALIZED = new AtomicBoolean();
@Test
void rejectsUnregisteredPersistedSpecBeforeClassInitialization() throws Exception {
System.out.println("rejectsUnregisteredPersistedSpecBeforeClassInitialization");
KeyringStore store = new KeyringStore(new ZeroEchoSession());
String text = "# KeyringStore v1\n"
+ "@entry\n"
+ "alias=attacker.pub\n"
+ "algorithm=RSA\n"
+ "kind=PUBLIC_KEY\n"
+ "spec=zeroecho.core.storage.KeyringStoreSecurityTest$UnregisteredSpec\n\n";
store.importText(text, false);
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
() -> store.getPublic("attacker"));
assertFalse(UNREGISTERED_INITIALIZED.get());
assertTrue(failure.getMessage().contains("not registered"));
System.out.println("...classInitialized=false");
System.out.println("rejectsUnregisteredPersistedSpecBeforeClassInitialization...ok");
}
@Test
void rejectsSpecRegisteredForDifferentOperation() throws Exception {
System.out.println("rejectsSpecRegisteredForDifferentOperation");
KeyringStore store = new KeyringStore(new ZeroEchoSession());
String text = "# KeyringStore v1\n"
+ "@entry\n"
+ "alias=mismatch.pub\n"
+ "algorithm=RSA\n"
+ "kind=PUBLIC_KEY\n"
+ "spec=zeroecho.core.alg.rsa.RsaPrivateKeySpec\n\n";
store.importText(text, false);
assertThrows(IllegalArgumentException.class, () -> store.getPublic("mismatch"));
System.out.println("...mismatchedOperationRejected=true");
System.out.println("rejectsSpecRegisteredForDifferentOperation...ok");
}
@Test
void temporarySpecDestructionIsIdempotentAndObservable() throws Exception {
System.out.println("temporarySpecDestructionIsIdempotentAndObservable");
ControlledDestroyableSpec spec = new ControlledDestroyableSpec(false);
KeyringStore.destroyTemporarySpec(spec, null);
KeyringStore.destroyTemporarySpec(spec, null);
assertTrue(spec.isDestroyed());
assertEquals(1, spec.destroyCalls);
System.out.println("...destroyCalls=" + spec.destroyCalls);
System.out.println("temporarySpecDestructionIsIdempotentAndObservable...ok");
}
@Test
void destructionFailureIsSuppressedOnPrimaryFailure() throws Exception {
System.out.println("destructionFailureIsSuppressedOnPrimaryFailure");
ControlledDestroyableSpec spec = new ControlledDestroyableSpec(true);
IllegalStateException primary = new IllegalStateException("controlled primary");
KeyringStore.destroyTemporarySpec(spec, primary);
assertEquals(1, primary.getSuppressed().length);
assertTrue(primary.getSuppressed()[0] instanceof DestroyFailedException);
System.out.println("...suppressedFailures=1");
System.out.println("destructionFailureIsSuppressedOnPrimaryFailure...ok");
}
@Test
void destructionFailureWithoutPrimaryUsesSecurityExceptionFamily() {
System.out.println("destructionFailureWithoutPrimaryUsesSecurityExceptionFamily");
ControlledDestroyableSpec spec = new ControlledDestroyableSpec(true);
GeneralSecurityException failure = assertThrows(GeneralSecurityException.class,
() -> KeyringStore.destroyTemporarySpec(spec, null));
assertSame(DestroyFailedException.class, failure.getCause().getClass());
System.out.println("...failureType=" + failure.getClass().getSimpleName());
System.out.println("destructionFailureWithoutPrimaryUsesSecurityExceptionFamily...ok");
}
/**
* A deliberately unregistered type whose initialization must never occur.
*/
public static final class UnregisteredSpec implements AlgorithmKeySpec {
static {
UNREGISTERED_INITIALIZED.set(true);
}
}
private static final class ControlledDestroyableSpec implements AlgorithmKeySpec, Destroyable {
private final boolean fail;
private boolean destroyed;
private int destroyCalls;
private ControlledDestroyableSpec(boolean fail) {
this.fail = fail;
}
@Override
public void destroy() throws DestroyFailedException {
destroyCalls++;
if (fail) {
throw new DestroyFailedException("controlled destruction failure");
}
destroyed = true;
}
@Override
public boolean isDestroyed() {
return destroyed;
}
}
}

View File

@@ -0,0 +1,202 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.sdk;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.security.Key;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import javax.security.auth.DestroyFailedException;
import javax.security.auth.Destroyable;
import org.junit.jupiter.api.Test;
import zeroecho.core.audit.AuditListener;
class ZeroEchoSessionDestroyKeyTest {
@Test
void strictDestroyDistinguishesEveryLifecycleOutcome() throws Exception {
System.out.print("ZeroEchoSession/destroy-strict...");
AtomicInteger audits = new AtomicInteger();
ZeroEchoSession session = new ZeroEchoSession().withAuditListener(new AuditListener() {
@Override
public void onKeyDestroyed(String id, String provider, Key key) {
audits.incrementAndGet();
}
});
TestKey success = new TestKey(Behavior.SUCCESS);
assertTrue(session.destroyKey("test", "provider", success));
assertFalse(session.destroyKey("test", "provider", success));
assertEquals(1, audits.get());
assertFalse(session.destroyKey("test", "provider", new PlainKey()));
assertThrows(NullPointerException.class, () -> session.destroyKey("test", "provider", null));
assertThrows(DestroyFailedException.class,
() -> session.destroyKey("test", "provider", new TestKey(Behavior.FAIL_CHECKED)));
assertThrows(DestroyFailedException.class,
() -> session.destroyKey("test", "provider", new TestKey(Behavior.NO_TRANSITION)));
assertThrows(IllegalStateException.class,
() -> session.destroyKey("test", "provider", new TestKey(Behavior.FAIL_RUNTIME)));
assertEquals(1, audits.get());
System.out.println("ok");
}
@Test
void concurrentStrictDestroyReportsAndAuditsOneTransition() throws Exception {
System.out.print("ZeroEchoSession/destroy-concurrent...");
AtomicInteger audits = new AtomicInteger();
ZeroEchoSession session = new ZeroEchoSession().withAuditListener(new AuditListener() {
@Override
public void onKeyDestroyed(String id, String provider, Key key) {
audits.incrementAndGet();
}
});
BlockingKey key = new BlockingKey();
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
Future<Boolean> first = executor.submit(() -> session.destroyKey("test", "provider", key));
key.destroyEntered.await();
Future<Boolean> second = executor.submit(() -> session.destroyKey("test", "provider", key));
key.allowDestroy.countDown();
boolean firstResult = first.get();
boolean secondResult = second.get();
assertTrue(firstResult ^ secondResult);
assertEquals(1, audits.get());
assertEquals(1, key.destroyCalls.get());
} finally {
executor.shutdownNow();
}
System.out.println("ok");
}
private enum Behavior {
SUCCESS,
FAIL_CHECKED,
FAIL_RUNTIME,
NO_TRANSITION
}
private static final class TestKey implements Key, Destroyable {
private static final long serialVersionUID = 1L;
private static final String SECRET = "secret-key-marker";
private final Behavior behavior;
private boolean destroyed;
private boolean encodedCalled;
private boolean toStringCalled;
private TestKey(Behavior behavior) {
this.behavior = behavior;
}
@Override
public void destroy() throws DestroyFailedException {
switch (behavior) {
case SUCCESS -> destroyed = true;
case FAIL_CHECKED -> throw new DestroyFailedException(SECRET);
case FAIL_RUNTIME -> throw new IllegalStateException(SECRET);
case NO_TRANSITION -> {
// Intentionally does not transition.
}
}
}
@Override
public boolean isDestroyed() {
return destroyed;
}
@Override
public String getAlgorithm() {
return "test";
}
@Override
public String getFormat() {
return "RAW";
}
@Override
public byte[] getEncoded() {
encodedCalled = true;
return SECRET.getBytes(java.nio.charset.StandardCharsets.UTF_8);
}
@Override
public String toString() {
toStringCalled = true;
return SECRET;
}
}
private static final class PlainKey implements Key {
private static final long serialVersionUID = 1L;
@Override
public String getAlgorithm() {
return "plain";
}
@Override
public String getFormat() {
return null;
}
@Override
public byte[] getEncoded() {
return null;
}
}
private static final class BlockingKey implements Key, Destroyable {
private static final long serialVersionUID = 1L;
private final CountDownLatch destroyEntered = new CountDownLatch(1);
private final CountDownLatch allowDestroy = new CountDownLatch(1);
private final AtomicInteger destroyCalls = new AtomicInteger();
private boolean destroyed;
@Override
public void destroy() throws DestroyFailedException {
destroyCalls.incrementAndGet();
destroyEntered.countDown();
try {
allowDestroy.await();
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new DestroyFailedException("Interrupted while testing destruction");
}
destroyed = true;
}
@Override
public boolean isDestroyed() {
return destroyed;
}
@Override
public String getAlgorithm() {
return "test";
}
@Override
public String getFormat() {
return null;
}
@Override
public byte[] getEncoded() {
return null;
}
}
}

View File

@@ -43,15 +43,20 @@ import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.security.KeyPair;
import java.util.Arrays;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import zeroecho.core.CryptoAlgorithms;
import zeroecho.core.audit.AuditListener;
import zeroecho.core.audit.AuditMode;
import zeroecho.core.alg.common.agreement.KeyPairKey;
import zeroecho.core.alg.kyber.KyberKeyGenSpec;
import zeroecho.core.alg.xdh.XdhSpec;
import zeroecho.sdk.ZeroEchoSession;
import zeroecho.sdk.hybrid.kex.HybridKexContext;
import zeroecho.sdk.hybrid.kex.HybridKexContexts;
import zeroecho.sdk.hybrid.kex.HybridKexPolicy;
import zeroecho.sdk.hybrid.kex.HybridKexProfile;
import zeroecho.sdk.hybrid.kex.HybridKexTranscript;
@@ -87,20 +92,20 @@ class HybridKexBuilderTest {
HybridKexTranscript transcript = new HybridKexTranscript().addUtf8("suite", "X25519+ML-KEM-768").addUtf8("role",
"builder-test");
KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
KeyPair aliceClassic = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassic = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobPqc = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
HybridKexContext alice = null;
HybridKexContext bob = null;
try {
alice = HybridKexBuilder.builder().profile(profile).transcript(transcript).classicAgreement()
alice = HybridKexBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).profile(profile).transcript(transcript).classicAgreement()
.algorithm("Xdh").spec(XdhSpec.X25519).privateKey(aliceClassic.getPrivate())
.peerPublic(bobClassic.getPublic()).pqcKem().algorithm("ML-KEM").peerPublic(bobPqc.getPublic())
.buildInitiator();
bob = HybridKexBuilder.builder().profile(profile).transcript(transcript).classicAgreement().algorithm("Xdh")
bob = HybridKexBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).profile(profile).transcript(transcript).classicAgreement().algorithm("Xdh")
.spec(XdhSpec.X25519).privateKey(bobClassic.getPrivate()).peerPublic(aliceClassic.getPublic())
.pqcKem().algorithm("ML-KEM").privateKey(bobPqc.getPrivate()).buildResponder();
@@ -133,19 +138,19 @@ class HybridKexBuilderTest {
HybridKexProfile profile = HybridKexProfile.defaultProfile(32);
KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
KeyPair aliceClassic = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassic = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobPqc = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
HybridKexContext alice = null;
HybridKexContext bob = null;
try {
alice = HybridKexBuilder.builder().profile(profile).classicPairMessage().algorithm("Xdh")
alice = HybridKexBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).profile(profile).classicPairMessage().algorithm("Xdh")
.spec(XdhSpec.X25519).keyPair(new KeyPairKey(aliceClassic)).pqcKem().algorithm("ML-KEM")
.peerPublic(bobPqc.getPublic()).buildInitiator();
bob = HybridKexBuilder.builder().profile(profile).classicPairMessage().algorithm("Xdh").spec(XdhSpec.X25519)
bob = HybridKexBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).profile(profile).classicPairMessage().algorithm("Xdh").spec(XdhSpec.X25519)
.keyPair(new KeyPairKey(bobClassic)).pqcKem().algorithm("ML-KEM").privateKey(bobPqc.getPrivate())
.buildResponder();
@@ -179,12 +184,12 @@ class HybridKexBuilderTest {
void buildInitiatorWithoutProfileFails() throws Exception {
System.out.println("buildInitiatorWithoutProfileFails");
KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
KeyPair aliceClassic = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassic = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobPqc = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
IllegalStateException exception = assertThrows(IllegalStateException.class, () -> {
HybridKexBuilder.builder().classicAgreement().algorithm("Xdh").spec(XdhSpec.X25519)
HybridKexBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).classicAgreement().algorithm("Xdh").spec(XdhSpec.X25519)
.privateKey(aliceClassic.getPrivate()).peerPublic(bobClassic.getPublic()).pqcKem()
.algorithm("ML-KEM").peerPublic(bobPqc.getPublic()).buildInitiator();
});
@@ -200,10 +205,10 @@ class HybridKexBuilderTest {
System.out.println("buildInitiatorWithoutClassicModeFails");
HybridKexProfile profile = HybridKexProfile.defaultProfile(32);
KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
KeyPair bobPqc = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
IllegalStateException exception = assertThrows(IllegalStateException.class, () -> {
HybridKexBuilder.builder().profile(profile).pqcKem().algorithm("ML-KEM").peerPublic(bobPqc.getPublic())
HybridKexBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).profile(profile).pqcKem().algorithm("ML-KEM").peerPublic(bobPqc.getPublic())
.buildInitiator();
});
@@ -218,11 +223,11 @@ class HybridKexBuilderTest {
System.out.println("buildInitiatorClassicAgreementWithoutPeerPublicFails");
HybridKexProfile profile = HybridKexProfile.defaultProfile(32);
KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
KeyPair aliceClassic = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobPqc = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
IllegalStateException exception = assertThrows(IllegalStateException.class, () -> {
HybridKexBuilder.builder().profile(profile).classicAgreement().algorithm("Xdh").spec(XdhSpec.X25519)
HybridKexBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).profile(profile).classicAgreement().algorithm("Xdh").spec(XdhSpec.X25519)
.privateKey(aliceClassic.getPrivate()).pqcKem().algorithm("ML-KEM").peerPublic(bobPqc.getPublic())
.buildInitiator();
});
@@ -238,10 +243,10 @@ class HybridKexBuilderTest {
System.out.println("buildResponderPairMessageWithoutKeyPairFails");
HybridKexProfile profile = HybridKexProfile.defaultProfile(32);
KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
KeyPair bobPqc = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
IllegalStateException exception = assertThrows(IllegalStateException.class, () -> {
HybridKexBuilder.builder().profile(profile).classicPairMessage().algorithm("Xdh").spec(XdhSpec.X25519)
HybridKexBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).profile(profile).classicPairMessage().algorithm("Xdh").spec(XdhSpec.X25519)
.pqcKem().algorithm("ML-KEM").privateKey(bobPqc.getPrivate()).buildResponder();
});
@@ -256,11 +261,11 @@ class HybridKexBuilderTest {
System.out.println("buildInitiatorWithoutPqcPeerPublicFails");
HybridKexProfile profile = HybridKexProfile.defaultProfile(32);
KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair aliceClassic = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassic = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
IllegalStateException exception = assertThrows(IllegalStateException.class, () -> {
HybridKexBuilder.builder().profile(profile).classicAgreement().algorithm("Xdh").spec(XdhSpec.X25519)
HybridKexBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).profile(profile).classicAgreement().algorithm("Xdh").spec(XdhSpec.X25519)
.privateKey(aliceClassic.getPrivate()).peerPublic(bobClassic.getPublic()).pqcKem()
.algorithm("ML-KEM").buildInitiator();
});
@@ -276,11 +281,11 @@ class HybridKexBuilderTest {
System.out.println("buildResponderWithoutPqcPrivateFails");
HybridKexProfile profile = HybridKexProfile.defaultProfile(32);
KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassic = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair aliceClassic = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
IllegalStateException exception = assertThrows(IllegalStateException.class, () -> {
HybridKexBuilder.builder().profile(profile).classicAgreement().algorithm("Xdh").spec(XdhSpec.X25519)
HybridKexBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).profile(profile).classicAgreement().algorithm("Xdh").spec(XdhSpec.X25519)
.privateKey(bobClassic.getPrivate()).peerPublic(aliceClassic.getPublic()).pqcKem()
.algorithm("ML-KEM").buildResponder();
});
@@ -298,12 +303,12 @@ class HybridKexBuilderTest {
HybridKexProfile profile = HybridKexProfile.defaultProfile(16);
HybridKexPolicy policy = new HybridKexPolicy(0, 0, 32);
KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
KeyPair aliceClassic = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassic = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobPqc = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> {
HybridKexBuilder.builder().profile(profile).policy(policy).classicAgreement().algorithm("Xdh")
HybridKexBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).profile(profile).policy(policy).classicAgreement().algorithm("Xdh")
.spec(XdhSpec.X25519).privateKey(aliceClassic.getPrivate()).peerPublic(bobClassic.getPublic())
.pqcKem().algorithm("ML-KEM").peerPublic(bobPqc.getPublic()).buildInitiator();
});
@@ -314,18 +319,111 @@ class HybridKexBuilderTest {
System.out.println("buildInitiatorRejectsPolicyWhenOkmTooShort...ok");
}
@Test
void policyFailureClosesBothConstructedLegs() throws Exception {
System.out.println("policyFailureClosesBothConstructedLegs");
AtomicInteger closedContexts = new AtomicInteger();
AuditListener listener = new AuditListener() {
@Override
public void onContextClosed(String contextId, long bodyBytes, long trailerBytes, long durationMillis) {
closedContexts.incrementAndGet();
}
};
ZeroEchoSession session = new ZeroEchoSession()
.withAuditListener(listener)
.withAuditMode(AuditMode.WRAP);
HybridKexProfile profile = HybridKexProfile.defaultProfile(16);
HybridKexPolicy policy = new HybridKexPolicy(0, 0, 32);
KeyPair aliceClassic = session.keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassic = session.keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobPqc = session.keyBuilders().asymmetric().generateKeyPair("ML-KEM",
KyberKeyGenSpec.kyber768());
assertThrows(IllegalArgumentException.class,
() -> HybridKexBuilder.builder(session).profile(profile).policy(policy).classicAgreement()
.algorithm("Xdh").spec(XdhSpec.X25519).privateKey(aliceClassic.getPrivate())
.peerPublic(bobClassic.getPublic()).pqcKem().algorithm("ML-KEM")
.peerPublic(bobPqc.getPublic()).buildInitiator());
assertEquals(2, closedContexts.get());
System.out.println("...closedContexts=" + closedContexts.get());
System.out.println("policyFailureClosesBothConstructedLegs...ok");
}
@Test
void secondLegPolicyFailureClosesFirstFactoryLeg() throws Exception {
System.out.println("secondLegPolicyFailureClosesFirstFactoryLeg");
AtomicInteger closedContexts = new AtomicInteger();
AuditListener listener = new AuditListener() {
@Override
public void onContextClosed(String contextId, long bodyBytes, long trailerBytes, long durationMillis) {
closedContexts.incrementAndGet();
}
};
ZeroEchoSession keySession = new ZeroEchoSession();
KeyPair aliceClassic = keySession.keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassic = keySession.keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobPqc = keySession.keyBuilders().asymmetric().generateKeyPair("ML-KEM",
KyberKeyGenSpec.kyber768());
ZeroEchoSession operationSession = new ZeroEchoSession()
.withAuditListener(listener)
.withAuditMode(AuditMode.WRAP)
.withPolicy((id, role, key, spec) -> {
if ("ML-KEM".equals(id)) {
throw new IllegalArgumentException("controlled PQ policy denial");
}
});
assertThrows(IllegalArgumentException.class,
() -> HybridKexContexts.initiator(operationSession, HybridKexProfile.defaultProfile(32),
"Xdh", aliceClassic.getPrivate(), bobClassic.getPublic(), XdhSpec.X25519,
"ML-KEM", bobPqc.getPublic(), null));
assertEquals(1, closedContexts.get());
System.out.println("...closedContexts=" + closedContexts.get());
System.out.println("secondLegPolicyFailureClosesFirstFactoryLeg...ok");
}
@Test
void invalidFactoryInputIsRejectedBeforeContextAllocation() throws Exception {
System.out.println("invalidFactoryInputIsRejectedBeforeContextAllocation");
AtomicInteger createdContexts = new AtomicInteger();
AuditListener listener = new AuditListener() {
@Override
public void onContextCreatedMeta(String contextId, String algorithmId, String provider,
zeroecho.core.KeyUsage role, String keyFingerprint,
java.util.Map<String, Object> specMeta) {
createdContexts.incrementAndGet();
}
};
ZeroEchoSession session = new ZeroEchoSession()
.withAuditListener(listener)
.withAuditMode(AuditMode.WRAP);
KeyPair aliceClassic = session.keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassic = session.keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
assertThrows(NullPointerException.class,
() -> HybridKexContexts.initiator(session, HybridKexProfile.defaultProfile(32),
"Xdh", aliceClassic.getPrivate(), bobClassic.getPublic(), XdhSpec.X25519,
"ML-KEM", null, null));
assertEquals(0, createdContexts.get());
System.out.println("...createdContexts=0");
System.out.println("invalidFactoryInputIsRejectedBeforeContextAllocation...ok");
}
@Test
void switchingClassicModeClearsConflictingStateAndBuildsPairMessage() throws Exception {
System.out.println("switchingClassicModeClearsConflictingStateAndBuildsPairMessage");
HybridKexProfile profile = HybridKexProfile.defaultProfile(32);
KeyPair agreementKeyPair = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair pairMessageKeyPair = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
KeyPair agreementKeyPair = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair pairMessageKeyPair = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobPqc = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
HybridKexContext context = null;
try {
HybridKexBuilder builder = HybridKexBuilder.builder().profile(profile);
HybridKexBuilder builder = HybridKexBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).profile(profile);
builder.classicAgreement().algorithm("Xdh").spec(XdhSpec.X25519).privateKey(agreementKeyPair.getPrivate())
.peerPublic(agreementKeyPair.getPublic());
@@ -352,13 +450,13 @@ class HybridKexBuilderTest {
HybridKexProfile profile = HybridKexProfile.defaultProfile(32);
KeyPair aliceClassicA = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassicA = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobPqcA = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
KeyPair aliceClassicA = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassicA = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobPqcA = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
KeyPair aliceClassicB = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassicB = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobPqcB = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
KeyPair aliceClassicB = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassicB = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobPqcB = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
HybridKexTranscript transcriptA = new HybridKexTranscript().addUtf8("context", "A");
HybridKexTranscript transcriptB = new HybridKexTranscript().addUtf8("context", "B");
@@ -372,12 +470,12 @@ class HybridKexBuilderTest {
HybridKexContext bobB = null;
try {
aliceA = HybridKexBuilder.builder().profile(profile).transcript(transcriptA).classicAgreement()
aliceA = HybridKexBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).profile(profile).transcript(transcriptA).classicAgreement()
.algorithm("Xdh").spec(XdhSpec.X25519).privateKey(aliceClassicA.getPrivate())
.peerPublic(bobClassicA.getPublic()).pqcKem().algorithm("ML-KEM").peerPublic(bobPqcA.getPublic())
.buildInitiator();
bobA = HybridKexBuilder.builder().profile(profile).transcript(transcriptA).classicAgreement()
bobA = HybridKexBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).profile(profile).transcript(transcriptA).classicAgreement()
.algorithm("Xdh").spec(XdhSpec.X25519).privateKey(bobClassicA.getPrivate())
.peerPublic(aliceClassicA.getPublic()).pqcKem().algorithm("ML-KEM").privateKey(bobPqcA.getPrivate())
.buildResponder();
@@ -389,12 +487,12 @@ class HybridKexBuilderTest {
System.out.println("...responderA=" + hex(responderA));
assertArrayEquals(secretA, responderA);
aliceB = HybridKexBuilder.builder().profile(profile).transcript(transcriptB).classicAgreement()
aliceB = HybridKexBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).profile(profile).transcript(transcriptB).classicAgreement()
.algorithm("Xdh").spec(XdhSpec.X25519).privateKey(aliceClassicB.getPrivate())
.peerPublic(bobClassicB.getPublic()).pqcKem().algorithm("ML-KEM").peerPublic(bobPqcB.getPublic())
.buildInitiator();
bobB = HybridKexBuilder.builder().profile(profile).transcript(transcriptB).classicAgreement()
bobB = HybridKexBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).profile(profile).transcript(transcriptB).classicAgreement()
.algorithm("Xdh").spec(XdhSpec.X25519).privateKey(bobClassicB.getPrivate())
.peerPublic(aliceClassicB.getPublic()).pqcKem().algorithm("ML-KEM").privateKey(bobPqcB.getPrivate())
.buildResponder();
@@ -468,4 +566,4 @@ class HybridKexBuilderTest {
return "classicLen=?, pqcLen=?";
}
}
}
}

View File

@@ -63,8 +63,10 @@ import zeroecho.core.alg.sphincsplus.SphincsPlusKeyGenSpec;
import zeroecho.core.context.EncryptionContext;
import zeroecho.core.context.KemContext;
import zeroecho.core.spec.AlgorithmKeySpec;
import zeroecho.core.spi.AsymmetricKeyBuilder;
import zeroecho.core.spi.SymmetricKeyBuilder;
import zeroecho.core.KeyOperation;
import zeroecho.core.KeyOperationInfo;
import zeroecho.core.spi.AsymmetricKeyPairGenerator;
import zeroecho.core.spi.SymmetricKeyGenerator;
import zeroecho.core.tag.TagEngine;
import zeroecho.core.tag.TagEngineBuilder;
import zeroecho.sdk.builders.alg.AesDataContentBuilder;
@@ -173,26 +175,26 @@ public class TagTrailerDataContentBuilderTest {
System.out.println("...msg=" + msg.length + " bytes");
// AES key
SecretKey aesKey = CryptoAlgorithms.require("AES").symmetricKeyBuilder(AesKeyGenSpec.class)
SecretKey aesKey = CryptoAlgorithms.require("AES").symmetricKeyGenerator(AesKeyGenSpec.class)
.generateSecret(AesKeyGenSpec.aes256());
// Ed25519 keys (JCA)
KeyPair ed = CryptoAlgorithms.keyPair("Ed25519", Ed25519KeyGenSpec.defaultSpec());
KeyPair ed = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("Ed25519", Ed25519KeyGenSpec.defaultSpec());
TagEngine<Signature> tagEnc = TagEngineBuilder.ed25519Sign(ed.getPrivate()).get();
TagEngine<Signature> tagDec = TagEngineBuilder.ed25519Verify(ed.getPublic()).get();
TagEngine<Signature> tagEnc = TagEngineBuilder.ed25519Sign(new zeroecho.sdk.ZeroEchoSession(), ed.getPrivate()).get();
TagEngine<Signature> tagDec = TagEngineBuilder.ed25519Verify(new zeroecho.sdk.ZeroEchoSession(), ed.getPublic()).get();
// ENCRYPT: body -> [body||signature] -> AES-GCM
DataContent enc = DataContentChainBuilder.encrypt().add(BytesSourceBuilder.of(msg))
.add(new TagTrailerDataContentBuilder<>(tagEnc).bufferSize(8192))
.add(AesDataContentBuilder.builder().withKey(aesKey).modeGcm(128).withHeader()).build();
.add(AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withKey(aesKey).modeGcm(128).withHeader()).build();
byte[] ct = readAll(enc.getStream());
System.out.println("...ct=" + ct.length + " bytes");
// DECRYPT: AES-GCM -> strip trailer -> verify Ed25519 at EOF
DataContent dec = DataContentChainBuilder.decrypt().add(BytesSourceBuilder.of(ct))
.add(AesDataContentBuilder.builder().withKey(aesKey).modeGcm(128).withHeader())
.add(AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withKey(aesKey).modeGcm(128).withHeader())
.add(new TagTrailerDataContentBuilder<>(tagDec).bufferSize(8192).throwOnMismatch()).build();
byte[] pt = readAll(dec.getStream());
@@ -210,28 +212,28 @@ public class TagTrailerDataContentBuilderTest {
System.out.println("...msg=" + msg.length + " bytes");
// AES key
SecretKey aesKey = CryptoAlgorithms.require("AES").symmetricKeyBuilder(AesKeyGenSpec.class)
SecretKey aesKey = CryptoAlgorithms.require("AES").symmetricKeyGenerator(AesKeyGenSpec.class)
.generateSecret(AesKeyGenSpec.aes256());
// SPHINCS+ key pair via registry (uses default param set from
// SphincsPlusKeyGenSpec)
KeyPair spx = CryptoAlgorithms.keyPair("SPHINCS+", SphincsPlusKeyGenSpec.defaultSpec());
KeyPair spx = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("SPHINCS+", SphincsPlusKeyGenSpec.defaultSpec());
// Tag engines (SPHINCS+)
TagEngine<Signature> tagEnc = TagEngineBuilder.sphincsPlusSign(spx.getPrivate()).get();
TagEngine<Signature> tagDec = TagEngineBuilder.sphincsPlusVerify(spx.getPublic()).get();
TagEngine<Signature> tagEnc = TagEngineBuilder.sphincsPlusSign(new zeroecho.sdk.ZeroEchoSession(), spx.getPrivate()).get();
TagEngine<Signature> tagDec = TagEngineBuilder.sphincsPlusVerify(new zeroecho.sdk.ZeroEchoSession(), spx.getPublic()).get();
// ENCRYPT: body -> [body||spxSig] -> AES-GCM
DataContent enc = DataContentChainBuilder.encrypt().add(BytesSourceBuilder.of(msg))
.add(new TagTrailerDataContentBuilder<>(tagEnc).bufferSize(8192))
.add(AesDataContentBuilder.builder().withKey(aesKey).modeGcm(128).withHeader()).build();
.add(AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withKey(aesKey).modeGcm(128).withHeader()).build();
byte[] ct = readAll(enc.getStream());
System.out.println("...ct=" + ct.length + " bytes");
// DECRYPT: AES-GCM -> strip trailer -> verify SPHINCS+ at EOF
DataContent dec = DataContentChainBuilder.decrypt().add(BytesSourceBuilder.of(ct))
.add(AesDataContentBuilder.builder().withKey(aesKey).modeGcm(128).withHeader())
.add(AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withKey(aesKey).modeGcm(128).withHeader())
.add(new TagTrailerDataContentBuilder<>(tagDec).bufferSize(8192).throwOnMismatch()).build();
byte[] pt = readAll(dec.getStream());
@@ -249,28 +251,28 @@ public class TagTrailerDataContentBuilderTest {
System.out.println("...msg=" + msg.length + " bytes");
// AES key
SecretKey aesKey = CryptoAlgorithms.require("AES").symmetricKeyBuilder(AesKeyGenSpec.class)
SecretKey aesKey = CryptoAlgorithms.require("AES").symmetricKeyGenerator(AesKeyGenSpec.class)
.generateSecret(AesKeyGenSpec.aes256());
// RSA-2048 keys (use registry for convenience)
KeyPair rsa = CryptoAlgorithms.keyPair("RSA", RsaKeyGenSpec.rsa2048());
KeyPair rsa = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("RSA", RsaKeyGenSpec.rsa2048());
// Tag engines (SHA-256, saltLen=32)
RsaSigSpec pss = RsaSigSpec.pss(RsaSigSpec.Hash.SHA256, 32);
TagEngine<Signature> tagEnc = TagEngineBuilder.rsaSign(rsa.getPrivate(), pss).get();
TagEngine<Signature> tagDec = TagEngineBuilder.rsaVerify(rsa.getPublic(), pss).get();
TagEngine<Signature> tagEnc = TagEngineBuilder.rsaSign(new zeroecho.sdk.ZeroEchoSession(), rsa.getPrivate(), pss).get();
TagEngine<Signature> tagDec = TagEngineBuilder.rsaVerify(new zeroecho.sdk.ZeroEchoSession(), rsa.getPublic(), pss).get();
// ENCRYPT: body -> [body||pssSig] -> AES-GCM
DataContent enc = DataContentChainBuilder.encrypt().add(BytesSourceBuilder.of(msg))
.add(new TagTrailerDataContentBuilder<>(tagEnc).bufferSize(8192))
.add(AesDataContentBuilder.builder().withKey(aesKey).modeGcm(128).withHeader()).build();
.add(AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withKey(aesKey).modeGcm(128).withHeader()).build();
byte[] ct = readAll(enc.getStream());
System.out.println("...ct=" + ct.length + " bytes");
// DECRYPT: AES-GCM -> strip trailer -> verify PSS at EOF
DataContent dec = DataContentChainBuilder.decrypt().add(BytesSourceBuilder.of(ct))
.add(AesDataContentBuilder.builder().withKey(aesKey).modeGcm(128).withHeader())
.add(AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withKey(aesKey).modeGcm(128).withHeader())
.add(new TagTrailerDataContentBuilder<>(tagDec).bufferSize(8192).throwOnMismatch()).build();
byte[] pt = readAll(dec.getStream());
@@ -289,14 +291,14 @@ public class TagTrailerDataContentBuilderTest {
System.out.println("...msg=" + msg.length + " bytes");
// AES key up-front so decrypt can reuse it
SymmetricKeyBuilder<AesKeyGenSpec> gen = CryptoAlgorithms.require("AES")
.symmetricKeyBuilder(AesKeyGenSpec.class);
SymmetricKeyGenerator<AesKeyGenSpec> gen = CryptoAlgorithms.require("AES")
.symmetricKeyGenerator(AesKeyGenSpec.class);
SecretKey aesKey = gen.generateSecret(AesKeyGenSpec.aes256());
// ENCRYPT: [source] -> [tag trailer] -> [aes gcm]
DataContent encChain = DataContentChainBuilder.encrypt().add(BytesSourceBuilder.of(msg))
.add(new TagTrailerDataContentBuilder<>(TagEngineBuilder.digest(DigestSpec.sha256())).bufferSize(8192))
.add(AesDataContentBuilder.builder().withKey(aesKey).modeGcm(128).withHeader()) // writes IV/AAD headers
.add(new TagTrailerDataContentBuilder<>(TagEngineBuilder.digest(new zeroecho.sdk.ZeroEchoSession(), DigestSpec.sha256())).bufferSize(8192))
.add(AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withKey(aesKey).modeGcm(128).withHeader()) // writes IV/AAD headers
// into stream
.build();
@@ -305,9 +307,9 @@ public class TagTrailerDataContentBuilderTest {
// DECRYPT: [source(ct)] -> [aes gcm] -> [tag trailer verify]
DataContent decChain = DataContentChainBuilder.decrypt().add(BytesSourceBuilder.of(ciphertext))
.add(AesDataContentBuilder.builder().withKey(aesKey).modeGcm(128).withHeader()) // reads IV/AAD headers
.add(AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withKey(aesKey).modeGcm(128).withHeader()) // reads IV/AAD headers
// back
.add(new TagTrailerDataContentBuilder<>(TagEngineBuilder.digest(DigestSpec.sha256())).bufferSize(8192)
.add(new TagTrailerDataContentBuilder<>(TagEngineBuilder.digest(new zeroecho.sdk.ZeroEchoSession(), DigestSpec.sha256())).bufferSize(8192)
.throwOnMismatch())
.build();
@@ -328,12 +330,12 @@ public class TagTrailerDataContentBuilderTest {
msg = Arrays.copyOf(msg, SIZE); // pad deterministic length for the test
System.out.println("...msg=" + msg.length + " bytes");
KeyPair kp = CryptoAlgorithms.keyPair("RSA", RsaKeyGenSpec.rsa2048());
KeyPair kp = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("RSA", RsaKeyGenSpec.rsa2048());
// ENCRYPT: [source] -> [tag trailer] -> [rsa/oaep]
DataContent enc = DataContentChainBuilder.encrypt().add(BytesSourceBuilder.of(msg))
.add(new TagTrailerDataContentBuilder<>(TagEngineBuilder.digest(DigestSpec.sha256())).bufferSize(8192))
.add(RsaEncDataContentBuilder.builder().oaep(RsaEncSpec.Hash.SHA256).withPublicKey(kp.getPublic()))
.add(new TagTrailerDataContentBuilder<>(TagEngineBuilder.digest(new zeroecho.sdk.ZeroEchoSession(), DigestSpec.sha256())).bufferSize(8192))
.add(RsaEncDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).oaep(RsaEncSpec.Hash.SHA256).withPublicKey(kp.getPublic()))
.build();
byte[] ct = readAll(enc.getStream());
@@ -341,8 +343,8 @@ public class TagTrailerDataContentBuilderTest {
// DECRYPT: [source(ct)] -> [rsa/oaep] -> [tag verify]
DataContent dec = DataContentChainBuilder.decrypt().add(BytesSourceBuilder.of(ct))
.add(RsaEncDataContentBuilder.builder().oaep(RsaEncSpec.Hash.SHA256).withPrivateKey(kp.getPrivate()))
.add(new TagTrailerDataContentBuilder<>(TagEngineBuilder.digest(DigestSpec.sha256())).bufferSize(8192)
.add(RsaEncDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).oaep(RsaEncSpec.Hash.SHA256).withPrivateKey(kp.getPrivate()))
.add(new TagTrailerDataContentBuilder<>(TagEngineBuilder.digest(new zeroecho.sdk.ZeroEchoSession(), DigestSpec.sha256())).bufferSize(8192)
.throwOnMismatch())
.build();
@@ -377,12 +379,12 @@ public class TagTrailerDataContentBuilderTest {
System.out.println("...msg=" + msg.length + " bytes");
// ENCRYPT: [source] -> [tag trailer] -> [KEM envelope with AES/GCM payload]
AesDataContentBuilder aesEnc = AesDataContentBuilder.builder().modeGcm(128) // 128-bit tag
AesDataContentBuilder aesEnc = AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).modeGcm(128) // 128-bit tag
.withHeader(); // carry IV etc.
DataContent enc = DataContentChainBuilder.encrypt().add(BytesSourceBuilder.of(msg))
.add(new TagTrailerDataContentBuilder<>(TagEngineBuilder.digest(DigestSpec.sha256())).bufferSize(8192))
.add(KemDataContentBuilder.builder().kem(kemId).recipientPublic(kemKeys.getPublic()).derivedKeyBytes(32) // AES-256
.add(new TagTrailerDataContentBuilder<>(TagEngineBuilder.digest(new zeroecho.sdk.ZeroEchoSession(), DigestSpec.sha256())).bufferSize(8192))
.add(KemDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).kem(kemId).recipientPublic(kemKeys.getPublic()).derivedKeyBytes(32) // AES-256
// key
// derived
// from
@@ -396,14 +398,14 @@ public class TagTrailerDataContentBuilderTest {
System.out.println("...envelope=" + envelope.length + " bytes");
// DECRYPT: [source(envelope)] -> [KEM] -> [tag verify]
AesDataContentBuilder aesDec = AesDataContentBuilder.builder().modeGcm(128).withHeader();
AesDataContentBuilder aesDec = AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).modeGcm(128).withHeader();
DataContent dec = DataContentChainBuilder.decrypt().add(BytesSourceBuilder.of(envelope))
.add(KemDataContentBuilder.builder().kem(kemId).recipientPrivate(kemKeys.getPrivate())
.add(KemDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).kem(kemId).recipientPrivate(kemKeys.getPrivate())
.derivedKeyBytes(32)
.hkdfSha256("KEM-tag-demo".getBytes(java.nio.charset.StandardCharsets.US_ASCII))
.withAes(aesDec))
.add(new TagTrailerDataContentBuilder<>(TagEngineBuilder.digest(DigestSpec.sha256())).bufferSize(8192)
.add(new TagTrailerDataContentBuilder<>(TagEngineBuilder.digest(new zeroecho.sdk.ZeroEchoSession(), DigestSpec.sha256())).bufferSize(8192)
.throwOnMismatch())
.build();
@@ -428,26 +430,26 @@ public class TagTrailerDataContentBuilderTest {
// --- recipients ---
// RSA
KeyPair rsa = CryptoAlgorithms.keyPair("RSA", RsaKeyGenSpec.rsa2048());
KeyPair rsa = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("RSA", RsaKeyGenSpec.rsa2048());
// ML-KEM (Kyber768 as a good mid-level)
KeyPair kem = CryptoAlgorithms.keyPair("ML-KEM", KyberKeyGenSpec.kyber768());
KeyPair kem = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
// --- symmetric payload (AES-256/GCM, tag 128) ---
// IV length is handled internally (12 bytes for GCM) and persisted via header.
AesDataContentBuilder aesEnc = AesDataContentBuilder.builder().modeGcm(128).withHeader(); // write
AesDataContentBuilder aesEnc = AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).modeGcm(128).withHeader(); // write
// IV/tagBits/AAD-hash
// header for decrypt
// side
// --- tag trailer (SHA-256 digest as a trailer) ---
TagTrailerDataContentBuilder<byte[]> tagEnc = new TagTrailerDataContentBuilder<>(
TagEngineBuilder.digest(DigestSpec.sha256())).bufferSize(8192);
TagEngineBuilder.digest(new zeroecho.sdk.ZeroEchoSession(), DigestSpec.sha256())).bufferSize(8192);
EncryptionContext rsaEnc = CryptoAlgorithms.create("RSA", KeyUsage.ENCRYPT, rsa.getPublic());
KemContext kybKem = CryptoAlgorithms.create("ML-KEM", KeyUsage.ENCAPSULATE, kem.getPublic());
EncryptionContext rsaEnc = new zeroecho.sdk.ZeroEchoSession().createContext("RSA", KeyUsage.ENCRYPT, rsa.getPublic());
KemContext kybKem = new zeroecho.sdk.ZeroEchoSession().createContext("ML-KEM", KeyUsage.ENCAPSULATE, kem.getPublic());
// --- envelope (ENCRYPT) with 3 recipients ---
MultiRecipientDataSourceBuilder envEnc = new MultiRecipientDataSourceBuilder().withAes(aesEnc)
MultiRecipientDataSourceBuilder envEnc = MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesEnc)
// .addRsaOaepRecipient(rsa.getPublic()) // RSA-OAEP with SHA-256 MGF1
// .addKemRecipient("ML-KEM", kem.getPublic(), 32 /* kekBytes */, 16 /*
// hkdfSaltLen */)
@@ -465,35 +467,35 @@ public class TagTrailerDataContentBuilderTest {
// -------------- Decrypt three ways on the same ciphertext --------------
// a) by RSA private key
AesDataContentBuilder aesDecRsa = AesDataContentBuilder.builder().modeGcm(128).withHeader(); // read header to
AesDataContentBuilder aesDecRsa = AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).modeGcm(128).withHeader(); // read header to
// recover
// IV/tagBits
MultiRecipientDataSourceBuilder envDecRsa = new MultiRecipientDataSourceBuilder().withAes(aesDecRsa)
MultiRecipientDataSourceBuilder envDecRsa = MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesDecRsa)
.unlockWith(new UnlockMaterial.Private(rsa.getPrivate()));
byte[] ptRsa = readAll(DataContentChainBuilder.decrypt().add(BytesSourceBuilder.of(encrypted)).add(envDecRsa)
.add(new TagTrailerDataContentBuilder<>(TagEngineBuilder.digest(DigestSpec.sha256())).bufferSize(8192)
.add(new TagTrailerDataContentBuilder<>(TagEngineBuilder.digest(new zeroecho.sdk.ZeroEchoSession(), DigestSpec.sha256())).bufferSize(8192)
.throwOnMismatch())
.build().getStream());
System.out.println("...decrypted(RSA)=" + ptRsa.length);
assertArrayEquals(msg, ptRsa, "RSA path failed to recover the original");
// b) by KEM private key
AesDataContentBuilder aesDecKem = AesDataContentBuilder.builder().modeGcm(128).withHeader();
MultiRecipientDataSourceBuilder envDecKem = new MultiRecipientDataSourceBuilder().withAes(aesDecKem)
AesDataContentBuilder aesDecKem = AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).modeGcm(128).withHeader();
MultiRecipientDataSourceBuilder envDecKem = MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesDecKem)
.unlockWith(new UnlockMaterial.Private(kem.getPrivate()));
byte[] ptKem = readAll(DataContentChainBuilder.decrypt().add(BytesSourceBuilder.of(encrypted)).add(envDecKem)
.add(new TagTrailerDataContentBuilder<>(TagEngineBuilder.digest(DigestSpec.sha256())).bufferSize(8192)
.add(new TagTrailerDataContentBuilder<>(TagEngineBuilder.digest(new zeroecho.sdk.ZeroEchoSession(), DigestSpec.sha256())).bufferSize(8192)
.throwOnMismatch())
.build().getStream());
System.out.println("...decrypted(KEM)=" + ptKem.length);
assertArrayEquals(msg, ptKem, "KEM path failed to recover the original");
// c) by password
AesDataContentBuilder aesDecPwd = AesDataContentBuilder.builder().modeGcm(128).withHeader();
MultiRecipientDataSourceBuilder envDecPwd = new MultiRecipientDataSourceBuilder().withAes(aesDecPwd)
AesDataContentBuilder aesDecPwd = AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).modeGcm(128).withHeader();
MultiRecipientDataSourceBuilder envDecPwd = MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesDecPwd)
.unlockWith(new UnlockMaterial.Password(PASSWORD));
byte[] ptPwd = readAll(DataContentChainBuilder.decrypt().add(BytesSourceBuilder.of(encrypted)).add(envDecPwd)
.add(new TagTrailerDataContentBuilder<>(TagEngineBuilder.digest(DigestSpec.sha256())).bufferSize(8192)
.add(new TagTrailerDataContentBuilder<>(TagEngineBuilder.digest(new zeroecho.sdk.ZeroEchoSession(), DigestSpec.sha256())).bufferSize(8192)
.throwOnMismatch())
.build().getStream());
System.out.println("...decrypted(PASSWORD)=" + ptPwd.length);
@@ -514,23 +516,23 @@ public class TagTrailerDataContentBuilderTest {
byte[] msg = random(SIZE);
System.out.println("...input=" + msg.length);
KeyPair rsa = CryptoAlgorithms.keyPair("RSA", RsaKeyGenSpec.rsa2048());
KeyPair rsa = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("RSA", RsaKeyGenSpec.rsa2048());
// AES-256/CBC with header so IV/params are serialized by the AES stage
AesDataContentBuilder aesCbc = AesDataContentBuilder.builder().modeCbcPkcs5().withHeader();
AesDataContentBuilder aesCbc = AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).modeCbcPkcs5().withHeader();
TagTrailerDataContentBuilder<byte[]> tagEnc = new TagTrailerDataContentBuilder<>(
TagEngineBuilder.digest(DigestSpec.sha256())).bufferSize(8192);
TagEngineBuilder.digest(new zeroecho.sdk.ZeroEchoSession(), DigestSpec.sha256())).bufferSize(8192);
TagTrailerDataContentBuilder<byte[]> tagDec = new TagTrailerDataContentBuilder<>(
TagEngineBuilder.digest(DigestSpec.sha256())).bufferSize(8192)
TagEngineBuilder.digest(new zeroecho.sdk.ZeroEchoSession(), DigestSpec.sha256())).bufferSize(8192)
// explicit for clarity
.throwOnMismatch();
EncryptionContext rsaEnc = CryptoAlgorithms.create("RSA", KeyUsage.ENCRYPT, rsa.getPublic());
EncryptionContext rsaEnc = new zeroecho.sdk.ZeroEchoSession().createContext("RSA", KeyUsage.ENCRYPT, rsa.getPublic());
// Envelope: recipient table (RSA-OAEP) + AES payload (CBC/PKCS7 with header)
MultiRecipientDataSourceBuilder envEnc = new MultiRecipientDataSourceBuilder().withAes(aesCbc)
MultiRecipientDataSourceBuilder envEnc = MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesCbc)
// CEK length for AES-256
.payloadKeyBytes(32)
// .addRsaOaepRecipient(rsa.getPublic()); old API
@@ -543,8 +545,8 @@ public class TagTrailerDataContentBuilderTest {
byte[] encrypted = readAll(encTail.getStream());
System.out.println("...encrypted=" + encrypted.length);
MultiRecipientDataSourceBuilder envDec = new MultiRecipientDataSourceBuilder()
.withAes(AesDataContentBuilder.builder().modeCbcPkcs5()
MultiRecipientDataSourceBuilder envDec = MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000)))
.withAes(AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).modeCbcPkcs5()
// must match encrypt side
.withHeader())
.payloadKeyBytes(32).unlockWith(new UnlockMaterial.Private(rsa.getPrivate()));
@@ -571,27 +573,27 @@ public class TagTrailerDataContentBuilderTest {
System.out.println("...msg=" + msg.length + " bytes");
// AES key
SecretKey aesKey = CryptoAlgorithms.require("AES").symmetricKeyBuilder(AesKeyGenSpec.class)
SecretKey aesKey = CryptoAlgorithms.require("AES").symmetricKeyGenerator(AesKeyGenSpec.class)
.generateSecret(AesKeyGenSpec.aes256());
// ECDSA P-256 keys (via your unified ECDSA algorithm)
KeyPair ecdsa = CryptoAlgorithms.keyPair("ECDSA", zeroecho.core.alg.ecdsa.EcdsaCurveSpec.P256);
KeyPair ecdsa = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ECDSA", zeroecho.core.alg.ecdsa.EcdsaCurveSpec.P256);
// Tag engines (ECDSA/P-256 using P1363 format, fixed 64-byte tag)
TagEngine<Signature> tagEnc = TagEngineBuilder.ecdsaP256Sign(ecdsa.getPrivate()).get();
TagEngine<Signature> tagDec = TagEngineBuilder.ecdsaP256Verify(ecdsa.getPublic()).get();
TagEngine<Signature> tagEnc = TagEngineBuilder.ecdsaP256Sign(new zeroecho.sdk.ZeroEchoSession(), ecdsa.getPrivate()).get();
TagEngine<Signature> tagDec = TagEngineBuilder.ecdsaP256Verify(new zeroecho.sdk.ZeroEchoSession(), ecdsa.getPublic()).get();
// ENCRYPT: body -> [body||ecdsaSig] -> AES-GCM
DataContent enc = DataContentChainBuilder.encrypt().add(BytesSourceBuilder.of(msg))
.add(new TagTrailerDataContentBuilder<>(tagEnc).bufferSize(8192))
.add(AesDataContentBuilder.builder().withKey(aesKey).modeGcm(128).withHeader()).build();
.add(AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withKey(aesKey).modeGcm(128).withHeader()).build();
byte[] ct = readAll(enc.getStream());
System.out.println("...ct=" + ct.length + " bytes");
// DECRYPT: AES-GCM -> strip trailer -> verify ECDSA at EOF
DataContent dec = DataContentChainBuilder.decrypt().add(BytesSourceBuilder.of(ct))
.add(AesDataContentBuilder.builder().withKey(aesKey).modeGcm(128).withHeader())
.add(AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withKey(aesKey).modeGcm(128).withHeader())
.add(new TagTrailerDataContentBuilder<>(tagDec).bufferSize(8192).throwOnMismatch()).build();
byte[] pt = readAll(dec.getStream());
@@ -612,14 +614,14 @@ public class TagTrailerDataContentBuilderTest {
private static KeyPair tryKeyPairWithDefaultSpec(CryptoAlgorithm alg) {
try {
for (CryptoAlgorithm.AsymBuilderInfo bi : alg.asymmetricBuildersInfo()) {
if (bi.defaultKeySpec == null) {
for (KeyOperationInfo bi : alg.keyOperations()) {
if (bi.operation() != KeyOperation.ASYMMETRIC_KEY_PAIR_GENERATE || bi.defaultSpec() == null) {
continue;
}
@SuppressWarnings("unchecked")
Class<AlgorithmKeySpec> specType = (Class<AlgorithmKeySpec>) bi.specType;
AlgorithmKeySpec spec = (AlgorithmKeySpec) bi.defaultKeySpec;
AsymmetricKeyBuilder<AlgorithmKeySpec> b = alg.asymmetricKeyBuilder(specType);
Class<AlgorithmKeySpec> specType = (Class<AlgorithmKeySpec>) bi.specType();
AlgorithmKeySpec spec = bi.defaultSpec();
AsymmetricKeyPairGenerator<AlgorithmKeySpec> b = alg.asymmetricKeyPairGenerator(specType);
KeyPair kp = b.generateKeyPair(spec);
if (kp != null) {
return kp;

View File

@@ -218,7 +218,7 @@ class KemHybridRoundTripTest {
new Random(123456789L).nextBytes(input);
// keypair via generic registry path
KeyPair kp = CryptoAlgorithms.keyPair(kemId, keyGenSpec);
KeyPair kp = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair(kemId, keyGenSpec);
// encrypt
DataContent enc = encryptStage(kemId, kp, mode);
@@ -244,24 +244,24 @@ class KemHybridRoundTripTest {
}
private static DataContent encryptStage(String kemId, KeyPair kp, String mode) {
KemDataContentBuilder kem = KemDataContentBuilder.builder().kem(kemId).recipientPublic(kp.getPublic())
KemDataContentBuilder kem = KemDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).kem(kemId).recipientPublic(kp.getPublic())
.derivedKeyBytes(32); // AES-256 or ChaCha20 key
switch (mode) {
case "GCM": {
AesDataContentBuilder aes = AesDataContentBuilder.builder().modeGcm(128).withHeader().withAad(AAD);
AesDataContentBuilder aes = AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).modeGcm(128).withHeader().withAad(AAD);
return kem.withAes(aes).build(true);
}
case "CBC": {
AesDataContentBuilder aes = AesDataContentBuilder.builder().modeCbcPkcs5().withHeader();
AesDataContentBuilder aes = AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).modeCbcPkcs5().withHeader();
return kem.withAes(aes).build(true);
}
case "CTR": {
AesDataContentBuilder aes = AesDataContentBuilder.builder().modeCtr().withHeader();
AesDataContentBuilder aes = AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).modeCtr().withHeader();
return kem.withAes(aes).build(true);
}
case "CHACHA20-POLY1305": {
ChaChaDataContentBuilder ch = ChaChaDataContentBuilder.builder().withAad(AAD) // non-empty → AEAD
ChaChaDataContentBuilder ch = ChaChaDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withAad(AAD) // non-empty → AEAD
// variant
.withHeader(); // carry nonce
return kem.withChaCha(ch).build(true);
@@ -272,24 +272,24 @@ class KemHybridRoundTripTest {
}
private static DataContent decryptStage(String kemId, KeyPair kp, String mode) {
KemDataContentBuilder kem = KemDataContentBuilder.builder().kem(kemId).recipientPrivate(kp.getPrivate())
KemDataContentBuilder kem = KemDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).kem(kemId).recipientPrivate(kp.getPrivate())
.derivedKeyBytes(32);
switch (mode) {
case "GCM": {
AesDataContentBuilder aes = AesDataContentBuilder.builder().modeGcm(128).withHeader().withAad(AAD);
AesDataContentBuilder aes = AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).modeGcm(128).withHeader().withAad(AAD);
return kem.withAes(aes).build(false);
}
case "CBC": {
AesDataContentBuilder aes = AesDataContentBuilder.builder().modeCbcPkcs5().withHeader();
AesDataContentBuilder aes = AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).modeCbcPkcs5().withHeader();
return kem.withAes(aes).build(false);
}
case "CTR": {
AesDataContentBuilder aes = AesDataContentBuilder.builder().modeCtr().withHeader();
AesDataContentBuilder aes = AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).modeCtr().withHeader();
return kem.withAes(aes).build(false);
}
case "CHACHA20-POLY1305": {
ChaChaDataContentBuilder ch = ChaChaDataContentBuilder.builder().withAad(AAD).withHeader();
ChaChaDataContentBuilder ch = ChaChaDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withAad(AAD).withHeader();
return kem.withChaCha(ch).build(false);
}
default:

View File

@@ -0,0 +1,106 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.sdk.builders.alg;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.InputStream;
import java.security.Key;
import java.util.Map;
import javax.crypto.spec.SecretKeySpec;
import org.junit.jupiter.api.Test;
import zeroecho.core.KeyUsage;
import zeroecho.core.audit.AuditListener;
import zeroecho.core.audit.AuditMode;
import zeroecho.core.spec.AlgorithmKeySpec;
import zeroecho.sdk.ZeroEchoSession;
import zeroecho.sdk.content.api.DataContent;
import zeroecho.sdk.content.builtin.PlainBytes;
class SessionBoundBuilderTest {
@Test
void generatedKeyUsesBuilderSessionAuditSink() {
System.out.println("generatedKeyUsesBuilderSessionAuditSink");
RecordingListener listener = new RecordingListener();
ZeroEchoSession session = new ZeroEchoSession().withAuditListener(listener);
AesDataContentBuilder.builder(session).generateKey(128).modeGcm(128).build(true);
assertEquals(1, listener.keyBuilt);
System.out.println("...keyBuiltEvents=" + listener.keyBuilt);
System.out.println("generatedKeyUsesBuilderSessionAuditSink...ok");
}
@Test
void policyDenialOccursBeforeContextCreation() {
System.out.println("policyDenialOccursBeforeContextCreation");
RecordingListener listener = new RecordingListener();
ZeroEchoSession session = new ZeroEchoSession()
.withAuditListener(listener)
.withPolicy((id, role, key, spec) -> {
throw new IllegalArgumentException("controlled policy denial");
});
DataContent encryption = AesDataContentBuilder.builder(session)
.withKey(new SecretKeySpec(new byte[16], "AES"))
.modeGcm(128)
.build(true);
encryption.setInput(new PlainBytes(new byte[] { 1 }));
assertThrows(IllegalArgumentException.class, encryption::getStream);
assertEquals(0, listener.contextCreated);
System.out.println("...contextCreatedEvents=0");
System.out.println("policyDenialOccursBeforeContextCreation...ok");
}
@Test
void wrappedContextUsesBuilderSessionAuditConfiguration() throws Exception {
System.out.println("wrappedContextUsesBuilderSessionAuditConfiguration");
RecordingListener listener = new RecordingListener();
ZeroEchoSession session = new ZeroEchoSession()
.withAuditListener(listener)
.withAuditMode(AuditMode.WRAP);
DataContent encryption = AesDataContentBuilder.builder(session)
.withKey(new SecretKeySpec(new byte[16], "AES"))
.modeGcm(128)
.build(true);
encryption.setInput(new PlainBytes(new byte[] { 1, 2, 3 }));
try (InputStream input = encryption.getStream()) {
input.readAllBytes();
}
assertEquals(1, listener.contextCreated);
assertEquals(1, listener.contextClosed);
System.out.println("...created=1...closed=1");
System.out.println("wrappedContextUsesBuilderSessionAuditConfiguration...ok");
}
private static final class RecordingListener implements AuditListener {
private int keyBuilt;
private int contextCreated;
private int contextClosed;
@Override
public void onKeyBuilt(String id, String provider, AlgorithmKeySpec spec, Key key) {
keyBuilt++;
}
@Override
public void onContextCreatedMeta(String contextId, String algorithmId, String provider, KeyUsage role,
String keyFingerprint, Map<String, Object> specMeta) {
contextCreated++;
}
@Override
public void onContextClosed(String contextId, long bodyBytes, long trailerBytes, long durationMillis) {
contextClosed++;
}
}
}

View File

@@ -33,22 +33,69 @@
******************************************************************************/
package zeroecho.sdk.content.builtin;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.InputStream;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
class SecretPasswordTest {
@Test
void testGetPlainText() {
void redactsDiagnosticsAndOwnsPasswordCharacters() {
System.out.print("SecretPassword/redaction...");
char[] caller = "correct horse".toCharArray();
char[] expected = caller.clone();
SecretPassword password = new SecretPassword(caller);
Arrays.fill(caller, 'x');
int len = 12;
assertEquals("[REDACTED]", password.toText());
assertEquals("[REDACTED]", password.toString());
assertArrayEquals(expected, password.chars());
char[] copy = password.chars();
copy[0] = 'x';
assertArrayEquals(expected, password.chars());
System.out.println("ok");
}
System.out.printf("generating password, %d characters...%n", len);
@Test
void destroysOwnedCharactersAndRejectsSecretAccess() throws Exception {
System.out.print("SecretPassword/destroy...");
SecretPassword password = new SecretPassword("sensitive".toCharArray());
assertFalse(password.isDestroyed());
SecretPassword sp = new SecretPassword(len);
password.destroy();
password.destroy();
System.out.printf("...string %s (length %d)%n", sp.toText(), sp.toText().length());
assertTrue(password.isDestroyed());
assertThrows(IllegalStateException.class, password::chars);
assertThrows(IllegalStateException.class, password::toBytes);
assertThrows(IllegalStateException.class, password::getStream);
Field field = SecretPassword.class.getDeclaredField("password");
field.setAccessible(true);
char[] owned = (char[]) field.get(password);
assertTrue(Arrays.equals(new char[owned.length], owned));
System.out.println("ok");
}
assertEquals(len, sp.toText().length());
@Test
void streamOwnsAndWipesItsUtf8BufferOnClose() throws Exception {
System.out.print("SecretPassword/stream...");
SecretPassword password = new SecretPassword("päss".toCharArray());
InputStream stream = password.getStream();
assertArrayEquals("päss".getBytes(StandardCharsets.UTF_8), stream.readAllBytes());
Field field = stream.getClass().getDeclaredField("ownedBuffer");
field.setAccessible(true);
byte[] buffer = (byte[]) field.get(stream);
stream.close();
assertTrue(Arrays.equals(new byte[buffer.length], buffer));
System.out.println("ok");
}
}

View File

@@ -0,0 +1,143 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.sdk.guard;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import org.junit.jupiter.api.Test;
import zeroecho.core.io.Util;
import zeroecho.sdk.ZeroEchoSession;
import zeroecho.sdk.builders.alg.AesDataContentBuilder;
class DecryptorCekCleanupTest {
@Test
void rejectedOversizedCekIsCleared() throws Exception {
System.out.println("rejectedOversizedCekIsCleared");
byte[] rejected = new byte[17];
RecipientOpener opener = (entryId, entryBlob, material) -> rejected;
Decryptor decryptor = decryptor(opener);
decryptor.setInput(() -> new ByteArrayInputStream(envelopeHeader()));
assertThrows(IOException.class, decryptor::getStream);
assertArrayEquals(new byte[rejected.length], rejected);
System.out.println("...rejectedBytesCleared=17");
System.out.println("rejectedOversizedCekIsCleared...ok");
}
@Test
void acceptedCekIsClearedWhenCiphertextHeaderIsTruncated() throws Exception {
System.out.println("acceptedCekIsClearedWhenCiphertextHeaderIsTruncated");
byte[] accepted = new byte[16];
RecipientOpener opener = (entryId, entryBlob, material) -> accepted;
Decryptor decryptor = decryptor(opener);
decryptor.setInput(() -> new ByteArrayInputStream(envelopeHeader()));
assertThrows(IOException.class, decryptor::getStream);
assertArrayEquals(new byte[accepted.length], accepted);
System.out.println("...acceptedBytesCleared=16");
System.out.println("acceptedCekIsClearedWhenCiphertextHeaderIsTruncated...ok");
}
@Test
void acceptedCekIsClearedWhenPayloadSetupFails() throws Exception {
System.out.println("acceptedCekIsClearedWhenPayloadSetupFails");
byte[] accepted = new byte[16];
RecipientOpener opener = (entryId, entryBlob, material) -> accepted;
Decryptor decryptor = new Decryptor(List.of(opener), new UnlockMaterial.Password(new char[] { 'p' }),
null, null, 16, 4, 128);
decryptor.setInput(() -> new ByteArrayInputStream(envelopeHeader()));
assertThrows(NullPointerException.class, decryptor::getStream);
assertArrayEquals(new byte[accepted.length], accepted);
System.out.println("...failurePathCleared=true");
System.out.println("acceptedCekIsClearedWhenPayloadSetupFails...ok");
}
@Test
void preReturnFailureClosesUpstreamStream() throws Exception {
System.out.println("preReturnFailureClosesUpstreamStream");
ByteArrayOutputStream header = new ByteArrayOutputStream();
Util.writePack7I(header, 5);
TrackingInputStream input = new TrackingInputStream(header.toByteArray(), false);
Decryptor decryptor = decryptor((entryId, entryBlob, material) -> null);
decryptor.setInput(() -> input);
assertThrows(IOException.class, decryptor::getStream);
assertTrue(input.closed);
System.out.println("...upstreamClosed=true");
System.out.println("preReturnFailureClosesUpstreamStream...ok");
}
@Test
void cleanupFailureIsSuppressedOnPrimaryFailure() throws Exception {
System.out.println("cleanupFailureIsSuppressedOnPrimaryFailure");
ByteArrayOutputStream header = new ByteArrayOutputStream();
Util.writePack7I(header, 5);
TrackingInputStream input = new TrackingInputStream(header.toByteArray(), true);
Decryptor decryptor = decryptor((entryId, entryBlob, material) -> null);
decryptor.setInput(() -> input);
IOException failure = assertThrows(IOException.class, decryptor::getStream);
assertEquals(1, failure.getSuppressed().length);
assertTrue(input.closed);
System.out.println("...suppressedCleanupFailures=1");
System.out.println("cleanupFailureIsSuppressedOnPrimaryFailure...ok");
}
private static Decryptor decryptor(RecipientOpener opener) {
AesDataContentBuilder aes = AesDataContentBuilder.builder(new ZeroEchoSession()).withHeader().modeGcm(128);
return new Decryptor(List.of(opener), new UnlockMaterial.Password(new char[] { 'p' }),
aes, null, 16, 4, 128);
}
private static byte[] envelopeHeader() throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
Util.writePack7I(output, 1);
Util.writeUTF8(output, "controlled");
Util.write(output, new byte[] { 1 });
return output.toByteArray();
}
private static final class TrackingInputStream extends InputStream {
private final ByteArrayInputStream delegate;
private final boolean failClose;
private boolean closed;
private TrackingInputStream(byte[] bytes, boolean failClose) {
this.delegate = new ByteArrayInputStream(bytes);
this.failClose = failClose;
}
@Override
public int read() {
return delegate.read();
}
@Override
public void close() throws IOException {
closed = true;
if (failClose) {
throw new IOException("controlled close failure");
}
delegate.close();
}
}
}

View File

@@ -0,0 +1,218 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.sdk.guard;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.security.GeneralSecurityException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.IntFunction;
import org.junit.jupiter.api.Test;
import zeroecho.sdk.Pbkdf2Limits;
import zeroecho.sdk.builders.alg.AesDataContentBuilder;
class EncryptorCekAllocationTest {
private static final int KEY_BYTES = 16;
@Test
void noDecoysGenerateOnlyPayloadCek() throws Exception {
System.out.print("EncryptorCekAllocation/noDecoysGenerateOnlyPayloadCek...");
RecordingRandomFactory randomFactory = new RecordingRandomFactory();
CapturingRecipient first = new CapturingRecipient(false);
CapturingRecipient second = new CapturingRecipient(false);
openEncryptor(List.of(first, second), randomFactory).close();
assertEquals(1, randomFactory.calls());
assertArrayEquals(repeated((byte) 1), first.cek);
assertArrayEquals(first.cek, second.cek);
assertAllCleared(randomFactory.outputs);
System.out.println("...factoryCalls=" + randomFactory.calls());
System.out.println("ok");
}
@Test
void oneDecoyGeneratesOneLazyDecoyCek() throws Exception {
System.out.print("EncryptorCekAllocation/oneDecoyGeneratesOneLazyDecoyCek...");
RecordingRandomFactory randomFactory = new RecordingRandomFactory();
CapturingRecipient decoy = new CapturingRecipient(true);
openEncryptor(List.of(decoy), randomFactory).close();
assertEquals(2, randomFactory.calls());
assertArrayEquals(repeated((byte) 2), decoy.cek);
assertAllCleared(randomFactory.outputs);
System.out.println("...factoryCalls=" + randomFactory.calls());
System.out.println("ok");
}
@Test
void mixedDecoysGenerateOneLazyCekAndReuseItInOrder() throws Exception {
System.out.print("EncryptorCekAllocation/mixedDecoysGenerateOneLazyCekAndReuseItInOrder...");
RecordingRandomFactory randomFactory = new RecordingRandomFactory();
CapturingRecipient realBefore = new CapturingRecipient(false);
CapturingRecipient firstDecoy = new CapturingRecipient(true);
CapturingRecipient realAfter = new CapturingRecipient(false);
CapturingRecipient secondDecoy = new CapturingRecipient(true);
openEncryptor(List.of(realBefore, firstDecoy, realAfter, secondDecoy), randomFactory).close();
assertEquals(2, randomFactory.calls());
assertArrayEquals(repeated((byte) 1), realBefore.cek);
assertArrayEquals(realBefore.cek, realAfter.cek);
assertArrayEquals(repeated((byte) 2), firstDecoy.cek);
assertArrayEquals(firstDecoy.cek, secondDecoy.cek);
assertAllCleared(randomFactory.outputs);
System.out.println("...factoryCalls=" + randomFactory.calls());
System.out.println("ok");
}
@Test
void recipientLimitIsCheckedBeforeCekGeneration() {
System.out.print("EncryptorCekAllocation/recipientLimitIsCheckedBeforeCekGeneration...");
RecordingRandomFactory randomFactory = new RecordingRandomFactory();
Encryptor encryptor = newEncryptor(List.of(new CapturingRecipient(false), new CapturingRecipient(false)),
1, randomFactory);
encryptor.setInput(() -> new ByteArrayInputStream(new byte[0]));
assertThrows(IOException.class, encryptor::getStream);
assertEquals(0, randomFactory.calls());
System.out.println("...factoryCalls=0");
System.out.println("ok");
}
@Test
void recipientFailureClearsGenuineAndDecoyCeks() {
System.out.print("EncryptorCekAllocation/recipientFailureClearsGenuineAndDecoyCeks...");
RecordingRandomFactory randomFactory = new RecordingRandomFactory();
Encryptor encryptor = newEncryptor(List.of(new CapturingRecipient(false), new FailingRecipient(true)),
8, randomFactory);
encryptor.setInput(() -> new ByteArrayInputStream(new byte[0]));
assertThrows(IOException.class, encryptor::getStream);
assertEquals(2, randomFactory.calls());
assertAllCleared(randomFactory.outputs);
System.out.println("...factoryCalls=" + randomFactory.calls());
System.out.println("ok");
}
@Test
void successfulRecipientProcessingDestroysOwnedPassword() throws Exception {
System.out.print("EncryptorCekAllocation/successfulRecipientProcessingDestroysOwnedPassword...");
PasswordRecipient passwordRecipient = passwordRecipient();
openEncryptor(List.of(passwordRecipient), new RecordingRandomFactory()).close();
assertTrue(passwordRecipient.isDestroyed());
System.out.println("...passwordDestroyed=true");
System.out.println("ok");
}
@Test
void failedRecipientProcessingDestroysOwnedPassword() {
System.out.print("EncryptorCekAllocation/failedRecipientProcessingDestroysOwnedPassword...");
PasswordRecipient passwordRecipient = passwordRecipient();
Encryptor encryptor = newEncryptor(List.of(passwordRecipient, new FailingRecipient(false)),
8, new RecordingRandomFactory());
encryptor.setInput(() -> new ByteArrayInputStream(new byte[0]));
assertThrows(IOException.class, encryptor::getStream);
assertTrue(passwordRecipient.isDestroyed());
System.out.println("...passwordDestroyed=true");
System.out.println("ok");
}
private static InputStream openEncryptor(List<Recipient> recipients, IntFunction<byte[]> randomFactory)
throws IOException {
Encryptor encryptor = newEncryptor(recipients, 8, randomFactory);
encryptor.setInput(() -> new ByteArrayInputStream(new byte[] { 1, 2, 3 }));
return encryptor.getStream();
}
private static Encryptor newEncryptor(List<Recipient> recipients, int maxRecipients,
IntFunction<byte[]> randomFactory) {
return new Encryptor(recipients, AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).modeGcm(128), null, KEY_BYTES,
maxRecipients, 1024, randomFactory);
}
private static byte[] repeated(byte value) {
byte[] result = new byte[KEY_BYTES];
Arrays.fill(result, value);
return result;
}
private static PasswordRecipient passwordRecipient() {
return new PasswordRecipient(new char[] { 'p' }, Pbkdf2Limits.MINIMUM, 16, 32, false,
new Pbkdf2Limits(20_000, 30_000));
}
private static void assertAllCleared(List<byte[]> arrays) {
for (byte[] array : arrays) {
assertTrue(Arrays.equals(new byte[array.length], array));
}
}
private static class CapturingRecipient implements Recipient {
private final boolean decoy;
private byte[] cek;
private CapturingRecipient(boolean decoy) {
this.decoy = decoy;
}
@Override
public boolean decoy() {
return decoy;
}
@Override
public String id() {
return decoy ? "test-decoy" : "test-real";
}
@Override
public byte[] buildRecipientEntry(byte[] inputCek) throws GeneralSecurityException {
cek = inputCek.clone();
return new byte[] { 1 };
}
}
private static final class FailingRecipient extends CapturingRecipient {
private FailingRecipient(boolean decoy) {
super(decoy);
}
@Override
public byte[] buildRecipientEntry(byte[] inputCek) throws GeneralSecurityException {
throw new GeneralSecurityException("controlled recipient failure");
}
}
private static final class RecordingRandomFactory implements IntFunction<byte[]> {
private final List<byte[]> outputs = new ArrayList<>();
@Override
public byte[] apply(int length) {
byte[] result = new byte[length];
Arrays.fill(result, (byte) (outputs.size() + 1));
outputs.add(result);
return result;
}
private int calls() {
return outputs.size();
}
}
}

View File

@@ -0,0 +1,156 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.sdk.guard;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.lang.reflect.Field;
import java.io.IOException;
import java.security.Key;
import java.util.List;
import org.junit.jupiter.api.Test;
import zeroecho.core.CryptoAlgorithm;
import zeroecho.core.NullKey;
import zeroecho.core.context.KemContext;
import zeroecho.sdk.Pbkdf2Limits;
import zeroecho.sdk.ZeroEchoSession;
import zeroecho.sdk.builders.alg.AesDataContentBuilder;
class KemRecipientLifecycleTest {
private static final CryptoAlgorithm ALGORITHM = new TestAlgorithm();
@Test
void supportedKekSizesClearSenderSharedSecrets() throws Exception {
System.out.println("supportedKekSizesClearSenderSharedSecrets");
for (int kekBytes : new int[] { 16, 32 }) {
byte[] senderSecret = filled(32, (byte) 0x41);
ControlledKemContext sender = new ControlledKemContext(senderSecret);
byte[] cek = filled(32, (byte) 0x52);
byte[] entry = new KemCtxRecipient(sender, kekBytes, 16).buildRecipientEntry(cek);
assertTrue(entry.length > cek.length);
assertArrayEquals(new byte[senderSecret.length], senderSecret);
assertTrue(sender.closed);
System.out.println("...kekBytes=" + kekBytes);
}
System.out.println("...senderSecretsCleared=true");
System.out.println("supportedKekSizesClearSenderSharedSecrets...ok");
}
@Test
void constructorsAndBuilderRejectUnsupportedKekBeforeOwnershipTransfer() throws Exception {
System.out.println("constructorsAndBuilderRejectUnsupportedKekBeforeOwnershipTransfer");
int[] invalidValues = { -1, 0, 1, 15, 17, 24, 31, 33, Integer.MAX_VALUE };
ZeroEchoSession session = new ZeroEchoSession()
.withPbkdf2Limits(new Pbkdf2Limits(20_000, 30_000));
MultiRecipientDataSourceBuilder builder = MultiRecipientDataSourceBuilder.builder(session)
.withAes(AesDataContentBuilder.builder(session).modeGcm(128).withHeader());
for (int kekBytes : invalidValues) {
ControlledKemContext direct = new ControlledKemContext(filled(32, (byte) 0x11));
assertThrows(IllegalArgumentException.class,
() -> new KemCtxRecipient(direct, kekBytes, 16));
assertFalse(direct.closed);
direct.close();
ControlledKemContext normal = new ControlledKemContext(filled(32, (byte) 0x22));
assertThrows(IllegalArgumentException.class,
() -> builder.addRecipient(normal, kekBytes, 16));
assertEquals(0, recipients(builder).size());
assertFalse(normal.closed);
normal.close();
ControlledKemContext decoy = new ControlledKemContext(filled(32, (byte) 0x33));
assertThrows(IllegalArgumentException.class,
() -> builder.addRecipientDecoy(decoy, kekBytes, 16));
assertEquals(0, recipients(builder).size());
assertFalse(decoy.closed);
decoy.close();
}
builder.close();
System.out.println("...recipientCount=0");
System.out.println("constructorsAndBuilderRejectUnsupportedKekBeforeOwnershipTransfer...ok");
}
@Test
void wrapFailureClearsSharedSecretAndClosesContext() {
System.out.println("wrapFailureClearsSharedSecretAndClosesContext");
byte[] sharedSecret = filled(32, (byte) 0x33);
ControlledKemContext context = new ControlledKemContext(sharedSecret, ALGORITHM, null);
assertThrows(NullPointerException.class,
() -> new KemCtxRecipient(context, 16, 16).buildRecipientEntry(new byte[32]));
assertArrayEquals(new byte[sharedSecret.length], sharedSecret);
assertTrue(context.closed);
System.out.println("...failureCleanup=true");
System.out.println("wrapFailureClearsSharedSecretAndClosesContext...ok");
}
private static byte[] filled(int length, byte value) {
byte[] result = new byte[length];
java.util.Arrays.fill(result, value);
return result;
}
private static final class TestAlgorithm extends CryptoAlgorithm {
private TestAlgorithm() {
super("TEST-KEM", "Test KEM");
}
}
private static final class ControlledKemContext implements KemContext {
private final byte[] sharedSecret;
private final CryptoAlgorithm algorithm;
private final byte[] ciphertext;
private boolean closed;
private ControlledKemContext(byte[] sharedSecret) {
this(sharedSecret, ALGORITHM, new byte[] { 1, 2, 3 });
}
private ControlledKemContext(byte[] sharedSecret, CryptoAlgorithm algorithm, byte[] ciphertext) {
this.sharedSecret = sharedSecret;
this.algorithm = algorithm;
this.ciphertext = ciphertext;
}
@Override
public KemResult encapsulate() {
return new KemResult(ciphertext, sharedSecret);
}
@Override
public byte[] decapsulate(byte[] ciphertext) {
return sharedSecret;
}
@Override
public CryptoAlgorithm algorithm() {
return algorithm;
}
@Override
public Key key() {
return NullKey.INSTANCE;
}
@Override
public void close() throws IOException {
closed = true;
}
}
@SuppressWarnings("unchecked")
private static List<Recipient> recipients(MultiRecipientDataSourceBuilder builder) throws Exception {
Field field = MultiRecipientDataSourceBuilder.class.getDeclaredField("recipients");
field.setAccessible(true);
return (List<Recipient>) field.get(builder);
}
}

View File

@@ -34,6 +34,8 @@
package zeroecho.sdk.guard;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayInputStream;
import java.io.IOException;
@@ -43,6 +45,7 @@ import java.security.KeyPairGenerator;
import java.security.SecureRandom;
import java.security.Security;
import java.security.Signature;
import java.util.Arrays;
import java.util.function.Supplier;
import java.util.random.RandomGenerator;
@@ -62,6 +65,8 @@ import zeroecho.core.alg.sphincsplus.SphincsPlusKeyGenSpec;
import zeroecho.core.context.EncryptionContext;
import zeroecho.core.context.KemContext;
import zeroecho.core.tag.TagEngineBuilder;
import zeroecho.sdk.Pbkdf2Limits;
import zeroecho.sdk.ZeroEchoSession;
import zeroecho.sdk.builders.TagTrailerDataContentBuilder;
import zeroecho.sdk.builders.alg.AesDataContentBuilder;
import zeroecho.sdk.builders.core.DataContentBuilder;
@@ -99,10 +104,10 @@ public class MultiRecipientEnvelopeTest {
final char[] password = "CorrectHorseBatteryStaple".toCharArray();
// AES-256-GCM with header so IV/tag are persisted in-band
Supplier<AesDataContentBuilder> aesGcm = () -> AesDataContentBuilder.builder().modeGcm(128).withHeader();
Supplier<AesDataContentBuilder> aesGcm = () -> AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).modeGcm(128).withHeader();
// Encrypt
MultiRecipientDataSourceBuilder enc = new MultiRecipientDataSourceBuilder().withAes(aesGcm.get())
MultiRecipientDataSourceBuilder enc = MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesGcm.get())
.payloadKeyBytes(32)
.addPasswordRecipient(password, /* iterations */ 10000, /* saltLen */ 16, /* kekBytes */ 32);
@@ -115,8 +120,9 @@ public class MultiRecipientEnvelopeTest {
}
// Decrypt
MultiRecipientDataSourceBuilder dec = new MultiRecipientDataSourceBuilder().withAes(aesGcm.get())
.payloadKeyBytes(32).unlockWith(new UnlockMaterial.Password(password));
UnlockMaterial.Password unlockMaterial = new UnlockMaterial.Password(password);
MultiRecipientDataSourceBuilder dec = MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesGcm.get())
.payloadKeyBytes(32).unlockWith(unlockMaterial);
DataContent decryptor = dec.build(false);
decryptor.setInput(new BytesContent(encrypted));
@@ -127,9 +133,54 @@ public class MultiRecipientEnvelopeTest {
}
assertArrayEquals(input, decrypted);
assertFalse(unlockMaterial.isDestroyed());
unlockMaterial.destroy();
assertTrue(unlockMaterial.isDestroyed());
System.out.println("... borrowed unlock material retained by caller");
System.out.println("...ok");
}
@Test
void passwordRecipientWithAes128KekRoundTrips() throws Exception {
System.out.println("passwordRecipientWithAes128KekRoundTrips");
byte[] input = randomInput(257);
char[] password = "controlled-password".toCharArray();
ZeroEchoSession session = new ZeroEchoSession()
.withPbkdf2Limits(new Pbkdf2Limits(1_000_000, 1_000_000));
byte[] encrypted;
try (MultiRecipientDataSourceBuilder builder = MultiRecipientDataSourceBuilder.builder(session)
.withAes(AesDataContentBuilder.builder(session).modeGcm(128).withHeader())
.payloadKeyBytes(32)
.addPasswordRecipient(password, 10_000, 16, 16);
MultiRecipientContent content = builder.build(true)) {
content.setInput(new BytesContent(input));
try (InputStream stream = content.getStream()) {
encrypted = stream.readAllBytes();
}
}
UnlockMaterial.Password unlock = new UnlockMaterial.Password(password);
byte[] decrypted;
try (MultiRecipientDataSourceBuilder builder = MultiRecipientDataSourceBuilder.builder(session)
.withAes(AesDataContentBuilder.builder(session).modeGcm(128).withHeader())
.payloadKeyBytes(32)
.unlockWith(unlock);
MultiRecipientContent content = builder.build(false)) {
content.setInput(new BytesContent(encrypted));
try (InputStream stream = content.getStream()) {
decrypted = stream.readAllBytes();
}
} finally {
unlock.destroy();
Arrays.fill(password, '\0');
}
assertArrayEquals(input, decrypted);
System.out.println("...kekBytes=16");
System.out.println("passwordRecipientWithAes128KekRoundTrips...ok");
}
@Test
@DisplayName("Password guardian + AES-256-CBC/PKCS7")
void testPasswordGuardian_Aes256Cbc() throws Exception {
@@ -142,9 +193,9 @@ public class MultiRecipientEnvelopeTest {
final char[] password = "Tr0ub4dor&3".toCharArray();
// AES-256-CBC with PKCS7 padding, header persists IV
Supplier<AesDataContentBuilder> aesCbc = () -> AesDataContentBuilder.builder().modeCbcPkcs5().withHeader();
Supplier<AesDataContentBuilder> aesCbc = () -> AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).modeCbcPkcs5().withHeader();
MultiRecipientDataSourceBuilder enc = new MultiRecipientDataSourceBuilder().withAes(aesCbc.get())
MultiRecipientDataSourceBuilder enc = MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesCbc.get())
.payloadKeyBytes(32)
.addPasswordRecipient(password, /* iterations */ 10000, /* saltLen */ 16, /* kekBytes */ 32);
@@ -156,7 +207,7 @@ public class MultiRecipientEnvelopeTest {
encrypted = readAllBytesAndPrint(es, "... encrypted size");
}
MultiRecipientDataSourceBuilder dec = new MultiRecipientDataSourceBuilder().withAes(aesCbc.get())
MultiRecipientDataSourceBuilder dec = MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesCbc.get())
.payloadKeyBytes(32).unlockWith(new UnlockMaterial.Password(password));
DataContent decryptor = dec.build(false);
@@ -184,12 +235,12 @@ public class MultiRecipientEnvelopeTest {
final byte[] input = randomInput(128 * 1024 + 7);
System.out.println("... input size: " + input.length);
KeyPair elg = CryptoAlgorithms.keyPair("ElGamal", ElgamalParamSpec.ffdhe2048());
EncryptionContext elgEnc = CryptoAlgorithms.create("ElGamal", KeyUsage.ENCRYPT, elg.getPublic());
KeyPair elg = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ElGamal", ElgamalParamSpec.ffdhe2048());
EncryptionContext elgEnc = new zeroecho.sdk.ZeroEchoSession().createContext("ElGamal", KeyUsage.ENCRYPT, elg.getPublic());
Supplier<AesDataContentBuilder> aesGcm = () -> AesDataContentBuilder.builder().modeGcm(128).withHeader();
Supplier<AesDataContentBuilder> aesGcm = () -> AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).modeGcm(128).withHeader();
MultiRecipientDataSourceBuilder enc = new MultiRecipientDataSourceBuilder().withAes(aesGcm.get())
MultiRecipientDataSourceBuilder enc = MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesGcm.get())
.payloadKeyBytes(32).addRecipient(elgEnc);
DataContent encryptor = enc.build(true);
@@ -200,7 +251,7 @@ public class MultiRecipientEnvelopeTest {
encrypted = readAllBytesAndPrint(es, "... encrypted size");
}
MultiRecipientDataSourceBuilder dec = new MultiRecipientDataSourceBuilder().withAes(aesGcm.get())
MultiRecipientDataSourceBuilder dec = MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesGcm.get())
.payloadKeyBytes(32).unlockWith(new UnlockMaterial.Private(elg.getPrivate()));
DataContent decryptor = dec.build(false);
@@ -224,12 +275,12 @@ public class MultiRecipientEnvelopeTest {
final byte[] input = randomInput(128 * 1024 + 13); // cross blocks
System.out.println("... input size: " + input.length);
KeyPair elg = CryptoAlgorithms.keyPair("ElGamal", ElgamalParamSpec.ffdhe2048());
EncryptionContext elgEnc = CryptoAlgorithms.create("ElGamal", KeyUsage.ENCRYPT, elg.getPublic());
KeyPair elg = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ElGamal", ElgamalParamSpec.ffdhe2048());
EncryptionContext elgEnc = new zeroecho.sdk.ZeroEchoSession().createContext("ElGamal", KeyUsage.ENCRYPT, elg.getPublic());
Supplier<AesDataContentBuilder> aesCbc = () -> AesDataContentBuilder.builder().modeCbcPkcs5().withHeader();
Supplier<AesDataContentBuilder> aesCbc = () -> AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).modeCbcPkcs5().withHeader();
MultiRecipientDataSourceBuilder enc = new MultiRecipientDataSourceBuilder().withAes(aesCbc.get())
MultiRecipientDataSourceBuilder enc = MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesCbc.get())
.payloadKeyBytes(32).addRecipient(elgEnc);
DataContent encryptor = enc.build(true);
@@ -240,7 +291,7 @@ public class MultiRecipientEnvelopeTest {
encrypted = readAllBytesAndPrint(es, "... encrypted size");
}
MultiRecipientDataSourceBuilder dec = new MultiRecipientDataSourceBuilder().withAes(aesCbc.get())
MultiRecipientDataSourceBuilder dec = MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesCbc.get())
.payloadKeyBytes(32).unlockWith(new UnlockMaterial.Private(elg.getPrivate()));
DataContent decryptor = dec.build(false);
@@ -272,11 +323,11 @@ public class MultiRecipientEnvelopeTest {
kpg.initialize(3072, new SecureRandom());
KeyPair rsa = kpg.generateKeyPair();
EncryptionContext rsaEnc = CryptoAlgorithms.create("RSA", KeyUsage.ENCRYPT, rsa.getPublic());
EncryptionContext rsaEnc = new zeroecho.sdk.ZeroEchoSession().createContext("RSA", KeyUsage.ENCRYPT, rsa.getPublic());
Supplier<AesDataContentBuilder> aesGcm = () -> AesDataContentBuilder.builder().modeGcm(128).withHeader();
Supplier<AesDataContentBuilder> aesGcm = () -> AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).modeGcm(128).withHeader();
MultiRecipientDataSourceBuilder enc = new MultiRecipientDataSourceBuilder().withAes(aesGcm.get())
MultiRecipientDataSourceBuilder enc = MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesGcm.get())
.payloadKeyBytes(32).addRecipient(rsaEnc);
DataContent encryptor = enc.build(true);
@@ -287,7 +338,7 @@ public class MultiRecipientEnvelopeTest {
encrypted = readAllBytesAndPrint(es, "... encrypted size");
}
MultiRecipientDataSourceBuilder dec = new MultiRecipientDataSourceBuilder().withAes(aesGcm.get())
MultiRecipientDataSourceBuilder dec = MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesGcm.get())
.payloadKeyBytes(32).unlockWith(new UnlockMaterial.Private(rsa.getPrivate()));
DataContent decryptor = dec.build(false);
@@ -315,11 +366,11 @@ public class MultiRecipientEnvelopeTest {
kpg.initialize(3072, new SecureRandom());
KeyPair rsa = kpg.generateKeyPair();
EncryptionContext rsaEnc = CryptoAlgorithms.create("RSA", KeyUsage.ENCRYPT, rsa.getPublic());
EncryptionContext rsaEnc = new zeroecho.sdk.ZeroEchoSession().createContext("RSA", KeyUsage.ENCRYPT, rsa.getPublic());
Supplier<AesDataContentBuilder> aesCbc = () -> AesDataContentBuilder.builder().modeCbcPkcs5().withHeader();
Supplier<AesDataContentBuilder> aesCbc = () -> AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).modeCbcPkcs5().withHeader();
MultiRecipientDataSourceBuilder enc = new MultiRecipientDataSourceBuilder().withAes(aesCbc.get())
MultiRecipientDataSourceBuilder enc = MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesCbc.get())
.payloadKeyBytes(32).addRecipient(rsaEnc);
DataContent encryptor = enc.build(true);
@@ -330,7 +381,7 @@ public class MultiRecipientEnvelopeTest {
encrypted = readAllBytesAndPrint(es, "... encrypted size");
}
MultiRecipientDataSourceBuilder dec = new MultiRecipientDataSourceBuilder().withAes(aesCbc.get())
MultiRecipientDataSourceBuilder dec = MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesCbc.get())
.payloadKeyBytes(32).unlockWith(new UnlockMaterial.Private(rsa.getPrivate()));
DataContent decryptor = dec.build(false);
@@ -349,6 +400,129 @@ public class MultiRecipientEnvelopeTest {
// Multi-recipient (Password + RSA + KEM/ML-KEM + ElGamal) via contexts
// ------------------------------------------------------------------------------------
@Test
void testKemRecipientWith128BitKekRoundTrip() throws Exception {
System.out.println("testKemRecipientWith128BitKekRoundTrip");
byte[] input = randomInput(257);
KeyPair keyPair = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric()
.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber512());
byte[] encrypted = encryptKemRecipients(input, new KeyPair[] { keyPair }, new int[] { 16 });
decryptAndAssert("...128-bit KEK", aesGcmSupplier(), new UnlockMaterial.Private(keyPair.getPrivate()),
input, encrypted);
System.out.println("...encryptedLength=" + encrypted.length);
System.out.println("testKemRecipientWith128BitKekRoundTrip...ok");
}
@Test
void testMixedKemRecipientKekSizesRoundTrip() throws Exception {
System.out.println("testMixedKemRecipientKekSizesRoundTrip");
zeroecho.sdk.ZeroEchoSession session = new zeroecho.sdk.ZeroEchoSession();
KeyPair first = session.keyBuilders().asymmetric()
.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber512());
KeyPair second = session.keyBuilders().asymmetric()
.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber512());
byte[] input = randomInput(513);
byte[] encrypted = encryptKemRecipients(input, new KeyPair[] { first, second }, new int[] { 16, 32 });
decryptAndAssert("...mixed 16-byte KEK", aesGcmSupplier(),
new UnlockMaterial.Private(first.getPrivate()), input, encrypted);
decryptAndAssert("...mixed 32-byte KEK", aesGcmSupplier(),
new UnlockMaterial.Private(second.getPrivate()), input, encrypted);
System.out.println("...recipientCount=2");
System.out.println("testMixedKemRecipientKekSizesRoundTrip...ok");
}
@Test
void defaultKemOpenerContinuesAfterSameAlgorithmDecoy() throws Exception {
System.out.println("defaultKemOpenerContinuesAfterSameAlgorithmDecoy");
ZeroEchoSession session = new ZeroEchoSession();
KeyPair decoy = session.keyBuilders().asymmetric()
.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber512());
KeyPair legitimate = session.keyBuilders().asymmetric()
.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber512());
byte[] input = randomInput(385);
byte[] encrypted;
KemContext decoyContext =
session.createContext("ML-KEM", KeyUsage.ENCAPSULATE, decoy.getPublic());
KemContext legitimateContext =
session.createContext("ML-KEM", KeyUsage.ENCAPSULATE, legitimate.getPublic());
try (MultiRecipientDataSourceBuilder builder = MultiRecipientDataSourceBuilder.builder(session)
.withAes(AesDataContentBuilder.builder(session).modeGcm(128).withHeader())
.payloadKeyBytes(32)
.addRecipient(decoyContext, 32, 16)
.addRecipient(legitimateContext, 32, 16);
MultiRecipientContent content = builder.build(true)) {
content.setInput(new BytesContent(input));
try (InputStream stream = content.getStream()) {
encrypted = stream.readAllBytes();
}
}
byte[] decrypted;
try (MultiRecipientDataSourceBuilder builder = MultiRecipientDataSourceBuilder.builder(session)
.withAes(AesDataContentBuilder.builder(session).modeGcm(128).withHeader())
.payloadKeyBytes(32)
.unlockWith(new UnlockMaterial.Private(legitimate.getPrivate()));
MultiRecipientContent content = builder.build(false)) {
content.setInput(new BytesContent(encrypted));
try (InputStream stream = content.getStream()) {
decrypted = stream.readAllBytes();
}
}
assertArrayEquals(input, decrypted);
System.out.println("...sameAlgorithmEntries=2");
System.out.println("defaultKemOpenerContinuesAfterSameAlgorithmDecoy...ok");
}
@Test
void defaultEncryptionOpenerContinuesAfterSameAlgorithmDecoy() throws Exception {
System.out.println("defaultEncryptionOpenerContinuesAfterSameAlgorithmDecoy");
ZeroEchoSession session = new ZeroEchoSession();
KeyPair decoy = session.keyBuilders().asymmetric()
.generateKeyPair("RSA", RsaKeyGenSpec.rsa2048());
KeyPair legitimate = session.keyBuilders().asymmetric()
.generateKeyPair("RSA", RsaKeyGenSpec.rsa2048());
byte[] input = randomInput(385);
byte[] encrypted;
EncryptionContext decoyContext =
session.createContext("RSA", KeyUsage.ENCRYPT, decoy.getPublic());
EncryptionContext legitimateContext =
session.createContext("RSA", KeyUsage.ENCRYPT, legitimate.getPublic());
try (MultiRecipientDataSourceBuilder builder = MultiRecipientDataSourceBuilder.builder(session)
.withAes(AesDataContentBuilder.builder(session).modeGcm(128).withHeader())
.payloadKeyBytes(32)
.addRecipient(decoyContext)
.addRecipient(legitimateContext);
MultiRecipientContent content = builder.build(true)) {
content.setInput(new BytesContent(input));
try (InputStream stream = content.getStream()) {
encrypted = stream.readAllBytes();
}
}
byte[] decrypted;
try (MultiRecipientDataSourceBuilder builder = MultiRecipientDataSourceBuilder.builder(session)
.withAes(AesDataContentBuilder.builder(session).modeGcm(128).withHeader())
.payloadKeyBytes(32)
.unlockWith(new UnlockMaterial.Private(legitimate.getPrivate()));
MultiRecipientContent content = builder.build(false)) {
content.setInput(new BytesContent(encrypted));
try (InputStream stream = content.getStream()) {
decrypted = stream.readAllBytes();
}
}
assertArrayEquals(input, decrypted);
System.out.println("...sameAlgorithmEntries=2");
System.out.println("defaultEncryptionOpenerContinuesAfterSameAlgorithmDecoy...ok");
}
@Test
@DisplayName("Multi recipients (PWD+RSA+ML-KEM kyber512+ElGamal) + AES-256-GCM")
void testMultiRecipients_Kyber512_Aes256Gcm_AllUnlocks() throws Exception {
@@ -364,16 +538,16 @@ public class MultiRecipientEnvelopeTest {
kpg.initialize(3072, new SecureRandom());
KeyPair rsa = kpg.generateKeyPair();
KeyPair kyber = CryptoAlgorithms.keyPair("ML-KEM", KyberKeyGenSpec.kyber512());
KeyPair elg = CryptoAlgorithms.keyPair("ElGamal", ElgamalParamSpec.ffdhe2048());
KeyPair kyber = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber512());
KeyPair elg = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ElGamal", ElgamalParamSpec.ffdhe2048());
EncryptionContext rsaEnc = CryptoAlgorithms.create("RSA", KeyUsage.ENCRYPT, rsa.getPublic());
EncryptionContext elgEnc = CryptoAlgorithms.create("ElGamal", KeyUsage.ENCRYPT, elg.getPublic());
KemContext kybKem = CryptoAlgorithms.create("ML-KEM", KeyUsage.ENCAPSULATE, kyber.getPublic());
EncryptionContext rsaEnc = new zeroecho.sdk.ZeroEchoSession().createContext("RSA", KeyUsage.ENCRYPT, rsa.getPublic());
EncryptionContext elgEnc = new zeroecho.sdk.ZeroEchoSession().createContext("ElGamal", KeyUsage.ENCRYPT, elg.getPublic());
KemContext kybKem = new zeroecho.sdk.ZeroEchoSession().createContext("ML-KEM", KeyUsage.ENCAPSULATE, kyber.getPublic());
Supplier<AesDataContentBuilder> aesGcm = () -> AesDataContentBuilder.builder().modeGcm(128).withHeader();
Supplier<AesDataContentBuilder> aesGcm = () -> AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).modeGcm(128).withHeader();
MultiRecipientDataSourceBuilder enc = new MultiRecipientDataSourceBuilder().withAes(aesGcm.get())
MultiRecipientDataSourceBuilder enc = MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesGcm.get())
.payloadKeyBytes(32)
.addPasswordRecipient(password, /* iterations */ 10000, /* saltLen */ 16, /* kekBytes */ 32)
.addRecipient(rsaEnc).addRecipient(elgEnc).addRecipient(kybKem, /* kekBytes */ 32, /* saltLen */ 32);
@@ -412,17 +586,17 @@ public class MultiRecipientEnvelopeTest {
kpg.initialize(3072, new SecureRandom());
KeyPair rsa = kpg.generateKeyPair();
rsa = CryptoAlgorithms.keyPair("RSA", RsaKeyGenSpec.rsa2048());
KeyPair kyber = CryptoAlgorithms.keyPair("ML-KEM", KyberKeyGenSpec.kyber768());
KeyPair elg = CryptoAlgorithms.keyPair("ElGamal", ElgamalParamSpec.ffdhe2048());
rsa = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("RSA", RsaKeyGenSpec.rsa2048());
KeyPair kyber = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
KeyPair elg = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ElGamal", ElgamalParamSpec.ffdhe2048());
EncryptionContext rsaEnc = CryptoAlgorithms.create("RSA", KeyUsage.ENCRYPT, rsa.getPublic());
EncryptionContext elgEnc = CryptoAlgorithms.create("ElGamal", KeyUsage.ENCRYPT, elg.getPublic());
KemContext kybKem = CryptoAlgorithms.create("ML-KEM", KeyUsage.ENCAPSULATE, kyber.getPublic());
EncryptionContext rsaEnc = new zeroecho.sdk.ZeroEchoSession().createContext("RSA", KeyUsage.ENCRYPT, rsa.getPublic());
EncryptionContext elgEnc = new zeroecho.sdk.ZeroEchoSession().createContext("ElGamal", KeyUsage.ENCRYPT, elg.getPublic());
KemContext kybKem = new zeroecho.sdk.ZeroEchoSession().createContext("ML-KEM", KeyUsage.ENCAPSULATE, kyber.getPublic());
Supplier<AesDataContentBuilder> aesCbc = () -> AesDataContentBuilder.builder().modeCbcPkcs5().withHeader();
Supplier<AesDataContentBuilder> aesCbc = () -> AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).modeCbcPkcs5().withHeader();
MultiRecipientDataSourceBuilder enc = new MultiRecipientDataSourceBuilder().withAes(aesCbc.get())
MultiRecipientDataSourceBuilder enc = MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesCbc.get())
.payloadKeyBytes(32)
.addPasswordRecipient(password, /* iterations */ 10000, /* saltLen */ 16, /* kekBytes */ 32)
.addRecipient(rsaEnc).addRecipient(elgEnc).addRecipient(kybKem, /* kekBytes */ 32, /* saltLen */ 32);
@@ -463,29 +637,29 @@ public class MultiRecipientEnvelopeTest {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(3072, new SecureRandom());
KeyPair rsa = kpg.generateKeyPair();
KeyPair kyber = CryptoAlgorithms.keyPair("ML-KEM", KyberKeyGenSpec.kyber768());
KeyPair elg = CryptoAlgorithms.keyPair("ElGamal", ElgamalParamSpec.ffdhe2048());
KeyPair kyber = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
KeyPair elg = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ElGamal", ElgamalParamSpec.ffdhe2048());
// Sender signature keys (Ed25519)
KeyPair ed = CryptoAlgorithms.keyPair("Ed25519", Ed25519KeyGenSpec.defaultSpec());
KeyPair ed = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("Ed25519", Ed25519KeyGenSpec.defaultSpec());
// AES-256-GCM payload builder
Supplier<AesDataContentBuilder> aesGcm = () -> AesDataContentBuilder.builder().modeGcm(128).withHeader();
Supplier<AesDataContentBuilder> aesGcm = () -> AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).modeGcm(128).withHeader();
// Context recipients
EncryptionContext rsaEnc = CryptoAlgorithms.create("RSA", KeyUsage.ENCRYPT, rsa.getPublic());
EncryptionContext elgEnc = CryptoAlgorithms.create("ElGamal", KeyUsage.ENCRYPT, elg.getPublic());
KemContext kybKem = CryptoAlgorithms.create("ML-KEM", KeyUsage.ENCAPSULATE, kyber.getPublic());
EncryptionContext rsaEnc = new zeroecho.sdk.ZeroEchoSession().createContext("RSA", KeyUsage.ENCRYPT, rsa.getPublic());
EncryptionContext elgEnc = new zeroecho.sdk.ZeroEchoSession().createContext("ElGamal", KeyUsage.ENCRYPT, elg.getPublic());
KemContext kybKem = new zeroecho.sdk.ZeroEchoSession().createContext("ML-KEM", KeyUsage.ENCAPSULATE, kyber.getPublic());
// Envelope (encrypt)
MultiRecipientDataSourceBuilder envEnc = new MultiRecipientDataSourceBuilder().withAes(aesGcm.get())
MultiRecipientDataSourceBuilder envEnc = MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesGcm.get())
.payloadKeyBytes(32).addRecipient(rsaEnc).addRecipient(elgEnc)
.addRecipient(kybKem, /* kekBytes */ 32, /* saltLen */ 16)
.addPasswordRecipient(password, /* iterations */ 100_000, /* saltLen */ 16, /* kekBytes */ 32);
// Tag trailer for SIGNING (Ed25519)
TagTrailerDataContentBuilder<Signature> signTrailer = new TagTrailerDataContentBuilder<>(
TagEngineBuilder.ed25519Sign(ed.getPrivate())).bufferSize(8192);
TagEngineBuilder.ed25519Sign(new zeroecho.sdk.ZeroEchoSession(), ed.getPrivate())).bufferSize(8192);
// Encrypt chain
DataContent encryptChain = DataContentChainBuilder.encrypt().add(BytesSourceBuilder.of(msg)).add(signTrailer)
@@ -500,10 +674,10 @@ public class MultiRecipientEnvelopeTest {
TagTrailerDataContentBuilder<Signature> verifyTrailer;
// via Password
verifyTrailer = new TagTrailerDataContentBuilder<>(TagEngineBuilder.ed25519Verify(ed.getPublic()))
verifyTrailer = new TagTrailerDataContentBuilder<>(TagEngineBuilder.ed25519Verify(new zeroecho.sdk.ZeroEchoSession(), ed.getPublic()))
.bufferSize(8192).throwOnMismatch();
DataContent decPwd = DataContentChainBuilder
.decrypt().add(BytesSourceBuilder.of(ciphertext)).add(new MultiRecipientDataSourceBuilder()
.decrypt().add(BytesSourceBuilder.of(ciphertext)).add(MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000)))
.withAes(aesGcm.get()).payloadKeyBytes(32).unlockWith(new UnlockMaterial.Password(password)))
.add(verifyTrailer).build();
byte[] ptPwd;
@@ -513,10 +687,10 @@ public class MultiRecipientEnvelopeTest {
assertArrayEquals(msg, ptPwd);
// via RSA
verifyTrailer = new TagTrailerDataContentBuilder<>(TagEngineBuilder.ed25519Verify(ed.getPublic()))
verifyTrailer = new TagTrailerDataContentBuilder<>(TagEngineBuilder.ed25519Verify(new zeroecho.sdk.ZeroEchoSession(), ed.getPublic()))
.bufferSize(8192).throwOnMismatch();
DataContent decRsa = DataContentChainBuilder.decrypt().add(BytesSourceBuilder.of(ciphertext))
.add(new MultiRecipientDataSourceBuilder().withAes(aesGcm.get()).payloadKeyBytes(32)
.add(MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesGcm.get()).payloadKeyBytes(32)
.unlockWith(new UnlockMaterial.Private(rsa.getPrivate())))
.add(verifyTrailer).build();
byte[] ptRsa;
@@ -526,10 +700,10 @@ public class MultiRecipientEnvelopeTest {
assertArrayEquals(msg, ptRsa);
// via ElGamal
verifyTrailer = new TagTrailerDataContentBuilder<>(TagEngineBuilder.ed25519Verify(ed.getPublic()))
verifyTrailer = new TagTrailerDataContentBuilder<>(TagEngineBuilder.ed25519Verify(new zeroecho.sdk.ZeroEchoSession(), ed.getPublic()))
.bufferSize(8192).throwOnMismatch();
DataContent decElgamal = DataContentChainBuilder.decrypt().add(BytesSourceBuilder.of(ciphertext))
.add(new MultiRecipientDataSourceBuilder().withAes(aesGcm.get()).payloadKeyBytes(32)
.add(MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesGcm.get()).payloadKeyBytes(32)
.unlockWith(new UnlockMaterial.Private(elg.getPrivate())))
.add(verifyTrailer).build();
byte[] ptElgamal;
@@ -539,10 +713,10 @@ public class MultiRecipientEnvelopeTest {
assertArrayEquals(msg, ptElgamal);
// via ML-KEM
verifyTrailer = new TagTrailerDataContentBuilder<>(TagEngineBuilder.ed25519Verify(ed.getPublic()))
verifyTrailer = new TagTrailerDataContentBuilder<>(TagEngineBuilder.ed25519Verify(new zeroecho.sdk.ZeroEchoSession(), ed.getPublic()))
.bufferSize(8192).throwOnMismatch();
DataContent decKem = DataContentChainBuilder.decrypt().add(BytesSourceBuilder.of(ciphertext))
.add(new MultiRecipientDataSourceBuilder().withAes(aesGcm.get()).payloadKeyBytes(32)
.add(MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesGcm.get()).payloadKeyBytes(32)
.unlockWith(new UnlockMaterial.Private(kyber.getPrivate())))
.add(verifyTrailer).build();
byte[] ptKem;
@@ -568,28 +742,28 @@ public class MultiRecipientEnvelopeTest {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(3072, new SecureRandom());
KeyPair rsa = kpg.generateKeyPair();
KeyPair kyber = CryptoAlgorithms.keyPair("ML-KEM", KyberKeyGenSpec.kyber512());
KeyPair elg = CryptoAlgorithms.keyPair("ElGamal", ElgamalParamSpec.ffdhe2048());
KeyPair kyber = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber512());
KeyPair elg = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ElGamal", ElgamalParamSpec.ffdhe2048());
// Sender signature keys (SPHINCS+, default/best)
KeyPair spx = CryptoAlgorithms.keyPair("SPHINCS+", SphincsPlusKeyGenSpec.defaultSpec());
KeyPair spx = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("SPHINCS+", SphincsPlusKeyGenSpec.defaultSpec());
Supplier<AesDataContentBuilder> aesGcm = () -> AesDataContentBuilder.builder().modeGcm(128).withHeader();
Supplier<AesDataContentBuilder> aesGcm = () -> AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).modeGcm(128).withHeader();
// Context recipients
EncryptionContext rsaEnc = CryptoAlgorithms.create("RSA", KeyUsage.ENCRYPT, rsa.getPublic());
EncryptionContext elgEnc = CryptoAlgorithms.create("ElGamal", KeyUsage.ENCRYPT, elg.getPublic());
KemContext kybKem = CryptoAlgorithms.create("ML-KEM", KeyUsage.ENCAPSULATE, kyber.getPublic());
EncryptionContext rsaEnc = new zeroecho.sdk.ZeroEchoSession().createContext("RSA", KeyUsage.ENCRYPT, rsa.getPublic());
EncryptionContext elgEnc = new zeroecho.sdk.ZeroEchoSession().createContext("ElGamal", KeyUsage.ENCRYPT, elg.getPublic());
KemContext kybKem = new zeroecho.sdk.ZeroEchoSession().createContext("ML-KEM", KeyUsage.ENCAPSULATE, kyber.getPublic());
// Envelope recipients
MultiRecipientDataSourceBuilder envEnc = new MultiRecipientDataSourceBuilder().withAes(aesGcm.get())
MultiRecipientDataSourceBuilder envEnc = MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesGcm.get())
.payloadKeyBytes(32).addRecipient(rsaEnc).addRecipient(elgEnc)
.addRecipient(kybKem, /* kekBytes */ 32, /* saltLen */ 16)
.addPasswordRecipient(password, /* iterations */ 120_000, /* saltLen */ 16, /* kekBytes */ 32);
// Tag trailer for SIGNING (SPHINCS+)
TagTrailerDataContentBuilder<Signature> signTrailer = new TagTrailerDataContentBuilder<>(
TagEngineBuilder.sphincsPlusSign(spx.getPrivate())).bufferSize(8192);
TagEngineBuilder.sphincsPlusSign(new zeroecho.sdk.ZeroEchoSession(), spx.getPrivate())).bufferSize(8192);
// Encrypt chain
DataContent encryptChain = DataContentChainBuilder.encrypt().add(BytesSourceBuilder.of(msg)).add(signTrailer)
@@ -603,10 +777,10 @@ public class MultiRecipientEnvelopeTest {
TagTrailerDataContentBuilder<Signature> verifyTrailer;
// via Password
verifyTrailer = new TagTrailerDataContentBuilder<>(TagEngineBuilder.sphincsPlusVerify(spx.getPublic()))
verifyTrailer = new TagTrailerDataContentBuilder<>(TagEngineBuilder.sphincsPlusVerify(new zeroecho.sdk.ZeroEchoSession(), spx.getPublic()))
.bufferSize(8192).throwOnMismatch();
DataContent decPwd = DataContentChainBuilder
.decrypt().add(BytesSourceBuilder.of(ciphertext)).add(new MultiRecipientDataSourceBuilder()
.decrypt().add(BytesSourceBuilder.of(ciphertext)).add(MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000)))
.withAes(aesGcm.get()).payloadKeyBytes(32).unlockWith(new UnlockMaterial.Password(password)))
.add(verifyTrailer).build();
byte[] ptPwd;
@@ -616,10 +790,10 @@ public class MultiRecipientEnvelopeTest {
assertArrayEquals(msg, ptPwd);
// via RSA
verifyTrailer = new TagTrailerDataContentBuilder<>(TagEngineBuilder.sphincsPlusVerify(spx.getPublic()))
verifyTrailer = new TagTrailerDataContentBuilder<>(TagEngineBuilder.sphincsPlusVerify(new zeroecho.sdk.ZeroEchoSession(), spx.getPublic()))
.bufferSize(8192).throwOnMismatch();
DataContent decRsa = DataContentChainBuilder.decrypt().add(BytesSourceBuilder.of(ciphertext))
.add(new MultiRecipientDataSourceBuilder().withAes(aesGcm.get()).payloadKeyBytes(32)
.add(MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesGcm.get()).payloadKeyBytes(32)
.unlockWith(new UnlockMaterial.Private(rsa.getPrivate())))
.add(verifyTrailer).build();
byte[] ptRsa;
@@ -629,10 +803,10 @@ public class MultiRecipientEnvelopeTest {
assertArrayEquals(msg, ptRsa);
// via ElGamal
verifyTrailer = new TagTrailerDataContentBuilder<>(TagEngineBuilder.sphincsPlusVerify(spx.getPublic()))
verifyTrailer = new TagTrailerDataContentBuilder<>(TagEngineBuilder.sphincsPlusVerify(new zeroecho.sdk.ZeroEchoSession(), spx.getPublic()))
.bufferSize(8192).throwOnMismatch();
DataContent decElgamal = DataContentChainBuilder.decrypt().add(BytesSourceBuilder.of(ciphertext))
.add(new MultiRecipientDataSourceBuilder().withAes(aesGcm.get()).payloadKeyBytes(32)
.add(MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesGcm.get()).payloadKeyBytes(32)
.unlockWith(new UnlockMaterial.Private(elg.getPrivate())))
.add(verifyTrailer).build();
byte[] ptElgamal;
@@ -642,10 +816,10 @@ public class MultiRecipientEnvelopeTest {
assertArrayEquals(msg, ptElgamal);
// via ML-KEM
verifyTrailer = new TagTrailerDataContentBuilder<>(TagEngineBuilder.sphincsPlusVerify(spx.getPublic()))
verifyTrailer = new TagTrailerDataContentBuilder<>(TagEngineBuilder.sphincsPlusVerify(new zeroecho.sdk.ZeroEchoSession(), spx.getPublic()))
.bufferSize(8192).throwOnMismatch();
DataContent decKem = DataContentChainBuilder.decrypt().add(BytesSourceBuilder.of(ciphertext))
.add(new MultiRecipientDataSourceBuilder().withAes(aesGcm.get()).payloadKeyBytes(32)
.add(MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesGcm.get()).payloadKeyBytes(32)
.unlockWith(new UnlockMaterial.Private(kyber.getPrivate())))
.add(verifyTrailer).build();
byte[] ptKem;
@@ -661,6 +835,28 @@ public class MultiRecipientEnvelopeTest {
// Helpers
// ------------------------------------------------------------------------------------
private static byte[] encryptKemRecipients(byte[] input, KeyPair[] keyPairs, int[] kekSizes)
throws Exception {
zeroecho.sdk.ZeroEchoSession session = new zeroecho.sdk.ZeroEchoSession();
MultiRecipientDataSourceBuilder builder = MultiRecipientDataSourceBuilder.builder(session)
.withAes(aesGcmSupplier().get()).payloadKeyBytes(32);
for (int index = 0; index < keyPairs.length; index++) {
KemContext context = session.createContext("ML-KEM", KeyUsage.ENCAPSULATE,
keyPairs[index].getPublic());
builder.addRecipient(context, kekSizes[index], 16);
}
DataContent encryptor = builder.build(true);
encryptor.setInput(new BytesContent(input));
try (InputStream stream = encryptor.getStream()) {
return stream.readAllBytes();
}
}
private static Supplier<AesDataContentBuilder> aesGcmSupplier() {
return () -> AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession())
.modeGcm(128).withHeader();
}
/** Minimal source builder so we can compose pull-style chains. */
private static final class BytesSourceBuilder implements DataContentBuilder<PlainContent> {
private final byte[] data;
@@ -690,7 +886,7 @@ public class MultiRecipientEnvelopeTest {
private static void decryptAndAssert(String banner, Supplier<AesDataContentBuilder> aesFactory,
UnlockMaterial material, byte[] original, byte[] encrypted) throws IOException {
MultiRecipientDataSourceBuilder dec = new MultiRecipientDataSourceBuilder().withAes(aesFactory.get())
MultiRecipientDataSourceBuilder dec = MultiRecipientDataSourceBuilder.builder(new zeroecho.sdk.ZeroEchoSession().withPbkdf2Limits(new zeroecho.sdk.Pbkdf2Limits(1_000_000, 1_000_000))).withAes(aesFactory.get())
.payloadKeyBytes(32).unlockWith(material);
DataContent decryptor = dec.build(false);

View File

@@ -0,0 +1,251 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.sdk.guard;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.ByteArrayOutputStream;
import java.io.IOException;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.Test;
import zeroecho.core.io.Util;
import zeroecho.sdk.Pbkdf2Limits;
import zeroecho.sdk.ZeroEchoSession;
import zeroecho.sdk.builders.alg.AesDataContentBuilder;
import zeroecho.sdk.content.api.DataContent;
class PasswordRecipientTest {
private static final Pbkdf2Limits LIMITS = new Pbkdf2Limits(20_000, 30_000);
@Test
void ownsAndDestroysRetainedPassword() throws Exception {
System.out.print("PasswordRecipient/lifecycle...");
char[] caller = "envelope-password".toCharArray();
char[] expected = caller.clone();
PasswordRecipient recipient = new PasswordRecipient(caller, 10_000, 16, 32, false, LIMITS);
Arrays.fill(caller, 'x');
Field field = PasswordRecipient.class.getDeclaredField("password");
field.setAccessible(true);
char[] owned = (char[]) field.get(recipient);
assertArrayEquals(expected, owned);
recipient.destroy();
recipient.destroy();
assertTrue(recipient.isDestroyed());
assertTrue(Arrays.equals(new char[owned.length], owned));
assertThrows(IllegalStateException.class, () -> recipient.buildRecipientEntry(new byte[32]));
System.out.println("ok");
}
@Test
void constructorsRejectIterationsBelowMinimumAndAcceptBoundary() {
System.out.print("PasswordRecipient/constructorsRejectIterationsBelowMinimumAndAcceptBoundary...");
int[] invalidValues = { -1, 0, 1, 9_999 };
for (int iterations : invalidValues) {
assertThrows(IllegalArgumentException.class,
() -> new PasswordRecipient(new char[] { 'p' }, iterations, 16, 32, false, LIMITS));
assertThrows(IllegalArgumentException.class,
() -> new PasswordRecipient(new char[] { 'p' }, iterations, 16, 32, true, LIMITS));
}
new PasswordRecipient(new char[] { 'p' }, 10_000, 16, 32, false, LIMITS);
new PasswordRecipient(new char[] { 'p' }, 10_001, 16, 32, true, LIMITS);
System.out.println("...minimum=" + Pbkdf2Limits.MINIMUM);
System.out.println("ok");
}
@Test
void constructorsAcceptOnlyOpenableKekSizes() throws Exception {
System.out.println("constructorsAcceptOnlyOpenableKekSizes");
int[] invalidValues = { -1, 0, 1, 15, 17, 24, 31, 33, Integer.MAX_VALUE };
for (int kekBytes : invalidValues) {
assertThrows(IllegalArgumentException.class,
() -> new PasswordRecipient(new char[] { 'p' }, 10_000, 16, kekBytes, false, LIMITS));
}
PasswordRecipient aes128 =
new PasswordRecipient(new char[] { 'p' }, 10_000, 16, 16, false, LIMITS);
PasswordRecipient aes256 =
new PasswordRecipient(new char[] { 'p' }, 10_000, 16, 32, false, LIMITS);
aes128.close();
aes256.close();
System.out.println("...acceptedKekBytes=16,32");
System.out.println("constructorsAcceptOnlyOpenableKekSizes...ok");
}
@Test
void builderMethodsRejectBeforeRecipientListMutation() throws Exception {
System.out.print("PasswordRecipient/builderMethodsRejectBeforeRecipientListMutation...");
MultiRecipientDataSourceBuilder builder = MultiRecipientDataSourceBuilder
.builder(new ZeroEchoSession().withPbkdf2Limits(LIMITS));
int[] invalidValues = { -1, 0, 1, 9_999 };
for (int iterations : invalidValues) {
assertThrows(IllegalArgumentException.class,
() -> builder.addPasswordRecipient(new char[] { 'p' }, iterations, 16, 32));
assertThrows(IllegalArgumentException.class,
() -> builder.addPasswordRecipientDecoy(new char[] { 'p' }, iterations, 16, 32));
assertEquals(0, recipients(builder).size());
}
builder.addPasswordRecipient(new char[] { 'p' }, 10_000, 16, 32);
builder.addPasswordRecipientDecoy(new char[] { 'p' }, 10_001, 16, 32);
assertEquals(2, recipients(builder).size());
System.out.println("...recipientCount=" + recipients(builder).size());
System.out.println("ok");
}
@Test
void builderRejectsUnsupportedKekBeforeStateMutation() throws Exception {
System.out.println("builderRejectsUnsupportedKekBeforeStateMutation");
MultiRecipientDataSourceBuilder builder = builderWithAes();
int[] invalidValues = { -1, 0, 1, 15, 17, 24, 31, 33, Integer.MAX_VALUE };
for (int kekBytes : invalidValues) {
assertThrows(IllegalArgumentException.class,
() -> builder.addPasswordRecipient(new char[] { 'p' }, 10_000, 16, kekBytes));
assertThrows(IllegalArgumentException.class,
() -> builder.addPasswordRecipientDecoy(new char[] { 'p' }, 10_000, 16, kekBytes));
assertEquals(0, recipients(builder).size());
}
builder.close();
System.out.println("...recipientCount=0");
System.out.println("builderRejectsUnsupportedKekBeforeStateMutation...ok");
}
@Test
void passwordOpenerRejectsSubminimumDecodedIterations() throws Exception {
System.out.print("PasswordRecipient/passwordOpenerRejectsSubminimumDecodedIterations...");
ByteArrayOutputStream blob = new ByteArrayOutputStream();
Util.writePack7I(blob, 9_999);
assertThrows(IOException.class, () -> new PasswordOpener(LIMITS).tryOpen(
"PWD:PBKDF2-SHA256:GCM-WRAP", blob.toByteArray(),
new UnlockMaterial.Password(new char[] { 'p' })));
System.out.println("ok");
}
@Test
void passwordOpenerRejectsUnterminatedIterationEncoding() {
System.out.print("PasswordRecipient/passwordOpenerRejectsUnterminatedIterationEncoding...");
PasswordOpener opener = new PasswordOpener(LIMITS);
UnlockMaterial.Password material = new UnlockMaterial.Password(new char[] { 'p' });
assertThrows(IOException.class,
() -> opener.tryOpen("PWD:PBKDF2-SHA256:GCM-WRAP", new byte[] { 1 }, material));
assertThrows(IOException.class,
() -> opener.tryOpen("PWD:PBKDF2-SHA256:GCM-WRAP",
new byte[] { 1, 1, 1, 1, 1, (byte) 0x80 }, material));
System.out.println("...malformedCases=2");
System.out.println("ok");
}
@Test
void builderCloseDestroysAbandonedPasswordRecipients() throws Exception {
System.out.println("builderCloseDestroysAbandonedPasswordRecipients");
MultiRecipientDataSourceBuilder builder = builderWithAes();
builder.addPasswordRecipient(new char[] { 'p' }, 10_000, 16, 32);
PasswordRecipient recipient = (PasswordRecipient) recipients(builder).get(0);
builder.close();
builder.close();
assertTrue(builder.isDestroyed());
assertTrue(recipient.isDestroyed());
assertThrows(IllegalStateException.class, () -> builder.payloadKeyBytes(16));
System.out.println("...destroyed=true");
System.out.println("builderCloseDestroysAbandonedPasswordRecipients...ok");
}
@Test
void transferredRecipientSurvivesBuilderCloseUntilTerminalFailure() throws Exception {
System.out.println("transferredRecipientSurvivesBuilderCloseUntilTerminalFailure");
MultiRecipientDataSourceBuilder builder = builderWithAes();
builder.addPasswordRecipient(new char[] { 'p' }, 10_000, 16, 32);
PasswordRecipient recipient = (PasswordRecipient) recipients(builder).get(0);
DataContent encryptor = builder.build(true);
builder.close();
assertFalse(recipient.isDestroyed());
assertThrows(NullPointerException.class, encryptor::getStream);
assertTrue(recipient.isDestroyed());
System.out.println("...ownershipTransferred=true");
System.out.println("transferredRecipientSurvivesBuilderCloseUntilTerminalFailure...ok");
}
@Test
void abandonedBuiltContentOwnsAndDestroysTransferredRecipient() throws Exception {
System.out.println("abandonedBuiltContentOwnsAndDestroysTransferredRecipient");
MultiRecipientDataSourceBuilder builder = builderWithAes();
builder.addPasswordRecipient(new char[] { 'p' }, 10_000, 16, 32);
PasswordRecipient recipient = (PasswordRecipient) recipients(builder).get(0);
MultiRecipientContent content = builder.build(true);
builder.close();
assertFalse(recipient.isDestroyed());
content.close();
content.close();
assertTrue(content.isDestroyed());
assertTrue(recipient.isDestroyed());
assertThrows(IllegalStateException.class,
() -> content.setInput(() -> new ByteArrayInputStream(new byte[0])));
assertThrows(IllegalStateException.class, content::getStream);
System.out.println("...contentDestroyed=true");
System.out.println("abandonedBuiltContentOwnsAndDestroysTransferredRecipient...ok");
}
@Test
void terminalLimitAndRandomFailuresDestroyPasswordRecipients() throws Exception {
System.out.println("terminalLimitAndRandomFailuresDestroyPasswordRecipients");
MultiRecipientDataSourceBuilder limitedBuilder = builderWithAes().headerLimits(1, 1024);
limitedBuilder.addPasswordRecipient(new char[] { 'a' }, 10_000, 16, 32);
limitedBuilder.addPasswordRecipient(new char[] { 'b' }, 10_000, 16, 32);
List<Recipient> limitedRecipients = List.copyOf(recipients(limitedBuilder));
DataContent limited = limitedBuilder.build(true);
limited.setInput(() -> new ByteArrayInputStream(new byte[0]));
assertThrows(IOException.class, limited::getStream);
assertTrue(limitedRecipients.stream()
.map(PasswordRecipient.class::cast).allMatch(PasswordRecipient::isDestroyed));
PasswordRecipient randomRecipient =
new PasswordRecipient(new char[] { 'c' }, 10_000, 16, 32, false, LIMITS);
Encryptor randomFailure = new Encryptor(List.of(randomRecipient),
AesDataContentBuilder.builder(new ZeroEchoSession()).modeGcm(128).withHeader(),
null, 32, 4, 1024, ignored -> {
throw new IllegalStateException("controlled random failure");
});
randomFailure.setInput(() -> new ByteArrayInputStream(new byte[0]));
assertThrows(IllegalStateException.class, randomFailure::getStream);
assertTrue(randomRecipient.isDestroyed());
System.out.println("...terminalFailures=2");
System.out.println("terminalLimitAndRandomFailuresDestroyPasswordRecipients...ok");
}
private static MultiRecipientDataSourceBuilder builderWithAes() {
ZeroEchoSession session = new ZeroEchoSession().withPbkdf2Limits(LIMITS);
return MultiRecipientDataSourceBuilder.builder(session)
.withAes(AesDataContentBuilder.builder(session).modeGcm(128).withHeader());
}
@SuppressWarnings("unchecked")
private static List<Recipient> recipients(MultiRecipientDataSourceBuilder builder) throws Exception {
Field field = MultiRecipientDataSourceBuilder.class.getDeclaredField("recipients");
field.setAccessible(true);
return (List<Recipient>) field.get(builder);
}
}

View File

@@ -0,0 +1,116 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.sdk.guard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.Test;
import zeroecho.core.io.Util;
import zeroecho.sdk.Pbkdf2Limits;
import zeroecho.sdk.ZeroEchoSession;
import zeroecho.sdk.builders.alg.AesDataContentBuilder;
class SessionRecipientOpenerContractTest {
@Test
void publicApiExposesOnlyReusableSessionOpeners() {
System.out.println("publicApiExposesOnlyReusableSessionOpeners");
assertSessionOnlyConstructor(KemCtxOpener.class);
assertSessionOnlyConstructor(EncCtxOpener.class);
List<Method> addOpenerMethods = Arrays.stream(MultiRecipientDataSourceBuilder.class.getMethods())
.filter(method -> method.getName().equals("addOpener"))
.toList();
assertEquals(1, addOpenerMethods.size());
assertEquals(RecipientOpener.class, addOpenerMethods.get(0).getParameterTypes()[0]);
System.out.println("...addOpenerOverloads=1");
System.out.println("publicApiExposesOnlyReusableSessionOpeners...ok");
}
@Test
void sessionOpenersIgnoreUnrelatedEntryFamilies() throws Exception {
System.out.println("sessionOpenersIgnoreUnrelatedEntryFamilies");
ZeroEchoSession session = new ZeroEchoSession();
UnlockMaterial.Password material = new UnlockMaterial.Password(new char[] { 'p' });
try {
assertNull(new KemCtxOpener(session).tryOpen(
"CTX-ENC:RSA", new byte[0], material));
assertNull(new EncCtxOpener(session).tryOpen(
"KEM:ML-KEM:GCM-WRAP", new byte[0], material));
} finally {
material.destroy();
}
System.out.println("...ignoredFamilies=2");
System.out.println("sessionOpenersIgnoreUnrelatedEntryFamilies...ok");
}
@Test
void customReusableOpenerScansEveryEntryAndClosesOnce() throws Exception {
System.out.println("customReusableOpenerScansEveryEntryAndClosesOnce");
ZeroEchoSession session = new ZeroEchoSession()
.withPbkdf2Limits(new Pbkdf2Limits(20_000, 30_000));
TrackingOpener opener = new TrackingOpener();
UnlockMaterial.Password material = new UnlockMaterial.Password(new char[] { 'p' });
try (MultiRecipientDataSourceBuilder builder = MultiRecipientDataSourceBuilder.builder(session)
.withAes(AesDataContentBuilder.builder(session).modeGcm(128).withHeader())
.unlockWith(material)
.addOpener(opener);
MultiRecipientContent content = builder.build(false)) {
content.setInput(() -> new ByteArrayInputStream(twoEntryHeader()));
assertThrows(IOException.class, content::getStream);
} finally {
material.destroy();
}
assertEquals(2, opener.attempts);
assertEquals(1, opener.closeCount);
System.out.println("...attempts=" + opener.attempts);
System.out.println("customReusableOpenerScansEveryEntryAndClosesOnce...ok");
}
private static void assertSessionOnlyConstructor(Class<?> openerClass) {
Constructor<?>[] constructors = openerClass.getConstructors();
assertEquals(1, constructors.length);
assertEquals(ZeroEchoSession.class, constructors[0].getParameterTypes()[0]);
}
private static byte[] twoEntryHeader() throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
Util.writePack7I(output, 2);
Util.writeUTF8(output, "first");
Util.write(output, new byte[] { 1 });
Util.writeUTF8(output, "second");
Util.write(output, new byte[] { 2 });
return output.toByteArray();
}
private static final class TrackingOpener implements RecipientOpener {
private int attempts;
private int closeCount;
@Override
public byte[] tryOpen(String entryId, byte[] entryBlob, UnlockMaterial material) {
attempts++;
return null;
}
@Override
public void close() {
closeCount++;
}
}
}

View File

@@ -76,7 +76,7 @@ public class HybridDerivedTest {
byte[] aad = "aad".getBytes(StandardCharsets.UTF_8);
byte[] msg = fixedBytes(1024, (byte) 0x5A);
AesDataContentBuilder encAes = AesDataContentBuilder.builder().withHeader().modeGcm(128);
AesDataContentBuilder encAes = AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withHeader().modeGcm(128);
AesDataContentBuilder returnedEnc = HybridDerived.from(exporter).label("app/enc/aes").transcript(transcript)
.aad(aad).applyToAesGcm(encAes, 256, 12);
@@ -88,7 +88,7 @@ public class HybridDerivedTest {
System.out.println("...ciphertextLen=" + ciphertext.length);
System.out.println("...ciphertextPrefix=" + shortHex(ciphertext, 32));
AesDataContentBuilder decAes = AesDataContentBuilder.builder().withHeader().modeGcm(128);
AesDataContentBuilder decAes = AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withHeader().modeGcm(128);
HybridDerived.from(exporter).label("app/enc/aes").transcript(transcript).aad(aad).applyToAesGcm(decAes, 256,
12);
@@ -109,7 +109,7 @@ public class HybridDerivedTest {
byte[] aad = "aad".getBytes(StandardCharsets.UTF_8);
byte[] msg = fixedBytes(256, (byte) 0x1C);
AesDataContentBuilder encAes = AesDataContentBuilder.builder().withHeader().modeGcm(128);
AesDataContentBuilder encAes = AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withHeader().modeGcm(128);
HybridDerived.from(exporter).label("app/enc/aes").transcript(transcript).aad(aad).applyToAesGcm(encAes, 256,
12);
@@ -117,7 +117,7 @@ public class HybridDerivedTest {
byte[] ciphertext = runEncrypt(encAes, msg);
System.out.println("...ciphertextLen=" + ciphertext.length);
AesDataContentBuilder decAesWrong = AesDataContentBuilder.builder().withHeader().modeGcm(128);
AesDataContentBuilder decAesWrong = AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withHeader().modeGcm(128);
// ...label mismatch -> wrong key/iv/aad -> decryption must fail
HybridDerived.from(exporter).label("app/enc/aes_WRONG").transcript(transcript).aad(aad)
@@ -137,7 +137,7 @@ public class HybridDerivedTest {
byte[] aad = "aad".getBytes(StandardCharsets.UTF_8);
byte[] msg = fixedBytes(777, (byte) 0x33);
ChaChaDataContentBuilder encChaCha = ChaChaDataContentBuilder.builder().withHeader();
ChaChaDataContentBuilder encChaCha = ChaChaDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withHeader();
ChaChaDataContentBuilder returnedEnc = HybridDerived.from(exporter).label("app/enc/chacha")
.transcript(transcript).aad(aad).applyToChaChaAead(encChaCha, 256, 12);
@@ -149,7 +149,7 @@ public class HybridDerivedTest {
System.out.println("...ciphertextLen=" + ciphertext.length);
System.out.println("...ciphertextPrefix=" + shortHex(ciphertext, 32));
ChaChaDataContentBuilder decChaCha = ChaChaDataContentBuilder.builder().withHeader();
ChaChaDataContentBuilder decChaCha = ChaChaDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withHeader();
HybridDerived.from(exporter).label("app/enc/chacha").transcript(transcript).aad(aad)
.applyToChaChaAead(decChaCha, 256, 12);
@@ -174,7 +174,7 @@ public class HybridDerivedTest {
// recommended bits
// --------------------
HmacDataContentBuilder macBuilder = HmacDataContentBuilder.builder().sha256().emitHexTag();
HmacDataContentBuilder macBuilder = HmacDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).sha256().emitHexTag();
int recommendedBits = macBuilder.recommendedKeyBits();
System.out.println("...recommendedBits=" + recommendedBits);
@@ -184,7 +184,7 @@ public class HybridDerivedTest {
String tagHex = runHmacHex(macBuilder, msg);
System.out.println("...tagHexPrefix=" + shortText(tagHex, 64));
HmacDataContentBuilder verifyBuilder = HmacDataContentBuilder.builder().sha256().expectedTagHex(tagHex)
HmacDataContentBuilder verifyBuilder = HmacDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).sha256().expectedTagHex(tagHex)
.emitVerificationBoolean();
HybridDerived.from(exporter).label("app/mac/hmac-default").transcript(transcript).applyToHmac(verifyBuilder);
@@ -197,7 +197,7 @@ public class HybridDerivedTest {
// Override key size path: applyToHmac(hmac, keyBits)
// --------------------
HmacDataContentBuilder macBuilderOv = HmacDataContentBuilder.builder().sha256().emitHexTag();
HmacDataContentBuilder macBuilderOv = HmacDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).sha256().emitHexTag();
// ...override to 512-bit keying material (still valid for HMAC; explicit expert
// choice)
@@ -207,7 +207,7 @@ public class HybridDerivedTest {
String tagHexOv = runHmacHex(macBuilderOv, msg);
System.out.println("...tagHexOvPrefix=" + shortText(tagHexOv, 64));
HmacDataContentBuilder verifyBuilderOv = HmacDataContentBuilder.builder().sha256().expectedTagHex(tagHexOv)
HmacDataContentBuilder verifyBuilderOv = HmacDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).sha256().expectedTagHex(tagHexOv)
.emitVerificationBoolean();
HybridDerived.from(exporter).label("app/mac/hmac-override").transcript(transcript).applyToHmac(verifyBuilderOv,
@@ -221,7 +221,7 @@ public class HybridDerivedTest {
// Negative: wrong expected tag -> must emit "false"
// --------------------
HmacDataContentBuilder verifyBad = HmacDataContentBuilder.builder().sha256()
HmacDataContentBuilder verifyBad = HmacDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).sha256()
.expectedTagHex(tagHex.substring(0, Math.max(0, tagHex.length() - 2)) + "00").emitVerificationBoolean();
HybridDerived.from(exporter).label("app/mac/hmac-default").transcript(transcript).applyToHmac(verifyBad);

View File

@@ -0,0 +1,84 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.sdk.hybrid.kex;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
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.lang.reflect.Field;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
class HybridKexExporterLifecycleTest {
@Test
void constructorOwnsDefensiveCopies() throws Exception {
System.out.println("constructorOwnsDefensiveCopies");
byte[] root = filled(32, (byte) 1);
byte[] salt = filled(16, (byte) 2);
HybridKexExporter exporter = new HybridKexExporter(root, salt);
Arrays.fill(root, (byte) 0);
Arrays.fill(salt, (byte) 0);
assertFalse(Arrays.equals(root, exporter.rootSecretCopy()));
assertArrayEquals(filled(32, (byte) 1), internal(exporter, "rootSecret"));
assertArrayEquals(filled(16, (byte) 2), internal(exporter, "salt"));
exporter.close();
System.out.println("...ownedCopies=true");
System.out.println("constructorOwnsDefensiveCopies...ok");
}
@Test
void destructionClearsSecretsAndRejectsFurtherUse() throws Exception {
System.out.println("destructionClearsSecretsAndRejectsFurtherUse");
HybridKexExporter exporter = new HybridKexExporter(filled(32, (byte) 3), filled(16, (byte) 4));
byte[] internalRoot = internal(exporter, "rootSecret");
byte[] internalSalt = internal(exporter, "salt");
exporter.destroy();
exporter.destroy();
exporter.close();
assertTrue(exporter.isDestroyed());
assertArrayEquals(new byte[internalRoot.length], internalRoot);
assertArrayEquals(new byte[internalSalt.length], internalSalt);
assertThrows(IllegalStateException.class, () -> exporter.export("label", null, 16));
assertThrows(IllegalStateException.class, exporter::rootSecretCopy);
System.out.println("...destroyed=true...postDestroyRejected=true");
System.out.println("destructionClearsSecretsAndRejectsFurtherUse...ok");
}
@Test
void successfulExportDoesNotDestroyExporter() {
System.out.println("successfulExportDoesNotDestroyExporter");
HybridKexExporter exporter = new HybridKexExporter(filled(32, (byte) 5), null);
byte[] first = exporter.export("first", null, 16);
byte[] second = exporter.export("second", new byte[] { 1 }, 16);
assertFalse(exporter.isDestroyed());
assertFalse(Arrays.equals(first, second));
exporter.close();
Arrays.fill(first, (byte) 0);
Arrays.fill(second, (byte) 0);
System.out.println("...derivedLengths=16,16");
System.out.println("successfulExportDoesNotDestroyExporter...ok");
}
private static byte[] internal(HybridKexExporter exporter, String fieldName) throws Exception {
Field field = HybridKexExporter.class.getDeclaredField(fieldName);
field.setAccessible(true);
return (byte[]) field.get(exporter);
}
private static byte[] filled(int length, byte value) {
byte[] result = new byte[length];
Arrays.fill(result, value);
return result;
}
}

View File

@@ -0,0 +1,196 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.sdk.hybrid.kex;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.security.GeneralSecurityException;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
import zeroecho.core.context.AgreementContext;
import zeroecho.core.context.MessageAgreementContext;
/**
* Verifies provider-independent hybrid frame validation and size boundaries.
*/
class HybridKexFrameCodecTest {
@Test
void acceptsZeroLengthComponents() throws Exception {
String name = start("acceptsZeroLengthComponents");
byte[] frame = HybridKexContext.encode(new byte[0], new byte[0]);
HybridKexContext.Parts parts = HybridKexContext.decode(frame);
assertEquals(8, frame.length);
assertArrayEquals(new byte[0], parts.classicPart());
assertArrayEquals(new byte[0], parts.pqcPart());
progress("frameBytes=" + frame.length);
ok(name);
}
@Test
void acceptsInclusiveMaximumFrame() throws Exception {
String name = start("acceptsInclusiveMaximumFrame");
byte[] classic = new byte[HybridKexContext.MAX_FRAME_BYTES - 8];
byte[] frame = HybridKexContext.encode(classic, new byte[0]);
assertEquals(HybridKexContext.MAX_FRAME_BYTES, frame.length);
assertEquals(classic.length, HybridKexContext.decode(frame).classicPart().length);
progress("frameBytes=" + frame.length);
ok(name);
}
@Test
void rejectsOversizedEncoding() {
String name = start("rejectsOversizedEncoding");
byte[] classic = new byte[HybridKexContext.MAX_FRAME_BYTES - 7];
assertThrows(IOException.class, () -> HybridKexContext.encode(classic, new byte[0]));
progress("classicBytes=" + classic.length);
ok(name);
}
@Test
void rejectsMalformedFrames() {
String name = start("rejectsMalformedFrames");
byte[][] malformed = {
new byte[7],
ints(-1, 0),
ints(0, -1),
ints(1, 0),
ints(0, 1),
append(ints(0, 0), (byte) 0x7f),
new byte[HybridKexContext.MAX_FRAME_BYTES + 1],
ints(0, HybridKexContext.MAX_FRAME_BYTES),
ints(Integer.MAX_VALUE, 0)
};
for (byte[] frame : malformed) {
assertThrows(IOException.class, () -> HybridKexContext.decode(frame));
}
progress("cases=" + malformed.length);
ok(name);
}
@Test
void publicApiMapsMalformedFrame() {
String name = start("publicApiMapsMalformedFrame");
AgreementContext classic = mock(AgreementContext.class);
MessageAgreementContext pqc = mock(MessageAgreementContext.class);
HybridKexContext context = new HybridKexContext(HybridKexProfile.defaultProfile(32), classic, pqc);
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
() -> context.setPeerMessage(ints(Integer.MAX_VALUE, 0)));
assertInstanceOf(IOException.class, failure.getCause());
verifyNoInteractions(classic, pqc);
progress("mapped=IllegalArgument");
ok(name);
}
@Test
void clearsComponentSecretsAfterSuccessfulDerivation() {
String name = start("clearsComponentSecretsAfterSuccessfulDerivation");
byte[] classicSecret = { 1, 2, 3 };
byte[] pqcSecret = { 4, 5, 6 };
AgreementContext classic = mock(AgreementContext.class);
MessageAgreementContext pqc = mock(MessageAgreementContext.class);
when(classic.deriveSecret()).thenReturn(classicSecret);
when(pqc.deriveSecret()).thenReturn(pqcSecret);
HybridKexContext context = new HybridKexContext(HybridKexProfile.defaultProfile(16), classic, pqc);
byte[] result = context.deriveSecret();
assertEquals(16, result.length);
assertZeroized(classicSecret);
assertZeroized(pqcSecret);
progress("resultBytes=" + result.length);
ok(name);
}
@Test
void clearsClassicSecretWhenSecondLegFails() {
String name = start("clearsClassicSecretWhenSecondLegFails");
byte[] classicSecret = { 1, 2, 3 };
IllegalStateException expected = new IllegalStateException("controlled second-leg failure");
AgreementContext classic = mock(AgreementContext.class);
MessageAgreementContext pqc = mock(MessageAgreementContext.class);
when(classic.deriveSecret()).thenReturn(classicSecret);
when(pqc.deriveSecret()).thenThrow(expected);
HybridKexContext context = new HybridKexContext(HybridKexProfile.defaultProfile(16), classic, pqc);
assertSame(expected, assertThrows(IllegalStateException.class, context::deriveSecret));
assertZeroized(classicSecret);
progress("failure=secondLeg");
ok(name);
}
@Test
void clearsAllTemporarySecretsWhenKdfFails() {
String name = start("clearsAllTemporarySecretsWhenKdfFails");
byte[] classicSecret = { 1, 2, 3 };
byte[] pqcSecret = { 4, 5, 6 };
byte[][] observedInput = new byte[1][];
AgreementContext classic = mock(AgreementContext.class);
MessageAgreementContext pqc = mock(MessageAgreementContext.class);
when(classic.deriveSecret()).thenReturn(classicSecret);
when(pqc.deriveSecret()).thenReturn(pqcSecret);
HybridKexContext context = new HybridKexContext(HybridKexProfile.defaultProfile(16), classic, pqc,
(ikm, salt, info, outputLength) -> {
observedInput[0] = ikm;
throw new GeneralSecurityException("controlled KDF failure");
});
IllegalStateException failure = assertThrows(IllegalStateException.class, context::deriveSecret);
assertInstanceOf(GeneralSecurityException.class, failure.getCause());
assertZeroized(classicSecret);
assertZeroized(pqcSecret);
assertZeroized(observedInput[0]);
progress("failure=KDF");
ok(name);
}
private static byte[] ints(int first, int second) {
return ByteBuffer.allocate(8).putInt(first).putInt(second).array();
}
private static byte[] append(byte[] input, byte value) {
byte[] output = new byte[input.length + 1];
System.arraycopy(input, 0, output, 0, input.length);
output[input.length] = value;
return output;
}
private static void assertZeroized(byte[] value) {
assertTrue(value != null && Arrays.equals(new byte[value.length], value));
}
private static String start(String routine) {
String label = routine.length() <= 30 ? routine : routine.substring(0, 27) + "...";
System.out.println(label);
return label;
}
private static void progress(String detail) {
System.out.println("..." + detail);
}
private static void ok(String name) {
System.out.println(name + "...ok");
}
}

View File

@@ -129,11 +129,11 @@ public class HybridKexTest {
HybridKexProfile profile = HybridKexProfile.defaultProfile(32);
// Classic: X25519 key pairs (Xdh + XdhSpec.X25519)
KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair aliceClassic = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassic = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
// PQC: ML-KEM key pair (Kyber variant)
KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
KeyPair bobPqc = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
HybridKexContext alice = null;
HybridKexContext bob = null;
@@ -141,12 +141,12 @@ public class HybridKexTest {
try {
// Initiator: classic uses Alice private + Bob classic public; PQC uses Bob PQC
// public
alice = HybridKexContexts.initiator(profile, "Xdh", aliceClassic.getPrivate(), bobClassic.getPublic(),
alice = HybridKexContexts.initiator(new zeroecho.sdk.ZeroEchoSession(), profile, "Xdh", aliceClassic.getPrivate(), bobClassic.getPublic(),
XdhSpec.X25519, "ML-KEM", bobPqc.getPublic(), null);
// Responder: classic uses Bob private + Alice classic public; PQC uses Bob PQC
// private
bob = HybridKexContexts.responder(profile, "Xdh", bobClassic.getPrivate(), aliceClassic.getPublic(),
bob = HybridKexContexts.responder(new zeroecho.sdk.ZeroEchoSession(), profile, "Xdh", bobClassic.getPrivate(), aliceClassic.getPublic(),
XdhSpec.X25519, "ML-KEM", bobPqc.getPrivate(), null);
// Alice produces message (contains PQC ciphertext; classic part is empty here)
@@ -190,11 +190,11 @@ public class HybridKexTest {
HybridKexProfile profile = HybridKexProfile.defaultProfile(32);
// Classic: X25519 key pairs (Xdh + XdhSpec.X25519)
KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair aliceClassic = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassic = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("Xdh", XdhSpec.X25519);
// PQC: ML-KEM key pair (recipient/responder)
KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
KeyPair bobPqc = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
HybridKexContext alice = null;
HybridKexContext bob = null;
@@ -204,10 +204,10 @@ public class HybridKexTest {
// KeyPairKey + ContextSpec).
// PQC leg is KEM-style: initiator uses recipient public key; responder uses
// recipient private key.
alice = HybridKexContexts.initiatorPairMessage(profile, "Xdh", new KeyPairKey(aliceClassic), XdhSpec.X25519,
alice = HybridKexContexts.initiatorPairMessage(new zeroecho.sdk.ZeroEchoSession(), profile, "Xdh", new KeyPairKey(aliceClassic), XdhSpec.X25519,
"ML-KEM", bobPqc.getPublic(), null);
bob = HybridKexContexts.responderPairMessage(profile, "Xdh", new KeyPairKey(bobClassic), XdhSpec.X25519,
bob = HybridKexContexts.responderPairMessage(new zeroecho.sdk.ZeroEchoSession(), profile, "Xdh", new KeyPairKey(bobClassic), XdhSpec.X25519,
"ML-KEM", bobPqc.getPrivate(), null);
// Step 1: Alice -> Bob (classic SPKI + PQC ciphertext)

View File

@@ -53,10 +53,14 @@ import org.junit.jupiter.api.Test;
import zeroecho.core.CryptoAlgorithms;
import zeroecho.core.KeyUsage;
import zeroecho.core.alg.ed25519.Ed25519KeyGenSpec;
import zeroecho.core.alg.rsa.RsaKeyGenSpec;
import zeroecho.core.alg.sphincsplus.SphincsPlusKeyGenSpec;
import zeroecho.core.context.SignatureContext;
import zeroecho.core.io.TailStrippingInputStream;
import zeroecho.core.spec.ContextSpec;
import zeroecho.sdk.builders.TagTrailerDataContentBuilder;
import zeroecho.sdk.ZeroEchoSession;
import zeroecho.sdk.builders.core.DataContentBuilder;
import zeroecho.sdk.builders.core.DataContentChainBuilder;
import zeroecho.sdk.content.api.DataContent;
@@ -167,7 +171,7 @@ public class HybridSignatureTest {
}
private static int tagLen(String algoId, KeyUsage role, Key key, ContextSpec specOrNull) throws Exception {
try (SignatureContext ctx = CryptoAlgorithms.create(algoId, role, key, specOrNull)) {
try (SignatureContext ctx = new zeroecho.sdk.ZeroEchoSession().createContext(algoId, role, key, specOrNull)) {
return ctx.tagLength();
}
}
@@ -237,8 +241,11 @@ public class HybridSignatureTest {
byte[] msg = randomBytes(size);
System.out.println("...msg=" + msg.length + " bytes");
KeyPair ed = CryptoAlgorithms.require("Ed25519").generateKeyPair();
KeyPair spx = CryptoAlgorithms.require("SPHINCS+").generateKeyPair();
ZeroEchoSession session = new ZeroEchoSession();
KeyPair ed = session.keyBuilders().asymmetric().generateKeyPair("Ed25519",
Ed25519KeyGenSpec.defaultSpec());
KeyPair spx = session.keyBuilders().asymmetric().generateKeyPair("SPHINCS+",
SphincsPlusKeyGenSpec.defaultSpec());
int edLen = tagLen("Ed25519", KeyUsage.SIGN, ed.getPrivate(), null);
int spxLen = tagLen("SPHINCS+", KeyUsage.SIGN, spx.getPrivate(), null);
@@ -249,14 +256,14 @@ public class HybridSignatureTest {
HybridSignatureProfile.VerifyRule.AND);
byte[] sigAnd;
try (SignatureContext signer = HybridSignatureContexts.sign(andProfile, ed.getPrivate(), spx.getPrivate(),
try (SignatureContext signer = HybridSignatureContexts.sign(new zeroecho.sdk.ZeroEchoSession(), andProfile, ed.getPrivate(), spx.getPrivate(),
2 * 1024 * 1024)) {
sigAnd = signTrailer(signer, msg);
}
System.out.println("...sig(AND).len=" + sigAnd.length + ", head=" + hexShort(sigAnd));
// verify OK
try (SignatureContext verifier = HybridSignatureContexts.verify(andProfile, ed.getPublic(), spx.getPublic(),
try (SignatureContext verifier = HybridSignatureContexts.verify(new zeroecho.sdk.ZeroEchoSession(), andProfile, ed.getPublic(), spx.getPublic(),
2 * 1024 * 1024)) {
verifier.setVerificationApproach(verifier.getVerificationCore().getThrowOnMismatch());
verifier.setExpectedTag(sigAnd);
@@ -268,7 +275,7 @@ public class HybridSignatureTest {
// corrupt classic => must fail
byte[] badClassic = concat(flipOneBit(sub(sigAnd, 0, edLen), 0), sub(sigAnd, edLen, spxLen));
try (SignatureContext verifier = HybridSignatureContexts.verify(andProfile, ed.getPublic(), spx.getPublic(),
try (SignatureContext verifier = HybridSignatureContexts.verify(new zeroecho.sdk.ZeroEchoSession(), andProfile, ed.getPublic(), spx.getPublic(),
2 * 1024 * 1024)) {
verifier.setVerificationApproach(verifier.getVerificationCore().getThrowOnMismatch());
verifier.setExpectedTag(badClassic);
@@ -282,7 +289,7 @@ public class HybridSignatureTest {
// corrupt pqc => must fail
byte[] badPqc = concat(sub(sigAnd, 0, edLen), flipOneBit(sub(sigAnd, edLen, spxLen), 0));
try (SignatureContext verifier = HybridSignatureContexts.verify(andProfile, ed.getPublic(), spx.getPublic(),
try (SignatureContext verifier = HybridSignatureContexts.verify(new zeroecho.sdk.ZeroEchoSession(), andProfile, ed.getPublic(), spx.getPublic(),
2 * 1024 * 1024)) {
verifier.setVerificationApproach(verifier.getVerificationCore().getThrowOnMismatch());
verifier.setExpectedTag(badPqc);
@@ -299,7 +306,7 @@ public class HybridSignatureTest {
HybridSignatureProfile.VerifyRule.OR);
byte[] sigOr;
try (SignatureContext signer = HybridSignatureContexts.sign(orProfile, ed.getPrivate(), spx.getPrivate(),
try (SignatureContext signer = HybridSignatureContexts.sign(new zeroecho.sdk.ZeroEchoSession(), orProfile, ed.getPrivate(), spx.getPrivate(),
2 * 1024 * 1024)) {
sigOr = signTrailer(signer, msg);
}
@@ -307,7 +314,7 @@ public class HybridSignatureTest {
// corrupt classic => OR must pass
byte[] orBadClassic = concat(flipOneBit(sub(sigOr, 0, edLen), 0), sub(sigOr, edLen, spxLen));
try (SignatureContext verifier = HybridSignatureContexts.verify(orProfile, ed.getPublic(), spx.getPublic(),
try (SignatureContext verifier = HybridSignatureContexts.verify(new zeroecho.sdk.ZeroEchoSession(), orProfile, ed.getPublic(), spx.getPublic(),
2 * 1024 * 1024)) {
verifier.setVerificationApproach(verifier.getVerificationCore().getThrowOnMismatch());
verifier.setExpectedTag(orBadClassic);
@@ -319,7 +326,7 @@ public class HybridSignatureTest {
// corrupt pqc => OR must pass
byte[] orBadPqc = concat(sub(sigOr, 0, edLen), flipOneBit(sub(sigOr, edLen, spxLen), 0));
try (SignatureContext verifier = HybridSignatureContexts.verify(orProfile, ed.getPublic(), spx.getPublic(),
try (SignatureContext verifier = HybridSignatureContexts.verify(new zeroecho.sdk.ZeroEchoSession(), orProfile, ed.getPublic(), spx.getPublic(),
2 * 1024 * 1024)) {
verifier.setVerificationApproach(verifier.getVerificationCore().getThrowOnMismatch());
verifier.setExpectedTag(orBadPqc);
@@ -331,7 +338,7 @@ public class HybridSignatureTest {
// corrupt both => OR must fail
byte[] orBadBoth = concat(flipOneBit(sub(sigOr, 0, edLen), 0), flipOneBit(sub(sigOr, edLen, spxLen), 0));
try (SignatureContext verifier = HybridSignatureContexts.verify(orProfile, ed.getPublic(), spx.getPublic(),
try (SignatureContext verifier = HybridSignatureContexts.verify(new zeroecho.sdk.ZeroEchoSession(), orProfile, ed.getPublic(), spx.getPublic(),
2 * 1024 * 1024)) {
verifier.setVerificationApproach(verifier.getVerificationCore().getThrowOnMismatch());
verifier.setExpectedTag(orBadBoth);
@@ -357,8 +364,10 @@ public class HybridSignatureTest {
byte[] msg = randomBytes(size);
System.out.println("...msg=" + msg.length + " bytes");
KeyPair rsa = CryptoAlgorithms.require("RSA").generateKeyPair();
KeyPair spx = CryptoAlgorithms.require("SPHINCS+").generateKeyPair();
ZeroEchoSession session = new ZeroEchoSession();
KeyPair rsa = session.keyBuilders().asymmetric().generateKeyPair("RSA", RsaKeyGenSpec.rsa2048());
KeyPair spx = session.keyBuilders().asymmetric().generateKeyPair("SPHINCS+",
SphincsPlusKeyGenSpec.defaultSpec());
int rsaLen = tagLen("RSA", KeyUsage.SIGN, rsa.getPrivate(), null);
int spxLen = tagLen("SPHINCS+", KeyUsage.SIGN, spx.getPrivate(), null);
@@ -368,13 +377,13 @@ public class HybridSignatureTest {
HybridSignatureProfile.VerifyRule.AND);
byte[] sig;
try (SignatureContext signer = HybridSignatureContexts.sign(profile, rsa.getPrivate(), spx.getPrivate(),
try (SignatureContext signer = HybridSignatureContexts.sign(new zeroecho.sdk.ZeroEchoSession(), profile, rsa.getPrivate(), spx.getPrivate(),
2 * 1024 * 1024)) {
sig = signTrailer(signer, msg);
}
System.out.println("...sig.len=" + sig.length + ", head=" + hexShort(sig));
try (SignatureContext verifier = HybridSignatureContexts.verify(profile, rsa.getPublic(), spx.getPublic(),
try (SignatureContext verifier = HybridSignatureContexts.verify(new zeroecho.sdk.ZeroEchoSession(), profile, rsa.getPublic(), spx.getPublic(),
2 * 1024 * 1024)) {
verifier.setVerificationApproach(verifier.getVerificationCore().getThrowOnMismatch());
verifier.setExpectedTag(sig);
@@ -386,7 +395,7 @@ public class HybridSignatureTest {
// negative sanity: corrupt classic => must fail (AND)
byte[] badClassic = concat(flipOneBit(sub(sig, 0, rsaLen), 0), sub(sig, rsaLen, spxLen));
try (SignatureContext verifier = HybridSignatureContexts.verify(profile, rsa.getPublic(), spx.getPublic(),
try (SignatureContext verifier = HybridSignatureContexts.verify(new zeroecho.sdk.ZeroEchoSession(), profile, rsa.getPublic(), spx.getPublic(),
2 * 1024 * 1024)) {
verifier.setVerificationApproach(verifier.getVerificationCore().getThrowOnMismatch());
verifier.setExpectedTag(badClassic);
@@ -416,8 +425,11 @@ public class HybridSignatureTest {
byte[] msg = randomBytes(size);
System.out.println("...msg=" + msg.length + " bytes");
KeyPair ed = CryptoAlgorithms.require("Ed25519").generateKeyPair();
KeyPair spx = CryptoAlgorithms.require("SPHINCS+").generateKeyPair();
ZeroEchoSession session = new ZeroEchoSession();
KeyPair ed = session.keyBuilders().asymmetric().generateKeyPair("Ed25519",
Ed25519KeyGenSpec.defaultSpec());
KeyPair spx = session.keyBuilders().asymmetric().generateKeyPair("SPHINCS+",
SphincsPlusKeyGenSpec.defaultSpec());
HybridSignatureProfile profile = new HybridSignatureProfile("Ed25519", "SPHINCS+", null, null,
HybridSignatureProfile.VerifyRule.AND);
@@ -425,7 +437,7 @@ public class HybridSignatureTest {
byte[] out;
int tagLen;
try (SignatureContext tagEnc = HybridSignatureContexts.sign(profile, ed.getPrivate(), spx.getPrivate(),
try (SignatureContext tagEnc = HybridSignatureContexts.sign(new zeroecho.sdk.ZeroEchoSession(), profile, ed.getPrivate(), spx.getPrivate(),
2 * 1024 * 1024)) {
DataContent enc = DataContentChainBuilder.encrypt().add(BytesSourceBuilder.of(msg))
@@ -437,7 +449,7 @@ public class HybridSignatureTest {
System.out.println("...out=" + out.length + " bytes");
try (SignatureContext tagDec = HybridSignatureContexts.verify(profile, ed.getPublic(), spx.getPublic(),
try (SignatureContext tagDec = HybridSignatureContexts.verify(new zeroecho.sdk.ZeroEchoSession(), profile, ed.getPublic(), spx.getPublic(),
2 * 1024 * 1024)) {
tagDec.setVerificationApproach(tagDec.getVerificationCore().getThrowOnMismatch());
@@ -468,8 +480,11 @@ public class HybridSignatureTest {
msg = Arrays.copyOf(msg, size);
System.out.println("...msg=" + msg.length + " bytes");
KeyPair ed = CryptoAlgorithms.require("Ed25519").generateKeyPair();
KeyPair spx = CryptoAlgorithms.require("SPHINCS+").generateKeyPair();
ZeroEchoSession session = new ZeroEchoSession();
KeyPair ed = session.keyBuilders().asymmetric().generateKeyPair("Ed25519",
Ed25519KeyGenSpec.defaultSpec());
KeyPair spx = session.keyBuilders().asymmetric().generateKeyPair("SPHINCS+",
SphincsPlusKeyGenSpec.defaultSpec());
int edLen = tagLen("Ed25519", KeyUsage.SIGN, ed.getPrivate(), null);
int spxLen = tagLen("SPHINCS+", KeyUsage.SIGN, spx.getPrivate(), null);
@@ -481,7 +496,7 @@ public class HybridSignatureTest {
byte[] out;
int tagLen;
try (SignatureContext tagEnc = HybridSignatureContexts.sign(profile, ed.getPrivate(), spx.getPrivate(),
try (SignatureContext tagEnc = HybridSignatureContexts.sign(new zeroecho.sdk.ZeroEchoSession(), profile, ed.getPrivate(), spx.getPrivate(),
2 * 1024 * 1024)) {
DataContent enc = DataContentChainBuilder.encrypt().add(BytesSourceBuilder.of(msg))
@@ -500,7 +515,7 @@ public class HybridSignatureTest {
byte[] badClassic = concat(flipOneBit(sub(tag, 0, edLen), 0), sub(tag, edLen, spxLen));
byte[] outBadClassic = concat(body, badClassic);
try (SignatureContext tagDec = HybridSignatureContexts.verify(profile, ed.getPublic(), spx.getPublic(),
try (SignatureContext tagDec = HybridSignatureContexts.verify(new zeroecho.sdk.ZeroEchoSession(), profile, ed.getPublic(), spx.getPublic(),
2 * 1024 * 1024)) {
tagDec.setVerificationApproach(tagDec.getVerificationCore().getThrowOnMismatch());
@@ -518,7 +533,7 @@ public class HybridSignatureTest {
byte[] badPqc = concat(sub(tag, 0, edLen), flipOneBit(sub(tag, edLen, spxLen), 0));
byte[] outBadPqc = concat(body, badPqc);
try (SignatureContext tagDec = HybridSignatureContexts.verify(profile, ed.getPublic(), spx.getPublic(),
try (SignatureContext tagDec = HybridSignatureContexts.verify(new zeroecho.sdk.ZeroEchoSession(), profile, ed.getPublic(), spx.getPublic(),
2 * 1024 * 1024)) {
tagDec.setVerificationApproach(tagDec.getVerificationCore().getThrowOnMismatch());
@@ -536,7 +551,7 @@ public class HybridSignatureTest {
byte[] badBoth = concat(flipOneBit(sub(tag, 0, edLen), 0), flipOneBit(sub(tag, edLen, spxLen), 0));
byte[] outBadBoth = concat(body, badBoth);
try (SignatureContext tagDec = HybridSignatureContexts.verify(profile, ed.getPublic(), spx.getPublic(),
try (SignatureContext tagDec = HybridSignatureContexts.verify(new zeroecho.sdk.ZeroEchoSession(), profile, ed.getPublic(), spx.getPublic(),
2 * 1024 * 1024)) {
tagDec.setVerificationApproach(tagDec.getVerificationCore().getThrowOnMismatch());
@@ -564,15 +579,17 @@ public class HybridSignatureTest {
byte[] msg = randomBytes(size);
System.out.println("...msg=" + msg.length + " bytes");
KeyPair rsa = CryptoAlgorithms.require("RSA").generateKeyPair();
KeyPair spx = CryptoAlgorithms.require("SPHINCS+").generateKeyPair();
ZeroEchoSession session = new ZeroEchoSession();
KeyPair rsa = session.keyBuilders().asymmetric().generateKeyPair("RSA", RsaKeyGenSpec.rsa2048());
KeyPair spx = session.keyBuilders().asymmetric().generateKeyPair("SPHINCS+",
SphincsPlusKeyGenSpec.defaultSpec());
HybridSignatureProfile profile = new HybridSignatureProfile("RSA", "SPHINCS+", null, null,
HybridSignatureProfile.VerifyRule.AND);
byte[] out;
try (SignatureContext tagEnc = HybridSignatureContexts.sign(profile, rsa.getPrivate(), spx.getPrivate(),
try (SignatureContext tagEnc = HybridSignatureContexts.sign(new zeroecho.sdk.ZeroEchoSession(), profile, rsa.getPrivate(), spx.getPrivate(),
2 * 1024 * 1024)) {
DataContent enc = DataContentChainBuilder.encrypt().add(BytesSourceBuilder.of(msg))
@@ -583,7 +600,7 @@ public class HybridSignatureTest {
System.out.println("...out=" + out.length + " bytes");
try (SignatureContext tagDec = HybridSignatureContexts.verify(profile, rsa.getPublic(), spx.getPublic(),
try (SignatureContext tagDec = HybridSignatureContexts.verify(new zeroecho.sdk.ZeroEchoSession(), profile, rsa.getPublic(), spx.getPublic(),
2 * 1024 * 1024)) {
tagDec.setVerificationApproach(tagDec.getVerificationCore().getThrowOnMismatch());

View File

@@ -0,0 +1,69 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.sdk.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
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 java.security.SecureRandom;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.junit.jupiter.api.Test;
class PasswordTest {
@Test
void canonicalRandomFacadeValidatesNullAndAcceptsEmptyArrays() {
System.out.print("Password/random-boundaries...");
assertThrows(NullPointerException.class, () -> Password.generateRandom(null));
byte[] empty = new byte[0];
assertSame(empty, Password.generateRandom(empty));
assertThrows(IllegalArgumentException.class, () -> Password.generatePrintablePasswordChars(0));
assertThrows(IllegalArgumentException.class, () -> Password.generatePrintablePasswordChars(-1));
char[] printable = Password.generatePrintablePasswordChars(128);
assertEquals(128, printable.length);
for (char character : printable) {
assertTrue(character >= '!' && character <= '~');
}
System.out.println("ok");
}
@Test
void sharedSourceSupportsParallelUse() throws Exception {
System.out.print("RandomSupport/concurrent...");
SecureRandom source = RandomSupport.getRandom();
assertSame(source, RandomSupport.getRandom());
ExecutorService executor = Executors.newFixedThreadPool(8);
try {
List<Future<byte[]>> futures = new ArrayList<>();
for (int i = 0; i < 64; i++) {
futures.add(executor.submit(() -> {
assertSame(source, RandomSupport.getRandom());
return Password.generateRandom(new byte[32]);
}));
}
byte[] first = futures.get(0).get();
boolean differentOutputObserved = false;
for (int i = 1; i < futures.size(); i++) {
byte[] output = futures.get(i).get();
if (!java.util.Arrays.equals(first, output)) {
differentOutputObserved = true;
}
}
assertTrue(differentOutputObserved);
assertNotEquals(0, first.length);
} finally {
executor.shutdownNow();
}
System.out.println("ok");
}
}