Files
ZeroEcho/samples/src/test/java/demo/HybridKexDemoTest.java
Leo Galambos 55da24735f feat: add hybrid key exchange framework
Introduce a complete SDK-level hybrid KEX framework combining classic
(DH/ECDH/XDH) and post-quantum (KEM adapter) agreement contexts.

Key additions:
- HybridKexContext and HybridKexContexts for hybrid handshake
  orchestration over existing AgreementContext and
  MessageAgreementContext APIs
- HybridKexProfile, HybridKexTranscript and HybridKexExporter providing
  HKDF-based key derivation, transcript binding and key schedule support
- HybridKexPolicy for optional security strength and output-length
  gating
- HybridKexBuilder offering a fluent, professional API for constructing
  CLASSIC_AGREEMENT + KEM_ADAPTER and PAIR_MESSAGE + KEM_ADAPTER
  variants
- Comprehensive JUnit tests and documented demo illustrating both hybrid
  modes

No changes to core cryptographic APIs; all hybrid logic is implemented
as additive functionality in the SDK layer.

Signed-off-by: Leo Galambos <lg@hq.egothor.org>
2025-12-26 17:48:39 +01:00

537 lines
23 KiB
Java

/*******************************************************************************
* Copyright (C) 2025, 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 demo;
import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.security.KeyPair;
import java.util.Arrays;
import java.util.logging.Logger;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import zeroecho.core.CryptoAlgorithms;
import zeroecho.core.alg.common.agreement.KeyPairKey;
import zeroecho.core.alg.kyber.KyberKeyGenSpec;
import zeroecho.core.alg.xdh.XdhSpec;
import zeroecho.sdk.hybrid.kex.HybridKexContext;
import zeroecho.sdk.hybrid.kex.HybridKexContexts;
import zeroecho.sdk.hybrid.kex.HybridKexProfile;
import zeroecho.sdk.util.BouncyCastleActivator;
/**
* Demonstration of hybrid key exchange (KEX) usage in ZeroEcho.
*
* <p>
* Hybrid KEX in this project means:
* </p>
* <ul>
* <li>A <b>classic agreement</b> leg (DH/ECDH/XDH), and</li>
* <li>A <b>post-quantum</b> leg implemented as a message-based agreement (KEM
* adapter, e.g. ML-KEM).</li>
* </ul>
*
* <p>
* The two independent secrets are combined using HKDF-SHA256 in the SDK hybrid
* layer. The application consumes only the final derived keying material (OKM),
* not the raw leg secrets.
* </p>
*
* <h2>Available hybrid variants</h2>
* <p>
* The classic leg can be wired in two ways:
* </p>
* <ul>
* <li><b>CLASSIC_AGREEMENT + KEM_ADAPTER</b> (most common in practice):
* <ul>
* <li>Classic peer public key is supplied out-of-band
* (certificate/directory/session state).</li>
* <li>The hybrid message carries only the PQC payload (KEM ciphertext).</li>
* </ul>
* </li>
* <li><b>PAIR_MESSAGE + KEM_ADAPTER</b> (fully message-oriented hybrid):
* <ul>
* <li>Classic public keys are carried explicitly as messages (SPKI
* encodings).</li>
* <li>The hybrid message carries both: classic public-key message and PQC
* ciphertext.</li>
* <li>Responder may reply with a classic message only (PQC part empty),
* depending on PQC role.</li>
* </ul>
* </li>
* </ul>
*
* <h2>Note on resource management</h2>
* <p>
* These examples intentionally avoid {@code try-with-resources}. Agreement
* contexts represent protocol-level state rather than traditional I/O
* resources; in real protocols their lifecycle often spans multiple
* send/receive steps and does not naturally fit a single lexical scope. Using
* explicit close blocks keeps the handshake lifecycle visible to the reader.
* </p>
*/
class HybridKexDemoTest {
private static final Logger LOG = Logger.getLogger(HybridKexDemoTest.class.getName());
@BeforeAll
static void setup() {
// Optional: enable BC/BCPQC if present.
try {
BouncyCastleActivator.init();
} catch (Throwable ignore) {
// keep runnable without BC if not present
}
}
@Test
void x25519_classicAgreement_plus_mlKem768_kemAdapter() throws Exception {
logBegin("CLASSIC_AGREEMENT + KEM_ADAPTER", "Xdh/X25519 + ML-KEM-768", "HKDF-SHA256", "OKM=32B");
// Classic leg keys (Xdh + XdhSpec.X25519).
KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
// PQC leg: Bob is the KEM recipient (has ML-KEM keypair).
KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
// Hybrid profile: default HKDF label, 32-byte output suitable for symmetric
// keys.
HybridKexProfile profile = HybridKexProfile.defaultProfile(32);
HybridKexContext alice = null;
HybridKexContext bob = null;
try {
// Alice (initiator): classic uses Alice private + Bob classic public
// (out-of-band).
// ...PQC uses Bob PQC public and will produce a KEM ciphertext.
alice = HybridKexContexts.initiator(profile, "Xdh", aliceClassic.getPrivate(), bobClassic.getPublic(),
XdhSpec.X25519, "ML-KEM", bobPqc.getPublic(), null);
// Bob (responder): classic uses Bob private + Alice classic public
// (out-of-band).
// ...PQC uses Bob PQC private and will consume Alice's ciphertext.
bob = HybridKexContexts.responder(profile, "Xdh", bobClassic.getPrivate(), aliceClassic.getPublic(),
XdhSpec.X25519, "ML-KEM", bobPqc.getPrivate(), null);
// Alice -> Bob: hybrid message carries PQC ciphertext; classic part is empty.
byte[] msgA = alice.getPeerMessage();
System.out.println("...msgA " + lens(msgA) + " " + shortHex(msgA));
bob.setPeerMessage(msgA);
// Both derive the final hybrid OKM (HKDF output).
byte[] okmA = alice.deriveSecret();
byte[] okmB = bob.deriveSecret();
System.out.println("...okmA " + shortHex(okmA));
System.out.println("...okmB " + shortHex(okmB));
System.out.println("...equal " + Arrays.equals(okmA, okmB));
// Application would now feed OKM into symmetric key schedule / AEAD keys / etc.
} finally {
closeQuiet(alice);
closeQuiet(bob);
}
logEnd();
}
@Test
void x25519_pairMessage_plus_mlKem768_kemAdapter() throws Exception {
logBegin("PAIR_MESSAGE + KEM_ADAPTER", "Xdh/X25519 + ML-KEM-768", "HKDF-SHA256", "OKM=32B");
// Classic leg keys (Xdh + XdhSpec.X25519).
KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
// PQC leg: Bob is the KEM recipient (has ML-KEM keypair).
KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
HybridKexProfile profile = HybridKexProfile.defaultProfile(32);
HybridKexContext alice = null;
HybridKexContext bob = null;
try {
// Alice classic leg is message-based (PAIR_MESSAGE): it will emit her public
// key as SPKI bytes.
// ...PQC leg (KEM initiator) will emit ciphertext.
alice = HybridKexContexts.initiatorPairMessage(profile, "Xdh", new KeyPairKey(aliceClassic), XdhSpec.X25519,
"ML-KEM", bobPqc.getPublic(), null);
// Bob classic leg is message-based (PAIR_MESSAGE): it will emit his public key
// as SPKI bytes.
// ...PQC leg (KEM responder) consumes ciphertext and typically does not emit a
// PQC message.
bob = HybridKexContexts.responderPairMessage(profile, "Xdh", new KeyPairKey(bobClassic), XdhSpec.X25519,
"ML-KEM", bobPqc.getPrivate(), null);
// Alice -> Bob: hybrid message carries classic SPKI + PQC ciphertext.
byte[] msgA = alice.getPeerMessage();
System.out.println("...msgA " + lens(msgA) + " " + shortHex(msgA));
bob.setPeerMessage(msgA);
// Bob -> Alice: hybrid message carries classic SPKI; PQC part may be empty
// (depends on PQC role).
byte[] msgB = bob.getPeerMessage();
System.out.println("...msgB " + lens(msgB) + " " + shortHex(msgB));
alice.setPeerMessage(msgB);
// Both derive the final hybrid OKM (HKDF output).
byte[] okmA = alice.deriveSecret();
byte[] okmB = bob.deriveSecret();
System.out.println("...okmA " + shortHex(okmA));
System.out.println("...okmB " + shortHex(okmB));
System.out.println("...equal " + Arrays.equals(okmA, okmB));
} finally {
closeQuiet(alice);
closeQuiet(bob);
}
logEnd();
}
@Test
void builder_x25519_classicAgreement_plus_mlKem768() throws Exception {
logBegin("Builder", "CLASSIC_AGREEMENT + KEM_ADAPTER", "Xdh/X25519 + ML-KEM-768", "HKDF-SHA256", "OKM=32B");
// ...Generate classic leg keys (Xdh + XdhSpec.X25519).
java.security.KeyPair aliceClassic = zeroecho.core.CryptoAlgorithms.generateKeyPair("Xdh",
zeroecho.core.alg.xdh.XdhSpec.X25519);
java.security.KeyPair bobClassic = zeroecho.core.CryptoAlgorithms.generateKeyPair("Xdh",
zeroecho.core.alg.xdh.XdhSpec.X25519);
// ...Generate PQC (recipient) keys (ML-KEM-768).
java.security.KeyPair bobPqc = zeroecho.core.CryptoAlgorithms.generateKeyPair("ML-KEM",
zeroecho.core.alg.kyber.KyberKeyGenSpec.kyber768());
// ...Create a profile for HKDF (output length 32 bytes).
zeroecho.sdk.hybrid.kex.HybridKexProfile profile = zeroecho.sdk.hybrid.kex.HybridKexProfile.defaultProfile(32);
// ...Build a transcript to bind HKDF info to public handshake context.
zeroecho.sdk.hybrid.kex.HybridKexTranscript transcript = new zeroecho.sdk.hybrid.kex.HybridKexTranscript()
.addUtf8("suite", "X25519+MLKEM768").addUtf8("role", "demo");
// ...Define a minimum-strength policy (example: classic >= 128, PQC >= 192, OKM
// >= 32 bytes).
zeroecho.sdk.hybrid.kex.HybridKexPolicy policy = new zeroecho.sdk.hybrid.kex.HybridKexPolicy(128, 192, 32);
// ...Start the builder.
zeroecho.sdk.builders.HybridKexBuilder b = zeroecho.sdk.builders.HybridKexBuilder.builder()
// ...Set HKDF profile (salt/info/outLen).
.profile(profile)
// ...Bind HKDF info to transcript (protocol context).
.transcript(transcript)
// ...Enable hybrid policy gating for this build.
.policy(policy);
// ...Select classic mode where peer public key is known out-of-band.
zeroecho.sdk.builders.HybridKexBuilder.ClassicAgreement classicCfg = b.classicAgreement()
// ...Set classic algorithm id (X25519 is represented as "Xdh" with
// XdhSpec.X25519).
.algorithm("Xdh")
// ...Set classic parameters (X25519 curve spec).
.spec(zeroecho.core.alg.xdh.XdhSpec.X25519)
// ...Set local classic private key (Alice).
.privateKey(aliceClassic.getPrivate())
// ...Set peer classic public key (Bob).
.peerPublic(bobClassic.getPublic());
// ...Continue with PQC KEM adapter configuration for initiator role.
zeroecho.sdk.hybrid.kex.HybridKexContext alice = classicCfg.pqcKem()
// ...Set PQC algorithm id (ML-KEM).
.algorithm("ML-KEM")
// ...Set PQC recipient public key (Bob).
.peerPublic(bobPqc.getPublic())
// ...Build initiator-side hybrid context.
.buildInitiator();
// ...Build responder-side context (Bob) with symmetric configuration (note: PQC
// uses private key).
zeroecho.sdk.hybrid.kex.HybridKexContext bob = zeroecho.sdk.builders.HybridKexBuilder.builder()
// ...Set the same HKDF profile to derive the same OKM.
.profile(profile)
// ...Bind the same transcript to ensure both sides derive the same OKM.
.transcript(transcript)
// ...Enable the same policy gate.
.policy(policy)
// ...Select classic agreement mode (peer public is out-of-band).
.classicAgreement()
// ...Set classic algorithm id.
.algorithm("Xdh")
// ...Set classic parameters.
.spec(zeroecho.core.alg.xdh.XdhSpec.X25519)
// ...Set local classic private key (Bob).
.privateKey(bobClassic.getPrivate())
// ...Set peer classic public key (Alice).
.peerPublic(aliceClassic.getPublic())
// ...Continue to PQC configuration.
.pqcKem()
// ...Set PQC algorithm id.
.algorithm("ML-KEM")
// ...Set PQC recipient private key (Bob).
.privateKey(bobPqc.getPrivate())
// ...Build responder-side hybrid context.
.buildResponder();
try {
// ...Alice produces the hybrid message (PQC ciphertext; classic part is empty
// in this mode).
byte[] msgA = alice.getPeerMessage();
System.out.println("...msgA " + lens(msgA) + " " + shortHex(msgA));
// ...Bob consumes Alice message.
bob.setPeerMessage(msgA);
// ...Both sides derive identical OKM.
byte[] okmA = alice.deriveSecret();
byte[] okmB = bob.deriveSecret();
System.out.println("...okmA " + shortHex(okmA));
System.out.println("...okmB " + shortHex(okmB));
System.out.println("...equal " + java.util.Arrays.equals(okmA, okmB));
// ...Use exporter to derive purpose-specific keys bound to transcript.
zeroecho.sdk.hybrid.kex.HybridKexExporter exporterA = b.exporterFromOkm(okmA);
byte[] txA = exporterA.export("app/tx", transcript.toByteArray(), 32);
byte[] rxA = exporterA.export("app/rx", transcript.toByteArray(), 32);
System.out.println("...txA " + shortHex(txA));
System.out.println("...rxA " + shortHex(rxA));
} finally {
closeQuiet(alice);
closeQuiet(bob);
}
logEnd();
}
@Test
void builder_x25519_pairMessage_plus_mlKem768() throws Exception {
logBegin("Builder", "PAIR_MESSAGE + KEM_ADAPTER", "Xdh/X25519 + ML-KEM-768", "HKDF-SHA256", "OKM=32B");
// ...Generate classic leg keys (Xdh + XdhSpec.X25519).
KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
// ...Generate PQC (recipient) keys (ML-KEM-768).
KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
// ...Create a profile for HKDF (output length 32 bytes).
HybridKexProfile profile = HybridKexProfile.defaultProfile(32);
// ...Build a transcript to bind HKDF info to public handshake context.
// ...(Use identical transcript on both sides to derive the same OKM.)
zeroecho.sdk.hybrid.kex.HybridKexTranscript transcript = new zeroecho.sdk.hybrid.kex.HybridKexTranscript()
.addUtf8("suite", "X25519+MLKEM768").addUtf8("mode", "PAIR_MESSAGE").addUtf8("role", "demo");
// ...Define a minimum-strength policy (example: classic >= 128, PQC >= 192, OKM
// >= 32 bytes).
zeroecho.sdk.hybrid.kex.HybridKexPolicy policy = new zeroecho.sdk.hybrid.kex.HybridKexPolicy(128, 192, 32);
// ...Start the initiator builder.
zeroecho.sdk.builders.HybridKexBuilder initBuilder = zeroecho.sdk.builders.HybridKexBuilder.builder()
// ...Set HKDF profile (salt/info/outLen).
.profile(profile)
// ...Bind HKDF info to transcript (protocol context).
.transcript(transcript)
// ...Enable hybrid policy gating for this build.
.policy(policy);
// ...Select classic mode where the public key is carried in-band as a classic
// message (PAIR_MESSAGE).
zeroecho.sdk.hybrid.kex.HybridKexContext alice = initBuilder
// ...Switch classic leg to PAIR_MESSAGE mode.
.classicPairMessage()
// ...Set classic algorithm id (X25519 is represented as "Xdh" with
// XdhSpec.X25519).
.algorithm("Xdh")
// ...Set classic parameters (X25519 curve spec).
.spec(XdhSpec.X25519)
// ...Set local classic key pair wrapper (Alice).
.keyPair(new KeyPairKey(aliceClassic))
// ...Continue with PQC KEM adapter configuration.
.pqcKem()
// ...Set PQC algorithm id (ML-KEM).
.algorithm("ML-KEM")
// ...Set PQC recipient public key (Bob).
.peerPublic(bobPqc.getPublic())
// ...Build initiator-side hybrid context.
.buildInitiator();
// ...Start the responder builder.
zeroecho.sdk.builders.HybridKexBuilder respBuilder = zeroecho.sdk.builders.HybridKexBuilder.builder()
// ...Set the same HKDF profile to derive the same OKM.
.profile(profile)
// ...Bind the same transcript to ensure both sides derive the same OKM.
.transcript(transcript)
// ...Enable the same policy gate.
.policy(policy);
// ...Build responder-side context (Bob).
zeroecho.sdk.hybrid.kex.HybridKexContext bob = respBuilder
// ...Switch classic leg to PAIR_MESSAGE mode.
.classicPairMessage()
// ...Set classic algorithm id.
.algorithm("Xdh")
// ...Set classic parameters.
.spec(XdhSpec.X25519)
// ...Set local classic key pair wrapper (Bob).
.keyPair(new KeyPairKey(bobClassic))
// ...Continue with PQC KEM adapter configuration.
.pqcKem()
// ...Set PQC algorithm id.
.algorithm("ML-KEM")
// ...Set PQC recipient private key (Bob).
.privateKey(bobPqc.getPrivate())
// ...Build responder-side hybrid context.
.buildResponder();
try {
// ...Alice produces the hybrid message: classic SPKI + PQC ciphertext.
byte[] msgA = alice.getPeerMessage();
System.out.println("...msgA " + lens(msgA) + " " + shortHex(msgA));
// ...Bob consumes Alice message (learns Alice classic public + decapsulates PQC
// ciphertext).
bob.setPeerMessage(msgA);
// ...Bob produces response message: classic SPKI; PQC part may be empty
// (role-dependent).
byte[] msgB = bob.getPeerMessage();
System.out.println("...msgB " + lens(msgB) + " " + shortHex(msgB));
// ...Alice consumes Bob message (learns Bob classic public).
alice.setPeerMessage(msgB);
// ...Both sides derive identical OKM.
byte[] okmA = alice.deriveSecret();
byte[] okmB = bob.deriveSecret();
System.out.println("...okmA " + shortHex(okmA));
System.out.println("...okmB " + shortHex(okmB));
System.out.println("...equal " + Arrays.equals(okmA, okmB));
// ...Use exporter to derive purpose-specific keys bound to transcript.
zeroecho.sdk.hybrid.kex.HybridKexExporter exporterA = initBuilder.exporterFromOkm(okmA);
byte[] txA = exporterA.export("app/tx", transcript.toByteArray(), 32);
byte[] rxA = exporterA.export("app/rx", transcript.toByteArray(), 32);
System.out.println("...txA " + shortHex(txA));
System.out.println("...rxA " + shortHex(rxA));
} finally {
closeQuiet(alice);
closeQuiet(bob);
}
logEnd();
}
// -------------------------------------------------------------------------
// helpers (JUnit output conventions)
// -------------------------------------------------------------------------
private static void logBegin(Object... params) {
String thisClass = HybridKexDemoTest.class.getName();
String method = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE)
.walk(frames -> frames
.dropWhile(f -> !f.getClassName().equals(thisClass) || f.getMethodName().equals("logBegin"))
.findFirst().map(StackWalker.StackFrame::getMethodName).orElse("<?>"));
System.out.println(method + "(" + Arrays.deepToString(params) + ")");
}
private static void logEnd() {
String thisClass = HybridKexDemoTest.class.getName();
String method = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE)
.walk(frames -> frames
.dropWhile(f -> !f.getClassName().equals(thisClass) || f.getMethodName().equals("logEnd"))
.findFirst().map(StackWalker.StackFrame::getMethodName).orElse("<?>"));
System.out.println(method + "...ok");
}
private static void closeQuiet(HybridKexContext ctx) {
if (ctx == null) {
return;
}
try {
ctx.close();
} catch (Exception e) {
LOG.fine("close failed: " + e.getClass().getName());
}
}
private static String shortHex(byte[] b) {
if (b == null) {
return "null";
}
StringBuilder sb = new StringBuilder(b.length * 2);
for (byte v : b) {
if (sb.length() == 80) {
sb.append("...");
break;
}
if ((v & 0xFF) < 16) {
sb.append('0');
}
sb.append(Integer.toHexString(v & 0xFF));
}
return sb.toString();
}
private static String lens(byte[] msg) {
if (msg == null || msg.length < 8) {
return "[classicLen=?, pqcLen=?]";
}
try {
DataInputStream in = new DataInputStream(new ByteArrayInputStream(msg));
int classicLen = in.readInt();
if (classicLen < 0) {
return "[classicLen=?, pqcLen=?]";
}
if (classicLen > 0) {
in.skipBytes(classicLen);
}
int pqcLen = in.readInt();
return "[classicLen=" + classicLen + ", pqcLen=" + pqcLen + "]";
} catch (Exception e) {
return "[classicLen=?, pqcLen=?]";
}
}
}