From 55da24735f77a3ee866ad9d32633887a0a55030b Mon Sep 17 00:00:00 2001 From: Leo Galambos Date: Fri, 26 Dec 2025 17:48:39 +0100 Subject: [PATCH] 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 --- .../sdk/builders/HybridKexBuilder.java | 714 ++++++++++++++++++ .../sdk/hybrid/kex/HybridKexContext.java | 463 ++++++++++++ .../sdk/hybrid/kex/HybridKexContexts.java | 314 ++++++++ .../sdk/hybrid/kex/HybridKexExporter.java | 134 ++++ .../sdk/hybrid/kex/HybridKexPolicy.java | 124 +++ .../sdk/hybrid/kex/HybridKexProfile.java | 108 +++ .../sdk/hybrid/kex/HybridKexTranscript.java | 150 ++++ .../zeroecho/sdk/hybrid/kex/package-info.java | 81 ++ .../sdk/hybrid/kex/HybridKexTest.java | 251 ++++++ .../src/test/java/demo/HybridKexDemoTest.java | 536 +++++++++++++ 10 files changed, 2875 insertions(+) create mode 100644 lib/src/main/java/zeroecho/sdk/builders/HybridKexBuilder.java create mode 100644 lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexContext.java create mode 100644 lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexContexts.java create mode 100644 lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexExporter.java create mode 100644 lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexPolicy.java create mode 100644 lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexProfile.java create mode 100644 lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexTranscript.java create mode 100644 lib/src/main/java/zeroecho/sdk/hybrid/kex/package-info.java create mode 100644 lib/src/test/java/zeroecho/sdk/hybrid/kex/HybridKexTest.java create mode 100644 samples/src/test/java/demo/HybridKexDemoTest.java diff --git a/lib/src/main/java/zeroecho/sdk/builders/HybridKexBuilder.java b/lib/src/main/java/zeroecho/sdk/builders/HybridKexBuilder.java new file mode 100644 index 0000000..520037d --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/builders/HybridKexBuilder.java @@ -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. + * + *

+ * The builder supports the two practical hybrid variants: + *

+ * + * + *

+ * The builder also supports transcript binding and optional policy enforcement + * before returning the context. + *

+ * + *

Usage sketch

{@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();
+ * }
+ * + *

+ * Instances are mutable and not thread-safe. + *

+ * + * @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}. + * + *

+ * 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. + *

+ * + * @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. + * + *

+ * 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)}. + *

+ * + *

+ * Calling this method resets any previously configured {@code PAIR_MESSAGE} + * inputs ({@link #classicKeyPair}) to prevent ambiguous configuration. + *

+ * + * @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. + * + *

+ * 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). + *

+ * + *

+ * Calling this method resets any previously configured + * {@code CLASSIC_AGREEMENT} inputs ({@link #classicPrivate} and + * {@link #classicPeerPublic}) to prevent ambiguous configuration. + *

+ * + * @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). + * + *

+ * The PQC leg is always treated as a message-based agreement: + *

+ * + * + * @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. + * + *

+ * The classic leg is always an {@link AgreementContext}, but there are two + * operational models: + *

+ * + * + *

+ * 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. + *

+ * + * @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. + * + *

+ * Required inputs before building: + *

+ * + * + *

+ * Optional inputs: + *

+ * + * + *

+ * After configuring the classic leg, continue with {@link #pqcKem()} to + * configure the PQC leg. + *

+ * + * @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. + * + *

+ * Example for X25519 in this project: use {@code "Xdh"} with + * {@code XdhSpec.X25519}. + *

+ * + * @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. + * + *

+ * In {@link ClassicMode#CLASSIC_AGREEMENT} this key is assumed to be obtained + * out-of-band. + *

+ * + * @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. + * + *

+ * Required inputs before building: + *

+ * + * + *

+ * Optional inputs: + *

+ * + * + *

+ * 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. + *

+ * + * @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. + * + *

+ * The key pair is wrapped into {@link KeyPairKey} to match the + * {@code PAIR_MESSAGE} capability registered in core. + *

+ * + * @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). + * + *

+ * Required inputs differ by role: + *

+ * + * + *

+ * 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. + *

+ * + * @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(); + } + } + +} diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexContext.java b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexContext.java new file mode 100644 index 0000000..af3ce89 --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexContext.java @@ -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. + * + *

+ * This context composes two independent shared secrets: + *

+ * + * + *

Wire format

+ *

+ * This class implements {@link MessageAgreementContext} to provide a single + * hybrid message that can be transmitted between parties. The encoding is: + *

+ *
{@code
+ * [int classicLen][classicBytes...][int pqcLen][pqcBytes...]
+ * }
+ * + *

+ * 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. + *

+ * + *

Key derivation

+ *

+ * The final keying material is derived using HKDF-SHA256 (RFC 5869): + *

+ *
{@code
+ * classicSS = classic.deriveSecret()
+ * pqcSS     = pqc.deriveSecret()
+ * IKM       = classicSS || pqcSS
+ * OKM       = HKDF-SHA256(IKM, salt, info, outLen)
+ * }
+ * + *

+ * Intermediate raw secrets are treated as sensitive and are zeroized + * (best-effort) once HKDF completes. + *

+ * + *

CryptoContext identity

+ *

+ * 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. + *

+ * + *

Error handling

+ * + * + *

Thread safety

+ *

+ * Instances are mutable and not thread-safe; use one instance per + * handshake/session and thread. + *

+ * + * @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. + * + *

+ * This is currently delegated to the classic leg. Hybrid constructions bind + * multiple algorithms; callers that need full visibility should track both legs + * explicitly. + *

+ * + * @return representative algorithm (classic leg) + * @since 1.0 + */ + @Override + public CryptoAlgorithm algorithm() { + return classic.algorithm(); + } + + /** + * Returns the representative key of this context. + * + *

+ * 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. + *

+ * + * @return representative key (classic leg) + * @since 1.0 + */ + @Override + public Key key() { + return classic.key(); + } + + /** + * Sets the peer public key for the classic leg. + * + *

+ * 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. + *

+ * + * @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. + * + *

+ * 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. + *

+ * + *

+ * Passing {@code null} resets the peer-message related state of both legs + * (best-effort). + *

+ * + * @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. + * + *

+ * 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. + *

+ * + * @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. + * + *

+ * 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()}). + *

+ * + * @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. + * + *

+ * 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. + *

+ * + * @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. + * + *

+ * This is intended for diagnostics and testing. The returned array is a + * defensive copy. + *

+ * + * @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) { + } +} diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexContexts.java b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexContexts.java new file mode 100644 index 0000000..c1bc754 --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexContexts.java @@ -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. + * + *

+ * 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: + *

+ * + * + *

Handshake model

+ *

+ * The two legs have different wire semantics: + *

+ * + * + *

+ * {@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). + *

+ * + *

Error handling

+ *

+ * 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. + *

+ * + *

Thread safety

+ *

+ * This factory is stateless and thread-safe. Produced contexts are mutable and + * not thread-safe. + *

+ * + * @since 1.0 + */ +public final class HybridKexContexts { + + private HybridKexContexts() { + // utility + } + + /** + * Creates an initiator-side hybrid KEX context. + * + *

+ * This method constructs: + *

+ * + * + *

+ * 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. + *

+ * + * @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. + * + *

+ * This method constructs: + *

+ * + * + *

+ * The returned {@link HybridKexContext} is typically used after receiving the + * initiator's hybrid peer message and passing it to + * {@link HybridKexContext#setPeerMessage(byte[])}. + *

+ * + * @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). + * + *

+ * 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). + *

+ * + * @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). + * + *

+ * The responder must call {@link HybridKexContext#setPeerMessage(byte[])} with + * the initiator's hybrid message before calling + * {@link HybridKexContext#deriveSecret()}. + *

+ * + * @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); + } +} diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexExporter.java b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexExporter.java new file mode 100644 index 0000000..a6da588 --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexExporter.java @@ -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. + * + *

+ * 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. + *

+ * + *

Construction

+ *

+ * 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}. + *

+ * + *

Security notes

+ * + * + * @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. + * + *

+ * This is intended for diagnostics/testing only. Applications should prefer + * {@link #export(String, byte[], int)}. + *

+ * + * @return copy of root secret + */ + public byte[] rootSecretCopy() { + return rootSecret.clone(); + } +} diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexPolicy.java b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexPolicy.java new file mode 100644 index 0000000..794901d --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexPolicy.java @@ -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. + * + *

+ * 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). + *

+ * + *

What is checked

+ * + * + *

+ * Strength estimates are provided by + * {@link SecurityStrengthAdvisor#estimateBits(String, Key)} and are + * conservative heuristics suitable for gating and coarse comparisons. + *

+ * + * @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); + } + } +} diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexProfile.java b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexProfile.java new file mode 100644 index 0000000..f1772a9 --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexProfile.java @@ -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. + * + *

+ * A hybrid KEX combines two independently derived secrets: + *

+ * + * + *

+ * 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. + *

+ * + *

Notes

+ * + * + * @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(); + } +} diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexTranscript.java b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexTranscript.java new file mode 100644 index 0000000..935b16f --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexTranscript.java @@ -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. + * + *

+ * 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}. + *

+ * + *

Encoding

+ *

+ * The transcript is encoded as a sequence of TLV-like entries: + *

+ *
{@code
+ * [u16 tagLen][tagBytes...][u32 valueLen][valueBytes...] ...
+ * }
+ * + *

+ * 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. + *

+ * + *

Thread safety

+ *

+ * Instances are mutable and not thread-safe. + *

+ * + * @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. + * + *

+ * The value is defensively copied by the caller if needed; this method does not + * retain a reference to the provided array. + *

+ * + * @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(); + } +} diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/kex/package-info.java b/lib/src/main/java/zeroecho/sdk/hybrid/kex/package-info.java new file mode 100644 index 0000000..42f8e9a --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/kex/package-info.java @@ -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. + * + *

+ * This package provides an SDK-level composition layer over existing core + * contracts: + *

+ * + * + *

Wire model

+ *

+ * 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: + *

+ *
    + *
  1. classic message (often empty for classic agreements that only require a + * peer public key),
  2. + *
  3. PQC message (typically KEM ciphertext produced by + * {@link zeroecho.core.context.MessageAgreementContext}).
  4. + *
+ * + *

Key derivation

+ *

+ * 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. + *

+ * + *

Intended usage

+ *

+ * Use {@link zeroecho.sdk.hybrid.kex.HybridKexContexts} to build initiator and + * responder contexts and exchange + * {@link zeroecho.sdk.hybrid.kex.HybridKexContext#getPeerMessage()} between + * parties. + *

+ * + * @since 1.0 + */ +package zeroecho.sdk.hybrid.kex; diff --git a/lib/src/test/java/zeroecho/sdk/hybrid/kex/HybridKexTest.java b/lib/src/test/java/zeroecho/sdk/hybrid/kex/HybridKexTest.java new file mode 100644 index 0000000..fbee9ab --- /dev/null +++ b/lib/src/test/java/zeroecho/sdk/hybrid/kex/HybridKexTest.java @@ -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(); + } +} diff --git a/samples/src/test/java/demo/HybridKexDemoTest.java b/samples/src/test/java/demo/HybridKexDemoTest.java new file mode 100644 index 0000000..19f1269 --- /dev/null +++ b/samples/src/test/java/demo/HybridKexDemoTest.java @@ -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. + * + *

+ * Hybrid KEX in this project means: + *

+ * + * + *

+ * 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. + *

+ * + *

Available hybrid variants

+ *

+ * The classic leg can be wired in two ways: + *

+ * + * + *

Note on resource management

+ *

+ * 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. + *

+ */ +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=?]"; + } + } +}