/******************************************************************************* * Copyright (C) 2026, Leo Galambos * All rights reserved. * * Redistribution and use in source and binary forms, with or without modification, * are permitted provided that the following conditions are met: * * 1. Redistributions of source code must retain the above copyright notice, this * list of conditions and the following disclaimer. * * 2. Redistributions in binary form must reproduce the above copyright notice, * this list of conditions and the following disclaimer in the documentation * and/or other materials provided with the distribution. * * 3. All advertising materials mentioning features or use of this software must * display the following acknowledgement: * This product includes software developed by the Egothor project. * * 4. Neither the name of the copyright holder nor the names of its contributors * may be used to endorse or promote products derived from this software without * specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ******************************************************************************/ package zeroecho.sdk.builders; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; 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.alg.common.agreement.KeyPairKey; import zeroecho.core.alg.kyber.KyberKeyGenSpec; import zeroecho.core.alg.xdh.XdhSpec; import zeroecho.core.audit.AuditListener; import zeroecho.core.audit.AuditMode; 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; import zeroecho.sdk.util.BouncyCastleActivator; /** * Professional unit and regression tests for {@link HybridKexBuilder}. * *
* These tests verify both supported classic-leg construction modes, * builder-side validation failures, policy enforcement, transcript binding, and * mode-switch behavior. The builder is exercised strictly through its public * fluent API so that validation and resulting hybrid context construction are * covered together. *
*/ class HybridKexBuilderTest { @BeforeAll static void setup() { try { BouncyCastleActivator.init(); } catch (Throwable ignore) { // Keep tests runnable even when BC is not present. } } @Test void buildInitiatorResponderClassicAgreementRoundTrip() throws Exception { System.out.println("buildInitiatorResponderClassicAgreementRoundTrip"); HybridKexProfile profile = HybridKexProfile.defaultProfile(32); HybridKexTranscript transcript = new HybridKexTranscript().addUtf8("suite", "X25519+ML-KEM-768").addUtf8("role", "builder-test"); 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(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(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(); byte[] aliceMessage = alice.getPeerMessage(); System.out.println("...aliceMessage(" + lens(aliceMessage) + ")=" + hex(aliceMessage)); bob.setPeerMessage(aliceMessage); byte[] secretAlice = alice.deriveSecret(); byte[] secretBob = bob.deriveSecret(); System.out.println("...secretAlice=" + hex(secretAlice)); System.out.println("...secretBob=" + hex(secretBob)); assertNotNull(secretAlice); assertNotNull(secretBob); assertArrayEquals(secretAlice, secretBob); assertEquals(profile.outLenBytes(), secretAlice.length); } finally { closeQuietly(alice); closeQuietly(bob); } System.out.println("buildInitiatorResponderClassicAgreementRoundTrip...ok"); } @Test void buildInitiatorResponderPairMessageRoundTrip() throws Exception { System.out.println("buildInitiatorResponderPairMessageRoundTrip"); HybridKexProfile profile = HybridKexProfile.defaultProfile(32); 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(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(new zeroecho.sdk.ZeroEchoSession()).profile(profile).classicPairMessage() .algorithm("Xdh").spec(XdhSpec.X25519).keyPair(new KeyPairKey(bobClassic)).pqcKem() .algorithm("ML-KEM").privateKey(bobPqc.getPrivate()).buildResponder(); byte[] messageA = alice.getPeerMessage(); System.out.println("...messageA(" + lens(messageA) + ")=" + hex(messageA)); bob.setPeerMessage(messageA); byte[] messageB = bob.getPeerMessage(); System.out.println("...messageB(" + lens(messageB) + ")=" + hex(messageB)); alice.setPeerMessage(messageB); byte[] secretAlice = alice.deriveSecret(); byte[] secretBob = bob.deriveSecret(); System.out.println("...secretAlice=" + hex(secretAlice)); System.out.println("...secretBob=" + hex(secretBob)); assertNotNull(secretAlice); assertNotNull(secretBob); assertArrayEquals(secretAlice, secretBob); assertEquals(profile.outLenBytes(), secretAlice.length); } finally { closeQuietly(alice); closeQuietly(bob); } System.out.println("buildInitiatorResponderPairMessageRoundTrip...ok"); } @Test void buildInitiatorWithoutProfileFails() throws Exception { System.out.println("buildInitiatorWithoutProfileFails"); 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(new zeroecho.sdk.ZeroEchoSession()).classicAgreement().algorithm("Xdh") .spec(XdhSpec.X25519).privateKey(aliceClassic.getPrivate()).peerPublic(bobClassic.getPublic()) .pqcKem().algorithm("ML-KEM").peerPublic(bobPqc.getPublic()).buildInitiator(); }); System.out.println("...exception=" + exception.getMessage()); assertEquals("profile must be set", exception.getMessage()); System.out.println("buildInitiatorWithoutProfileFails...ok"); } @Test void buildInitiatorWithoutClassicModeFails() throws Exception { System.out.println("buildInitiatorWithoutClassicModeFails"); HybridKexProfile profile = HybridKexProfile.defaultProfile(32); KeyPair bobPqc = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768()); IllegalStateException exception = assertThrows(IllegalStateException.class, () -> { HybridKexBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).profile(profile).pqcKem().algorithm("ML-KEM") .peerPublic(bobPqc.getPublic()).buildInitiator(); }); System.out.println("...exception=" + exception.getMessage()); assertEquals("classic mode must be selected", exception.getMessage()); System.out.println("buildInitiatorWithoutClassicModeFails...ok"); } @Test void buildInitiatorClassicAgreementWithoutPeerPublicFails() throws Exception { System.out.println("buildInitiatorClassicAgreementWithoutPeerPublicFails"); HybridKexProfile profile = HybridKexProfile.defaultProfile(32); 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(new zeroecho.sdk.ZeroEchoSession()).profile(profile).classicAgreement() .algorithm("Xdh").spec(XdhSpec.X25519).privateKey(aliceClassic.getPrivate()).pqcKem() .algorithm("ML-KEM").peerPublic(bobPqc.getPublic()).buildInitiator(); }); System.out.println("...exception=" + exception.getMessage()); assertEquals("classic private key and peer public must be set for CLASSIC_AGREEMENT", exception.getMessage()); System.out.println("buildInitiatorClassicAgreementWithoutPeerPublicFails...ok"); } @Test void buildResponderPairMessageWithoutKeyPairFails() throws Exception { System.out.println("buildResponderPairMessageWithoutKeyPairFails"); HybridKexProfile profile = HybridKexProfile.defaultProfile(32); KeyPair bobPqc = new zeroecho.sdk.ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768()); IllegalStateException exception = assertThrows(IllegalStateException.class, () -> { HybridKexBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).profile(profile).classicPairMessage() .algorithm("Xdh").spec(XdhSpec.X25519).pqcKem().algorithm("ML-KEM").privateKey(bobPqc.getPrivate()) .buildResponder(); }); System.out.println("...exception=" + exception.getMessage()); assertEquals("classic key pair must be set for PAIR_MESSAGE", exception.getMessage()); System.out.println("buildResponderPairMessageWithoutKeyPairFails...ok"); } @Test void buildInitiatorWithoutPqcPeerPublicFails() throws Exception { System.out.println("buildInitiatorWithoutPqcPeerPublicFails"); HybridKexProfile profile = HybridKexProfile.defaultProfile(32); 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(new zeroecho.sdk.ZeroEchoSession()).profile(profile).classicAgreement() .algorithm("Xdh").spec(XdhSpec.X25519).privateKey(aliceClassic.getPrivate()) .peerPublic(bobClassic.getPublic()).pqcKem().algorithm("ML-KEM").buildInitiator(); }); System.out.println("...exception=" + exception.getMessage()); assertEquals("pqc peer public must be set for initiator", exception.getMessage()); System.out.println("buildInitiatorWithoutPqcPeerPublicFails...ok"); } @Test void buildResponderWithoutPqcPrivateFails() throws Exception { System.out.println("buildResponderWithoutPqcPrivateFails"); HybridKexProfile profile = HybridKexProfile.defaultProfile(32); 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(new zeroecho.sdk.ZeroEchoSession()).profile(profile).classicAgreement() .algorithm("Xdh").spec(XdhSpec.X25519).privateKey(bobClassic.getPrivate()) .peerPublic(aliceClassic.getPublic()).pqcKem().algorithm("ML-KEM").buildResponder(); }); System.out.println("...exception=" + exception.getMessage()); assertEquals("pqc private key must be set for responder", exception.getMessage()); System.out.println("buildResponderWithoutPqcPrivateFails...ok"); } @Test void buildInitiatorRejectsPolicyWhenOkmTooShort() throws Exception { System.out.println("buildInitiatorRejectsPolicyWhenOkmTooShort"); HybridKexProfile profile = HybridKexProfile.defaultProfile(16); HybridKexPolicy policy = new HybridKexPolicy(0, 0, 32); 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(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(); }); System.out.println("...exception=" + exception.getMessage()); assertEquals("Hybrid OKM length too small: 16 < 32", exception.getMessage()); 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