feat: add hybrid key exchange framework
Introduce a complete SDK-level hybrid KEX framework combining classic (DH/ECDH/XDH) and post-quantum (KEM adapter) agreement contexts. Key additions: - HybridKexContext and HybridKexContexts for hybrid handshake orchestration over existing AgreementContext and MessageAgreementContext APIs - HybridKexProfile, HybridKexTranscript and HybridKexExporter providing HKDF-based key derivation, transcript binding and key schedule support - HybridKexPolicy for optional security strength and output-length gating - HybridKexBuilder offering a fluent, professional API for constructing CLASSIC_AGREEMENT + KEM_ADAPTER and PAIR_MESSAGE + KEM_ADAPTER variants - Comprehensive JUnit tests and documented demo illustrating both hybrid modes No changes to core cryptographic APIs; all hybrid logic is implemented as additive functionality in the SDK layer. Signed-off-by: Leo Galambos <lg@hq.egothor.org>
This commit is contained in:
714
lib/src/main/java/zeroecho/sdk/builders/HybridKexBuilder.java
Normal file
714
lib/src/main/java/zeroecho/sdk/builders/HybridKexBuilder.java
Normal file
@@ -0,0 +1,714 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2025, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software
|
||||
* without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.sdk.builders;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.Objects;
|
||||
|
||||
import zeroecho.core.CryptoAlgorithms;
|
||||
import zeroecho.core.KeyUsage;
|
||||
import zeroecho.core.alg.common.agreement.KeyPairKey;
|
||||
import zeroecho.core.context.AgreementContext;
|
||||
import zeroecho.core.context.MessageAgreementContext;
|
||||
import zeroecho.core.spec.ContextSpec;
|
||||
import zeroecho.sdk.hybrid.kex.HybridKexContext;
|
||||
import zeroecho.sdk.hybrid.kex.HybridKexExporter;
|
||||
import zeroecho.sdk.hybrid.kex.HybridKexPolicy;
|
||||
import zeroecho.sdk.hybrid.kex.HybridKexProfile;
|
||||
import zeroecho.sdk.hybrid.kex.HybridKexTranscript;
|
||||
|
||||
/**
|
||||
* Fluent builder for constructing hybrid KEX contexts.
|
||||
*
|
||||
* <p>
|
||||
* The builder supports the two practical hybrid variants:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li><b>CLASSIC_AGREEMENT + KEM_ADAPTER</b> (classic peer public is provided
|
||||
* out-of-band)</li>
|
||||
* <li><b>PAIR_MESSAGE + KEM_ADAPTER</b> (classic public key is carried in the
|
||||
* hybrid message)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The builder also supports transcript binding and optional policy enforcement
|
||||
* before returning the context.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Usage sketch</h2> <pre>{@code
|
||||
* HybridKexContext ctx = HybridKexBuilder.builder()
|
||||
* .profile(HybridKexProfile.defaultProfile(32))
|
||||
* .transcript(new HybridKexTranscript().addUtf8("suite", "X25519+MLKEM768"))
|
||||
* .policy(new HybridKexPolicy(128, 192, 32))
|
||||
* .classicAgreement()
|
||||
* .algorithm("Xdh").spec(XdhSpec.X25519)
|
||||
* .privateKey(alicePriv).peerPublic(bobPub)
|
||||
* .pqcKem()
|
||||
* .algorithm("ML-KEM").peerPublic(bobPqcPub)
|
||||
* .buildInitiator();
|
||||
* }</pre>
|
||||
*
|
||||
* <p>
|
||||
* Instances are mutable and not thread-safe.
|
||||
* </p>
|
||||
*
|
||||
* @since 1.0
|
||||
*/
|
||||
public final class HybridKexBuilder {
|
||||
|
||||
private HybridKexProfile profile;
|
||||
private HybridKexTranscript transcript;
|
||||
private HybridKexPolicy policy;
|
||||
|
||||
private ClassicMode classicMode;
|
||||
|
||||
private String classicAlgId;
|
||||
private ContextSpec classicSpec;
|
||||
private PrivateKey classicPrivate;
|
||||
private PublicKey classicPeerPublic;
|
||||
private KeyPairKey classicKeyPair;
|
||||
|
||||
private String pqcAlgId;
|
||||
private ContextSpec pqcSpec;
|
||||
private PublicKey pqcPeerPublic;
|
||||
private PrivateKey pqcPrivate;
|
||||
|
||||
private HybridKexBuilder() {
|
||||
// builder
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new builder instance.
|
||||
*
|
||||
* @return new builder
|
||||
*/
|
||||
public static HybridKexBuilder builder() {
|
||||
return new HybridKexBuilder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the hybrid profile (HKDF salt/info/output length).
|
||||
*
|
||||
* @param profile profile (must not be null)
|
||||
* @return this builder
|
||||
*/
|
||||
public HybridKexBuilder profile(HybridKexProfile profile) {
|
||||
this.profile = Objects.requireNonNull(profile, "profile");
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional transcript used to bind HKDF {@code info}.
|
||||
*
|
||||
* <p>
|
||||
* If provided, its bytes are concatenated to the profile {@code hkdfInfo} with
|
||||
* a single zero byte separator. This preserves the profile label as a domain
|
||||
* separator while incorporating handshake context.
|
||||
* </p>
|
||||
*
|
||||
* @param transcript transcript (may be null to clear)
|
||||
* @return this builder
|
||||
*/
|
||||
public HybridKexBuilder transcript(HybridKexTranscript transcript) {
|
||||
this.transcript = transcript;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional hybrid policy enforcement.
|
||||
*
|
||||
* @param policy policy (may be null to clear)
|
||||
* @return this builder
|
||||
*/
|
||||
public HybridKexBuilder policy(HybridKexPolicy policy) {
|
||||
this.policy = policy;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the classic leg in {@link ClassicMode#CLASSIC_AGREEMENT} mode.
|
||||
*
|
||||
* <p>
|
||||
* In this mode, the classic peer public key is assumed to be available
|
||||
* out-of-band (for example via a certificate, a directory, or a higher-level
|
||||
* handshake message). The hybrid wire message therefore typically carries only
|
||||
* the PQC payload (KEM ciphertext), while the classic leg is configured via
|
||||
* {@link AgreementContext#setPeerPublic(PublicKey)}.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* Calling this method resets any previously configured {@code PAIR_MESSAGE}
|
||||
* inputs ({@link #classicKeyPair}) to prevent ambiguous configuration.
|
||||
* </p>
|
||||
*
|
||||
* @return classic agreement configurator
|
||||
* @since 1.0
|
||||
*/
|
||||
public ClassicAgreement classicAgreement() {
|
||||
this.classicMode = ClassicMode.CLASSIC_AGREEMENT;
|
||||
this.classicKeyPair = null;
|
||||
return new ClassicAgreement(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the classic leg in {@link ClassicMode#PAIR_MESSAGE} mode.
|
||||
*
|
||||
* <p>
|
||||
* In this mode, the classic leg is message-capable: the public key is carried
|
||||
* as an explicit classic message (typically an SPKI encoding) and becomes part
|
||||
* of the hybrid peer message. This enables a fully message-oriented handshake
|
||||
* where both legs contribute to the wire payload (classic public-key message +
|
||||
* PQC ciphertext).
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* Calling this method resets any previously configured
|
||||
* {@code CLASSIC_AGREEMENT} inputs ({@link #classicPrivate} and
|
||||
* {@link #classicPeerPublic}) to prevent ambiguous configuration.
|
||||
* </p>
|
||||
*
|
||||
* @return classic pair-message configurator
|
||||
* @since 1.0
|
||||
*/
|
||||
public ClassicPairMessage classicPairMessage() {
|
||||
this.classicMode = ClassicMode.PAIR_MESSAGE;
|
||||
this.classicPrivate = null;
|
||||
this.classicPeerPublic = null;
|
||||
return new ClassicPairMessage(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects configuration of the post-quantum leg (KEM adapter).
|
||||
*
|
||||
* <p>
|
||||
* The PQC leg is always treated as a message-based agreement:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>Initiator is created from the recipient's PQC {@link PublicKey} and
|
||||
* produces a message (typically a ciphertext) via
|
||||
* {@link MessageAgreementContext#getPeerMessage()}.</li>
|
||||
* <li>Responder is created from the recipient's PQC {@link PrivateKey} and
|
||||
* consumes the peer message via
|
||||
* {@link MessageAgreementContext#setPeerMessage(byte[])}.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @return PQC KEM configurator
|
||||
* @since 1.0
|
||||
*/
|
||||
public PqcKem pqcKem() {
|
||||
return new PqcKem(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds initiator-side context.
|
||||
*
|
||||
* @return initiator context
|
||||
* @throws IOException if underlying context creation fails
|
||||
*/
|
||||
public HybridKexContext buildInitiator() throws IOException {
|
||||
validateCommon();
|
||||
|
||||
AgreementContext classic;
|
||||
if (classicMode == ClassicMode.CLASSIC_AGREEMENT) {
|
||||
if (classicPrivate == null || classicPeerPublic == null) {
|
||||
throw new IllegalStateException(
|
||||
"classic private key and peer public must be set for CLASSIC_AGREEMENT");
|
||||
}
|
||||
classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT, classicPrivate, classicSpec);
|
||||
classic.setPeerPublic(classicPeerPublic);
|
||||
} else if (classicMode == ClassicMode.PAIR_MESSAGE) {
|
||||
if (classicKeyPair == null) {
|
||||
throw new IllegalStateException("classic key pair must be set for PAIR_MESSAGE");
|
||||
}
|
||||
classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT, classicKeyPair, classicSpec);
|
||||
} else {
|
||||
throw new IllegalStateException("classic mode must be selected");
|
||||
}
|
||||
|
||||
if (pqcPeerPublic == null) {
|
||||
throw new IllegalStateException("pqc peer public must be set for initiator");
|
||||
}
|
||||
MessageAgreementContext pqc = CryptoAlgorithms.create(pqcAlgId, KeyUsage.AGREEMENT, pqcPeerPublic, pqcSpec);
|
||||
|
||||
HybridKexProfile effective = effectiveProfile();
|
||||
if (policy != null) {
|
||||
policy.enforce(effective, classic, pqc);
|
||||
}
|
||||
return new HybridKexContext(effective, classic, pqc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds responder-side context.
|
||||
*
|
||||
* @return responder context
|
||||
* @throws IOException if underlying context creation fails
|
||||
*/
|
||||
public HybridKexContext buildResponder() throws IOException {
|
||||
validateCommon();
|
||||
|
||||
AgreementContext classic;
|
||||
if (classicMode == ClassicMode.CLASSIC_AGREEMENT) {
|
||||
if (classicPrivate == null || classicPeerPublic == null) {
|
||||
throw new IllegalStateException(
|
||||
"classic private key and peer public must be set for CLASSIC_AGREEMENT");
|
||||
}
|
||||
classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT, classicPrivate, classicSpec);
|
||||
classic.setPeerPublic(classicPeerPublic);
|
||||
} else if (classicMode == ClassicMode.PAIR_MESSAGE) {
|
||||
if (classicKeyPair == null) {
|
||||
throw new IllegalStateException("classic key pair must be set for PAIR_MESSAGE");
|
||||
}
|
||||
classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT, classicKeyPair, classicSpec);
|
||||
} else {
|
||||
throw new IllegalStateException("classic mode must be selected");
|
||||
}
|
||||
|
||||
if (pqcPrivate == null) {
|
||||
throw new IllegalStateException("pqc private key must be set for responder");
|
||||
}
|
||||
MessageAgreementContext pqc = CryptoAlgorithms.create(pqcAlgId, KeyUsage.AGREEMENT, pqcPrivate, pqcSpec);
|
||||
|
||||
HybridKexProfile effective = effectiveProfile();
|
||||
if (policy != null) {
|
||||
policy.enforce(effective, classic, pqc);
|
||||
}
|
||||
return new HybridKexContext(effective, classic, pqc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an exporter seeded from a derived OKM value.
|
||||
*
|
||||
* @param okm derived OKM (for example {@link HybridKexContext#deriveSecret()})
|
||||
* @return exporter
|
||||
*/
|
||||
public HybridKexExporter exporterFromOkm(byte[] okm) {
|
||||
validateCommon();
|
||||
HybridKexProfile effective = effectiveProfile();
|
||||
return new HybridKexExporter(okm, effective.hkdfSalt());
|
||||
}
|
||||
|
||||
private void validateCommon() {
|
||||
if (profile == null) {
|
||||
throw new IllegalStateException("profile must be set");
|
||||
}
|
||||
if (classicMode == null) {
|
||||
throw new IllegalStateException("classic mode must be selected");
|
||||
}
|
||||
if (classicAlgId == null) {
|
||||
throw new IllegalStateException("classic algorithm id must be set");
|
||||
}
|
||||
if (pqcAlgId == null) {
|
||||
throw new IllegalStateException("pqc algorithm id must be set");
|
||||
}
|
||||
}
|
||||
|
||||
private HybridKexProfile effectiveProfile() {
|
||||
byte[] info0 = profile.hkdfInfo();
|
||||
byte[] t = (transcript == null) ? null : transcript.toByteArray();
|
||||
|
||||
if (t == null || t.length == 0) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
byte[] base = (info0 == null) ? new byte[0] : info0.clone();
|
||||
byte[] merged = new byte[base.length + 1 + t.length];
|
||||
System.arraycopy(base, 0, merged, 0, base.length);
|
||||
merged[base.length] = 0;
|
||||
System.arraycopy(t, 0, merged, base.length + 1, t.length);
|
||||
|
||||
return new HybridKexProfile(profile.hkdfSalt(), merged, profile.outLenBytes());
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines how the classic (pre-quantum) agreement leg is wired into the
|
||||
* hybrid handshake.
|
||||
*
|
||||
* <p>
|
||||
* The classic leg is always an {@link AgreementContext}, but there are two
|
||||
* operational models:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>{@link #CLASSIC_AGREEMENT}: the peer public key is supplied out-of-band
|
||||
* (not transported by the hybrid message). This corresponds to the traditional
|
||||
* DH/ECDH/XDH usage pattern where the protocol already has a mechanism to
|
||||
* convey or authenticate the peer public key (certificate, static key, or
|
||||
* separate handshake structure).</li>
|
||||
* <li>{@link #PAIR_MESSAGE}: the classic leg is message-capable and
|
||||
* emits/consumes a public-key message (typically SPKI) through
|
||||
* {@link MessageAgreementContext}. In this model, the classic public key
|
||||
* travels inside the hybrid peer message alongside the PQC payload, enabling a
|
||||
* fully message-oriented exchange.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* This enum is internal to the builder but is documented because it defines the
|
||||
* wire semantics of the resulting {@link HybridKexContext} and determines which
|
||||
* builder inputs are required.
|
||||
* </p>
|
||||
*
|
||||
* @since 1.0
|
||||
*/
|
||||
private enum ClassicMode {
|
||||
|
||||
/**
|
||||
* Classic agreement where the peer public key is provided out-of-band and
|
||||
* configured via {@link AgreementContext#setPeerPublic(PublicKey)}.
|
||||
*
|
||||
* @since 1.0
|
||||
*/
|
||||
CLASSIC_AGREEMENT,
|
||||
|
||||
/**
|
||||
* Classic agreement where the classic public key is carried in-band as a
|
||||
* message produced/consumed via
|
||||
* {@link MessageAgreementContext#getPeerMessage()} and
|
||||
* {@link MessageAgreementContext#setPeerMessage(byte[])}.
|
||||
*
|
||||
* @since 1.0
|
||||
*/
|
||||
PAIR_MESSAGE
|
||||
}
|
||||
|
||||
/**
|
||||
* Configurator for the classic leg in {@link ClassicMode#CLASSIC_AGREEMENT}
|
||||
* mode.
|
||||
*
|
||||
* <p>
|
||||
* Required inputs before building:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>{@link #algorithm(String)} - classic algorithm id (for example
|
||||
* {@code "Xdh"})</li>
|
||||
* <li>{@link #privateKey(PrivateKey)} - local classic private key</li>
|
||||
* <li>{@link #peerPublic(PublicKey)} - peer classic public key
|
||||
* (out-of-band)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* Optional inputs:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>{@link #spec(ContextSpec)} - algorithm-specific context spec (for example
|
||||
* {@code XdhSpec.X25519})</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* After configuring the classic leg, continue with {@link #pqcKem()} to
|
||||
* configure the PQC leg.
|
||||
* </p>
|
||||
*
|
||||
* @since 1.0
|
||||
*/
|
||||
public static final class ClassicAgreement {
|
||||
private final HybridKexBuilder parent;
|
||||
|
||||
private ClassicAgreement(HybridKexBuilder parent) {
|
||||
this.parent = parent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the classic agreement algorithm identifier.
|
||||
*
|
||||
* <p>
|
||||
* Example for X25519 in this project: use {@code "Xdh"} with
|
||||
* {@code XdhSpec.X25519}.
|
||||
* </p>
|
||||
*
|
||||
* @param algId algorithm id (must not be null)
|
||||
* @return this configurator
|
||||
* @throws NullPointerException if {@code algId} is null
|
||||
* @since 1.0
|
||||
*/
|
||||
public ClassicAgreement algorithm(String algId) {
|
||||
parent.classicAlgId = Objects.requireNonNull(algId, "algId");
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the classic context spec (algorithm parameters).
|
||||
*
|
||||
* @param spec spec (may be null if the algorithm provides a default)
|
||||
* @return this configurator
|
||||
* @since 1.0
|
||||
*/
|
||||
public ClassicAgreement spec(ContextSpec spec) {
|
||||
parent.classicSpec = spec;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the local private key for the classic leg.
|
||||
*
|
||||
* @param key private key (must not be null)
|
||||
* @return this configurator
|
||||
* @throws NullPointerException if {@code key} is null
|
||||
* @since 1.0
|
||||
*/
|
||||
public ClassicAgreement privateKey(PrivateKey key) {
|
||||
parent.classicPrivate = Objects.requireNonNull(key, "key");
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the peer public key for the classic leg.
|
||||
*
|
||||
* <p>
|
||||
* In {@link ClassicMode#CLASSIC_AGREEMENT} this key is assumed to be obtained
|
||||
* out-of-band.
|
||||
* </p>
|
||||
*
|
||||
* @param key peer public key (must not be null)
|
||||
* @return this configurator
|
||||
* @throws NullPointerException if {@code key} is null
|
||||
* @since 1.0
|
||||
*/
|
||||
public ClassicAgreement peerPublic(PublicKey key) {
|
||||
parent.classicPeerPublic = Objects.requireNonNull(key, "key");
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Continues with PQC (KEM adapter) leg configuration.
|
||||
*
|
||||
* @return PQC configurator
|
||||
* @since 1.0
|
||||
*/
|
||||
public PqcKem pqcKem() {
|
||||
return parent.pqcKem();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configurator for the classic leg in {@link ClassicMode#PAIR_MESSAGE} mode.
|
||||
*
|
||||
* <p>
|
||||
* Required inputs before building:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>{@link #algorithm(String)} - classic algorithm id (for example
|
||||
* {@code "Xdh"})</li>
|
||||
* <li>{@link #keyPair(KeyPairKey)} - local classic key pair wrapped as
|
||||
* {@link KeyPairKey}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* Optional inputs:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>{@link #spec(ContextSpec)} - algorithm-specific context spec (for example
|
||||
* {@code XdhSpec.X25519})</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* In this mode, the classic leg contributes a public-key message (typically
|
||||
* SPKI bytes) to the hybrid peer message. The peer public key is therefore
|
||||
* learned from {@link HybridKexContext#setPeerMessage(byte[])} rather than
|
||||
* being supplied out-of-band.
|
||||
* </p>
|
||||
*
|
||||
* @since 1.0
|
||||
*/
|
||||
public static final class ClassicPairMessage {
|
||||
private final HybridKexBuilder parent;
|
||||
|
||||
private ClassicPairMessage(HybridKexBuilder parent) {
|
||||
this.parent = parent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the classic agreement algorithm identifier.
|
||||
*
|
||||
* @param algId algorithm id (must not be null)
|
||||
* @return this configurator
|
||||
* @throws NullPointerException if {@code algId} is null
|
||||
* @since 1.0
|
||||
*/
|
||||
public ClassicPairMessage algorithm(String algId) {
|
||||
parent.classicAlgId = Objects.requireNonNull(algId, "algId");
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the classic context spec (algorithm parameters).
|
||||
*
|
||||
* @param spec spec (may be null if the algorithm provides a default)
|
||||
* @return this configurator
|
||||
* @since 1.0
|
||||
*/
|
||||
public ClassicPairMessage spec(ContextSpec spec) {
|
||||
parent.classicSpec = spec;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the local classic key pair.
|
||||
*
|
||||
* <p>
|
||||
* The key pair is wrapped into {@link KeyPairKey} to match the
|
||||
* {@code PAIR_MESSAGE} capability registered in core.
|
||||
* </p>
|
||||
*
|
||||
* @param keyPair key pair wrapper (must not be null)
|
||||
* @return this configurator
|
||||
* @throws NullPointerException if {@code keyPair} is null
|
||||
* @since 1.0
|
||||
*/
|
||||
public ClassicPairMessage keyPair(KeyPairKey keyPair) {
|
||||
parent.classicKeyPair = Objects.requireNonNull(keyPair, "keyPair");
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Continues with PQC (KEM adapter) leg configuration.
|
||||
*
|
||||
* @return PQC configurator
|
||||
* @since 1.0
|
||||
*/
|
||||
public PqcKem pqcKem() {
|
||||
return parent.pqcKem();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configurator for the PQC leg (KEM adapter).
|
||||
*
|
||||
* <p>
|
||||
* Required inputs differ by role:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li><b>Initiator</b> requires {@link #peerPublic(PublicKey)} (recipient PQC
|
||||
* public key).</li>
|
||||
* <li><b>Responder</b> requires {@link #privateKey(PrivateKey)} (recipient PQC
|
||||
* private key).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The PQC leg is always message-based: initiator produces a peer message
|
||||
* (ciphertext) and responder consumes it. The hybrid context transports this
|
||||
* payload as the PQC part of the hybrid message.
|
||||
* </p>
|
||||
*
|
||||
* @since 1.0
|
||||
*/
|
||||
public static final class PqcKem {
|
||||
private final HybridKexBuilder parent;
|
||||
|
||||
private PqcKem(HybridKexBuilder parent) {
|
||||
this.parent = parent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the PQC algorithm identifier.
|
||||
*
|
||||
* @param algId algorithm id (must not be null)
|
||||
* @return this configurator
|
||||
* @throws NullPointerException if {@code algId} is null
|
||||
* @since 1.0
|
||||
*/
|
||||
public PqcKem algorithm(String algId) {
|
||||
parent.pqcAlgId = Objects.requireNonNull(algId, "algId");
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the PQC context spec (algorithm parameters).
|
||||
*
|
||||
* @param spec spec (may be null if the algorithm provides a default)
|
||||
* @return this configurator
|
||||
* @since 1.0
|
||||
*/
|
||||
public PqcKem spec(ContextSpec spec) {
|
||||
parent.pqcSpec = spec;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the recipient PQC public key for initiator-side construction.
|
||||
*
|
||||
* @param key recipient public key (must not be null)
|
||||
* @return this configurator
|
||||
* @throws NullPointerException if {@code key} is null
|
||||
* @since 1.0
|
||||
*/
|
||||
public PqcKem peerPublic(PublicKey key) {
|
||||
parent.pqcPeerPublic = Objects.requireNonNull(key, "key");
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the recipient PQC private key for responder-side construction.
|
||||
*
|
||||
* @param key recipient private key (must not be null)
|
||||
* @return this configurator
|
||||
* @throws NullPointerException if {@code key} is null
|
||||
* @since 1.0
|
||||
*/
|
||||
public PqcKem privateKey(PrivateKey key) {
|
||||
parent.pqcPrivate = Objects.requireNonNull(key, "key");
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an initiator-side {@link HybridKexContext} using the current builder
|
||||
* configuration.
|
||||
*
|
||||
* @return initiator context
|
||||
* @throws IOException if underlying context creation fails
|
||||
* @throws IllegalStateException if required configuration for initiator role is
|
||||
* missing
|
||||
* @since 1.0
|
||||
*/
|
||||
public HybridKexContext buildInitiator() throws IOException {
|
||||
return parent.buildInitiator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a responder-side {@link HybridKexContext} using the current builder
|
||||
* configuration.
|
||||
*
|
||||
* @return responder context
|
||||
* @throws IOException if underlying context creation fails
|
||||
* @throws IllegalStateException if required configuration for responder role is
|
||||
* missing
|
||||
* @since 1.0
|
||||
*/
|
||||
public HybridKexContext buildResponder() throws IOException {
|
||||
return parent.buildResponder();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
463
lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexContext.java
Normal file
463
lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexContext.java
Normal file
@@ -0,0 +1,463 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2025, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software
|
||||
* without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.sdk.hybrid.kex;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.Key;
|
||||
import java.security.PublicKey;
|
||||
import java.util.Objects;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import zeroecho.core.CryptoAlgorithm;
|
||||
import zeroecho.core.context.AgreementContext;
|
||||
import zeroecho.core.context.MessageAgreementContext;
|
||||
import zeroecho.sdk.util.Kdf;
|
||||
|
||||
/**
|
||||
* Hybrid key exchange (KEX) context that combines a classic agreement and a
|
||||
* post-quantum KEM-style agreement.
|
||||
*
|
||||
* <p>
|
||||
* This context composes two independent shared secrets:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li><b>Classic leg</b>: a {@link AgreementContext} (e.g. X25519, ECDH, DH)
|
||||
* that derives a raw shared secret after the peer public key is
|
||||
* configured.</li>
|
||||
* <li><b>PQC leg</b>: a {@link MessageAgreementContext} (typically ML-KEM
|
||||
* exposed via core capabilities) whose "peer message" represents the
|
||||
* encapsulation/decapsulation payload (e.g. KEM ciphertext).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Wire format</h2>
|
||||
* <p>
|
||||
* This class implements {@link MessageAgreementContext} to provide a single
|
||||
* hybrid message that can be transmitted between parties. The encoding is:
|
||||
* </p>
|
||||
* <pre>{@code
|
||||
* [int classicLen][classicBytes...][int pqcLen][pqcBytes...]
|
||||
* }</pre>
|
||||
*
|
||||
* <p>
|
||||
* For typical pairings such as {@code X25519 + ML-KEM}, the classic part is
|
||||
* empty because the classic leg is configured using the peer public key
|
||||
* out-of-band. If the classic leg itself is message-capable, the classic part
|
||||
* may be populated.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Key derivation</h2>
|
||||
* <p>
|
||||
* The final keying material is derived using HKDF-SHA256 (RFC 5869):
|
||||
* </p>
|
||||
* <pre>{@code
|
||||
* classicSS = classic.deriveSecret()
|
||||
* pqcSS = pqc.deriveSecret()
|
||||
* IKM = classicSS || pqcSS
|
||||
* OKM = HKDF-SHA256(IKM, salt, info, outLen)
|
||||
* }</pre>
|
||||
*
|
||||
* <p>
|
||||
* Intermediate raw secrets are treated as sensitive and are zeroized
|
||||
* (best-effort) once HKDF completes.
|
||||
* </p>
|
||||
*
|
||||
* <h2>CryptoContext identity</h2>
|
||||
* <p>
|
||||
* A hybrid exchange is inherently bound to multiple algorithms and keys. The
|
||||
* {@link #algorithm()} and {@link #key()} methods return representative values
|
||||
* from the classic leg to satisfy the
|
||||
* {@link zeroecho.core.context.CryptoContext} contract. Callers that require
|
||||
* full introspection should retain references to the underlying component
|
||||
* contexts.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Error handling</h2>
|
||||
* <ul>
|
||||
* <li>Malformed hybrid messages cause {@link IllegalArgumentException} in
|
||||
* {@link #setPeerMessage(byte[])}.</li>
|
||||
* <li>HKDF failures are surfaced as {@link IllegalStateException} in
|
||||
* {@link #deriveSecret()}.</li>
|
||||
* <li>{@link #close()} closes both component contexts and aggregates
|
||||
* {@link IOException}s via suppressed exceptions.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Thread safety</h2>
|
||||
* <p>
|
||||
* Instances are mutable and not thread-safe; use one instance per
|
||||
* handshake/session and thread.
|
||||
* </p>
|
||||
*
|
||||
* @since 1.0
|
||||
*/
|
||||
public final class HybridKexContext implements MessageAgreementContext {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(HybridKexContext.class.getName());
|
||||
|
||||
private final HybridKexProfile profile;
|
||||
private final AgreementContext classic;
|
||||
private final MessageAgreementContext pqc;
|
||||
|
||||
private byte[] peerMessage;
|
||||
|
||||
/**
|
||||
* Creates a hybrid KEX context by composing two underlying contexts.
|
||||
*
|
||||
* @param profile hybrid profile defining HKDF binding and output length
|
||||
* @param classic classic agreement context (must not be {@code null})
|
||||
* @param pqc PQC message agreement context (must not be {@code null})
|
||||
* @throws NullPointerException if any argument is {@code null}
|
||||
* @since 1.0
|
||||
*/
|
||||
public HybridKexContext(HybridKexProfile profile, AgreementContext classic, MessageAgreementContext pqc) {
|
||||
this.profile = Objects.requireNonNull(profile, "profile");
|
||||
this.classic = Objects.requireNonNull(classic, "classic");
|
||||
this.pqc = Objects.requireNonNull(pqc, "pqc");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the representative algorithm of this context.
|
||||
*
|
||||
* <p>
|
||||
* This is currently delegated to the classic leg. Hybrid constructions bind
|
||||
* multiple algorithms; callers that need full visibility should track both legs
|
||||
* explicitly.
|
||||
* </p>
|
||||
*
|
||||
* @return representative algorithm (classic leg)
|
||||
* @since 1.0
|
||||
*/
|
||||
@Override
|
||||
public CryptoAlgorithm algorithm() {
|
||||
return classic.algorithm();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the representative key of this context.
|
||||
*
|
||||
* <p>
|
||||
* This is delegated to the classic leg to satisfy the {@code CryptoContext}
|
||||
* contract. The hybrid KEX also depends on the PQC leg keys; callers should
|
||||
* store those keys separately if needed by the application.
|
||||
* </p>
|
||||
*
|
||||
* @return representative key (classic leg)
|
||||
* @since 1.0
|
||||
*/
|
||||
@Override
|
||||
public Key key() {
|
||||
return classic.key();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the peer public key for the classic leg.
|
||||
*
|
||||
* <p>
|
||||
* This is the usual configuration step for classic DH-style agreements. The PQC
|
||||
* leg typically does not use peer public keys through this method; however, the
|
||||
* call is forwarded on a best-effort basis for implementations that accept it.
|
||||
* </p>
|
||||
*
|
||||
* @param peer peer public key for the classic agreement
|
||||
* @since 1.0
|
||||
*/
|
||||
@Override
|
||||
public void setPeerPublic(PublicKey peer) {
|
||||
classic.setPeerPublic(peer);
|
||||
try {
|
||||
pqc.setPeerPublic(peer);
|
||||
} catch (RuntimeException ignore) { // NOPMD
|
||||
// KEM-style agreements usually do not accept peer public here.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supplies the hybrid peer message received from the remote party.
|
||||
*
|
||||
* <p>
|
||||
* The message is decoded into its classic and PQC parts. The PQC part is always
|
||||
* forwarded to the PQC leg via
|
||||
* {@link MessageAgreementContext#setPeerMessage(byte[])}. The classic part is
|
||||
* forwarded only if the classic leg also implements
|
||||
* {@link MessageAgreementContext}; otherwise it is ignored.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* Passing {@code null} resets the peer-message related state of both legs
|
||||
* (best-effort).
|
||||
* </p>
|
||||
*
|
||||
* @param message hybrid message, or {@code null} to reset
|
||||
* @throws IllegalArgumentException if the provided message does not conform to
|
||||
* the hybrid encoding
|
||||
* @since 1.0
|
||||
*/
|
||||
@Override
|
||||
public void setPeerMessage(byte[] message) {
|
||||
if (message == null) {
|
||||
this.peerMessage = null;
|
||||
try {
|
||||
pqc.setPeerMessage(null);
|
||||
} catch (RuntimeException ignore) { // NOPMD
|
||||
}
|
||||
try {
|
||||
setClassicMessageIfSupported(null);
|
||||
} catch (RuntimeException ignore) { // NOPMD
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Parts parts;
|
||||
try {
|
||||
parts = decode(message);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalArgumentException("Invalid hybrid peer message encoding", e);
|
||||
}
|
||||
|
||||
this.peerMessage = message.clone();
|
||||
|
||||
try {
|
||||
setClassicMessageIfSupported(parts.classicPart());
|
||||
} catch (RuntimeException ignore) { // NOPMD
|
||||
// classic leg may be non-message agreement; ignore
|
||||
}
|
||||
|
||||
byte[] pqcPart = parts.pqcPart();
|
||||
if (pqcPart != null && pqcPart.length > 0) {
|
||||
pqc.setPeerMessage(pqcPart);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the hybrid message to be sent to the peer.
|
||||
*
|
||||
* <p>
|
||||
* The PQC leg typically produces a non-empty message (encapsulation ciphertext)
|
||||
* for initiator roles. The classic leg contributes an empty message unless it
|
||||
* supports message mode.
|
||||
* </p>
|
||||
*
|
||||
* @return encoded hybrid peer message
|
||||
* @throws IllegalStateException if encoding fails or the PQC leg cannot produce
|
||||
* a message in the current role
|
||||
* @since 1.0
|
||||
*/
|
||||
@Override
|
||||
public byte[] getPeerMessage() {
|
||||
byte[] classicMsg = getClassicMessageIfSupported();
|
||||
byte[] pqcMsg;
|
||||
try {
|
||||
pqcMsg = pqc.getPeerMessage();
|
||||
} catch (RuntimeException e) { // NOPMD
|
||||
// Responder-side KEM leg typically does not produce an outbound message.
|
||||
pqcMsg = new byte[0];
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] msg = encode(classicMsg, pqcMsg);
|
||||
this.peerMessage = msg.clone();
|
||||
return msg;
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Unable to encode hybrid peer message", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the final hybrid shared secret (OKM) using HKDF-SHA256.
|
||||
*
|
||||
* <p>
|
||||
* This method derives both component secrets and then performs HKDF over their
|
||||
* concatenation. Higher-level protocols should additionally bind
|
||||
* transcript/context data through the HKDF {@code info} parameter
|
||||
* ({@link HybridKexProfile#hkdfInfo()}).
|
||||
* </p>
|
||||
*
|
||||
* @return derived keying material (OKM) of length
|
||||
* {@link HybridKexProfile#outLenBytes()}
|
||||
* @throws IllegalStateException if HKDF fails or underlying contexts are not
|
||||
* configured
|
||||
* @since 1.0
|
||||
*/
|
||||
@Override
|
||||
public byte[] deriveSecret() {
|
||||
byte[] classicSs = classic.deriveSecret();
|
||||
byte[] pqcSs = pqc.deriveSecret();
|
||||
|
||||
byte[] ikm = new byte[classicSs.length + pqcSs.length];
|
||||
System.arraycopy(classicSs, 0, ikm, 0, classicSs.length);
|
||||
System.arraycopy(pqcSs, 0, ikm, classicSs.length, pqcSs.length);
|
||||
|
||||
try {
|
||||
byte[] out = Kdf.hkdfSha256(ikm, profile.hkdfSalt(), profile.hkdfInfo(), profile.outLenBytes());
|
||||
if (LOG.isLoggable(Level.FINE)) {
|
||||
LOG.fine("HybridKexContext.deriveSecret(): derived OKM length=" + out.length);
|
||||
}
|
||||
return out;
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new IllegalStateException("HKDF-SHA256 failed", e);
|
||||
} finally {
|
||||
zeroize(classicSs);
|
||||
zeroize(pqcSs);
|
||||
zeroize(ikm);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes both underlying contexts.
|
||||
*
|
||||
* <p>
|
||||
* If closing the second context fails after the first one already failed, the
|
||||
* second failure is added as a suppressed exception on the first one.
|
||||
* </p>
|
||||
*
|
||||
* @throws IOException if closing either leg fails
|
||||
* @since 1.0
|
||||
*/
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
IOException first = null;
|
||||
|
||||
try {
|
||||
classic.close();
|
||||
} catch (IOException e) {
|
||||
first = e;
|
||||
}
|
||||
|
||||
try {
|
||||
pqc.close();
|
||||
} catch (IOException e) {
|
||||
if (first == null) {
|
||||
first = e;
|
||||
} else {
|
||||
first.addSuppressed(e);
|
||||
}
|
||||
}
|
||||
|
||||
if (first != null) {
|
||||
throw first;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last hybrid peer message that was produced or set on this
|
||||
* instance.
|
||||
*
|
||||
* <p>
|
||||
* This is intended for diagnostics and testing. The returned array is a
|
||||
* defensive copy.
|
||||
* </p>
|
||||
*
|
||||
* @return last hybrid message, or {@code null} if none has been produced or set
|
||||
* @since 1.0
|
||||
*/
|
||||
public byte[] lastPeerMessageOrNull() {
|
||||
return (peerMessage == null) ? null : peerMessage.clone();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Encoding helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static byte[] encode(byte[] classicMsg, byte[] pqcMsg) throws IOException {
|
||||
byte[] c = (classicMsg == null) ? new byte[0] : classicMsg;
|
||||
byte[] p = (pqcMsg == null) ? new byte[0] : pqcMsg;
|
||||
|
||||
ByteArrayOutputStream bout = new ByteArrayOutputStream();
|
||||
DataOutputStream out = new DataOutputStream(bout);
|
||||
|
||||
out.writeInt(c.length);
|
||||
out.write(c);
|
||||
|
||||
out.writeInt(p.length);
|
||||
out.write(p);
|
||||
|
||||
out.flush();
|
||||
return bout.toByteArray();
|
||||
}
|
||||
|
||||
private static Parts decode(byte[] msg) throws IOException {
|
||||
DataInputStream in = new DataInputStream(new ByteArrayInputStream(msg));
|
||||
|
||||
int cLen = in.readInt();
|
||||
if (cLen < 0) {
|
||||
throw new IOException("negative classic length");
|
||||
}
|
||||
byte[] c = new byte[cLen];
|
||||
in.readFully(c);
|
||||
|
||||
int pLen = in.readInt();
|
||||
if (pLen < 0) {
|
||||
throw new IOException("negative pqc length");
|
||||
}
|
||||
byte[] p = new byte[pLen];
|
||||
in.readFully(p);
|
||||
|
||||
return new Parts(c, p);
|
||||
}
|
||||
|
||||
private static void zeroize(byte[] b) {
|
||||
if (b == null) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < b.length; i++) {
|
||||
b[i] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] getClassicMessageIfSupported() {
|
||||
if (classic instanceof MessageAgreementContext) {
|
||||
try {
|
||||
return ((MessageAgreementContext) classic).getPeerMessage();
|
||||
} catch (RuntimeException ignore) { // NOPMD
|
||||
return new byte[0];
|
||||
}
|
||||
}
|
||||
return new byte[0];
|
||||
}
|
||||
|
||||
private void setClassicMessageIfSupported(byte[] msg) {
|
||||
if (classic instanceof MessageAgreementContext) {
|
||||
((MessageAgreementContext) classic).setPeerMessage(msg);
|
||||
}
|
||||
}
|
||||
|
||||
private record Parts(byte[] classicPart, byte[] pqcPart) {
|
||||
}
|
||||
}
|
||||
314
lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexContexts.java
Normal file
314
lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexContexts.java
Normal file
@@ -0,0 +1,314 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2025, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software
|
||||
* without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.sdk.hybrid.kex;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.Objects;
|
||||
|
||||
import zeroecho.core.CryptoAlgorithms;
|
||||
import zeroecho.core.KeyUsage;
|
||||
import zeroecho.core.context.AgreementContext;
|
||||
import zeroecho.core.context.MessageAgreementContext;
|
||||
import zeroecho.core.spec.ContextSpec;
|
||||
|
||||
/**
|
||||
* Factory utilities for constructing hybrid key exchange (KEX) contexts.
|
||||
*
|
||||
* <p>
|
||||
* This class implements the SDK-level composition layer for hybrid key
|
||||
* exchange. It does not introduce new core contracts; instead, it composes
|
||||
* existing core contexts:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>{@link AgreementContext} for the classic (pre-quantum) leg (e.g. X25519,
|
||||
* ECDH, DH), and</li>
|
||||
* <li>{@link MessageAgreementContext} for the post-quantum leg (typically a
|
||||
* KEM-style agreement, e.g. ML-KEM exposed as {@code KeyUsage.AGREEMENT}).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Handshake model</h2>
|
||||
* <p>
|
||||
* The two legs have different wire semantics:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li><b>Classic leg</b> generally uses a peer public key supplied out-of-band
|
||||
* (certificate, directory, or a higher-level handshake message). This is
|
||||
* represented by
|
||||
* {@link AgreementContext#setPeerPublic(java.security.PublicKey)} and does not
|
||||
* necessarily produce any explicit "to-be-sent" bytes.</li>
|
||||
* <li><b>PQC leg</b> is message-based: the initiator produces an encapsulation
|
||||
* message (e.g. KEM ciphertext) via
|
||||
* {@link MessageAgreementContext#getPeerMessage()}, and the responder consumes
|
||||
* it via {@link MessageAgreementContext#setPeerMessage(byte[])}.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* {@link HybridKexContext} unifies these semantics by emitting and consuming a
|
||||
* single hybrid peer message that carries both legs (with the classic part
|
||||
* typically empty for classic-only public-key exchange).
|
||||
* </p>
|
||||
*
|
||||
* <h2>Error handling</h2>
|
||||
* <p>
|
||||
* Underlying context construction uses
|
||||
* {@link CryptoAlgorithms#create(String, KeyUsage, java.security.Key, Object)}
|
||||
* which may throw {@link IOException}. These factory methods propagate the
|
||||
* checked exception to keep failures explicit and auditable.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Thread safety</h2>
|
||||
* <p>
|
||||
* This factory is stateless and thread-safe. Produced contexts are mutable and
|
||||
* not thread-safe.
|
||||
* </p>
|
||||
*
|
||||
* @since 1.0
|
||||
*/
|
||||
public final class HybridKexContexts {
|
||||
|
||||
private HybridKexContexts() {
|
||||
// utility
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an initiator-side hybrid KEX context.
|
||||
*
|
||||
* <p>
|
||||
* This method constructs:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>a classic {@link AgreementContext} from the initiator's classic
|
||||
* {@link PrivateKey} and configures it with the peer's classic
|
||||
* {@link PublicKey}, and</li>
|
||||
* <li>a PQC {@link MessageAgreementContext} from the peer's PQC
|
||||
* {@link PublicKey} (encapsulation side).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The returned {@link HybridKexContext} will typically produce a peer message
|
||||
* whose PQC part is non-empty (e.g. KEM ciphertext), while the classic part is
|
||||
* empty unless the chosen classic implementation itself supports message mode.
|
||||
* </p>
|
||||
*
|
||||
* @param profile hybrid profile defining HKDF binding and
|
||||
* output length
|
||||
* @param classicAlgId classic agreement algorithm identifier (for
|
||||
* example {@code "X25519"}, {@code "ECDH"},
|
||||
* {@code "DH"})
|
||||
* @param classicInitiatorPrivate initiator private key for the classic leg
|
||||
* @param classicPeerPublic peer public key for the classic leg
|
||||
* @param classicSpec optional classic context spec (may be
|
||||
* {@code null} if the algorithm supports a
|
||||
* default)
|
||||
* @param pqcAlgId post-quantum agreement algorithm identifier
|
||||
* (for example {@code "ML-KEM"} exposed via
|
||||
* {@code KeyUsage.AGREEMENT})
|
||||
* @param pqcPeerPublic peer public key for the PQC leg (encapsulation
|
||||
* side)
|
||||
* @param pqcSpec optional PQC context spec (may be {@code null}
|
||||
* if the algorithm supports a default)
|
||||
* @return initiator-side {@link HybridKexContext}
|
||||
* @throws NullPointerException if any required argument is {@code null}
|
||||
* @throws IOException if underlying context creation fails
|
||||
* @since 1.0
|
||||
*/
|
||||
public static HybridKexContext initiator(HybridKexProfile profile, String classicAlgId,
|
||||
PrivateKey classicInitiatorPrivate, PublicKey classicPeerPublic, ContextSpec classicSpec, String pqcAlgId,
|
||||
PublicKey pqcPeerPublic, ContextSpec pqcSpec) throws IOException {
|
||||
|
||||
Objects.requireNonNull(profile, "profile");
|
||||
Objects.requireNonNull(classicAlgId, "classicAlgId");
|
||||
Objects.requireNonNull(classicInitiatorPrivate, "classicInitiatorPrivate");
|
||||
Objects.requireNonNull(classicPeerPublic, "classicPeerPublic");
|
||||
Objects.requireNonNull(pqcAlgId, "pqcAlgId");
|
||||
Objects.requireNonNull(pqcPeerPublic, "pqcPeerPublic");
|
||||
|
||||
AgreementContext classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT, classicInitiatorPrivate,
|
||||
classicSpec);
|
||||
classic.setPeerPublic(classicPeerPublic);
|
||||
|
||||
MessageAgreementContext pqc = CryptoAlgorithms.create(pqcAlgId, KeyUsage.AGREEMENT, pqcPeerPublic, pqcSpec);
|
||||
|
||||
return new HybridKexContext(profile, classic, pqc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a responder-side hybrid KEX context.
|
||||
*
|
||||
* <p>
|
||||
* This method constructs:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>a classic {@link AgreementContext} from the responder's classic
|
||||
* {@link PrivateKey} and configures it with the peer's classic
|
||||
* {@link PublicKey}, and</li>
|
||||
* <li>a PQC {@link MessageAgreementContext} from the responder's PQC
|
||||
* {@link PrivateKey} (decapsulation side).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The returned {@link HybridKexContext} is typically used after receiving the
|
||||
* initiator's hybrid peer message and passing it to
|
||||
* {@link HybridKexContext#setPeerMessage(byte[])}.
|
||||
* </p>
|
||||
*
|
||||
* @param profile hybrid profile defining HKDF binding and
|
||||
* output length
|
||||
* @param classicAlgId classic agreement algorithm identifier
|
||||
* @param classicResponderPrivate responder private key for the classic leg
|
||||
* @param classicPeerPublic peer public key for the classic leg
|
||||
* @param classicSpec optional classic context spec (may be
|
||||
* {@code null})
|
||||
* @param pqcAlgId post-quantum agreement algorithm identifier
|
||||
* @param pqcResponderPrivate responder private key for the PQC leg
|
||||
* (decapsulation side)
|
||||
* @param pqcSpec optional PQC context spec (may be
|
||||
* {@code null})
|
||||
* @return responder-side {@link HybridKexContext}
|
||||
* @throws NullPointerException if any required argument is {@code null}
|
||||
* @throws IOException if underlying context creation fails
|
||||
* @since 1.0
|
||||
*/
|
||||
public static HybridKexContext responder(HybridKexProfile profile, String classicAlgId,
|
||||
PrivateKey classicResponderPrivate, PublicKey classicPeerPublic, ContextSpec classicSpec, String pqcAlgId,
|
||||
PrivateKey pqcResponderPrivate, ContextSpec pqcSpec) throws IOException {
|
||||
|
||||
Objects.requireNonNull(profile, "profile");
|
||||
Objects.requireNonNull(classicAlgId, "classicAlgId");
|
||||
Objects.requireNonNull(classicResponderPrivate, "classicResponderPrivate");
|
||||
Objects.requireNonNull(classicPeerPublic, "classicPeerPublic");
|
||||
Objects.requireNonNull(pqcAlgId, "pqcAlgId");
|
||||
Objects.requireNonNull(pqcResponderPrivate, "pqcResponderPrivate");
|
||||
|
||||
AgreementContext classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT, classicResponderPrivate,
|
||||
classicSpec);
|
||||
classic.setPeerPublic(classicPeerPublic);
|
||||
|
||||
MessageAgreementContext pqc = CryptoAlgorithms.create(pqcAlgId, KeyUsage.AGREEMENT, pqcResponderPrivate,
|
||||
pqcSpec);
|
||||
|
||||
return new HybridKexContext(profile, classic, pqc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an initiator-side hybrid KEX where the classic leg is message-based
|
||||
* (PAIR_MESSAGE).
|
||||
*
|
||||
* <p>
|
||||
* The classic message is the SPKI encoding of the initiator's classic public
|
||||
* key as produced by the underlying {@link MessageAgreementContext}. The PQC
|
||||
* message is the PQC encapsulation payload (typically KEM ciphertext).
|
||||
* </p>
|
||||
*
|
||||
* @param profile hybrid profile defining HKDF binding and
|
||||
* output length
|
||||
* @param classicAlgId classic agreement algorithm identifier (e.g.
|
||||
* "X25519", "ECDH", "DH")
|
||||
* @param classicInitiatorKeyPair classic initiator key pair wrapped as
|
||||
* {@code KeyPairKey}
|
||||
* @param classicSpec classic context spec (may be {@code null} if
|
||||
* supported)
|
||||
* @param pqcAlgId post-quantum agreement algorithm identifier
|
||||
* (e.g. "ML-KEM")
|
||||
* @param pqcPeerPublic PQC peer public key (encapsulation side)
|
||||
* @param pqcSpec optional PQC context spec (may be
|
||||
* {@code null})
|
||||
* @return initiator-side {@link HybridKexContext}
|
||||
* @throws NullPointerException if any required argument is {@code null}
|
||||
* @throws IOException if underlying context creation fails
|
||||
*/
|
||||
public static HybridKexContext initiatorPairMessage(HybridKexProfile profile, String classicAlgId,
|
||||
zeroecho.core.alg.common.agreement.KeyPairKey classicInitiatorKeyPair, ContextSpec classicSpec,
|
||||
String pqcAlgId, PublicKey pqcPeerPublic, ContextSpec pqcSpec) throws IOException {
|
||||
|
||||
Objects.requireNonNull(profile, "profile");
|
||||
Objects.requireNonNull(classicAlgId, "classicAlgId");
|
||||
Objects.requireNonNull(classicInitiatorKeyPair, "classicInitiatorKeyPair");
|
||||
Objects.requireNonNull(pqcAlgId, "pqcAlgId");
|
||||
Objects.requireNonNull(pqcPeerPublic, "pqcPeerPublic");
|
||||
|
||||
MessageAgreementContext classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT,
|
||||
classicInitiatorKeyPair, classicSpec);
|
||||
|
||||
MessageAgreementContext pqc = CryptoAlgorithms.create(pqcAlgId, KeyUsage.AGREEMENT, pqcPeerPublic, pqcSpec);
|
||||
|
||||
return new HybridKexContext(profile, classic, pqc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a responder-side hybrid KEX where the classic leg is message-based
|
||||
* (PAIR_MESSAGE).
|
||||
*
|
||||
* <p>
|
||||
* The responder must call {@link HybridKexContext#setPeerMessage(byte[])} with
|
||||
* the initiator's hybrid message before calling
|
||||
* {@link HybridKexContext#deriveSecret()}.
|
||||
* </p>
|
||||
*
|
||||
* @param profile hybrid profile defining HKDF binding and
|
||||
* output length
|
||||
* @param classicAlgId classic agreement algorithm identifier
|
||||
* @param classicResponderKeyPair classic responder key pair wrapped as
|
||||
* {@code KeyPairKey}
|
||||
* @param classicSpec classic context spec (may be {@code null})
|
||||
* @param pqcAlgId post-quantum agreement algorithm identifier
|
||||
* @param pqcResponderPrivate PQC responder private key (decapsulation side)
|
||||
* @param pqcSpec optional PQC context spec (may be
|
||||
* {@code null})
|
||||
* @return responder-side {@link HybridKexContext}
|
||||
* @throws NullPointerException if any required argument is {@code null}
|
||||
* @throws IOException if underlying context creation fails
|
||||
*/
|
||||
public static HybridKexContext responderPairMessage(HybridKexProfile profile, String classicAlgId,
|
||||
zeroecho.core.alg.common.agreement.KeyPairKey classicResponderKeyPair, ContextSpec classicSpec,
|
||||
String pqcAlgId, PrivateKey pqcResponderPrivate, ContextSpec pqcSpec) throws IOException {
|
||||
|
||||
Objects.requireNonNull(profile, "profile");
|
||||
Objects.requireNonNull(classicAlgId, "classicAlgId");
|
||||
Objects.requireNonNull(classicResponderKeyPair, "classicResponderKeyPair");
|
||||
Objects.requireNonNull(pqcAlgId, "pqcAlgId");
|
||||
Objects.requireNonNull(pqcResponderPrivate, "pqcResponderPrivate");
|
||||
|
||||
MessageAgreementContext classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT,
|
||||
classicResponderKeyPair, classicSpec);
|
||||
|
||||
MessageAgreementContext pqc = CryptoAlgorithms.create(pqcAlgId, KeyUsage.AGREEMENT, pqcResponderPrivate,
|
||||
pqcSpec);
|
||||
|
||||
return new HybridKexContext(profile, classic, pqc);
|
||||
}
|
||||
}
|
||||
134
lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexExporter.java
Normal file
134
lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexExporter.java
Normal file
@@ -0,0 +1,134 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2025, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software
|
||||
* without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.sdk.hybrid.kex;
|
||||
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.util.Objects;
|
||||
|
||||
import zeroecho.sdk.util.Kdf;
|
||||
|
||||
/**
|
||||
* Deterministic exporter for deriving multiple independent keys from a single
|
||||
* hybrid KEX.
|
||||
*
|
||||
* <p>
|
||||
* Many protocols require more than one key from a single handshake (for
|
||||
* example, separate traffic keys for each direction, confirmation keys, or
|
||||
* application exporters). This class provides a minimal key schedule API over
|
||||
* HKDF-SHA256.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Construction</h2>
|
||||
* <p>
|
||||
* The exporter is seeded by a caller-supplied secret (typically the output of
|
||||
* {@link HybridKexContext#deriveSecret()}). The exporter then derives sub-keys
|
||||
* by varying HKDF {@code info}.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Security notes</h2>
|
||||
* <ul>
|
||||
* <li>Use distinct labels per purpose (for example {@code "app/tx"} vs
|
||||
* {@code "app/rx"}).</li>
|
||||
* <li>Bind transcript/context by including it in the {@code info} bytes.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @since 1.0
|
||||
*/
|
||||
public final class HybridKexExporter {
|
||||
|
||||
private final byte[] rootSecret;
|
||||
private final byte[] salt;
|
||||
|
||||
/**
|
||||
* Creates an exporter seeded from a root secret.
|
||||
*
|
||||
* @param rootSecret root secret (must not be null)
|
||||
* @param salt optional HKDF salt (may be null)
|
||||
*/
|
||||
public HybridKexExporter(byte[] rootSecret, byte[] salt) {
|
||||
Objects.requireNonNull(rootSecret, "rootSecret");
|
||||
this.rootSecret = rootSecret.clone();
|
||||
this.salt = (salt == null) ? null : salt.clone();
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives {@code outLenBytes} bytes for a specific purpose.
|
||||
*
|
||||
* @param label ASCII/UTF-8 label identifying the purpose (must not be
|
||||
* null)
|
||||
* @param info optional additional binding info (may be null)
|
||||
* @param outLenBytes output length in bytes (1..8160)
|
||||
* @return derived bytes
|
||||
* @throws IllegalArgumentException if outLenBytes is out of range
|
||||
*/
|
||||
public byte[] export(String label, byte[] info, int outLenBytes) {
|
||||
Objects.requireNonNull(label, "label");
|
||||
|
||||
if (outLenBytes < 1 || outLenBytes > 255 * 32) {
|
||||
throw new IllegalArgumentException("outLenBytes must be in range 1.." + (255 * 32));
|
||||
}
|
||||
|
||||
byte[] labelBytes = label.getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
byte[] infoUse;
|
||||
if (info == null || info.length == 0) {
|
||||
infoUse = labelBytes;
|
||||
} else {
|
||||
infoUse = new byte[labelBytes.length + 1 + info.length];
|
||||
System.arraycopy(labelBytes, 0, infoUse, 0, labelBytes.length);
|
||||
infoUse[labelBytes.length] = 0;
|
||||
System.arraycopy(info, 0, infoUse, labelBytes.length + 1, info.length);
|
||||
}
|
||||
|
||||
try {
|
||||
return Kdf.hkdfSha256(rootSecret, salt, infoUse, outLenBytes);
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new IllegalStateException("HKDF-SHA256 failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a defensive copy of the exporter root secret.
|
||||
*
|
||||
* <p>
|
||||
* This is intended for diagnostics/testing only. Applications should prefer
|
||||
* {@link #export(String, byte[], int)}.
|
||||
* </p>
|
||||
*
|
||||
* @return copy of root secret
|
||||
*/
|
||||
public byte[] rootSecretCopy() {
|
||||
return rootSecret.clone();
|
||||
}
|
||||
}
|
||||
124
lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexPolicy.java
Normal file
124
lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexPolicy.java
Normal file
@@ -0,0 +1,124 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2025, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software
|
||||
* without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.sdk.hybrid.kex;
|
||||
|
||||
import java.security.Key;
|
||||
import java.util.Objects;
|
||||
|
||||
import zeroecho.core.context.AgreementContext;
|
||||
import zeroecho.core.context.MessageAgreementContext;
|
||||
import zeroecho.core.policy.SecurityStrengthAdvisor;
|
||||
|
||||
/**
|
||||
* Hybrid KEX policy helper for minimum security strength enforcement.
|
||||
*
|
||||
* <p>
|
||||
* This policy applies additional hybrid-specific checks beyond per-algorithm
|
||||
* policy validation. It is intended to prevent accidental downgrade
|
||||
* combinations (for example a strong PQC leg combined with a too-weak classic
|
||||
* leg, or an undersized OKM output).
|
||||
* </p>
|
||||
*
|
||||
* <h2>What is checked</h2>
|
||||
* <ul>
|
||||
* <li>Classic leg estimated strength in bits (algorithm id + key)</li>
|
||||
* <li>PQC leg estimated strength in bits (algorithm id + key)</li>
|
||||
* <li>Minimum OKM length (in bytes) for the intended usage</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* Strength estimates are provided by
|
||||
* {@link SecurityStrengthAdvisor#estimateBits(String, Key)} and are
|
||||
* conservative heuristics suitable for gating and coarse comparisons.
|
||||
* </p>
|
||||
*
|
||||
* @since 1.0
|
||||
*/
|
||||
public final class HybridKexPolicy {
|
||||
|
||||
private final int minClassicBits;
|
||||
private final int minPqcBits;
|
||||
private final int minOkmBytes;
|
||||
|
||||
/**
|
||||
* Creates a hybrid policy.
|
||||
*
|
||||
* @param minClassicBits minimum estimated bits for classic leg (for example 128
|
||||
* or 192)
|
||||
* @param minPqcBits minimum estimated bits for PQC leg (for example 192)
|
||||
* @param minOkmBytes minimum OKM output length in bytes (for example 32)
|
||||
*/
|
||||
public HybridKexPolicy(int minClassicBits, int minPqcBits, int minOkmBytes) {
|
||||
if (minClassicBits < 0 || minPqcBits < 0) {
|
||||
throw new IllegalArgumentException("min bits must be non-negative");
|
||||
}
|
||||
if (minOkmBytes < 1) { // NOPMD
|
||||
throw new IllegalArgumentException("minOkmBytes must be >= 1");
|
||||
}
|
||||
this.minClassicBits = minClassicBits;
|
||||
this.minPqcBits = minPqcBits;
|
||||
this.minOkmBytes = minOkmBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces the policy for a given hybrid configuration.
|
||||
*
|
||||
* @param profile hybrid profile
|
||||
* @param classic classic agreement context
|
||||
* @param pqc PQC message agreement context
|
||||
* @throws NullPointerException if any argument is null
|
||||
* @throws IllegalArgumentException if policy is violated
|
||||
*/
|
||||
public void enforce(HybridKexProfile profile, AgreementContext classic, MessageAgreementContext pqc) {
|
||||
Objects.requireNonNull(profile, "profile");
|
||||
Objects.requireNonNull(classic, "classic");
|
||||
Objects.requireNonNull(pqc, "pqc");
|
||||
|
||||
if (profile.outLenBytes() < minOkmBytes) {
|
||||
throw new IllegalArgumentException(
|
||||
"Hybrid OKM length too small: " + profile.outLenBytes() + " < " + minOkmBytes);
|
||||
}
|
||||
|
||||
int classicBits = SecurityStrengthAdvisor.estimateBits(classic.algorithm().id(), classic.key());
|
||||
int pqcBits = SecurityStrengthAdvisor.estimateBits(pqc.algorithm().id(), pqc.key());
|
||||
|
||||
if (classicBits < minClassicBits) {
|
||||
throw new IllegalArgumentException("Classic leg too weak: " + classicBits + " < " + minClassicBits);
|
||||
}
|
||||
if (pqcBits < minPqcBits) {
|
||||
throw new IllegalArgumentException("PQC leg too weak: " + pqcBits + " < " + minPqcBits);
|
||||
}
|
||||
}
|
||||
}
|
||||
108
lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexProfile.java
Normal file
108
lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexProfile.java
Normal file
@@ -0,0 +1,108 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2025, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software
|
||||
* without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.sdk.hybrid.kex;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* Profile for a hybrid key exchange (KEX) composition.
|
||||
*
|
||||
* <p>
|
||||
* A hybrid KEX combines two independently derived secrets:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>a classic (pre-quantum) agreement secret produced by an
|
||||
* {@code AgreementContext} (e.g. X25519, ECDH, DH), and</li>
|
||||
* <li>a post-quantum agreement secret produced by a
|
||||
* {@code MessageAgreementContext} (typically a KEM-style agreement such as
|
||||
* ML-KEM).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The two secrets are combined using HKDF-SHA256 (RFC 5869) to produce a final
|
||||
* keying material byte array of a caller-selected length. The default label is
|
||||
* intended to make cross-protocol misuse harder by providing an explicit domain
|
||||
* separator.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Notes</h2>
|
||||
* <ul>
|
||||
* <li>This profile does not store algorithm identifiers on purpose; it focuses
|
||||
* on KDF binding and output size. Algorithm selection happens in higher layers
|
||||
* when constructing the two underlying contexts.</li>
|
||||
* <li>{@code salt} is optional; if null/empty, HKDF uses a zero-filled salt as
|
||||
* per RFC 5869 and {@link zeroecho.sdk.util.Kdf}.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param hkdfSalt optional HKDF salt (defensively copied), may be
|
||||
* {@code null}
|
||||
* @param hkdfInfo optional HKDF info/label (defensively copied), may be
|
||||
* {@code null}
|
||||
* @param outLenBytes length of derived keying material in bytes (1..8160)
|
||||
*
|
||||
* @since 1.0
|
||||
*/
|
||||
public record HybridKexProfile(byte[] hkdfSalt, byte[] hkdfInfo, int outLenBytes) {
|
||||
|
||||
/**
|
||||
* Default HKDF label used when the caller does not provide an explicit one.
|
||||
*/
|
||||
public static final byte[] DEFAULT_INFO = "ZeroEcho-HybridKEX".getBytes(StandardCharsets.US_ASCII);
|
||||
|
||||
/**
|
||||
* Constructs a profile with a default HKDF info label.
|
||||
*
|
||||
* @param outLenBytes output length in bytes
|
||||
* @return profile with default HKDF info label and no explicit salt
|
||||
*/
|
||||
public static HybridKexProfile defaultProfile(int outLenBytes) {
|
||||
return new HybridKexProfile(null, DEFAULT_INFO, outLenBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical constructor with validation and defensive copies.
|
||||
*
|
||||
* @param hkdfSalt optional HKDF salt
|
||||
* @param hkdfInfo optional HKDF info/label
|
||||
* @param outLenBytes output length in bytes
|
||||
*/
|
||||
public HybridKexProfile {
|
||||
if (outLenBytes < 1 || outLenBytes > 255 * 32) {
|
||||
throw new IllegalArgumentException("outLenBytes must be in range 1.." + (255 * 32));
|
||||
}
|
||||
hkdfSalt = (hkdfSalt == null) ? null : hkdfSalt.clone();
|
||||
hkdfInfo = (hkdfInfo == null) ? null : hkdfInfo.clone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2025, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software
|
||||
* without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.sdk.hybrid.kex;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Canonical transcript builder for binding hybrid KEX derivation to public
|
||||
* handshake context.
|
||||
*
|
||||
* <p>
|
||||
* The derived hybrid keying material should be bound to protocol context to
|
||||
* reduce risk of cross-protocol key reuse and "unknown key-share" style
|
||||
* mistakes. This builder provides a deterministic and self-delimiting binary
|
||||
* encoding intended to be used as HKDF {@code info}.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Encoding</h2>
|
||||
* <p>
|
||||
* The transcript is encoded as a sequence of TLV-like entries:
|
||||
* </p>
|
||||
* <pre>{@code
|
||||
* [u16 tagLen][tagBytes...][u32 valueLen][valueBytes...] ...
|
||||
* }</pre>
|
||||
*
|
||||
* <p>
|
||||
* Tags are ASCII identifiers (for example {@code "suite"}, {@code "role"},
|
||||
* {@code "peerA"}, {@code "peerB"}, {@code "classicMsg"}, {@code "pqcMsg"}).
|
||||
* Values are arbitrary bytes. The encoding is stable across JVMs and
|
||||
* independent of locale.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Thread safety</h2>
|
||||
* <p>
|
||||
* Instances are mutable and not thread-safe.
|
||||
* </p>
|
||||
*
|
||||
* @since 1.0
|
||||
*/
|
||||
public final class HybridKexTranscript {
|
||||
|
||||
private final ByteArrayOutputStream buffer;
|
||||
private final DataOutputStream out;
|
||||
|
||||
/**
|
||||
* Creates a new empty transcript.
|
||||
*/
|
||||
public HybridKexTranscript() {
|
||||
this.buffer = new ByteArrayOutputStream();
|
||||
this.out = new DataOutputStream(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a UTF-8 string value under a tag.
|
||||
*
|
||||
* @param tag ASCII tag identifier
|
||||
* @param value UTF-8 string value (must not be null)
|
||||
* @return this transcript
|
||||
* @throws NullPointerException if tag or value is null
|
||||
* @throws IllegalArgumentException if tag is empty
|
||||
*/
|
||||
public HybridKexTranscript addUtf8(String tag, String value) {
|
||||
Objects.requireNonNull(value, "value");
|
||||
return addBytes(tag, value.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a raw byte value under a tag.
|
||||
*
|
||||
* <p>
|
||||
* The value is defensively copied by the caller if needed; this method does not
|
||||
* retain a reference to the provided array.
|
||||
* </p>
|
||||
*
|
||||
* @param tag ASCII tag identifier
|
||||
* @param value byte value (must not be null)
|
||||
* @return this transcript
|
||||
* @throws NullPointerException if tag or value is null
|
||||
* @throws IllegalArgumentException if tag is empty
|
||||
*/
|
||||
public HybridKexTranscript addBytes(String tag, byte[] value) {
|
||||
Objects.requireNonNull(tag, "tag");
|
||||
Objects.requireNonNull(value, "value");
|
||||
if (tag.isEmpty()) {
|
||||
throw new IllegalArgumentException("tag must not be empty");
|
||||
}
|
||||
|
||||
byte[] tagBytes = tag.getBytes(StandardCharsets.US_ASCII);
|
||||
|
||||
try {
|
||||
if (tagBytes.length > 65_535) { // NOPMD
|
||||
throw new IllegalArgumentException("tag too long");
|
||||
}
|
||||
out.writeShort(tagBytes.length);
|
||||
out.write(tagBytes);
|
||||
out.writeInt(value.length);
|
||||
out.write(value);
|
||||
out.flush();
|
||||
return this;
|
||||
} catch (IOException e) {
|
||||
// ByteArrayOutputStream should not throw, but keep failure explicit.
|
||||
throw new IllegalStateException("Unable to encode transcript entry", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the canonical transcript bytes (defensive copy).
|
||||
*
|
||||
* @return transcript bytes
|
||||
*/
|
||||
public byte[] toByteArray() {
|
||||
return buffer.toByteArray();
|
||||
}
|
||||
}
|
||||
81
lib/src/main/java/zeroecho/sdk/hybrid/kex/package-info.java
Normal file
81
lib/src/main/java/zeroecho/sdk/hybrid/kex/package-info.java
Normal file
@@ -0,0 +1,81 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2025, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software
|
||||
* without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
/**
|
||||
* Hybrid key exchange (KEX) utilities combining a classic agreement and a
|
||||
* post-quantum KEM-style agreement into one derived shared secret.
|
||||
*
|
||||
* <p>
|
||||
* This package provides an SDK-level composition layer over existing core
|
||||
* contracts:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>{@link zeroecho.core.context.AgreementContext} for classic DH-style
|
||||
* agreements (e.g. X25519, ECDH, DH), and</li>
|
||||
* <li>{@link zeroecho.core.context.MessageAgreementContext} for message-based
|
||||
* agreements (typically PQC KEM-style flows such as ML-KEM exposed via
|
||||
* {@code KeyUsage.AGREEMENT}).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Wire model</h2>
|
||||
* <p>
|
||||
* A hybrid exchange needs explicit "to-be-sent" bytes. This package unifies the
|
||||
* model by emitting a single peer message containing two length-prefixed parts:
|
||||
* </p>
|
||||
* <ol>
|
||||
* <li>classic message (often empty for classic agreements that only require a
|
||||
* peer public key),</li>
|
||||
* <li>PQC message (typically KEM ciphertext produced by
|
||||
* {@link zeroecho.core.context.MessageAgreementContext}).</li>
|
||||
* </ol>
|
||||
*
|
||||
* <h2>Key derivation</h2>
|
||||
* <p>
|
||||
* The two underlying secrets are combined using HKDF-SHA256 (RFC 5869) via
|
||||
* {@link zeroecho.sdk.util.Kdf#hkdfSha256(byte[], byte[], byte[], int)} rather
|
||||
* than concatenation. Callers should treat raw intermediate secrets as
|
||||
* sensitive and clear them as soon as possible.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Intended usage</h2>
|
||||
* <p>
|
||||
* Use {@link zeroecho.sdk.hybrid.kex.HybridKexContexts} to build initiator and
|
||||
* responder contexts and exchange
|
||||
* {@link zeroecho.sdk.hybrid.kex.HybridKexContext#getPeerMessage()} between
|
||||
* parties.
|
||||
* </p>
|
||||
*
|
||||
* @since 1.0
|
||||
*/
|
||||
package zeroecho.sdk.hybrid.kex;
|
||||
251
lib/src/test/java/zeroecho/sdk/hybrid/kex/HybridKexTest.java
Normal file
251
lib/src/test/java/zeroecho/sdk/hybrid/kex/HybridKexTest.java
Normal file
@@ -0,0 +1,251 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2025, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software
|
||||
* without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package zeroecho.sdk.hybrid.kex;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.security.KeyPair;
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import zeroecho.core.CryptoAlgorithms;
|
||||
import zeroecho.core.alg.common.agreement.KeyPairKey;
|
||||
import zeroecho.core.alg.kyber.KyberKeyGenSpec;
|
||||
import zeroecho.core.alg.xdh.XdhSpec;
|
||||
import zeroecho.sdk.util.BouncyCastleActivator;
|
||||
|
||||
/**
|
||||
* Hybrid KEX tests.
|
||||
*/
|
||||
public class HybridKexTest {
|
||||
|
||||
@BeforeAll
|
||||
static void setup() {
|
||||
// Optional: enable BC if you use BC-only modes in KEM payloads (EAX/OCB/CCM,
|
||||
// etc.)
|
||||
try {
|
||||
BouncyCastleActivator.init();
|
||||
} catch (Throwable ignore) {
|
||||
// keep tests runnable without BC if not present
|
||||
}
|
||||
}
|
||||
|
||||
private static void logBegin(Object... params) {
|
||||
String thisClass = HybridKexTest.class.getName();
|
||||
String method = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE)
|
||||
.walk(frames -> frames
|
||||
.dropWhile(f -> !f.getClassName().equals(thisClass) || f.getMethodName().equals("logBegin"))
|
||||
.findFirst().map(StackWalker.StackFrame::getMethodName).orElse("<?>"));
|
||||
System.out.println(method + "(" + Arrays.deepToString(params) + ")");
|
||||
}
|
||||
|
||||
private static void logEnd() {
|
||||
String thisClass = HybridKexTest.class.getName();
|
||||
String method = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE)
|
||||
.walk(frames -> frames
|
||||
.dropWhile(f -> !f.getClassName().equals(thisClass) || f.getMethodName().equals("logEnd"))
|
||||
.findFirst().map(StackWalker.StackFrame::getMethodName).orElse("<?>"));
|
||||
System.out.println(method + "...ok");
|
||||
}
|
||||
|
||||
private static String hex(byte[] b) {
|
||||
if (b == null) {
|
||||
return "null";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder(b.length * 2);
|
||||
for (byte v : b) {
|
||||
if (sb.length() == 80) {
|
||||
sb.append("...");
|
||||
break;
|
||||
}
|
||||
if ((v & 0xFF) < 16) {
|
||||
sb.append('0');
|
||||
}
|
||||
sb.append(Integer.toHexString(v & 0xFF));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String lens(byte[] msg) {
|
||||
if (msg == null || msg.length < 8) {
|
||||
return "classicLen=?, pqcLen=?";
|
||||
}
|
||||
try {
|
||||
DataInputStream in = new DataInputStream(new ByteArrayInputStream(msg));
|
||||
int classicLen = in.readInt();
|
||||
int pqcLen = 0;
|
||||
if (msg.length >= 8 + Math.max(0, classicLen)) {
|
||||
if (classicLen > 0) {
|
||||
in.skipBytes(classicLen);
|
||||
}
|
||||
pqcLen = in.readInt();
|
||||
}
|
||||
return "classicLen=" + classicLen + ", pqcLen=" + pqcLen;
|
||||
} catch (Exception e) {
|
||||
return "classicLen=?, pqcLen=?";
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void hybrid_x25519_mlkem_roundtrip() throws Exception {
|
||||
logBegin("CLASSIC_AGREEMENT + KEM_ADAPTER", "Xdh/X25519 + ML-KEM(768)", "HKDF-SHA256", "32 bytes");
|
||||
|
||||
HybridKexProfile profile = HybridKexProfile.defaultProfile(32);
|
||||
|
||||
// Classic: X25519 key pairs (Xdh + XdhSpec.X25519)
|
||||
KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
|
||||
KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
|
||||
|
||||
// PQC: ML-KEM key pair (Kyber variant)
|
||||
KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
|
||||
|
||||
HybridKexContext alice = null;
|
||||
HybridKexContext bob = null;
|
||||
|
||||
try {
|
||||
// Initiator: classic uses Alice private + Bob classic public; PQC uses Bob PQC
|
||||
// public
|
||||
alice = HybridKexContexts.initiator(profile, "Xdh", aliceClassic.getPrivate(), bobClassic.getPublic(),
|
||||
XdhSpec.X25519, "ML-KEM", bobPqc.getPublic(), null);
|
||||
|
||||
// Responder: classic uses Bob private + Alice classic public; PQC uses Bob PQC
|
||||
// private
|
||||
bob = HybridKexContexts.responder(profile, "Xdh", bobClassic.getPrivate(), aliceClassic.getPublic(),
|
||||
XdhSpec.X25519, "ML-KEM", bobPqc.getPrivate(), null);
|
||||
|
||||
// Alice produces message (contains PQC ciphertext; classic part is empty here)
|
||||
byte[] aliceMsg = alice.getPeerMessage();
|
||||
System.out.println("...aliceMsg(" + lens(aliceMsg) + ")=" + hex(aliceMsg));
|
||||
|
||||
// Bob consumes message
|
||||
bob.setPeerMessage(aliceMsg);
|
||||
|
||||
byte[] kA = alice.deriveSecret();
|
||||
byte[] kB = bob.deriveSecret();
|
||||
|
||||
System.out.println("...kA=" + hex(kA));
|
||||
System.out.println("...kB=" + hex(kB));
|
||||
|
||||
assertNotNull(kA);
|
||||
assertNotNull(kB);
|
||||
assertArrayEquals(kA, kB);
|
||||
} finally {
|
||||
if (alice != null) {
|
||||
try {
|
||||
alice.close();
|
||||
} catch (Exception ignore) {
|
||||
}
|
||||
}
|
||||
if (bob != null) {
|
||||
try {
|
||||
bob.close();
|
||||
} catch (Exception ignore) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logEnd();
|
||||
}
|
||||
|
||||
@Test
|
||||
void hybrid_x25519_pairmessage_mlkem_roundtrip() throws Exception {
|
||||
logBegin("PAIR_MESSAGE + KEM_ADAPTER", "Xdh/X25519 + ML-KEM(768)", "HKDF-SHA256", "32 bytes");
|
||||
|
||||
HybridKexProfile profile = HybridKexProfile.defaultProfile(32);
|
||||
|
||||
// Classic: X25519 key pairs (Xdh + XdhSpec.X25519)
|
||||
KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
|
||||
KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
|
||||
|
||||
// PQC: ML-KEM key pair (recipient/responder)
|
||||
KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
|
||||
|
||||
HybridKexContext alice = null;
|
||||
HybridKexContext bob = null;
|
||||
|
||||
try {
|
||||
// Classic leg is message-based on both sides (PAIR_MESSAGE capability:
|
||||
// KeyPairKey + ContextSpec).
|
||||
// PQC leg is KEM-style: initiator uses recipient public key; responder uses
|
||||
// recipient private key.
|
||||
alice = HybridKexContexts.initiatorPairMessage(profile, "Xdh", new KeyPairKey(aliceClassic), XdhSpec.X25519,
|
||||
"ML-KEM", bobPqc.getPublic(), null);
|
||||
|
||||
bob = HybridKexContexts.responderPairMessage(profile, "Xdh", new KeyPairKey(bobClassic), XdhSpec.X25519,
|
||||
"ML-KEM", bobPqc.getPrivate(), null);
|
||||
|
||||
// Step 1: Alice -> Bob (classic SPKI + PQC ciphertext)
|
||||
byte[] msgA = alice.getPeerMessage();
|
||||
System.out.println("...msgA(" + lens(msgA) + ")=" + hex(msgA));
|
||||
bob.setPeerMessage(msgA);
|
||||
|
||||
// Step 2: Bob -> Alice (classic SPKI only; PQC part is empty)
|
||||
byte[] msgB = bob.getPeerMessage();
|
||||
System.out.println("...msgB(" + lens(msgB) + ")=" + hex(msgB));
|
||||
alice.setPeerMessage(msgB);
|
||||
|
||||
// Both sides derive the final hybrid OKM.
|
||||
byte[] kA = alice.deriveSecret();
|
||||
byte[] kB = bob.deriveSecret();
|
||||
|
||||
System.out.println("...kA=" + hex(kA));
|
||||
System.out.println("...kB=" + hex(kB));
|
||||
|
||||
assertNotNull(kA);
|
||||
assertNotNull(kB);
|
||||
assertArrayEquals(kA, kB);
|
||||
} finally {
|
||||
if (alice != null) {
|
||||
try {
|
||||
alice.close();
|
||||
} catch (Exception ignore) {
|
||||
}
|
||||
}
|
||||
if (bob != null) {
|
||||
try {
|
||||
bob.close();
|
||||
} catch (Exception ignore) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logEnd();
|
||||
}
|
||||
}
|
||||
536
samples/src/test/java/demo/HybridKexDemoTest.java
Normal file
536
samples/src/test/java/demo/HybridKexDemoTest.java
Normal file
@@ -0,0 +1,536 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2025, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software
|
||||
* without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
package demo;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.security.KeyPair;
|
||||
import java.util.Arrays;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import zeroecho.core.CryptoAlgorithms;
|
||||
import zeroecho.core.alg.common.agreement.KeyPairKey;
|
||||
import zeroecho.core.alg.kyber.KyberKeyGenSpec;
|
||||
import zeroecho.core.alg.xdh.XdhSpec;
|
||||
import zeroecho.sdk.hybrid.kex.HybridKexContext;
|
||||
import zeroecho.sdk.hybrid.kex.HybridKexContexts;
|
||||
import zeroecho.sdk.hybrid.kex.HybridKexProfile;
|
||||
import zeroecho.sdk.util.BouncyCastleActivator;
|
||||
|
||||
/**
|
||||
* Demonstration of hybrid key exchange (KEX) usage in ZeroEcho.
|
||||
*
|
||||
* <p>
|
||||
* Hybrid KEX in this project means:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>A <b>classic agreement</b> leg (DH/ECDH/XDH), and</li>
|
||||
* <li>A <b>post-quantum</b> leg implemented as a message-based agreement (KEM
|
||||
* adapter, e.g. ML-KEM).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The two independent secrets are combined using HKDF-SHA256 in the SDK hybrid
|
||||
* layer. The application consumes only the final derived keying material (OKM),
|
||||
* not the raw leg secrets.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Available hybrid variants</h2>
|
||||
* <p>
|
||||
* The classic leg can be wired in two ways:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li><b>CLASSIC_AGREEMENT + KEM_ADAPTER</b> (most common in practice):
|
||||
* <ul>
|
||||
* <li>Classic peer public key is supplied out-of-band
|
||||
* (certificate/directory/session state).</li>
|
||||
* <li>The hybrid message carries only the PQC payload (KEM ciphertext).</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* <li><b>PAIR_MESSAGE + KEM_ADAPTER</b> (fully message-oriented hybrid):
|
||||
* <ul>
|
||||
* <li>Classic public keys are carried explicitly as messages (SPKI
|
||||
* encodings).</li>
|
||||
* <li>The hybrid message carries both: classic public-key message and PQC
|
||||
* ciphertext.</li>
|
||||
* <li>Responder may reply with a classic message only (PQC part empty),
|
||||
* depending on PQC role.</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Note on resource management</h2>
|
||||
* <p>
|
||||
* These examples intentionally avoid {@code try-with-resources}. Agreement
|
||||
* contexts represent protocol-level state rather than traditional I/O
|
||||
* resources; in real protocols their lifecycle often spans multiple
|
||||
* send/receive steps and does not naturally fit a single lexical scope. Using
|
||||
* explicit close blocks keeps the handshake lifecycle visible to the reader.
|
||||
* </p>
|
||||
*/
|
||||
class HybridKexDemoTest {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(HybridKexDemoTest.class.getName());
|
||||
|
||||
@BeforeAll
|
||||
static void setup() {
|
||||
// Optional: enable BC/BCPQC if present.
|
||||
try {
|
||||
BouncyCastleActivator.init();
|
||||
} catch (Throwable ignore) {
|
||||
// keep runnable without BC if not present
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void x25519_classicAgreement_plus_mlKem768_kemAdapter() throws Exception {
|
||||
logBegin("CLASSIC_AGREEMENT + KEM_ADAPTER", "Xdh/X25519 + ML-KEM-768", "HKDF-SHA256", "OKM=32B");
|
||||
|
||||
// Classic leg keys (Xdh + XdhSpec.X25519).
|
||||
KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
|
||||
KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
|
||||
|
||||
// PQC leg: Bob is the KEM recipient (has ML-KEM keypair).
|
||||
KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
|
||||
|
||||
// Hybrid profile: default HKDF label, 32-byte output suitable for symmetric
|
||||
// keys.
|
||||
HybridKexProfile profile = HybridKexProfile.defaultProfile(32);
|
||||
|
||||
HybridKexContext alice = null;
|
||||
HybridKexContext bob = null;
|
||||
|
||||
try {
|
||||
// Alice (initiator): classic uses Alice private + Bob classic public
|
||||
// (out-of-band).
|
||||
// ...PQC uses Bob PQC public and will produce a KEM ciphertext.
|
||||
alice = HybridKexContexts.initiator(profile, "Xdh", aliceClassic.getPrivate(), bobClassic.getPublic(),
|
||||
XdhSpec.X25519, "ML-KEM", bobPqc.getPublic(), null);
|
||||
|
||||
// Bob (responder): classic uses Bob private + Alice classic public
|
||||
// (out-of-band).
|
||||
// ...PQC uses Bob PQC private and will consume Alice's ciphertext.
|
||||
bob = HybridKexContexts.responder(profile, "Xdh", bobClassic.getPrivate(), aliceClassic.getPublic(),
|
||||
XdhSpec.X25519, "ML-KEM", bobPqc.getPrivate(), null);
|
||||
|
||||
// Alice -> Bob: hybrid message carries PQC ciphertext; classic part is empty.
|
||||
byte[] msgA = alice.getPeerMessage();
|
||||
System.out.println("...msgA " + lens(msgA) + " " + shortHex(msgA));
|
||||
|
||||
bob.setPeerMessage(msgA);
|
||||
|
||||
// Both derive the final hybrid OKM (HKDF output).
|
||||
byte[] okmA = alice.deriveSecret();
|
||||
byte[] okmB = bob.deriveSecret();
|
||||
|
||||
System.out.println("...okmA " + shortHex(okmA));
|
||||
System.out.println("...okmB " + shortHex(okmB));
|
||||
System.out.println("...equal " + Arrays.equals(okmA, okmB));
|
||||
|
||||
// Application would now feed OKM into symmetric key schedule / AEAD keys / etc.
|
||||
} finally {
|
||||
closeQuiet(alice);
|
||||
closeQuiet(bob);
|
||||
}
|
||||
|
||||
logEnd();
|
||||
}
|
||||
|
||||
@Test
|
||||
void x25519_pairMessage_plus_mlKem768_kemAdapter() throws Exception {
|
||||
logBegin("PAIR_MESSAGE + KEM_ADAPTER", "Xdh/X25519 + ML-KEM-768", "HKDF-SHA256", "OKM=32B");
|
||||
|
||||
// Classic leg keys (Xdh + XdhSpec.X25519).
|
||||
KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
|
||||
KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
|
||||
|
||||
// PQC leg: Bob is the KEM recipient (has ML-KEM keypair).
|
||||
KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
|
||||
|
||||
HybridKexProfile profile = HybridKexProfile.defaultProfile(32);
|
||||
|
||||
HybridKexContext alice = null;
|
||||
HybridKexContext bob = null;
|
||||
|
||||
try {
|
||||
// Alice classic leg is message-based (PAIR_MESSAGE): it will emit her public
|
||||
// key as SPKI bytes.
|
||||
// ...PQC leg (KEM initiator) will emit ciphertext.
|
||||
alice = HybridKexContexts.initiatorPairMessage(profile, "Xdh", new KeyPairKey(aliceClassic), XdhSpec.X25519,
|
||||
"ML-KEM", bobPqc.getPublic(), null);
|
||||
|
||||
// Bob classic leg is message-based (PAIR_MESSAGE): it will emit his public key
|
||||
// as SPKI bytes.
|
||||
// ...PQC leg (KEM responder) consumes ciphertext and typically does not emit a
|
||||
// PQC message.
|
||||
bob = HybridKexContexts.responderPairMessage(profile, "Xdh", new KeyPairKey(bobClassic), XdhSpec.X25519,
|
||||
"ML-KEM", bobPqc.getPrivate(), null);
|
||||
|
||||
// Alice -> Bob: hybrid message carries classic SPKI + PQC ciphertext.
|
||||
byte[] msgA = alice.getPeerMessage();
|
||||
System.out.println("...msgA " + lens(msgA) + " " + shortHex(msgA));
|
||||
bob.setPeerMessage(msgA);
|
||||
|
||||
// Bob -> Alice: hybrid message carries classic SPKI; PQC part may be empty
|
||||
// (depends on PQC role).
|
||||
byte[] msgB = bob.getPeerMessage();
|
||||
System.out.println("...msgB " + lens(msgB) + " " + shortHex(msgB));
|
||||
alice.setPeerMessage(msgB);
|
||||
|
||||
// Both derive the final hybrid OKM (HKDF output).
|
||||
byte[] okmA = alice.deriveSecret();
|
||||
byte[] okmB = bob.deriveSecret();
|
||||
|
||||
System.out.println("...okmA " + shortHex(okmA));
|
||||
System.out.println("...okmB " + shortHex(okmB));
|
||||
System.out.println("...equal " + Arrays.equals(okmA, okmB));
|
||||
} finally {
|
||||
closeQuiet(alice);
|
||||
closeQuiet(bob);
|
||||
}
|
||||
|
||||
logEnd();
|
||||
}
|
||||
|
||||
@Test
|
||||
void builder_x25519_classicAgreement_plus_mlKem768() throws Exception {
|
||||
logBegin("Builder", "CLASSIC_AGREEMENT + KEM_ADAPTER", "Xdh/X25519 + ML-KEM-768", "HKDF-SHA256", "OKM=32B");
|
||||
|
||||
// ...Generate classic leg keys (Xdh + XdhSpec.X25519).
|
||||
java.security.KeyPair aliceClassic = zeroecho.core.CryptoAlgorithms.generateKeyPair("Xdh",
|
||||
zeroecho.core.alg.xdh.XdhSpec.X25519);
|
||||
java.security.KeyPair bobClassic = zeroecho.core.CryptoAlgorithms.generateKeyPair("Xdh",
|
||||
zeroecho.core.alg.xdh.XdhSpec.X25519);
|
||||
|
||||
// ...Generate PQC (recipient) keys (ML-KEM-768).
|
||||
java.security.KeyPair bobPqc = zeroecho.core.CryptoAlgorithms.generateKeyPair("ML-KEM",
|
||||
zeroecho.core.alg.kyber.KyberKeyGenSpec.kyber768());
|
||||
|
||||
// ...Create a profile for HKDF (output length 32 bytes).
|
||||
zeroecho.sdk.hybrid.kex.HybridKexProfile profile = zeroecho.sdk.hybrid.kex.HybridKexProfile.defaultProfile(32);
|
||||
|
||||
// ...Build a transcript to bind HKDF info to public handshake context.
|
||||
zeroecho.sdk.hybrid.kex.HybridKexTranscript transcript = new zeroecho.sdk.hybrid.kex.HybridKexTranscript()
|
||||
.addUtf8("suite", "X25519+MLKEM768").addUtf8("role", "demo");
|
||||
|
||||
// ...Define a minimum-strength policy (example: classic >= 128, PQC >= 192, OKM
|
||||
// >= 32 bytes).
|
||||
zeroecho.sdk.hybrid.kex.HybridKexPolicy policy = new zeroecho.sdk.hybrid.kex.HybridKexPolicy(128, 192, 32);
|
||||
|
||||
// ...Start the builder.
|
||||
zeroecho.sdk.builders.HybridKexBuilder b = zeroecho.sdk.builders.HybridKexBuilder.builder()
|
||||
// ...Set HKDF profile (salt/info/outLen).
|
||||
.profile(profile)
|
||||
// ...Bind HKDF info to transcript (protocol context).
|
||||
.transcript(transcript)
|
||||
// ...Enable hybrid policy gating for this build.
|
||||
.policy(policy);
|
||||
|
||||
// ...Select classic mode where peer public key is known out-of-band.
|
||||
zeroecho.sdk.builders.HybridKexBuilder.ClassicAgreement classicCfg = b.classicAgreement()
|
||||
// ...Set classic algorithm id (X25519 is represented as "Xdh" with
|
||||
// XdhSpec.X25519).
|
||||
.algorithm("Xdh")
|
||||
// ...Set classic parameters (X25519 curve spec).
|
||||
.spec(zeroecho.core.alg.xdh.XdhSpec.X25519)
|
||||
// ...Set local classic private key (Alice).
|
||||
.privateKey(aliceClassic.getPrivate())
|
||||
// ...Set peer classic public key (Bob).
|
||||
.peerPublic(bobClassic.getPublic());
|
||||
|
||||
// ...Continue with PQC KEM adapter configuration for initiator role.
|
||||
zeroecho.sdk.hybrid.kex.HybridKexContext alice = classicCfg.pqcKem()
|
||||
// ...Set PQC algorithm id (ML-KEM).
|
||||
.algorithm("ML-KEM")
|
||||
// ...Set PQC recipient public key (Bob).
|
||||
.peerPublic(bobPqc.getPublic())
|
||||
// ...Build initiator-side hybrid context.
|
||||
.buildInitiator();
|
||||
|
||||
// ...Build responder-side context (Bob) with symmetric configuration (note: PQC
|
||||
// uses private key).
|
||||
zeroecho.sdk.hybrid.kex.HybridKexContext bob = zeroecho.sdk.builders.HybridKexBuilder.builder()
|
||||
// ...Set the same HKDF profile to derive the same OKM.
|
||||
.profile(profile)
|
||||
// ...Bind the same transcript to ensure both sides derive the same OKM.
|
||||
.transcript(transcript)
|
||||
// ...Enable the same policy gate.
|
||||
.policy(policy)
|
||||
// ...Select classic agreement mode (peer public is out-of-band).
|
||||
.classicAgreement()
|
||||
// ...Set classic algorithm id.
|
||||
.algorithm("Xdh")
|
||||
// ...Set classic parameters.
|
||||
.spec(zeroecho.core.alg.xdh.XdhSpec.X25519)
|
||||
// ...Set local classic private key (Bob).
|
||||
.privateKey(bobClassic.getPrivate())
|
||||
// ...Set peer classic public key (Alice).
|
||||
.peerPublic(aliceClassic.getPublic())
|
||||
// ...Continue to PQC configuration.
|
||||
.pqcKem()
|
||||
// ...Set PQC algorithm id.
|
||||
.algorithm("ML-KEM")
|
||||
// ...Set PQC recipient private key (Bob).
|
||||
.privateKey(bobPqc.getPrivate())
|
||||
// ...Build responder-side hybrid context.
|
||||
.buildResponder();
|
||||
|
||||
try {
|
||||
// ...Alice produces the hybrid message (PQC ciphertext; classic part is empty
|
||||
// in this mode).
|
||||
byte[] msgA = alice.getPeerMessage();
|
||||
System.out.println("...msgA " + lens(msgA) + " " + shortHex(msgA));
|
||||
|
||||
// ...Bob consumes Alice message.
|
||||
bob.setPeerMessage(msgA);
|
||||
|
||||
// ...Both sides derive identical OKM.
|
||||
byte[] okmA = alice.deriveSecret();
|
||||
byte[] okmB = bob.deriveSecret();
|
||||
|
||||
System.out.println("...okmA " + shortHex(okmA));
|
||||
System.out.println("...okmB " + shortHex(okmB));
|
||||
System.out.println("...equal " + java.util.Arrays.equals(okmA, okmB));
|
||||
|
||||
// ...Use exporter to derive purpose-specific keys bound to transcript.
|
||||
zeroecho.sdk.hybrid.kex.HybridKexExporter exporterA = b.exporterFromOkm(okmA);
|
||||
byte[] txA = exporterA.export("app/tx", transcript.toByteArray(), 32);
|
||||
byte[] rxA = exporterA.export("app/rx", transcript.toByteArray(), 32);
|
||||
|
||||
System.out.println("...txA " + shortHex(txA));
|
||||
System.out.println("...rxA " + shortHex(rxA));
|
||||
} finally {
|
||||
closeQuiet(alice);
|
||||
closeQuiet(bob);
|
||||
}
|
||||
|
||||
logEnd();
|
||||
}
|
||||
|
||||
@Test
|
||||
void builder_x25519_pairMessage_plus_mlKem768() throws Exception {
|
||||
logBegin("Builder", "PAIR_MESSAGE + KEM_ADAPTER", "Xdh/X25519 + ML-KEM-768", "HKDF-SHA256", "OKM=32B");
|
||||
|
||||
// ...Generate classic leg keys (Xdh + XdhSpec.X25519).
|
||||
KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
|
||||
KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
|
||||
|
||||
// ...Generate PQC (recipient) keys (ML-KEM-768).
|
||||
KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
|
||||
|
||||
// ...Create a profile for HKDF (output length 32 bytes).
|
||||
HybridKexProfile profile = HybridKexProfile.defaultProfile(32);
|
||||
|
||||
// ...Build a transcript to bind HKDF info to public handshake context.
|
||||
// ...(Use identical transcript on both sides to derive the same OKM.)
|
||||
zeroecho.sdk.hybrid.kex.HybridKexTranscript transcript = new zeroecho.sdk.hybrid.kex.HybridKexTranscript()
|
||||
.addUtf8("suite", "X25519+MLKEM768").addUtf8("mode", "PAIR_MESSAGE").addUtf8("role", "demo");
|
||||
|
||||
// ...Define a minimum-strength policy (example: classic >= 128, PQC >= 192, OKM
|
||||
// >= 32 bytes).
|
||||
zeroecho.sdk.hybrid.kex.HybridKexPolicy policy = new zeroecho.sdk.hybrid.kex.HybridKexPolicy(128, 192, 32);
|
||||
|
||||
// ...Start the initiator builder.
|
||||
zeroecho.sdk.builders.HybridKexBuilder initBuilder = zeroecho.sdk.builders.HybridKexBuilder.builder()
|
||||
// ...Set HKDF profile (salt/info/outLen).
|
||||
.profile(profile)
|
||||
// ...Bind HKDF info to transcript (protocol context).
|
||||
.transcript(transcript)
|
||||
// ...Enable hybrid policy gating for this build.
|
||||
.policy(policy);
|
||||
|
||||
// ...Select classic mode where the public key is carried in-band as a classic
|
||||
// message (PAIR_MESSAGE).
|
||||
zeroecho.sdk.hybrid.kex.HybridKexContext alice = initBuilder
|
||||
// ...Switch classic leg to PAIR_MESSAGE mode.
|
||||
.classicPairMessage()
|
||||
// ...Set classic algorithm id (X25519 is represented as "Xdh" with
|
||||
// XdhSpec.X25519).
|
||||
.algorithm("Xdh")
|
||||
// ...Set classic parameters (X25519 curve spec).
|
||||
.spec(XdhSpec.X25519)
|
||||
// ...Set local classic key pair wrapper (Alice).
|
||||
.keyPair(new KeyPairKey(aliceClassic))
|
||||
// ...Continue with PQC KEM adapter configuration.
|
||||
.pqcKem()
|
||||
// ...Set PQC algorithm id (ML-KEM).
|
||||
.algorithm("ML-KEM")
|
||||
// ...Set PQC recipient public key (Bob).
|
||||
.peerPublic(bobPqc.getPublic())
|
||||
// ...Build initiator-side hybrid context.
|
||||
.buildInitiator();
|
||||
|
||||
// ...Start the responder builder.
|
||||
zeroecho.sdk.builders.HybridKexBuilder respBuilder = zeroecho.sdk.builders.HybridKexBuilder.builder()
|
||||
// ...Set the same HKDF profile to derive the same OKM.
|
||||
.profile(profile)
|
||||
// ...Bind the same transcript to ensure both sides derive the same OKM.
|
||||
.transcript(transcript)
|
||||
// ...Enable the same policy gate.
|
||||
.policy(policy);
|
||||
|
||||
// ...Build responder-side context (Bob).
|
||||
zeroecho.sdk.hybrid.kex.HybridKexContext bob = respBuilder
|
||||
// ...Switch classic leg to PAIR_MESSAGE mode.
|
||||
.classicPairMessage()
|
||||
// ...Set classic algorithm id.
|
||||
.algorithm("Xdh")
|
||||
// ...Set classic parameters.
|
||||
.spec(XdhSpec.X25519)
|
||||
// ...Set local classic key pair wrapper (Bob).
|
||||
.keyPair(new KeyPairKey(bobClassic))
|
||||
// ...Continue with PQC KEM adapter configuration.
|
||||
.pqcKem()
|
||||
// ...Set PQC algorithm id.
|
||||
.algorithm("ML-KEM")
|
||||
// ...Set PQC recipient private key (Bob).
|
||||
.privateKey(bobPqc.getPrivate())
|
||||
// ...Build responder-side hybrid context.
|
||||
.buildResponder();
|
||||
|
||||
try {
|
||||
// ...Alice produces the hybrid message: classic SPKI + PQC ciphertext.
|
||||
byte[] msgA = alice.getPeerMessage();
|
||||
System.out.println("...msgA " + lens(msgA) + " " + shortHex(msgA));
|
||||
|
||||
// ...Bob consumes Alice message (learns Alice classic public + decapsulates PQC
|
||||
// ciphertext).
|
||||
bob.setPeerMessage(msgA);
|
||||
|
||||
// ...Bob produces response message: classic SPKI; PQC part may be empty
|
||||
// (role-dependent).
|
||||
byte[] msgB = bob.getPeerMessage();
|
||||
System.out.println("...msgB " + lens(msgB) + " " + shortHex(msgB));
|
||||
|
||||
// ...Alice consumes Bob message (learns Bob classic public).
|
||||
alice.setPeerMessage(msgB);
|
||||
|
||||
// ...Both sides derive identical OKM.
|
||||
byte[] okmA = alice.deriveSecret();
|
||||
byte[] okmB = bob.deriveSecret();
|
||||
|
||||
System.out.println("...okmA " + shortHex(okmA));
|
||||
System.out.println("...okmB " + shortHex(okmB));
|
||||
System.out.println("...equal " + Arrays.equals(okmA, okmB));
|
||||
|
||||
// ...Use exporter to derive purpose-specific keys bound to transcript.
|
||||
zeroecho.sdk.hybrid.kex.HybridKexExporter exporterA = initBuilder.exporterFromOkm(okmA);
|
||||
byte[] txA = exporterA.export("app/tx", transcript.toByteArray(), 32);
|
||||
byte[] rxA = exporterA.export("app/rx", transcript.toByteArray(), 32);
|
||||
|
||||
System.out.println("...txA " + shortHex(txA));
|
||||
System.out.println("...rxA " + shortHex(rxA));
|
||||
} finally {
|
||||
closeQuiet(alice);
|
||||
closeQuiet(bob);
|
||||
}
|
||||
|
||||
logEnd();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// helpers (JUnit output conventions)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static void logBegin(Object... params) {
|
||||
String thisClass = HybridKexDemoTest.class.getName();
|
||||
String method = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE)
|
||||
.walk(frames -> frames
|
||||
.dropWhile(f -> !f.getClassName().equals(thisClass) || f.getMethodName().equals("logBegin"))
|
||||
.findFirst().map(StackWalker.StackFrame::getMethodName).orElse("<?>"));
|
||||
System.out.println(method + "(" + Arrays.deepToString(params) + ")");
|
||||
}
|
||||
|
||||
private static void logEnd() {
|
||||
String thisClass = HybridKexDemoTest.class.getName();
|
||||
String method = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE)
|
||||
.walk(frames -> frames
|
||||
.dropWhile(f -> !f.getClassName().equals(thisClass) || f.getMethodName().equals("logEnd"))
|
||||
.findFirst().map(StackWalker.StackFrame::getMethodName).orElse("<?>"));
|
||||
System.out.println(method + "...ok");
|
||||
}
|
||||
|
||||
private static void closeQuiet(HybridKexContext ctx) {
|
||||
if (ctx == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ctx.close();
|
||||
} catch (Exception e) {
|
||||
LOG.fine("close failed: " + e.getClass().getName());
|
||||
}
|
||||
}
|
||||
|
||||
private static String shortHex(byte[] b) {
|
||||
if (b == null) {
|
||||
return "null";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder(b.length * 2);
|
||||
for (byte v : b) {
|
||||
if (sb.length() == 80) {
|
||||
sb.append("...");
|
||||
break;
|
||||
}
|
||||
if ((v & 0xFF) < 16) {
|
||||
sb.append('0');
|
||||
}
|
||||
sb.append(Integer.toHexString(v & 0xFF));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String lens(byte[] msg) {
|
||||
if (msg == null || msg.length < 8) {
|
||||
return "[classicLen=?, pqcLen=?]";
|
||||
}
|
||||
try {
|
||||
DataInputStream in = new DataInputStream(new ByteArrayInputStream(msg));
|
||||
int classicLen = in.readInt();
|
||||
if (classicLen < 0) {
|
||||
return "[classicLen=?, pqcLen=?]";
|
||||
}
|
||||
if (classicLen > 0) {
|
||||
in.skipBytes(classicLen);
|
||||
}
|
||||
int pqcLen = in.readInt();
|
||||
return "[classicLen=" + classicLen + ", pqcLen=" + pqcLen + "]";
|
||||
} catch (Exception e) {
|
||||
return "[classicLen=?, pqcLen=?]";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user