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>
This commit is contained in:
2025-12-26 17:48:39 +01:00
parent 34eca245f0
commit 55da24735f
10 changed files with 2875 additions and 0 deletions

View File

@@ -0,0 +1,714 @@
/*******************************************************************************
* 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 zeroecho.sdk.builders;
import java.io.IOException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.Objects;
import zeroecho.core.CryptoAlgorithms;
import zeroecho.core.KeyUsage;
import zeroecho.core.alg.common.agreement.KeyPairKey;
import zeroecho.core.context.AgreementContext;
import zeroecho.core.context.MessageAgreementContext;
import zeroecho.core.spec.ContextSpec;
import zeroecho.sdk.hybrid.kex.HybridKexContext;
import zeroecho.sdk.hybrid.kex.HybridKexExporter;
import zeroecho.sdk.hybrid.kex.HybridKexPolicy;
import zeroecho.sdk.hybrid.kex.HybridKexProfile;
import zeroecho.sdk.hybrid.kex.HybridKexTranscript;
/**
* Fluent builder for constructing hybrid KEX contexts.
*
* <p>
* The builder supports the two practical hybrid variants:
* </p>
* <ul>
* <li><b>CLASSIC_AGREEMENT + KEM_ADAPTER</b> (classic peer public is provided
* out-of-band)</li>
* <li><b>PAIR_MESSAGE + KEM_ADAPTER</b> (classic public key is carried in the
* hybrid message)</li>
* </ul>
*
* <p>
* The builder also supports transcript binding and optional policy enforcement
* before returning the context.
* </p>
*
* <h2>Usage sketch</h2> <pre>{@code
* HybridKexContext ctx = HybridKexBuilder.builder()
* .profile(HybridKexProfile.defaultProfile(32))
* .transcript(new HybridKexTranscript().addUtf8("suite", "X25519+MLKEM768"))
* .policy(new HybridKexPolicy(128, 192, 32))
* .classicAgreement()
* .algorithm("Xdh").spec(XdhSpec.X25519)
* .privateKey(alicePriv).peerPublic(bobPub)
* .pqcKem()
* .algorithm("ML-KEM").peerPublic(bobPqcPub)
* .buildInitiator();
* }</pre>
*
* <p>
* Instances are mutable and not thread-safe.
* </p>
*
* @since 1.0
*/
public final class HybridKexBuilder {
private HybridKexProfile profile;
private HybridKexTranscript transcript;
private HybridKexPolicy policy;
private ClassicMode classicMode;
private String classicAlgId;
private ContextSpec classicSpec;
private PrivateKey classicPrivate;
private PublicKey classicPeerPublic;
private KeyPairKey classicKeyPair;
private String pqcAlgId;
private ContextSpec pqcSpec;
private PublicKey pqcPeerPublic;
private PrivateKey pqcPrivate;
private HybridKexBuilder() {
// builder
}
/**
* Creates a new builder instance.
*
* @return new builder
*/
public static HybridKexBuilder builder() {
return new HybridKexBuilder();
}
/**
* Sets the hybrid profile (HKDF salt/info/output length).
*
* @param profile profile (must not be null)
* @return this builder
*/
public HybridKexBuilder profile(HybridKexProfile profile) {
this.profile = Objects.requireNonNull(profile, "profile");
return this;
}
/**
* Optional transcript used to bind HKDF {@code info}.
*
* <p>
* If provided, its bytes are concatenated to the profile {@code hkdfInfo} with
* a single zero byte separator. This preserves the profile label as a domain
* separator while incorporating handshake context.
* </p>
*
* @param transcript transcript (may be null to clear)
* @return this builder
*/
public HybridKexBuilder transcript(HybridKexTranscript transcript) {
this.transcript = transcript;
return this;
}
/**
* Optional hybrid policy enforcement.
*
* @param policy policy (may be null to clear)
* @return this builder
*/
public HybridKexBuilder policy(HybridKexPolicy policy) {
this.policy = policy;
return this;
}
/**
* Selects the classic leg in {@link ClassicMode#CLASSIC_AGREEMENT} mode.
*
* <p>
* In this mode, the classic peer public key is assumed to be available
* out-of-band (for example via a certificate, a directory, or a higher-level
* handshake message). The hybrid wire message therefore typically carries only
* the PQC payload (KEM ciphertext), while the classic leg is configured via
* {@link AgreementContext#setPeerPublic(PublicKey)}.
* </p>
*
* <p>
* Calling this method resets any previously configured {@code PAIR_MESSAGE}
* inputs ({@link #classicKeyPair}) to prevent ambiguous configuration.
* </p>
*
* @return classic agreement configurator
* @since 1.0
*/
public ClassicAgreement classicAgreement() {
this.classicMode = ClassicMode.CLASSIC_AGREEMENT;
this.classicKeyPair = null;
return new ClassicAgreement(this);
}
/**
* Selects the classic leg in {@link ClassicMode#PAIR_MESSAGE} mode.
*
* <p>
* In this mode, the classic leg is message-capable: the public key is carried
* as an explicit classic message (typically an SPKI encoding) and becomes part
* of the hybrid peer message. This enables a fully message-oriented handshake
* where both legs contribute to the wire payload (classic public-key message +
* PQC ciphertext).
* </p>
*
* <p>
* Calling this method resets any previously configured
* {@code CLASSIC_AGREEMENT} inputs ({@link #classicPrivate} and
* {@link #classicPeerPublic}) to prevent ambiguous configuration.
* </p>
*
* @return classic pair-message configurator
* @since 1.0
*/
public ClassicPairMessage classicPairMessage() {
this.classicMode = ClassicMode.PAIR_MESSAGE;
this.classicPrivate = null;
this.classicPeerPublic = null;
return new ClassicPairMessage(this);
}
/**
* Selects configuration of the post-quantum leg (KEM adapter).
*
* <p>
* The PQC leg is always treated as a message-based agreement:
* </p>
* <ul>
* <li>Initiator is created from the recipient's PQC {@link PublicKey} and
* produces a message (typically a ciphertext) via
* {@link MessageAgreementContext#getPeerMessage()}.</li>
* <li>Responder is created from the recipient's PQC {@link PrivateKey} and
* consumes the peer message via
* {@link MessageAgreementContext#setPeerMessage(byte[])}.</li>
* </ul>
*
* @return PQC KEM configurator
* @since 1.0
*/
public PqcKem pqcKem() {
return new PqcKem(this);
}
/**
* Builds initiator-side context.
*
* @return initiator context
* @throws IOException if underlying context creation fails
*/
public HybridKexContext buildInitiator() throws IOException {
validateCommon();
AgreementContext classic;
if (classicMode == ClassicMode.CLASSIC_AGREEMENT) {
if (classicPrivate == null || classicPeerPublic == null) {
throw new IllegalStateException(
"classic private key and peer public must be set for CLASSIC_AGREEMENT");
}
classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT, classicPrivate, classicSpec);
classic.setPeerPublic(classicPeerPublic);
} else if (classicMode == ClassicMode.PAIR_MESSAGE) {
if (classicKeyPair == null) {
throw new IllegalStateException("classic key pair must be set for PAIR_MESSAGE");
}
classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT, classicKeyPair, classicSpec);
} else {
throw new IllegalStateException("classic mode must be selected");
}
if (pqcPeerPublic == null) {
throw new IllegalStateException("pqc peer public must be set for initiator");
}
MessageAgreementContext pqc = CryptoAlgorithms.create(pqcAlgId, KeyUsage.AGREEMENT, pqcPeerPublic, pqcSpec);
HybridKexProfile effective = effectiveProfile();
if (policy != null) {
policy.enforce(effective, classic, pqc);
}
return new HybridKexContext(effective, classic, pqc);
}
/**
* Builds responder-side context.
*
* @return responder context
* @throws IOException if underlying context creation fails
*/
public HybridKexContext buildResponder() throws IOException {
validateCommon();
AgreementContext classic;
if (classicMode == ClassicMode.CLASSIC_AGREEMENT) {
if (classicPrivate == null || classicPeerPublic == null) {
throw new IllegalStateException(
"classic private key and peer public must be set for CLASSIC_AGREEMENT");
}
classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT, classicPrivate, classicSpec);
classic.setPeerPublic(classicPeerPublic);
} else if (classicMode == ClassicMode.PAIR_MESSAGE) {
if (classicKeyPair == null) {
throw new IllegalStateException("classic key pair must be set for PAIR_MESSAGE");
}
classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT, classicKeyPair, classicSpec);
} else {
throw new IllegalStateException("classic mode must be selected");
}
if (pqcPrivate == null) {
throw new IllegalStateException("pqc private key must be set for responder");
}
MessageAgreementContext pqc = CryptoAlgorithms.create(pqcAlgId, KeyUsage.AGREEMENT, pqcPrivate, pqcSpec);
HybridKexProfile effective = effectiveProfile();
if (policy != null) {
policy.enforce(effective, classic, pqc);
}
return new HybridKexContext(effective, classic, pqc);
}
/**
* Creates an exporter seeded from a derived OKM value.
*
* @param okm derived OKM (for example {@link HybridKexContext#deriveSecret()})
* @return exporter
*/
public HybridKexExporter exporterFromOkm(byte[] okm) {
validateCommon();
HybridKexProfile effective = effectiveProfile();
return new HybridKexExporter(okm, effective.hkdfSalt());
}
private void validateCommon() {
if (profile == null) {
throw new IllegalStateException("profile must be set");
}
if (classicMode == null) {
throw new IllegalStateException("classic mode must be selected");
}
if (classicAlgId == null) {
throw new IllegalStateException("classic algorithm id must be set");
}
if (pqcAlgId == null) {
throw new IllegalStateException("pqc algorithm id must be set");
}
}
private HybridKexProfile effectiveProfile() {
byte[] info0 = profile.hkdfInfo();
byte[] t = (transcript == null) ? null : transcript.toByteArray();
if (t == null || t.length == 0) {
return profile;
}
byte[] base = (info0 == null) ? new byte[0] : info0.clone();
byte[] merged = new byte[base.length + 1 + t.length];
System.arraycopy(base, 0, merged, 0, base.length);
merged[base.length] = 0;
System.arraycopy(t, 0, merged, base.length + 1, t.length);
return new HybridKexProfile(profile.hkdfSalt(), merged, profile.outLenBytes());
}
/**
* Determines how the classic (pre-quantum) agreement leg is wired into the
* hybrid handshake.
*
* <p>
* The classic leg is always an {@link AgreementContext}, but there are two
* operational models:
* </p>
* <ul>
* <li>{@link #CLASSIC_AGREEMENT}: the peer public key is supplied out-of-band
* (not transported by the hybrid message). This corresponds to the traditional
* DH/ECDH/XDH usage pattern where the protocol already has a mechanism to
* convey or authenticate the peer public key (certificate, static key, or
* separate handshake structure).</li>
* <li>{@link #PAIR_MESSAGE}: the classic leg is message-capable and
* emits/consumes a public-key message (typically SPKI) through
* {@link MessageAgreementContext}. In this model, the classic public key
* travels inside the hybrid peer message alongside the PQC payload, enabling a
* fully message-oriented exchange.</li>
* </ul>
*
* <p>
* This enum is internal to the builder but is documented because it defines the
* wire semantics of the resulting {@link HybridKexContext} and determines which
* builder inputs are required.
* </p>
*
* @since 1.0
*/
private enum ClassicMode {
/**
* Classic agreement where the peer public key is provided out-of-band and
* configured via {@link AgreementContext#setPeerPublic(PublicKey)}.
*
* @since 1.0
*/
CLASSIC_AGREEMENT,
/**
* Classic agreement where the classic public key is carried in-band as a
* message produced/consumed via
* {@link MessageAgreementContext#getPeerMessage()} and
* {@link MessageAgreementContext#setPeerMessage(byte[])}.
*
* @since 1.0
*/
PAIR_MESSAGE
}
/**
* Configurator for the classic leg in {@link ClassicMode#CLASSIC_AGREEMENT}
* mode.
*
* <p>
* Required inputs before building:
* </p>
* <ul>
* <li>{@link #algorithm(String)} - classic algorithm id (for example
* {@code "Xdh"})</li>
* <li>{@link #privateKey(PrivateKey)} - local classic private key</li>
* <li>{@link #peerPublic(PublicKey)} - peer classic public key
* (out-of-band)</li>
* </ul>
*
* <p>
* Optional inputs:
* </p>
* <ul>
* <li>{@link #spec(ContextSpec)} - algorithm-specific context spec (for example
* {@code XdhSpec.X25519})</li>
* </ul>
*
* <p>
* After configuring the classic leg, continue with {@link #pqcKem()} to
* configure the PQC leg.
* </p>
*
* @since 1.0
*/
public static final class ClassicAgreement {
private final HybridKexBuilder parent;
private ClassicAgreement(HybridKexBuilder parent) {
this.parent = parent;
}
/**
* Sets the classic agreement algorithm identifier.
*
* <p>
* Example for X25519 in this project: use {@code "Xdh"} with
* {@code XdhSpec.X25519}.
* </p>
*
* @param algId algorithm id (must not be null)
* @return this configurator
* @throws NullPointerException if {@code algId} is null
* @since 1.0
*/
public ClassicAgreement algorithm(String algId) {
parent.classicAlgId = Objects.requireNonNull(algId, "algId");
return this;
}
/**
* Sets the classic context spec (algorithm parameters).
*
* @param spec spec (may be null if the algorithm provides a default)
* @return this configurator
* @since 1.0
*/
public ClassicAgreement spec(ContextSpec spec) {
parent.classicSpec = spec;
return this;
}
/**
* Sets the local private key for the classic leg.
*
* @param key private key (must not be null)
* @return this configurator
* @throws NullPointerException if {@code key} is null
* @since 1.0
*/
public ClassicAgreement privateKey(PrivateKey key) {
parent.classicPrivate = Objects.requireNonNull(key, "key");
return this;
}
/**
* Sets the peer public key for the classic leg.
*
* <p>
* In {@link ClassicMode#CLASSIC_AGREEMENT} this key is assumed to be obtained
* out-of-band.
* </p>
*
* @param key peer public key (must not be null)
* @return this configurator
* @throws NullPointerException if {@code key} is null
* @since 1.0
*/
public ClassicAgreement peerPublic(PublicKey key) {
parent.classicPeerPublic = Objects.requireNonNull(key, "key");
return this;
}
/**
* Continues with PQC (KEM adapter) leg configuration.
*
* @return PQC configurator
* @since 1.0
*/
public PqcKem pqcKem() {
return parent.pqcKem();
}
}
/**
* Configurator for the classic leg in {@link ClassicMode#PAIR_MESSAGE} mode.
*
* <p>
* Required inputs before building:
* </p>
* <ul>
* <li>{@link #algorithm(String)} - classic algorithm id (for example
* {@code "Xdh"})</li>
* <li>{@link #keyPair(KeyPairKey)} - local classic key pair wrapped as
* {@link KeyPairKey}</li>
* </ul>
*
* <p>
* Optional inputs:
* </p>
* <ul>
* <li>{@link #spec(ContextSpec)} - algorithm-specific context spec (for example
* {@code XdhSpec.X25519})</li>
* </ul>
*
* <p>
* In this mode, the classic leg contributes a public-key message (typically
* SPKI bytes) to the hybrid peer message. The peer public key is therefore
* learned from {@link HybridKexContext#setPeerMessage(byte[])} rather than
* being supplied out-of-band.
* </p>
*
* @since 1.0
*/
public static final class ClassicPairMessage {
private final HybridKexBuilder parent;
private ClassicPairMessage(HybridKexBuilder parent) {
this.parent = parent;
}
/**
* Sets the classic agreement algorithm identifier.
*
* @param algId algorithm id (must not be null)
* @return this configurator
* @throws NullPointerException if {@code algId} is null
* @since 1.0
*/
public ClassicPairMessage algorithm(String algId) {
parent.classicAlgId = Objects.requireNonNull(algId, "algId");
return this;
}
/**
* Sets the classic context spec (algorithm parameters).
*
* @param spec spec (may be null if the algorithm provides a default)
* @return this configurator
* @since 1.0
*/
public ClassicPairMessage spec(ContextSpec spec) {
parent.classicSpec = spec;
return this;
}
/**
* Sets the local classic key pair.
*
* <p>
* The key pair is wrapped into {@link KeyPairKey} to match the
* {@code PAIR_MESSAGE} capability registered in core.
* </p>
*
* @param keyPair key pair wrapper (must not be null)
* @return this configurator
* @throws NullPointerException if {@code keyPair} is null
* @since 1.0
*/
public ClassicPairMessage keyPair(KeyPairKey keyPair) {
parent.classicKeyPair = Objects.requireNonNull(keyPair, "keyPair");
return this;
}
/**
* Continues with PQC (KEM adapter) leg configuration.
*
* @return PQC configurator
* @since 1.0
*/
public PqcKem pqcKem() {
return parent.pqcKem();
}
}
/**
* Configurator for the PQC leg (KEM adapter).
*
* <p>
* Required inputs differ by role:
* </p>
* <ul>
* <li><b>Initiator</b> requires {@link #peerPublic(PublicKey)} (recipient PQC
* public key).</li>
* <li><b>Responder</b> requires {@link #privateKey(PrivateKey)} (recipient PQC
* private key).</li>
* </ul>
*
* <p>
* The PQC leg is always message-based: initiator produces a peer message
* (ciphertext) and responder consumes it. The hybrid context transports this
* payload as the PQC part of the hybrid message.
* </p>
*
* @since 1.0
*/
public static final class PqcKem {
private final HybridKexBuilder parent;
private PqcKem(HybridKexBuilder parent) {
this.parent = parent;
}
/**
* Sets the PQC algorithm identifier.
*
* @param algId algorithm id (must not be null)
* @return this configurator
* @throws NullPointerException if {@code algId} is null
* @since 1.0
*/
public PqcKem algorithm(String algId) {
parent.pqcAlgId = Objects.requireNonNull(algId, "algId");
return this;
}
/**
* Sets the PQC context spec (algorithm parameters).
*
* @param spec spec (may be null if the algorithm provides a default)
* @return this configurator
* @since 1.0
*/
public PqcKem spec(ContextSpec spec) {
parent.pqcSpec = spec;
return this;
}
/**
* Sets the recipient PQC public key for initiator-side construction.
*
* @param key recipient public key (must not be null)
* @return this configurator
* @throws NullPointerException if {@code key} is null
* @since 1.0
*/
public PqcKem peerPublic(PublicKey key) {
parent.pqcPeerPublic = Objects.requireNonNull(key, "key");
return this;
}
/**
* Sets the recipient PQC private key for responder-side construction.
*
* @param key recipient private key (must not be null)
* @return this configurator
* @throws NullPointerException if {@code key} is null
* @since 1.0
*/
public PqcKem privateKey(PrivateKey key) {
parent.pqcPrivate = Objects.requireNonNull(key, "key");
return this;
}
/**
* Builds an initiator-side {@link HybridKexContext} using the current builder
* configuration.
*
* @return initiator context
* @throws IOException if underlying context creation fails
* @throws IllegalStateException if required configuration for initiator role is
* missing
* @since 1.0
*/
public HybridKexContext buildInitiator() throws IOException {
return parent.buildInitiator();
}
/**
* Builds a responder-side {@link HybridKexContext} using the current builder
* configuration.
*
* @return responder context
* @throws IOException if underlying context creation fails
* @throws IllegalStateException if required configuration for responder role is
* missing
* @since 1.0
*/
public HybridKexContext buildResponder() throws IOException {
return parent.buildResponder();
}
}
}

View File

@@ -0,0 +1,463 @@
/*******************************************************************************
* 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 zeroecho.sdk.hybrid.kex;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.Key;
import java.security.PublicKey;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
import zeroecho.core.CryptoAlgorithm;
import zeroecho.core.context.AgreementContext;
import zeroecho.core.context.MessageAgreementContext;
import zeroecho.sdk.util.Kdf;
/**
* Hybrid key exchange (KEX) context that combines a classic agreement and a
* post-quantum KEM-style agreement.
*
* <p>
* This context composes two independent shared secrets:
* </p>
* <ul>
* <li><b>Classic leg</b>: a {@link AgreementContext} (e.g. X25519, ECDH, DH)
* that derives a raw shared secret after the peer public key is
* configured.</li>
* <li><b>PQC leg</b>: a {@link MessageAgreementContext} (typically ML-KEM
* exposed via core capabilities) whose "peer message" represents the
* encapsulation/decapsulation payload (e.g. KEM ciphertext).</li>
* </ul>
*
* <h2>Wire format</h2>
* <p>
* This class implements {@link MessageAgreementContext} to provide a single
* hybrid message that can be transmitted between parties. The encoding is:
* </p>
* <pre>{@code
* [int classicLen][classicBytes...][int pqcLen][pqcBytes...]
* }</pre>
*
* <p>
* For typical pairings such as {@code X25519 + ML-KEM}, the classic part is
* empty because the classic leg is configured using the peer public key
* out-of-band. If the classic leg itself is message-capable, the classic part
* may be populated.
* </p>
*
* <h2>Key derivation</h2>
* <p>
* The final keying material is derived using HKDF-SHA256 (RFC 5869):
* </p>
* <pre>{@code
* classicSS = classic.deriveSecret()
* pqcSS = pqc.deriveSecret()
* IKM = classicSS || pqcSS
* OKM = HKDF-SHA256(IKM, salt, info, outLen)
* }</pre>
*
* <p>
* Intermediate raw secrets are treated as sensitive and are zeroized
* (best-effort) once HKDF completes.
* </p>
*
* <h2>CryptoContext identity</h2>
* <p>
* A hybrid exchange is inherently bound to multiple algorithms and keys. The
* {@link #algorithm()} and {@link #key()} methods return representative values
* from the classic leg to satisfy the
* {@link zeroecho.core.context.CryptoContext} contract. Callers that require
* full introspection should retain references to the underlying component
* contexts.
* </p>
*
* <h2>Error handling</h2>
* <ul>
* <li>Malformed hybrid messages cause {@link IllegalArgumentException} in
* {@link #setPeerMessage(byte[])}.</li>
* <li>HKDF failures are surfaced as {@link IllegalStateException} in
* {@link #deriveSecret()}.</li>
* <li>{@link #close()} closes both component contexts and aggregates
* {@link IOException}s via suppressed exceptions.</li>
* </ul>
*
* <h2>Thread safety</h2>
* <p>
* Instances are mutable and not thread-safe; use one instance per
* handshake/session and thread.
* </p>
*
* @since 1.0
*/
public final class HybridKexContext implements MessageAgreementContext {
private static final Logger LOG = Logger.getLogger(HybridKexContext.class.getName());
private final HybridKexProfile profile;
private final AgreementContext classic;
private final MessageAgreementContext pqc;
private byte[] peerMessage;
/**
* Creates a hybrid KEX context by composing two underlying contexts.
*
* @param profile hybrid profile defining HKDF binding and output length
* @param classic classic agreement context (must not be {@code null})
* @param pqc PQC message agreement context (must not be {@code null})
* @throws NullPointerException if any argument is {@code null}
* @since 1.0
*/
public HybridKexContext(HybridKexProfile profile, AgreementContext classic, MessageAgreementContext pqc) {
this.profile = Objects.requireNonNull(profile, "profile");
this.classic = Objects.requireNonNull(classic, "classic");
this.pqc = Objects.requireNonNull(pqc, "pqc");
}
/**
* Returns the representative algorithm of this context.
*
* <p>
* This is currently delegated to the classic leg. Hybrid constructions bind
* multiple algorithms; callers that need full visibility should track both legs
* explicitly.
* </p>
*
* @return representative algorithm (classic leg)
* @since 1.0
*/
@Override
public CryptoAlgorithm algorithm() {
return classic.algorithm();
}
/**
* Returns the representative key of this context.
*
* <p>
* This is delegated to the classic leg to satisfy the {@code CryptoContext}
* contract. The hybrid KEX also depends on the PQC leg keys; callers should
* store those keys separately if needed by the application.
* </p>
*
* @return representative key (classic leg)
* @since 1.0
*/
@Override
public Key key() {
return classic.key();
}
/**
* Sets the peer public key for the classic leg.
*
* <p>
* This is the usual configuration step for classic DH-style agreements. The PQC
* leg typically does not use peer public keys through this method; however, the
* call is forwarded on a best-effort basis for implementations that accept it.
* </p>
*
* @param peer peer public key for the classic agreement
* @since 1.0
*/
@Override
public void setPeerPublic(PublicKey peer) {
classic.setPeerPublic(peer);
try {
pqc.setPeerPublic(peer);
} catch (RuntimeException ignore) { // NOPMD
// KEM-style agreements usually do not accept peer public here.
}
}
/**
* Supplies the hybrid peer message received from the remote party.
*
* <p>
* The message is decoded into its classic and PQC parts. The PQC part is always
* forwarded to the PQC leg via
* {@link MessageAgreementContext#setPeerMessage(byte[])}. The classic part is
* forwarded only if the classic leg also implements
* {@link MessageAgreementContext}; otherwise it is ignored.
* </p>
*
* <p>
* Passing {@code null} resets the peer-message related state of both legs
* (best-effort).
* </p>
*
* @param message hybrid message, or {@code null} to reset
* @throws IllegalArgumentException if the provided message does not conform to
* the hybrid encoding
* @since 1.0
*/
@Override
public void setPeerMessage(byte[] message) {
if (message == null) {
this.peerMessage = null;
try {
pqc.setPeerMessage(null);
} catch (RuntimeException ignore) { // NOPMD
}
try {
setClassicMessageIfSupported(null);
} catch (RuntimeException ignore) { // NOPMD
}
return;
}
Parts parts;
try {
parts = decode(message);
} catch (IOException e) {
throw new IllegalArgumentException("Invalid hybrid peer message encoding", e);
}
this.peerMessage = message.clone();
try {
setClassicMessageIfSupported(parts.classicPart());
} catch (RuntimeException ignore) { // NOPMD
// classic leg may be non-message agreement; ignore
}
byte[] pqcPart = parts.pqcPart();
if (pqcPart != null && pqcPart.length > 0) {
pqc.setPeerMessage(pqcPart);
}
}
/**
* Returns the hybrid message to be sent to the peer.
*
* <p>
* The PQC leg typically produces a non-empty message (encapsulation ciphertext)
* for initiator roles. The classic leg contributes an empty message unless it
* supports message mode.
* </p>
*
* @return encoded hybrid peer message
* @throws IllegalStateException if encoding fails or the PQC leg cannot produce
* a message in the current role
* @since 1.0
*/
@Override
public byte[] getPeerMessage() {
byte[] classicMsg = getClassicMessageIfSupported();
byte[] pqcMsg;
try {
pqcMsg = pqc.getPeerMessage();
} catch (RuntimeException e) { // NOPMD
// Responder-side KEM leg typically does not produce an outbound message.
pqcMsg = new byte[0];
}
try {
byte[] msg = encode(classicMsg, pqcMsg);
this.peerMessage = msg.clone();
return msg;
} catch (IOException e) {
throw new IllegalStateException("Unable to encode hybrid peer message", e);
}
}
/**
* Derives the final hybrid shared secret (OKM) using HKDF-SHA256.
*
* <p>
* This method derives both component secrets and then performs HKDF over their
* concatenation. Higher-level protocols should additionally bind
* transcript/context data through the HKDF {@code info} parameter
* ({@link HybridKexProfile#hkdfInfo()}).
* </p>
*
* @return derived keying material (OKM) of length
* {@link HybridKexProfile#outLenBytes()}
* @throws IllegalStateException if HKDF fails or underlying contexts are not
* configured
* @since 1.0
*/
@Override
public byte[] deriveSecret() {
byte[] classicSs = classic.deriveSecret();
byte[] pqcSs = pqc.deriveSecret();
byte[] ikm = new byte[classicSs.length + pqcSs.length];
System.arraycopy(classicSs, 0, ikm, 0, classicSs.length);
System.arraycopy(pqcSs, 0, ikm, classicSs.length, pqcSs.length);
try {
byte[] out = Kdf.hkdfSha256(ikm, profile.hkdfSalt(), profile.hkdfInfo(), profile.outLenBytes());
if (LOG.isLoggable(Level.FINE)) {
LOG.fine("HybridKexContext.deriveSecret(): derived OKM length=" + out.length);
}
return out;
} catch (GeneralSecurityException e) {
throw new IllegalStateException("HKDF-SHA256 failed", e);
} finally {
zeroize(classicSs);
zeroize(pqcSs);
zeroize(ikm);
}
}
/**
* Closes both underlying contexts.
*
* <p>
* If closing the second context fails after the first one already failed, the
* second failure is added as a suppressed exception on the first one.
* </p>
*
* @throws IOException if closing either leg fails
* @since 1.0
*/
@Override
public void close() throws IOException {
IOException first = null;
try {
classic.close();
} catch (IOException e) {
first = e;
}
try {
pqc.close();
} catch (IOException e) {
if (first == null) {
first = e;
} else {
first.addSuppressed(e);
}
}
if (first != null) {
throw first;
}
}
/**
* Returns the last hybrid peer message that was produced or set on this
* instance.
*
* <p>
* This is intended for diagnostics and testing. The returned array is a
* defensive copy.
* </p>
*
* @return last hybrid message, or {@code null} if none has been produced or set
* @since 1.0
*/
public byte[] lastPeerMessageOrNull() {
return (peerMessage == null) ? null : peerMessage.clone();
}
// -------------------------------------------------------------------------
// Encoding helpers
// -------------------------------------------------------------------------
private static byte[] encode(byte[] classicMsg, byte[] pqcMsg) throws IOException {
byte[] c = (classicMsg == null) ? new byte[0] : classicMsg;
byte[] p = (pqcMsg == null) ? new byte[0] : pqcMsg;
ByteArrayOutputStream bout = new ByteArrayOutputStream();
DataOutputStream out = new DataOutputStream(bout);
out.writeInt(c.length);
out.write(c);
out.writeInt(p.length);
out.write(p);
out.flush();
return bout.toByteArray();
}
private static Parts decode(byte[] msg) throws IOException {
DataInputStream in = new DataInputStream(new ByteArrayInputStream(msg));
int cLen = in.readInt();
if (cLen < 0) {
throw new IOException("negative classic length");
}
byte[] c = new byte[cLen];
in.readFully(c);
int pLen = in.readInt();
if (pLen < 0) {
throw new IOException("negative pqc length");
}
byte[] p = new byte[pLen];
in.readFully(p);
return new Parts(c, p);
}
private static void zeroize(byte[] b) {
if (b == null) {
return;
}
for (int i = 0; i < b.length; i++) {
b[i] = 0;
}
}
private byte[] getClassicMessageIfSupported() {
if (classic instanceof MessageAgreementContext) {
try {
return ((MessageAgreementContext) classic).getPeerMessage();
} catch (RuntimeException ignore) { // NOPMD
return new byte[0];
}
}
return new byte[0];
}
private void setClassicMessageIfSupported(byte[] msg) {
if (classic instanceof MessageAgreementContext) {
((MessageAgreementContext) classic).setPeerMessage(msg);
}
}
private record Parts(byte[] classicPart, byte[] pqcPart) {
}
}

View File

@@ -0,0 +1,314 @@
/*******************************************************************************
* 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 zeroecho.sdk.hybrid.kex;
import java.io.IOException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.Objects;
import zeroecho.core.CryptoAlgorithms;
import zeroecho.core.KeyUsage;
import zeroecho.core.context.AgreementContext;
import zeroecho.core.context.MessageAgreementContext;
import zeroecho.core.spec.ContextSpec;
/**
* Factory utilities for constructing hybrid key exchange (KEX) contexts.
*
* <p>
* This class implements the SDK-level composition layer for hybrid key
* exchange. It does not introduce new core contracts; instead, it composes
* existing core contexts:
* </p>
* <ul>
* <li>{@link AgreementContext} for the classic (pre-quantum) leg (e.g. X25519,
* ECDH, DH), and</li>
* <li>{@link MessageAgreementContext} for the post-quantum leg (typically a
* KEM-style agreement, e.g. ML-KEM exposed as {@code KeyUsage.AGREEMENT}).</li>
* </ul>
*
* <h2>Handshake model</h2>
* <p>
* The two legs have different wire semantics:
* </p>
* <ul>
* <li><b>Classic leg</b> generally uses a peer public key supplied out-of-band
* (certificate, directory, or a higher-level handshake message). This is
* represented by
* {@link AgreementContext#setPeerPublic(java.security.PublicKey)} and does not
* necessarily produce any explicit "to-be-sent" bytes.</li>
* <li><b>PQC leg</b> is message-based: the initiator produces an encapsulation
* message (e.g. KEM ciphertext) via
* {@link MessageAgreementContext#getPeerMessage()}, and the responder consumes
* it via {@link MessageAgreementContext#setPeerMessage(byte[])}.</li>
* </ul>
*
* <p>
* {@link HybridKexContext} unifies these semantics by emitting and consuming a
* single hybrid peer message that carries both legs (with the classic part
* typically empty for classic-only public-key exchange).
* </p>
*
* <h2>Error handling</h2>
* <p>
* Underlying context construction uses
* {@link CryptoAlgorithms#create(String, KeyUsage, java.security.Key, Object)}
* which may throw {@link IOException}. These factory methods propagate the
* checked exception to keep failures explicit and auditable.
* </p>
*
* <h2>Thread safety</h2>
* <p>
* This factory is stateless and thread-safe. Produced contexts are mutable and
* not thread-safe.
* </p>
*
* @since 1.0
*/
public final class HybridKexContexts {
private HybridKexContexts() {
// utility
}
/**
* Creates an initiator-side hybrid KEX context.
*
* <p>
* This method constructs:
* </p>
* <ul>
* <li>a classic {@link AgreementContext} from the initiator's classic
* {@link PrivateKey} and configures it with the peer's classic
* {@link PublicKey}, and</li>
* <li>a PQC {@link MessageAgreementContext} from the peer's PQC
* {@link PublicKey} (encapsulation side).</li>
* </ul>
*
* <p>
* The returned {@link HybridKexContext} will typically produce a peer message
* whose PQC part is non-empty (e.g. KEM ciphertext), while the classic part is
* empty unless the chosen classic implementation itself supports message mode.
* </p>
*
* @param profile hybrid profile defining HKDF binding and
* output length
* @param classicAlgId classic agreement algorithm identifier (for
* example {@code "X25519"}, {@code "ECDH"},
* {@code "DH"})
* @param classicInitiatorPrivate initiator private key for the classic leg
* @param classicPeerPublic peer public key for the classic leg
* @param classicSpec optional classic context spec (may be
* {@code null} if the algorithm supports a
* default)
* @param pqcAlgId post-quantum agreement algorithm identifier
* (for example {@code "ML-KEM"} exposed via
* {@code KeyUsage.AGREEMENT})
* @param pqcPeerPublic peer public key for the PQC leg (encapsulation
* side)
* @param pqcSpec optional PQC context spec (may be {@code null}
* if the algorithm supports a default)
* @return initiator-side {@link HybridKexContext}
* @throws NullPointerException if any required argument is {@code null}
* @throws IOException if underlying context creation fails
* @since 1.0
*/
public static HybridKexContext initiator(HybridKexProfile profile, String classicAlgId,
PrivateKey classicInitiatorPrivate, PublicKey classicPeerPublic, ContextSpec classicSpec, String pqcAlgId,
PublicKey pqcPeerPublic, ContextSpec pqcSpec) throws IOException {
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(classicAlgId, "classicAlgId");
Objects.requireNonNull(classicInitiatorPrivate, "classicInitiatorPrivate");
Objects.requireNonNull(classicPeerPublic, "classicPeerPublic");
Objects.requireNonNull(pqcAlgId, "pqcAlgId");
Objects.requireNonNull(pqcPeerPublic, "pqcPeerPublic");
AgreementContext classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT, classicInitiatorPrivate,
classicSpec);
classic.setPeerPublic(classicPeerPublic);
MessageAgreementContext pqc = CryptoAlgorithms.create(pqcAlgId, KeyUsage.AGREEMENT, pqcPeerPublic, pqcSpec);
return new HybridKexContext(profile, classic, pqc);
}
/**
* Creates a responder-side hybrid KEX context.
*
* <p>
* This method constructs:
* </p>
* <ul>
* <li>a classic {@link AgreementContext} from the responder's classic
* {@link PrivateKey} and configures it with the peer's classic
* {@link PublicKey}, and</li>
* <li>a PQC {@link MessageAgreementContext} from the responder's PQC
* {@link PrivateKey} (decapsulation side).</li>
* </ul>
*
* <p>
* The returned {@link HybridKexContext} is typically used after receiving the
* initiator's hybrid peer message and passing it to
* {@link HybridKexContext#setPeerMessage(byte[])}.
* </p>
*
* @param profile hybrid profile defining HKDF binding and
* output length
* @param classicAlgId classic agreement algorithm identifier
* @param classicResponderPrivate responder private key for the classic leg
* @param classicPeerPublic peer public key for the classic leg
* @param classicSpec optional classic context spec (may be
* {@code null})
* @param pqcAlgId post-quantum agreement algorithm identifier
* @param pqcResponderPrivate responder private key for the PQC leg
* (decapsulation side)
* @param pqcSpec optional PQC context spec (may be
* {@code null})
* @return responder-side {@link HybridKexContext}
* @throws NullPointerException if any required argument is {@code null}
* @throws IOException if underlying context creation fails
* @since 1.0
*/
public static HybridKexContext responder(HybridKexProfile profile, String classicAlgId,
PrivateKey classicResponderPrivate, PublicKey classicPeerPublic, ContextSpec classicSpec, String pqcAlgId,
PrivateKey pqcResponderPrivate, ContextSpec pqcSpec) throws IOException {
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(classicAlgId, "classicAlgId");
Objects.requireNonNull(classicResponderPrivate, "classicResponderPrivate");
Objects.requireNonNull(classicPeerPublic, "classicPeerPublic");
Objects.requireNonNull(pqcAlgId, "pqcAlgId");
Objects.requireNonNull(pqcResponderPrivate, "pqcResponderPrivate");
AgreementContext classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT, classicResponderPrivate,
classicSpec);
classic.setPeerPublic(classicPeerPublic);
MessageAgreementContext pqc = CryptoAlgorithms.create(pqcAlgId, KeyUsage.AGREEMENT, pqcResponderPrivate,
pqcSpec);
return new HybridKexContext(profile, classic, pqc);
}
/**
* Creates an initiator-side hybrid KEX where the classic leg is message-based
* (PAIR_MESSAGE).
*
* <p>
* The classic message is the SPKI encoding of the initiator's classic public
* key as produced by the underlying {@link MessageAgreementContext}. The PQC
* message is the PQC encapsulation payload (typically KEM ciphertext).
* </p>
*
* @param profile hybrid profile defining HKDF binding and
* output length
* @param classicAlgId classic agreement algorithm identifier (e.g.
* "X25519", "ECDH", "DH")
* @param classicInitiatorKeyPair classic initiator key pair wrapped as
* {@code KeyPairKey}
* @param classicSpec classic context spec (may be {@code null} if
* supported)
* @param pqcAlgId post-quantum agreement algorithm identifier
* (e.g. "ML-KEM")
* @param pqcPeerPublic PQC peer public key (encapsulation side)
* @param pqcSpec optional PQC context spec (may be
* {@code null})
* @return initiator-side {@link HybridKexContext}
* @throws NullPointerException if any required argument is {@code null}
* @throws IOException if underlying context creation fails
*/
public static HybridKexContext initiatorPairMessage(HybridKexProfile profile, String classicAlgId,
zeroecho.core.alg.common.agreement.KeyPairKey classicInitiatorKeyPair, ContextSpec classicSpec,
String pqcAlgId, PublicKey pqcPeerPublic, ContextSpec pqcSpec) throws IOException {
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(classicAlgId, "classicAlgId");
Objects.requireNonNull(classicInitiatorKeyPair, "classicInitiatorKeyPair");
Objects.requireNonNull(pqcAlgId, "pqcAlgId");
Objects.requireNonNull(pqcPeerPublic, "pqcPeerPublic");
MessageAgreementContext classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT,
classicInitiatorKeyPair, classicSpec);
MessageAgreementContext pqc = CryptoAlgorithms.create(pqcAlgId, KeyUsage.AGREEMENT, pqcPeerPublic, pqcSpec);
return new HybridKexContext(profile, classic, pqc);
}
/**
* Creates a responder-side hybrid KEX where the classic leg is message-based
* (PAIR_MESSAGE).
*
* <p>
* The responder must call {@link HybridKexContext#setPeerMessage(byte[])} with
* the initiator's hybrid message before calling
* {@link HybridKexContext#deriveSecret()}.
* </p>
*
* @param profile hybrid profile defining HKDF binding and
* output length
* @param classicAlgId classic agreement algorithm identifier
* @param classicResponderKeyPair classic responder key pair wrapped as
* {@code KeyPairKey}
* @param classicSpec classic context spec (may be {@code null})
* @param pqcAlgId post-quantum agreement algorithm identifier
* @param pqcResponderPrivate PQC responder private key (decapsulation side)
* @param pqcSpec optional PQC context spec (may be
* {@code null})
* @return responder-side {@link HybridKexContext}
* @throws NullPointerException if any required argument is {@code null}
* @throws IOException if underlying context creation fails
*/
public static HybridKexContext responderPairMessage(HybridKexProfile profile, String classicAlgId,
zeroecho.core.alg.common.agreement.KeyPairKey classicResponderKeyPair, ContextSpec classicSpec,
String pqcAlgId, PrivateKey pqcResponderPrivate, ContextSpec pqcSpec) throws IOException {
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(classicAlgId, "classicAlgId");
Objects.requireNonNull(classicResponderKeyPair, "classicResponderKeyPair");
Objects.requireNonNull(pqcAlgId, "pqcAlgId");
Objects.requireNonNull(pqcResponderPrivate, "pqcResponderPrivate");
MessageAgreementContext classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT,
classicResponderKeyPair, classicSpec);
MessageAgreementContext pqc = CryptoAlgorithms.create(pqcAlgId, KeyUsage.AGREEMENT, pqcResponderPrivate,
pqcSpec);
return new HybridKexContext(profile, classic, pqc);
}
}

View File

@@ -0,0 +1,134 @@
/*******************************************************************************
* 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 zeroecho.sdk.hybrid.kex;
import java.security.GeneralSecurityException;
import java.util.Objects;
import zeroecho.sdk.util.Kdf;
/**
* Deterministic exporter for deriving multiple independent keys from a single
* hybrid KEX.
*
* <p>
* Many protocols require more than one key from a single handshake (for
* example, separate traffic keys for each direction, confirmation keys, or
* application exporters). This class provides a minimal key schedule API over
* HKDF-SHA256.
* </p>
*
* <h2>Construction</h2>
* <p>
* The exporter is seeded by a caller-supplied secret (typically the output of
* {@link HybridKexContext#deriveSecret()}). The exporter then derives sub-keys
* by varying HKDF {@code info}.
* </p>
*
* <h2>Security notes</h2>
* <ul>
* <li>Use distinct labels per purpose (for example {@code "app/tx"} vs
* {@code "app/rx"}).</li>
* <li>Bind transcript/context by including it in the {@code info} bytes.</li>
* </ul>
*
* @since 1.0
*/
public final class HybridKexExporter {
private final byte[] rootSecret;
private final byte[] salt;
/**
* Creates an exporter seeded from a root secret.
*
* @param rootSecret root secret (must not be null)
* @param salt optional HKDF salt (may be null)
*/
public HybridKexExporter(byte[] rootSecret, byte[] salt) {
Objects.requireNonNull(rootSecret, "rootSecret");
this.rootSecret = rootSecret.clone();
this.salt = (salt == null) ? null : salt.clone();
}
/**
* Derives {@code outLenBytes} bytes for a specific purpose.
*
* @param label ASCII/UTF-8 label identifying the purpose (must not be
* null)
* @param info optional additional binding info (may be null)
* @param outLenBytes output length in bytes (1..8160)
* @return derived bytes
* @throws IllegalArgumentException if outLenBytes is out of range
*/
public byte[] export(String label, byte[] info, int outLenBytes) {
Objects.requireNonNull(label, "label");
if (outLenBytes < 1 || outLenBytes > 255 * 32) {
throw new IllegalArgumentException("outLenBytes must be in range 1.." + (255 * 32));
}
byte[] labelBytes = label.getBytes(java.nio.charset.StandardCharsets.UTF_8);
byte[] infoUse;
if (info == null || info.length == 0) {
infoUse = labelBytes;
} else {
infoUse = new byte[labelBytes.length + 1 + info.length];
System.arraycopy(labelBytes, 0, infoUse, 0, labelBytes.length);
infoUse[labelBytes.length] = 0;
System.arraycopy(info, 0, infoUse, labelBytes.length + 1, info.length);
}
try {
return Kdf.hkdfSha256(rootSecret, salt, infoUse, outLenBytes);
} catch (GeneralSecurityException e) {
throw new IllegalStateException("HKDF-SHA256 failed", e);
}
}
/**
* Returns a defensive copy of the exporter root secret.
*
* <p>
* This is intended for diagnostics/testing only. Applications should prefer
* {@link #export(String, byte[], int)}.
* </p>
*
* @return copy of root secret
*/
public byte[] rootSecretCopy() {
return rootSecret.clone();
}
}

View File

@@ -0,0 +1,124 @@
/*******************************************************************************
* 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 zeroecho.sdk.hybrid.kex;
import java.security.Key;
import java.util.Objects;
import zeroecho.core.context.AgreementContext;
import zeroecho.core.context.MessageAgreementContext;
import zeroecho.core.policy.SecurityStrengthAdvisor;
/**
* Hybrid KEX policy helper for minimum security strength enforcement.
*
* <p>
* This policy applies additional hybrid-specific checks beyond per-algorithm
* policy validation. It is intended to prevent accidental downgrade
* combinations (for example a strong PQC leg combined with a too-weak classic
* leg, or an undersized OKM output).
* </p>
*
* <h2>What is checked</h2>
* <ul>
* <li>Classic leg estimated strength in bits (algorithm id + key)</li>
* <li>PQC leg estimated strength in bits (algorithm id + key)</li>
* <li>Minimum OKM length (in bytes) for the intended usage</li>
* </ul>
*
* <p>
* Strength estimates are provided by
* {@link SecurityStrengthAdvisor#estimateBits(String, Key)} and are
* conservative heuristics suitable for gating and coarse comparisons.
* </p>
*
* @since 1.0
*/
public final class HybridKexPolicy {
private final int minClassicBits;
private final int minPqcBits;
private final int minOkmBytes;
/**
* Creates a hybrid policy.
*
* @param minClassicBits minimum estimated bits for classic leg (for example 128
* or 192)
* @param minPqcBits minimum estimated bits for PQC leg (for example 192)
* @param minOkmBytes minimum OKM output length in bytes (for example 32)
*/
public HybridKexPolicy(int minClassicBits, int minPqcBits, int minOkmBytes) {
if (minClassicBits < 0 || minPqcBits < 0) {
throw new IllegalArgumentException("min bits must be non-negative");
}
if (minOkmBytes < 1) { // NOPMD
throw new IllegalArgumentException("minOkmBytes must be >= 1");
}
this.minClassicBits = minClassicBits;
this.minPqcBits = minPqcBits;
this.minOkmBytes = minOkmBytes;
}
/**
* Enforces the policy for a given hybrid configuration.
*
* @param profile hybrid profile
* @param classic classic agreement context
* @param pqc PQC message agreement context
* @throws NullPointerException if any argument is null
* @throws IllegalArgumentException if policy is violated
*/
public void enforce(HybridKexProfile profile, AgreementContext classic, MessageAgreementContext pqc) {
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(classic, "classic");
Objects.requireNonNull(pqc, "pqc");
if (profile.outLenBytes() < minOkmBytes) {
throw new IllegalArgumentException(
"Hybrid OKM length too small: " + profile.outLenBytes() + " < " + minOkmBytes);
}
int classicBits = SecurityStrengthAdvisor.estimateBits(classic.algorithm().id(), classic.key());
int pqcBits = SecurityStrengthAdvisor.estimateBits(pqc.algorithm().id(), pqc.key());
if (classicBits < minClassicBits) {
throw new IllegalArgumentException("Classic leg too weak: " + classicBits + " < " + minClassicBits);
}
if (pqcBits < minPqcBits) {
throw new IllegalArgumentException("PQC leg too weak: " + pqcBits + " < " + minPqcBits);
}
}
}

View File

@@ -0,0 +1,108 @@
/*******************************************************************************
* 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 zeroecho.sdk.hybrid.kex;
import java.nio.charset.StandardCharsets;
/**
* Profile for a hybrid key exchange (KEX) composition.
*
* <p>
* A hybrid KEX combines two independently derived secrets:
* </p>
* <ul>
* <li>a classic (pre-quantum) agreement secret produced by an
* {@code AgreementContext} (e.g. X25519, ECDH, DH), and</li>
* <li>a post-quantum agreement secret produced by a
* {@code MessageAgreementContext} (typically a KEM-style agreement such as
* ML-KEM).</li>
* </ul>
*
* <p>
* The two secrets are combined using HKDF-SHA256 (RFC 5869) to produce a final
* keying material byte array of a caller-selected length. The default label is
* intended to make cross-protocol misuse harder by providing an explicit domain
* separator.
* </p>
*
* <h2>Notes</h2>
* <ul>
* <li>This profile does not store algorithm identifiers on purpose; it focuses
* on KDF binding and output size. Algorithm selection happens in higher layers
* when constructing the two underlying contexts.</li>
* <li>{@code salt} is optional; if null/empty, HKDF uses a zero-filled salt as
* per RFC 5869 and {@link zeroecho.sdk.util.Kdf}.</li>
* </ul>
*
* @param hkdfSalt optional HKDF salt (defensively copied), may be
* {@code null}
* @param hkdfInfo optional HKDF info/label (defensively copied), may be
* {@code null}
* @param outLenBytes length of derived keying material in bytes (1..8160)
*
* @since 1.0
*/
public record HybridKexProfile(byte[] hkdfSalt, byte[] hkdfInfo, int outLenBytes) {
/**
* Default HKDF label used when the caller does not provide an explicit one.
*/
public static final byte[] DEFAULT_INFO = "ZeroEcho-HybridKEX".getBytes(StandardCharsets.US_ASCII);
/**
* Constructs a profile with a default HKDF info label.
*
* @param outLenBytes output length in bytes
* @return profile with default HKDF info label and no explicit salt
*/
public static HybridKexProfile defaultProfile(int outLenBytes) {
return new HybridKexProfile(null, DEFAULT_INFO, outLenBytes);
}
/**
* Canonical constructor with validation and defensive copies.
*
* @param hkdfSalt optional HKDF salt
* @param hkdfInfo optional HKDF info/label
* @param outLenBytes output length in bytes
*/
public HybridKexProfile {
if (outLenBytes < 1 || outLenBytes > 255 * 32) {
throw new IllegalArgumentException("outLenBytes must be in range 1.." + (255 * 32));
}
hkdfSalt = (hkdfSalt == null) ? null : hkdfSalt.clone();
hkdfInfo = (hkdfInfo == null) ? null : hkdfInfo.clone();
}
}

View File

@@ -0,0 +1,150 @@
/*******************************************************************************
* 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 zeroecho.sdk.hybrid.kex;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
/**
* Canonical transcript builder for binding hybrid KEX derivation to public
* handshake context.
*
* <p>
* The derived hybrid keying material should be bound to protocol context to
* reduce risk of cross-protocol key reuse and "unknown key-share" style
* mistakes. This builder provides a deterministic and self-delimiting binary
* encoding intended to be used as HKDF {@code info}.
* </p>
*
* <h2>Encoding</h2>
* <p>
* The transcript is encoded as a sequence of TLV-like entries:
* </p>
* <pre>{@code
* [u16 tagLen][tagBytes...][u32 valueLen][valueBytes...] ...
* }</pre>
*
* <p>
* Tags are ASCII identifiers (for example {@code "suite"}, {@code "role"},
* {@code "peerA"}, {@code "peerB"}, {@code "classicMsg"}, {@code "pqcMsg"}).
* Values are arbitrary bytes. The encoding is stable across JVMs and
* independent of locale.
* </p>
*
* <h2>Thread safety</h2>
* <p>
* Instances are mutable and not thread-safe.
* </p>
*
* @since 1.0
*/
public final class HybridKexTranscript {
private final ByteArrayOutputStream buffer;
private final DataOutputStream out;
/**
* Creates a new empty transcript.
*/
public HybridKexTranscript() {
this.buffer = new ByteArrayOutputStream();
this.out = new DataOutputStream(buffer);
}
/**
* Adds a UTF-8 string value under a tag.
*
* @param tag ASCII tag identifier
* @param value UTF-8 string value (must not be null)
* @return this transcript
* @throws NullPointerException if tag or value is null
* @throws IllegalArgumentException if tag is empty
*/
public HybridKexTranscript addUtf8(String tag, String value) {
Objects.requireNonNull(value, "value");
return addBytes(tag, value.getBytes(StandardCharsets.UTF_8));
}
/**
* Adds a raw byte value under a tag.
*
* <p>
* The value is defensively copied by the caller if needed; this method does not
* retain a reference to the provided array.
* </p>
*
* @param tag ASCII tag identifier
* @param value byte value (must not be null)
* @return this transcript
* @throws NullPointerException if tag or value is null
* @throws IllegalArgumentException if tag is empty
*/
public HybridKexTranscript addBytes(String tag, byte[] value) {
Objects.requireNonNull(tag, "tag");
Objects.requireNonNull(value, "value");
if (tag.isEmpty()) {
throw new IllegalArgumentException("tag must not be empty");
}
byte[] tagBytes = tag.getBytes(StandardCharsets.US_ASCII);
try {
if (tagBytes.length > 65_535) { // NOPMD
throw new IllegalArgumentException("tag too long");
}
out.writeShort(tagBytes.length);
out.write(tagBytes);
out.writeInt(value.length);
out.write(value);
out.flush();
return this;
} catch (IOException e) {
// ByteArrayOutputStream should not throw, but keep failure explicit.
throw new IllegalStateException("Unable to encode transcript entry", e);
}
}
/**
* Returns the canonical transcript bytes (defensive copy).
*
* @return transcript bytes
*/
public byte[] toByteArray() {
return buffer.toByteArray();
}
}

View File

@@ -0,0 +1,81 @@
/*******************************************************************************
* 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.
******************************************************************************/
/**
* Hybrid key exchange (KEX) utilities combining a classic agreement and a
* post-quantum KEM-style agreement into one derived shared secret.
*
* <p>
* This package provides an SDK-level composition layer over existing core
* contracts:
* </p>
* <ul>
* <li>{@link zeroecho.core.context.AgreementContext} for classic DH-style
* agreements (e.g. X25519, ECDH, DH), and</li>
* <li>{@link zeroecho.core.context.MessageAgreementContext} for message-based
* agreements (typically PQC KEM-style flows such as ML-KEM exposed via
* {@code KeyUsage.AGREEMENT}).</li>
* </ul>
*
* <h2>Wire model</h2>
* <p>
* A hybrid exchange needs explicit "to-be-sent" bytes. This package unifies the
* model by emitting a single peer message containing two length-prefixed parts:
* </p>
* <ol>
* <li>classic message (often empty for classic agreements that only require a
* peer public key),</li>
* <li>PQC message (typically KEM ciphertext produced by
* {@link zeroecho.core.context.MessageAgreementContext}).</li>
* </ol>
*
* <h2>Key derivation</h2>
* <p>
* The two underlying secrets are combined using HKDF-SHA256 (RFC 5869) via
* {@link zeroecho.sdk.util.Kdf#hkdfSha256(byte[], byte[], byte[], int)} rather
* than concatenation. Callers should treat raw intermediate secrets as
* sensitive and clear them as soon as possible.
* </p>
*
* <h2>Intended usage</h2>
* <p>
* Use {@link zeroecho.sdk.hybrid.kex.HybridKexContexts} to build initiator and
* responder contexts and exchange
* {@link zeroecho.sdk.hybrid.kex.HybridKexContext#getPeerMessage()} between
* parties.
* </p>
*
* @since 1.0
*/
package zeroecho.sdk.hybrid.kex;

View File

@@ -0,0 +1,251 @@
/*******************************************************************************
* 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 zeroecho.sdk.hybrid.kex;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.security.KeyPair;
import java.util.Arrays;
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.util.BouncyCastleActivator;
/**
* Hybrid KEX tests.
*/
public class HybridKexTest {
@BeforeAll
static void setup() {
// Optional: enable BC if you use BC-only modes in KEM payloads (EAX/OCB/CCM,
// etc.)
try {
BouncyCastleActivator.init();
} catch (Throwable ignore) {
// keep tests runnable without BC if not present
}
}
private static void logBegin(Object... params) {
String thisClass = HybridKexTest.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 = HybridKexTest.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 String hex(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();
int pqcLen = 0;
if (msg.length >= 8 + Math.max(0, classicLen)) {
if (classicLen > 0) {
in.skipBytes(classicLen);
}
pqcLen = in.readInt();
}
return "classicLen=" + classicLen + ", pqcLen=" + pqcLen;
} catch (Exception e) {
return "classicLen=?, pqcLen=?";
}
}
@Test
void hybrid_x25519_mlkem_roundtrip() throws Exception {
logBegin("CLASSIC_AGREEMENT + KEM_ADAPTER", "Xdh/X25519 + ML-KEM(768)", "HKDF-SHA256", "32 bytes");
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);
// PQC: ML-KEM key pair (Kyber variant)
KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
HybridKexContext alice = null;
HybridKexContext bob = null;
try {
// Initiator: classic uses Alice private + Bob classic public; PQC uses Bob PQC
// public
alice = HybridKexContexts.initiator(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(),
XdhSpec.X25519, "ML-KEM", bobPqc.getPrivate(), null);
// Alice produces message (contains PQC ciphertext; classic part is empty here)
byte[] aliceMsg = alice.getPeerMessage();
System.out.println("...aliceMsg(" + lens(aliceMsg) + ")=" + hex(aliceMsg));
// Bob consumes message
bob.setPeerMessage(aliceMsg);
byte[] kA = alice.deriveSecret();
byte[] kB = bob.deriveSecret();
System.out.println("...kA=" + hex(kA));
System.out.println("...kB=" + hex(kB));
assertNotNull(kA);
assertNotNull(kB);
assertArrayEquals(kA, kB);
} finally {
if (alice != null) {
try {
alice.close();
} catch (Exception ignore) {
}
}
if (bob != null) {
try {
bob.close();
} catch (Exception ignore) {
}
}
}
logEnd();
}
@Test
void hybrid_x25519_pairmessage_mlkem_roundtrip() throws Exception {
logBegin("PAIR_MESSAGE + KEM_ADAPTER", "Xdh/X25519 + ML-KEM(768)", "HKDF-SHA256", "32 bytes");
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);
// PQC: ML-KEM key pair (recipient/responder)
KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
HybridKexContext alice = null;
HybridKexContext bob = null;
try {
// Classic leg is message-based on both sides (PAIR_MESSAGE capability:
// 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,
"ML-KEM", bobPqc.getPublic(), null);
bob = HybridKexContexts.responderPairMessage(profile, "Xdh", new KeyPairKey(bobClassic), XdhSpec.X25519,
"ML-KEM", bobPqc.getPrivate(), null);
// Step 1: Alice -> Bob (classic SPKI + PQC ciphertext)
byte[] msgA = alice.getPeerMessage();
System.out.println("...msgA(" + lens(msgA) + ")=" + hex(msgA));
bob.setPeerMessage(msgA);
// Step 2: Bob -> Alice (classic SPKI only; PQC part is empty)
byte[] msgB = bob.getPeerMessage();
System.out.println("...msgB(" + lens(msgB) + ")=" + hex(msgB));
alice.setPeerMessage(msgB);
// Both sides derive the final hybrid OKM.
byte[] kA = alice.deriveSecret();
byte[] kB = bob.deriveSecret();
System.out.println("...kA=" + hex(kA));
System.out.println("...kB=" + hex(kB));
assertNotNull(kA);
assertNotNull(kB);
assertArrayEquals(kA, kB);
} finally {
if (alice != null) {
try {
alice.close();
} catch (Exception ignore) {
}
}
if (bob != null) {
try {
bob.close();
} catch (Exception ignore) {
}
}
}
logEnd();
}
}

View File

@@ -0,0 +1,536 @@
/*******************************************************************************
* 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=?]";
}
}
}