From 7f79082adc3ce9676c57feed9d434652f9b42236 Mon Sep 17 00:00:00 2001
From: Leo Galambos
+ * SLH-DSA is the NIST-standardized hash-based signature scheme (FIPS 205). The
+ * concrete parameter set is encoded in the key material and interpreted by the
+ * underlying {@link CryptoAlgorithms} implementation.
+ *
+ * SLH-DSA is the NIST-standardized hash-based signature scheme (FIPS 205). The
+ * concrete parameter set is encoded in the key material and interpreted by the
+ * underlying {@link CryptoAlgorithms} implementation.
+ *
+ * ML-DSA is the NIST-standardized module-lattice signature scheme (FIPS 204).
+ * The concrete parameter set and any pre-hash variant is encoded in the key
+ * material and interpreted by the underlying {@link CryptoAlgorithms}
+ * implementation.
+ *
+ * ML-DSA is the NIST-standardized module-lattice signature scheme (FIPS 204).
+ * The concrete parameter set and any pre-hash variant is encoded in the key
+ * material and interpreted by the underlying {@link CryptoAlgorithms}
+ * implementation.
+ *
+ * This class is intended as a convenient, signature-specialized replacement
+ * for:
+ *
+ * It keeps {@link TagTrailerDataContentBuilder} as the generic implementation
+ * while providing a compact API for {@code Signature} usage, including
+ * construction of {@code SignatureContext} for both single-algorithm and hybrid
+ * signatures.
+ *
+ * Context construction may involve I/O (e.g., catalog/provider loading) and
+ * therefore throw {@link IOException}. This builder converts such failures to
+ * {@link IllegalStateException} because fluent builder APIs are expected to be
+ * used in configuration code without mandatory checked-exception plumbing.
+ *
+ * This is the direct signature-specialized equivalent of:
+ * {@code new TagTrailerDataContentBuilder
+ * This is the direct signature-specialized equivalent of:
+ * {@code new TagTrailerDataContentBuilder
+ * This wrapper delegates verification to a supplied
+ * {@link VerificationBiPredicate} and records the boolean outcome into a shared
+ * {@link AtomicBoolean}. If the delegate throws {@link VerificationException},
+ * the exception is suppressed and the verification is treated as a failure
+ * (returns {@code false}).
+ *
+ * This behavior is required for robust hybrid verification semantics, most
+ * notably for logical OR composition: individual component verifiers may throw
+ * on malformed or structurally invalid signatures (for example, certain Ed25519
+ * invalid point encodings). Translating such exceptions into a boolean failure
+ * allows the hybrid engine to continue evaluating alternative verification
+ * paths and to aggregate the final decision deterministically.
+ *
+ * For diagnostic purposes, a {@link Level#FINE} log entry is emitted on each
+ * invocation containing:
+ *
+ * The logged tag prefix is intentionally truncated to reduce the risk of
+ * leaking sensitive material. The log message is produced using a JUL
+ * formatting string (no string concatenation in the hot path) and is only
+ * constructed when {@code FINE} is enabled.
+ *
+ * Security note: this class must not log keys, plaintext, shared secrets, full
+ * tags, or other sensitive material. Only a bounded prefix is logged at
+ * {@code FINE} level.
+ *
+ * If the delegate completes normally, its return value is recorded into
+ * {@link #ok} and returned. If the delegate throws
+ * {@link VerificationException}, the exception is suppressed, {@code false} is
+ * recorded into {@link #ok}, and {@code false} is returned.
+ *
+ * A {@link Level#FINE} log entry is emitted with a truncated hexadecimal prefix
+ * of {@code expectedTag} and the resulting decision.
+ *
+ * At most {@link #TAG_LOG_PREFIX_BYTES} bytes are included. If the tag is
+ * longer, the output is suffixed with {@code "..."}.
+ *
+ * The output format uses lowercase hexadecimal digits without separators.
+ *
+ * This predicate does not perform any cryptographic verification on its own.
+ * Instead, it acts as a bridge between the generic verification pipeline and
+ * the hybrid streaming engine, where the actual verification of classic and PQC
+ * signatures is performed once the complete payload has been consumed.
+ *
+ * The boolean result returned by {@link #verify(Signature, byte[])} reflects
+ * the outcome computed during EOF processing and stored in the shared
+ * {@link AtomicBoolean} instance. This allows the verification decision to be
+ * made exactly once, based on the fully buffered message body and the hybrid
+ * verification rule.
+ *
+ * Security note: this predicate must not leak any sensitive material. It merely
+ * returns a boolean decision computed elsewhere and does not inspect keys,
+ * signatures, or plaintext data.
+ *
+ * This method performs no validation of the provided {@code signature} or
+ * {@code expectedTag}. All cryptographic checks have already been executed by
+ * the hybrid stream once the complete payload was available.
+ *
+ * The signature trailer is {@code sigClassic || sigPqc} in the fixed order
+ * defined by the {@link HybridSignatureProfile}. The trailer carries no
+ * algorithm identifiers; the profile is the source of truth.
+ *
+ * This context is compatible with the existing ZeroEcho signing/verification
+ * pipelines: callers wrap an {@link InputStream} and read until EOF. At EOF,
+ * the underlying engines produce/verify the trailer.
+ *
+ * Implementation note: the wrapper buffers the message body and runs the
+ * component engines at EOF. This avoids any need for changes in core contracts
+ * while keeping the same external streaming behavior.
+ *
+ * Verification is performed for both component signatures and aggregated by the
+ * profile rule (AND / OR). The final decision is applied via this context's
+ * {@link #setVerificationApproach(VerificationBiPredicate)} predicate, enabling
+ * the standard ZeroEcho behavior (throw-on-mismatch, flagging, etc.).
+ *
+ * The stream proxies reads from an underlying upstream {@link InputStream}.
+ * While reading, it buffers the payload body (bounded by
+ * {@code maxBufferedBytes}) to enable hybrid signature processing at EOF:
+ *
+ * Security note: this implementation must not expose sensitive materials (keys,
+ * seeds, plaintext, signatures, or intermediate state) via logging or exception
+ * messages. Exceptions raised by this stream are limited to generic error
+ * descriptions and non-sensitive metadata (e.g., length and limits).
+ *
+ * Delegates to {@link #read(byte[], int, int)} and follows the standard
+ * {@link InputStream} contract.
+ *
+ * The method first serves any pending trailer bytes (produce mode). Otherwise
+ * it reads from the upstream stream, buffering all successfully read bytes for
+ * later hybrid signature processing at EOF. When the upstream stream returns
+ * {@code -1} for the first time, {@link #finishAtEof()} is invoked and may
+ * either prepare a trailer for emission (produce mode) or perform verification
+ * (verify mode).
+ *
+ * Note: closing this stream closes the wrapped {@code upstream} stream.
+ *
+ * The buffer is bounded by {@code maxBufferedBytes}. If appending would exceed
+ * the configured limit, the method fails fast with an {@link IOException}.
+ *
+ * This method is invoked exactly once upon the first observation of upstream
+ * EOF. It uses the buffered body to either:
+ *
+ * The split is determined by querying tag lengths from freshly created
+ * verification contexts (classic and PQC). The expected tag must match the
+ * exact combined length {@code classicLen + pqcLen}; otherwise an
+ * {@link IOException} is thrown.
+ *
+ * Instances are created only after the expected tag length is validated and
+ * then used to feed the per-algorithm verification routines.
+ *
+ * Security note: this structure stores signature bytes; it must not be logged
+ * or exposed outside the narrow verification flow.
+ *
+ * The returned contexts implement the standard ZeroEcho streaming contract:
+ * wrapping a stream produces/verifies a signature trailer at EOF.
+ *
+ * The profile is the single source of truth for hybrid signature
+ * processing. No algorithm identifiers or metadata are embedded in the
+ * signature trailer itself; both signing and verification sides must use the
+ * same profile.
+ *
+ * Instances of this record are immutable and thread-safe.
+ *
+ * Uses {@link Objects#requireNonNull(Object, String)} to enforce mandatory
+ * components while keeping validation idiomatic and consistent with the rest of
+ * the ZeroEcho codebase.
+ *
+ * This package provides SDK-level hybrid signatures that combine two
+ * independent signature schemes (typically a classical and a post-quantum
+ * algorithm) and expose them as a single streaming signature engine suitable
+ * for {@link zeroecho.sdk.builders.TagTrailerDataContentBuilder} and related
+ * pipeline stages.
+ *
+ * A hybrid signature computes two component signatures over the same message
+ * stream and then aggregates verification according to a configured rule:
+ *
+ * The AND rule is intended for security-hardening scenarios (both schemes must
+ * hold). The OR rule is intended for migration/fallback scenarios (accept if at
+ * least one scheme verifies), and should be used with clear policy and
+ * operational intent.
+ *
+ * The hybrid signature contexts created by this package are intended to be used
+ * as engines in trailer-oriented pipeline stages. In particular:
+ *
+ * Hybrid signature computation and verification are streaming operations. The
+ * resulting contexts and streams must be closed to release resources and to
+ * finalize tag/signature production or verification.
+ *
+ * Context instances are not thread-safe and are intended for single-use in a
+ * single pipeline execution. Create a new context instance for each independent
+ * signing or verification operation.
+ *
+ * Tests focus on practical combinations: Ed25519 + SPHINCS+, and
+ * RSA-PSS(SHA-256) + SPHINCS+ (if registered).
+ *
+ * This sample shows both canonical compositions:
+ *
+ * new TagTrailerDataContentBuilder<Signature>(engine).bufferSize(8192)
+ * new TagTrailerDataContentBuilder<Signature>(engine).bufferSize(8192).throwOnMismatch()
+ *
+ *
+ * Mode selection
+ *
+ *
+ *
+ * Checked exceptions
+ * Typical usage
{@code
@@ -136,4 +146,4 @@
*
* @since 1.0
*/
-package zeroecho.sdk.builders;
\ No newline at end of file
+package zeroecho.sdk.builders;
diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/HybridException.java b/lib/src/main/java/zeroecho/sdk/hybrid/HybridException.java
new file mode 100644
index 0000000..5c4c346
--- /dev/null
+++ b/lib/src/main/java/zeroecho/sdk/hybrid/HybridException.java
@@ -0,0 +1,63 @@
+/*******************************************************************************
+ * 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;
+
+/**
+ * Base exception type for hybrid framework failures.
+ *
+ * @since 1.0
+ */
+public class HybridException extends Exception {
+ private static final long serialVersionUID = -3704484377176409054L;
+
+ /**
+ * Creates a new exception with a message.
+ *
+ * @param message error description
+ */
+ public HybridException(String message) {
+ super(message);
+ }
+
+ /**
+ * Creates a new exception with a message and a cause.
+ *
+ * @param message error description
+ * @param cause underlying cause
+ */
+ public HybridException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/signature/CapturePredicate.java b/lib/src/main/java/zeroecho/sdk/hybrid/signature/CapturePredicate.java
new file mode 100644
index 0000000..b693d7c
--- /dev/null
+++ b/lib/src/main/java/zeroecho/sdk/hybrid/signature/CapturePredicate.java
@@ -0,0 +1,212 @@
+/*******************************************************************************
+ * 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.signature;
+
+import java.security.Signature;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import zeroecho.core.err.VerificationException;
+import zeroecho.core.tag.ThrowingBiPredicate.VerificationBiPredicate;
+
+/**
+ * Verification predicate wrapper that captures the boolean verification result
+ * and never propagates {@link VerificationException} to its caller.
+ *
+ * Logging
+ *
+ *
+ *
+ * Threading and side effects
+ *
+ *
+ *
+ * Design notes
+ *
+ *
+ *
+ * Streaming semantics
+ * Verification aggregation
+ *
+ *
+ *
+ * Lifecycle and invariants
+ *
+ *
+ *
+ *
+ *
+ *
+ * @throws IOException if required inputs are missing, if signature
+ * creation/verification fails, if the expected tag has an
+ * invalid length, or if the verification approach rejects
+ * the tag
+ */
+ private void finishAtEof() throws IOException {
+ byte[] body = buffer.toByteArray();
+
+ if (produceMode) {
+ byte[] sigClassic = signOne(profile.classicSigId(), classicPrivate, profile.classicSpec(), body);
+ byte[] sigPqc = signOne(profile.pqcSigId(), pqcPrivate, profile.pqcSpec(), body);
+ trailer = concat(sigClassic, sigPqc);
+ trailerPos = 0;
+ return;
+ }
+
+ byte[] exp = expectedTag;
+ if (exp == null) {
+ throw new IOException("Expected tag not set");
+ }
+
+ VerificationSplit split = splitExpected(exp);
+
+ boolean okClassic = verifyOne(profile.classicSigId(), classicPublic, profile.classicSpec(), body,
+ split.expectedClassic);
+ boolean okPqc = verifyOne(profile.pqcSigId(), pqcPublic, profile.pqcSpec(), body, split.expectedPqc);
+
+ boolean finalOk = (profile.verifyRule() == HybridSignatureProfile.VerifyRule.OR) ? (okClassic || okPqc)
+ : (okClassic && okPqc);
+
+ lastOk.set(finalOk);
+
+ try {
+ verificationApproach.verify(null, exp);
+ } catch (VerificationException e) {
+ throw new IOException("Hybrid signature verification failed", e);
+ }
+
+ trailer = null;
+ trailerPos = 0;
+ }
+
+ /**
+ * Splits the expected hybrid tag into classic and PQC signature components.
+ *
+ *
+ *
+ *
+ * Concept
+ *
+ *
+ *
+ * Main types
+ *
+ *
+ *
+ * Integration with pipeline builders
+ *
+ *
+ *
+ * Streaming and resource management
+ * Security notes
+ *
+ *
+ *
+ * Thread safety
+ *
+ *
+ *
+ *
+ *
+ *
+ * Hybrid signature used here (popular practical choice): Ed25519 + SPHINCS+ + * with AND verification. + *
+ */ +class HybridSigningAesTest { + + private static final Logger LOG = Logger.getLogger(HybridSigningAesTest.class.getName()); + + @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 + } + } + + @Test + void aesRoundStE_withHybridSignature() throws GeneralSecurityException, IOException { + LOG.info("aesRoundStE_withHybridSignature - Sign then Encrypt (Hybrid signature)"); + + // Prepare plaintext + byte[] msg = randomBytes(100); + + // AES-GCM with header, runtime params are stored in header + AesDataContentBuilder aesBuilder = AesDataContentBuilder.builder().generateKey(256).modeGcm(128).withHeader(); + + // Hybrid signature: Ed25519 + SPHINCS+ (AND) + HybridSignatureProfile profile = new HybridSignatureProfile("Ed25519", "SPHINCS+", null, null, + HybridSignatureProfile.VerifyRule.AND); + + KeyPair ed = generateKeyPair("Ed25519"); + KeyPair spx = generateKeyPair("SPHINCS+"); + + SignatureContext tagEnc = HybridSignatureContexts.sign(profile, ed.getPrivate(), spx.getPrivate(), + 2 * 1024 * 1024); + SignatureContext tagDec = HybridSignatureContexts.verify(profile, ed.getPublic(), spx.getPublic(), + 2 * 1024 * 1024); + + // For verification, make mismatch behavior explicit (builder also supports + // throwOnMismatch()). + tagDec.setVerificationApproach(tagDec.getVerificationCore().getThrowOnMismatch()); + + // Build StE pipeline: PLAIN -> SIGN(trailer) -> ENCRYPT + DataContent dccb = DataContentChainBuilder.encrypt() + // plaintext source + .add(PlainBytesBuilder.builder().bytes(msg)) + // hybrid signature trailer + .add(new TagTrailerDataContentBuilder+ * This context provides a {@link MessageAgreementContext} view over a classical + * JCA Diffie–Hellman style agreement (e.g., ECDH, XDH). The protocol + * "to-be-sent" data for such agreements is the party's public key. This class + * therefore maps: + *
+ *+ * The message format is the standard X.509 SubjectPublicKeyInfo encoding + * returned by {@link java.security.PublicKey#getEncoded()}. This format is + * stable, widely interoperable and avoids ad-hoc or algorithm-specific wire + * formats. + *
+ * + *+ * The underlying key agreement algorithm name (for + * {@link javax.crypto.KeyAgreement}) and the key factory algorithm name (for + * {@link KeyFactory}) are supplied explicitly by the algorithm registration + * code. This keeps algorithm-specific knowledge in {@code *Algorithm} classes + * rather than embedding naming heuristics into shared code. + *
+ * + *+ * Instances are not thread-safe and are intended for single-use, + * single-threaded protocol executions. + *
+ * + * @since 1.0 + */ +public final class GenericJcaMessageAgreementContext extends GenericJcaAgreementContext + implements MessageAgreementContext { + + private final PublicKey localPublic; + private final String keyFactoryAlg; + private final String keyFactoryProvider; + + /** + * Creates a message-oriented agreement context over a JCA key agreement. + * + *+ * The wrapped {@link KeyPairKey} supplies both local private and public key + * components: the private key is used for the underlying agreement computation, + * while the public key is exported as the handshake message via + * {@link #getPeerMessage()}. + *
+ * + * @param alg algorithm descriptor that owns this context + * @param keyPairKey local key pair wrapper; must contain both a private + * and a public key + * @param jcaAgreementName JCA {@link javax.crypto.KeyAgreement} algorithm + * name (e.g., {@code "ECDH"}, {@code "X25519"}) + * @param agreementProvider optional JCA provider name for + * {@link javax.crypto.KeyAgreement}, or {@code null} + * for default provider selection + * @param keyFactoryAlg JCA {@link KeyFactory} algorithm name used to + * import peer public keys (e.g., {@code "EC"}, + * {@code "XDH"}) + * @param keyFactoryProvider optional JCA provider name for {@link KeyFactory}, + * or {@code null} for default provider selection + * @throws NullPointerException if any required argument is {@code null} + * @since 1.0 + */ + public GenericJcaMessageAgreementContext(CryptoAlgorithm alg, KeyPairKey keyPairKey, String jcaAgreementName, + String agreementProvider, String keyFactoryAlg, String keyFactoryProvider) { + super(Objects.requireNonNull(alg, "alg"), Objects.requireNonNull(keyPairKey, "keyPairKey").privateKey(), + Objects.requireNonNull(jcaAgreementName, "jcaAgreementName"), agreementProvider); + this.localPublic = Objects.requireNonNull(keyPairKey.publicKey(), "keyPairKey.public"); + this.keyFactoryAlg = Objects.requireNonNull(keyFactoryAlg, "keyFactoryAlg"); + this.keyFactoryProvider = keyFactoryProvider; + } + + /** + * Returns the local party's handshake message. + * + *+ * For DH/XDH-style agreements, the handshake message is the local public key + * encoding. The returned array is a defensive copy and may be safely + * transmitted over untrusted channels. + *
+ * + *+ * The encoding is the standard X.509 SubjectPublicKeyInfo bytes returned by + * {@link PublicKey#getEncoded()}. + *
+ * + * @return a defensive copy of the local public key encoding (never + * {@code null}) + * @throws IllegalStateException if the local public key does not provide an + * encoding + * @since 1.0 + */ + @Override + public byte[] getPeerMessage() { + byte[] encoded = localPublic.getEncoded(); + if (encoded == null) { + throw new IllegalStateException("Local public key does not provide an encoding"); + } + return encoded.clone(); + } + + /** + * Supplies the peer party's handshake message. + * + *+ * The provided message is interpreted as an X.509 SubjectPublicKeyInfo encoding + * of the peer public key. The key is imported using {@link KeyFactory} and then + * assigned as the peer key for the underlying agreement computation. + *
+ * + *+ * Passing {@code null} resets the peer binding and makes + * {@link #deriveSecret()} unusable until a new peer message is provided. + *
+ * + * @param message peer public key encoding (SPKI), or {@code null} to reset the + * peer state + * @throws IllegalArgumentException if the message cannot be imported as a + * public key using the configured + * {@link KeyFactory} algorithm/provider + * @since 1.0 + */ + @Override + public void setPeerMessage(byte[] message) { + if (message == null) { + setPeerPublic(null); + return; + } + + PublicKey peerPublic = importPeerPublic(message); + setPeerPublic(peerPublic); + } + + /** + * Imports a peer public key from an X.509 SubjectPublicKeyInfo encoding. + * + *+ * This method performs no caching and always imports the key anew. The caller + * is responsible for ensuring that the protocol-layer validation requirements + * are met (e.g., checking that the received public key belongs to an expected + * identity, or that the agreement mode requires ephemeral vs. static keys). + *
+ * + * @param spkiEncoded peer public key encoding (SPKI); must not be {@code null} + * @return imported peer {@link PublicKey} + * @throws IllegalArgumentException if the key cannot be imported + */ + private PublicKey importPeerPublic(byte[] spkiEncoded) { + try { + KeyFactory keyFactory = (keyFactoryProvider == null) ? KeyFactory.getInstance(keyFactoryAlg) + : KeyFactory.getInstance(keyFactoryAlg, keyFactoryProvider); + + X509EncodedKeySpec spec = new X509EncodedKeySpec(spkiEncoded); + return keyFactory.generatePublic(spec); + } catch (GeneralSecurityException e) { + throw new IllegalArgumentException("Failed to import peer public key using KeyFactory " + keyFactoryAlg, e); + } + } +} diff --git a/lib/src/main/java/zeroecho/core/alg/common/agreement/KeyPairKey.java b/lib/src/main/java/zeroecho/core/alg/common/agreement/KeyPairKey.java new file mode 100644 index 0000000..e9e87d3 --- /dev/null +++ b/lib/src/main/java/zeroecho/core/alg/common/agreement/KeyPairKey.java @@ -0,0 +1,147 @@ +/******************************************************************************* + * 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.core.alg.common.agreement; + +import java.io.Serial; +import java.io.Serializable; +import java.security.Key; +import java.security.KeyPair; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.util.Objects; + +/** + * A {@link Key} wrapper that carries a {@link KeyPair} through APIs that are + * constrained to {@link Key} types. + * + *+ * This type exists to support capabilities that require both private and public + * components (e.g., message-oriented key agreement contexts), while preserving + * backward-compatible capabilities that accept only a {@link PrivateKey}. + *
+ * + *+ * The wrapper does not expose an encoding via {@link #getEncoded()} because + * serializing private key material implicitly is dangerous and not required for + * capability dispatch. + *
+ * + * @since 1.0 + */ +public final class KeyPairKey implements Key, Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private final KeyPair keyPair; + + /** + * Creates a wrapper around a {@link KeyPair}. + * + * @param keyPair key pair to wrap (must not be {@code null}) + * @throws NullPointerException if {@code keyPair} is {@code null} + * @since 1.0 + */ + public KeyPairKey(KeyPair keyPair) { + this.keyPair = Objects.requireNonNull(keyPair, "keyPair"); + Objects.requireNonNull(keyPair.getPrivate(), "keyPair.private"); + Objects.requireNonNull(keyPair.getPublic(), "keyPair.public"); + } + + /** + * Returns the wrapped {@link KeyPair}. + * + * @return key pair + * @since 1.0 + */ + public KeyPair keyPair() { + return keyPair; + } + + /** + * Returns the wrapped private key. + * + * @return private key + * @since 1.0 + */ + public PrivateKey privateKey() { + return keyPair.getPrivate(); + } + + /** + * Returns the wrapped public key. + * + * @return public key + * @since 1.0 + */ + public PublicKey publicKey() { + return keyPair.getPublic(); + } + + /** + * Returns the algorithm name of the wrapped private key. + * + * @return algorithm name + * @since 1.0 + */ + @Override + public String getAlgorithm() { + return privateKey().getAlgorithm(); + } + + /** + * Returns {@code null}. This wrapper intentionally does not define a standard + * encoding format. + * + * @return {@code null} + * @since 1.0 + */ + @Override + public String getFormat() { + return null; + } + + /** + * Returns {@code null}. This wrapper intentionally does not expose a combined + * encoding. + * + * @return {@code null} + * @since 1.0 + */ + @Override + public byte[] getEncoded() { + return null; // NOPMD + } +} diff --git a/lib/src/main/java/zeroecho/core/alg/dh/DhAlgorithm.java b/lib/src/main/java/zeroecho/core/alg/dh/DhAlgorithm.java index 6df5525..8fce875 100644 --- a/lib/src/main/java/zeroecho/core/alg/dh/DhAlgorithm.java +++ b/lib/src/main/java/zeroecho/core/alg/dh/DhAlgorithm.java @@ -46,7 +46,10 @@ import zeroecho.core.AlgorithmFamily; import zeroecho.core.KeyUsage; import zeroecho.core.alg.AbstractCryptoAlgorithm; import zeroecho.core.alg.common.agreement.GenericJcaAgreementContext; +import zeroecho.core.alg.common.agreement.GenericJcaMessageAgreementContext; +import zeroecho.core.alg.common.agreement.KeyPairKey; import zeroecho.core.context.AgreementContext; +import zeroecho.core.context.MessageAgreementContext; import zeroecho.core.spi.AsymmetricKeyBuilder; /** @@ -133,6 +136,10 @@ public final class DhAlgorithm extends AbstractCryptoAlgorithm { DhSpec.class, (PrivateKey k, DhSpec s) -> new GenericJcaAgreementContext(this, k, "DiffieHellman", null), DhSpec::ffdhe2048); + capability(AlgorithmFamily.AGREEMENT, KeyUsage.AGREEMENT, MessageAgreementContext.class, KeyPairKey.class, + DhSpec.class, (KeyPairKey k, DhSpec s) -> new GenericJcaMessageAgreementContext(this, k, + "DiffieHellman", null, "DH", null), + DhSpec::ffdhe2048); registerAsymmetricKeyBuilder(DhSpec.class, new DhKeyGenBuilder(), DhSpec::ffdhe2048); registerAsymmetricKeyBuilder(DhPublicKeySpec.class, new AsymmetricKeyBuilder<>() { diff --git a/lib/src/main/java/zeroecho/core/alg/ecdh/EcdhAlgorithm.java b/lib/src/main/java/zeroecho/core/alg/ecdh/EcdhAlgorithm.java index 9e0457d..afcfa19 100644 --- a/lib/src/main/java/zeroecho/core/alg/ecdh/EcdhAlgorithm.java +++ b/lib/src/main/java/zeroecho/core/alg/ecdh/EcdhAlgorithm.java @@ -40,12 +40,15 @@ import zeroecho.core.AlgorithmFamily; import zeroecho.core.KeyUsage; import zeroecho.core.alg.AbstractCryptoAlgorithm; import zeroecho.core.alg.common.agreement.GenericJcaAgreementContext; +import zeroecho.core.alg.common.agreement.GenericJcaMessageAgreementContext; +import zeroecho.core.alg.common.agreement.KeyPairKey; import zeroecho.core.alg.ecdsa.EcdsaCurveSpec; import zeroecho.core.alg.ecdsa.EcdsaPrivateKeyBuilder; import zeroecho.core.alg.ecdsa.EcdsaPrivateKeySpec; import zeroecho.core.alg.ecdsa.EcdsaPublicKeyBuilder; import zeroecho.core.alg.ecdsa.EcdsaPublicKeySpec; import zeroecho.core.context.AgreementContext; +import zeroecho.core.context.MessageAgreementContext; /** *+ * This sample illustrates three complementary models used in practice: + *
+ *+ * All examples below produce a raw agreement secret (the direct output + * of KEM decapsulation or Diffie–Hellman agreement). Real protocols should feed + * the raw secret into a suitable KDF (typically HKDF) together with + * transcript/context info before using it as key material. + *
+ * + *+ * The examples in this class intentionally do not use the + * {@code try-with-resources} construct when working with + * {@link zeroecho.core.context.AgreementContext} and + * {@link zeroecho.core.context.MessageAgreementContext}. + *
+ * + *+ * Agreement contexts represent protocol-level state rather than traditional I/O + * resources. In real-world applications their lifecycle often spans multiple + * protocol steps (message send, receive, validation, key derivation) and may + * cross method or thread boundaries. Using explicit {@code try/finally} blocks + * in the examples makes this lifecycle visible and closer to how agreement + * contexts are typically managed in production code. + *
+ * + *+ * In short-lived, fully synchronous scenarios (such as unit tests), + * {@code try-with-resources} is perfectly acceptable. It is omitted here purely + * for didactic reasons. + *
+ */ +class AgreementVariantsTest { + + private static final Logger LOG = Logger.getLogger(AgreementVariantsTest.class.getName()); + + @BeforeAll + static void setup() { + // Optional: activate BC/BCPQC if present. + // Keeps tests runnable even when providers are missing. + try { + BouncyCastleActivator.init(); + } catch (Throwable ignore) { + // ignore + } + } + + /** + * KEM_ADAPTER example for ML-KEM (Kyber): + * + *+ * This models a common "send one message, derive shared secret" pattern: + *
+ *+ * This is the traditional Diffie–Hellman model: both parties generate their own + * key pair, each side keeps a private key, and the peer public key is provided + * out-of-band (protocol message or session state). + *
+ */ + @Test + void classicAgreement_x25519_roundTrip() throws Exception { + LOG.info("classicAgreement_x25519_roundTrip - CLASSIC_AGREEMENT (X25519)"); + + CryptoAlgorithm xdh = CryptoAlgorithms.require("Xdh"); + KeyPair alice = xdh.generateKeyPair(); + KeyPair bob = xdh.generateKeyPair(); + + AgreementContext aCtx = null; + AgreementContext bCtx = null; + + try { + // Both contexts are built from local private keys. + aCtx = CryptoAlgorithms.create("Xdh", KeyUsage.AGREEMENT, alice.getPrivate(), XdhSpec.X25519); + bCtx = CryptoAlgorithms.create("Xdh", KeyUsage.AGREEMENT, bob.getPrivate(), XdhSpec.X25519); + + // The protocol layer provides peer public keys (here we use in-memory + // exchange). + aCtx.setPeerPublic(bob.getPublic()); + bCtx.setPeerPublic(alice.getPublic()); + + byte[] s1 = aCtx.deriveSecret(); + byte[] s2 = bCtx.deriveSecret(); + + LOG.log(Level.INFO, "CLASSIC_AGREEMENT: aliceSecret={0}", Strings.toShortHexString(s1)); + LOG.log(Level.INFO, "CLASSIC_AGREEMENT: bobSecret={0}", Strings.toShortHexString(s2)); + LOG.log(Level.INFO, "CLASSIC_AGREEMENT: secretsEqual={0}", Boolean.valueOf(Arrays.equals(s1, s2))); + } finally { + if (aCtx != null) { + try { + aCtx.close(); + } catch (Exception ignore) { + // ignore + } + } + if (bCtx != null) { + try { + bCtx.close(); + } catch (Exception ignore) { + // ignore + } + } + } + } + + /** + * PAIR_MESSAGE example for XDH/X25519: + * + *+ * This demonstrates the "message-oriented" handshake for DH-style agreements. + * Each party holds a key pair and the outbound message is simply the local + * public key encoding (SPKI). The receiver imports the encoding and binds it as + * the peer key. + *
+ * + *+ * This model is particularly practical for protocol implementations because it + * makes the "to-be-sent" artifact explicit (a byte array message), similarly to + * KEM ciphertexts. + *
+ */ + @Test + void pairMessage_x25519_roundTrip() throws Exception { + LOG.info("pairMessage_x25519_roundTrip - PAIR_MESSAGE (X25519)"); + + CryptoAlgorithm xdh = CryptoAlgorithms.require("Xdh"); + KeyPair alice = xdh.generateKeyPair(); + KeyPair bob = xdh.generateKeyPair(); + + // Wrapper is required because ZeroEcho capability dispatch uses Key (KeyPair is + // not a Key). + KeyPairKey aliceKey = new KeyPairKey(alice); + KeyPairKey bobKey = new KeyPairKey(bob); + + MessageAgreementContext aCtx = null; + MessageAgreementContext bCtx = null; + + try { + aCtx = CryptoAlgorithms.create("Xdh", KeyUsage.AGREEMENT, aliceKey, XdhSpec.X25519); + bCtx = CryptoAlgorithms.create("Xdh", KeyUsage.AGREEMENT, bobKey, XdhSpec.X25519); + + // Outbound messages: SPKI encodings of local public keys. + byte[] aMsg = aCtx.getPeerMessage(); + byte[] bMsg = bCtx.getPeerMessage(); + + LOG.log(Level.INFO, "PAIR_MESSAGE: aliceMsg={0}", Strings.toShortHexString(aMsg)); + LOG.log(Level.INFO, "PAIR_MESSAGE: bobMsg={0}", Strings.toShortHexString(bMsg)); + + // Each side imports peer public key from message. + aCtx.setPeerMessage(bMsg); + bCtx.setPeerMessage(aMsg); + + byte[] s1 = aCtx.deriveSecret(); + byte[] s2 = bCtx.deriveSecret(); + + LOG.log(Level.INFO, "PAIR_MESSAGE: aliceSecret={0}", Strings.toShortHexString(s1)); + LOG.log(Level.INFO, "PAIR_MESSAGE: bobSecret={0}", Strings.toShortHexString(s2)); + LOG.log(Level.INFO, "PAIR_MESSAGE: secretsEqual={0}", Boolean.valueOf(Arrays.equals(s1, s2))); + } finally { + if (aCtx != null) { + try { + aCtx.close(); + } catch (Exception ignore) { + // ignore + } + } + if (bCtx != null) { + try { + bCtx.close(); + } catch (Exception ignore) { + // ignore + } + } + } + } +} From 55da24735f77a3ee866ad9d32633887a0a55030b Mon Sep 17 00:00:00 2001 From: Leo Galambos+ * The builder supports the two practical hybrid variants: + *
+ *+ * The builder also supports transcript binding and optional policy enforcement + * before returning the context. + *
+ * + *{@code
+ * HybridKexContext ctx = HybridKexBuilder.builder()
+ * .profile(HybridKexProfile.defaultProfile(32))
+ * .transcript(new HybridKexTranscript().addUtf8("suite", "X25519+MLKEM768"))
+ * .policy(new HybridKexPolicy(128, 192, 32))
+ * .classicAgreement()
+ * .algorithm("Xdh").spec(XdhSpec.X25519)
+ * .privateKey(alicePriv).peerPublic(bobPub)
+ * .pqcKem()
+ * .algorithm("ML-KEM").peerPublic(bobPqcPub)
+ * .buildInitiator();
+ * }
+ *
+ * + * Instances are mutable and not thread-safe. + *
+ * + * @since 1.0 + */ +public final class HybridKexBuilder { + + private HybridKexProfile profile; + private HybridKexTranscript transcript; + private HybridKexPolicy policy; + + private ClassicMode classicMode; + + private String classicAlgId; + private ContextSpec classicSpec; + private PrivateKey classicPrivate; + private PublicKey classicPeerPublic; + private KeyPairKey classicKeyPair; + + private String pqcAlgId; + private ContextSpec pqcSpec; + private PublicKey pqcPeerPublic; + private PrivateKey pqcPrivate; + + private HybridKexBuilder() { + // builder + } + + /** + * Creates a new builder instance. + * + * @return new builder + */ + public static HybridKexBuilder builder() { + return new HybridKexBuilder(); + } + + /** + * Sets the hybrid profile (HKDF salt/info/output length). + * + * @param profile profile (must not be null) + * @return this builder + */ + public HybridKexBuilder profile(HybridKexProfile profile) { + this.profile = Objects.requireNonNull(profile, "profile"); + return this; + } + + /** + * Optional transcript used to bind HKDF {@code info}. + * + *+ * If provided, its bytes are concatenated to the profile {@code hkdfInfo} with + * a single zero byte separator. This preserves the profile label as a domain + * separator while incorporating handshake context. + *
+ * + * @param transcript transcript (may be null to clear) + * @return this builder + */ + public HybridKexBuilder transcript(HybridKexTranscript transcript) { + this.transcript = transcript; + return this; + } + + /** + * Optional hybrid policy enforcement. + * + * @param policy policy (may be null to clear) + * @return this builder + */ + public HybridKexBuilder policy(HybridKexPolicy policy) { + this.policy = policy; + return this; + } + + /** + * Selects the classic leg in {@link ClassicMode#CLASSIC_AGREEMENT} mode. + * + *+ * In this mode, the classic peer public key is assumed to be available + * out-of-band (for example via a certificate, a directory, or a higher-level + * handshake message). The hybrid wire message therefore typically carries only + * the PQC payload (KEM ciphertext), while the classic leg is configured via + * {@link AgreementContext#setPeerPublic(PublicKey)}. + *
+ * + *+ * Calling this method resets any previously configured {@code PAIR_MESSAGE} + * inputs ({@link #classicKeyPair}) to prevent ambiguous configuration. + *
+ * + * @return classic agreement configurator + * @since 1.0 + */ + public ClassicAgreement classicAgreement() { + this.classicMode = ClassicMode.CLASSIC_AGREEMENT; + this.classicKeyPair = null; + return new ClassicAgreement(this); + } + + /** + * Selects the classic leg in {@link ClassicMode#PAIR_MESSAGE} mode. + * + *+ * In this mode, the classic leg is message-capable: the public key is carried + * as an explicit classic message (typically an SPKI encoding) and becomes part + * of the hybrid peer message. This enables a fully message-oriented handshake + * where both legs contribute to the wire payload (classic public-key message + + * PQC ciphertext). + *
+ * + *+ * Calling this method resets any previously configured + * {@code CLASSIC_AGREEMENT} inputs ({@link #classicPrivate} and + * {@link #classicPeerPublic}) to prevent ambiguous configuration. + *
+ * + * @return classic pair-message configurator + * @since 1.0 + */ + public ClassicPairMessage classicPairMessage() { + this.classicMode = ClassicMode.PAIR_MESSAGE; + this.classicPrivate = null; + this.classicPeerPublic = null; + return new ClassicPairMessage(this); + } + + /** + * Selects configuration of the post-quantum leg (KEM adapter). + * + *+ * The PQC leg is always treated as a message-based agreement: + *
+ *+ * The classic leg is always an {@link AgreementContext}, but there are two + * operational models: + *
+ *+ * This enum is internal to the builder but is documented because it defines the + * wire semantics of the resulting {@link HybridKexContext} and determines which + * builder inputs are required. + *
+ * + * @since 1.0 + */ + private enum ClassicMode { + + /** + * Classic agreement where the peer public key is provided out-of-band and + * configured via {@link AgreementContext#setPeerPublic(PublicKey)}. + * + * @since 1.0 + */ + CLASSIC_AGREEMENT, + + /** + * Classic agreement where the classic public key is carried in-band as a + * message produced/consumed via + * {@link MessageAgreementContext#getPeerMessage()} and + * {@link MessageAgreementContext#setPeerMessage(byte[])}. + * + * @since 1.0 + */ + PAIR_MESSAGE + } + + /** + * Configurator for the classic leg in {@link ClassicMode#CLASSIC_AGREEMENT} + * mode. + * + *+ * Required inputs before building: + *
+ *+ * Optional inputs: + *
+ *+ * After configuring the classic leg, continue with {@link #pqcKem()} to + * configure the PQC leg. + *
+ * + * @since 1.0 + */ + public static final class ClassicAgreement { + private final HybridKexBuilder parent; + + private ClassicAgreement(HybridKexBuilder parent) { + this.parent = parent; + } + + /** + * Sets the classic agreement algorithm identifier. + * + *+ * Example for X25519 in this project: use {@code "Xdh"} with + * {@code XdhSpec.X25519}. + *
+ * + * @param algId algorithm id (must not be null) + * @return this configurator + * @throws NullPointerException if {@code algId} is null + * @since 1.0 + */ + public ClassicAgreement algorithm(String algId) { + parent.classicAlgId = Objects.requireNonNull(algId, "algId"); + return this; + } + + /** + * Sets the classic context spec (algorithm parameters). + * + * @param spec spec (may be null if the algorithm provides a default) + * @return this configurator + * @since 1.0 + */ + public ClassicAgreement spec(ContextSpec spec) { + parent.classicSpec = spec; + return this; + } + + /** + * Sets the local private key for the classic leg. + * + * @param key private key (must not be null) + * @return this configurator + * @throws NullPointerException if {@code key} is null + * @since 1.0 + */ + public ClassicAgreement privateKey(PrivateKey key) { + parent.classicPrivate = Objects.requireNonNull(key, "key"); + return this; + } + + /** + * Sets the peer public key for the classic leg. + * + *+ * In {@link ClassicMode#CLASSIC_AGREEMENT} this key is assumed to be obtained + * out-of-band. + *
+ * + * @param key peer public key (must not be null) + * @return this configurator + * @throws NullPointerException if {@code key} is null + * @since 1.0 + */ + public ClassicAgreement peerPublic(PublicKey key) { + parent.classicPeerPublic = Objects.requireNonNull(key, "key"); + return this; + } + + /** + * Continues with PQC (KEM adapter) leg configuration. + * + * @return PQC configurator + * @since 1.0 + */ + public PqcKem pqcKem() { + return parent.pqcKem(); + } + } + + /** + * Configurator for the classic leg in {@link ClassicMode#PAIR_MESSAGE} mode. + * + *+ * Required inputs before building: + *
+ *+ * Optional inputs: + *
+ *+ * In this mode, the classic leg contributes a public-key message (typically + * SPKI bytes) to the hybrid peer message. The peer public key is therefore + * learned from {@link HybridKexContext#setPeerMessage(byte[])} rather than + * being supplied out-of-band. + *
+ * + * @since 1.0 + */ + public static final class ClassicPairMessage { + private final HybridKexBuilder parent; + + private ClassicPairMessage(HybridKexBuilder parent) { + this.parent = parent; + } + + /** + * Sets the classic agreement algorithm identifier. + * + * @param algId algorithm id (must not be null) + * @return this configurator + * @throws NullPointerException if {@code algId} is null + * @since 1.0 + */ + public ClassicPairMessage algorithm(String algId) { + parent.classicAlgId = Objects.requireNonNull(algId, "algId"); + return this; + } + + /** + * Sets the classic context spec (algorithm parameters). + * + * @param spec spec (may be null if the algorithm provides a default) + * @return this configurator + * @since 1.0 + */ + public ClassicPairMessage spec(ContextSpec spec) { + parent.classicSpec = spec; + return this; + } + + /** + * Sets the local classic key pair. + * + *+ * The key pair is wrapped into {@link KeyPairKey} to match the + * {@code PAIR_MESSAGE} capability registered in core. + *
+ * + * @param keyPair key pair wrapper (must not be null) + * @return this configurator + * @throws NullPointerException if {@code keyPair} is null + * @since 1.0 + */ + public ClassicPairMessage keyPair(KeyPairKey keyPair) { + parent.classicKeyPair = Objects.requireNonNull(keyPair, "keyPair"); + return this; + } + + /** + * Continues with PQC (KEM adapter) leg configuration. + * + * @return PQC configurator + * @since 1.0 + */ + public PqcKem pqcKem() { + return parent.pqcKem(); + } + } + + /** + * Configurator for the PQC leg (KEM adapter). + * + *+ * Required inputs differ by role: + *
+ *+ * The PQC leg is always message-based: initiator produces a peer message + * (ciphertext) and responder consumes it. The hybrid context transports this + * payload as the PQC part of the hybrid message. + *
+ * + * @since 1.0 + */ + public static final class PqcKem { + private final HybridKexBuilder parent; + + private PqcKem(HybridKexBuilder parent) { + this.parent = parent; + } + + /** + * Sets the PQC algorithm identifier. + * + * @param algId algorithm id (must not be null) + * @return this configurator + * @throws NullPointerException if {@code algId} is null + * @since 1.0 + */ + public PqcKem algorithm(String algId) { + parent.pqcAlgId = Objects.requireNonNull(algId, "algId"); + return this; + } + + /** + * Sets the PQC context spec (algorithm parameters). + * + * @param spec spec (may be null if the algorithm provides a default) + * @return this configurator + * @since 1.0 + */ + public PqcKem spec(ContextSpec spec) { + parent.pqcSpec = spec; + return this; + } + + /** + * Sets the recipient PQC public key for initiator-side construction. + * + * @param key recipient public key (must not be null) + * @return this configurator + * @throws NullPointerException if {@code key} is null + * @since 1.0 + */ + public PqcKem peerPublic(PublicKey key) { + parent.pqcPeerPublic = Objects.requireNonNull(key, "key"); + return this; + } + + /** + * Sets the recipient PQC private key for responder-side construction. + * + * @param key recipient private key (must not be null) + * @return this configurator + * @throws NullPointerException if {@code key} is null + * @since 1.0 + */ + public PqcKem privateKey(PrivateKey key) { + parent.pqcPrivate = Objects.requireNonNull(key, "key"); + return this; + } + + /** + * Builds an initiator-side {@link HybridKexContext} using the current builder + * configuration. + * + * @return initiator context + * @throws IOException if underlying context creation fails + * @throws IllegalStateException if required configuration for initiator role is + * missing + * @since 1.0 + */ + public HybridKexContext buildInitiator() throws IOException { + return parent.buildInitiator(); + } + + /** + * Builds a responder-side {@link HybridKexContext} using the current builder + * configuration. + * + * @return responder context + * @throws IOException if underlying context creation fails + * @throws IllegalStateException if required configuration for responder role is + * missing + * @since 1.0 + */ + public HybridKexContext buildResponder() throws IOException { + return parent.buildResponder(); + } + } + +} diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexContext.java b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexContext.java new file mode 100644 index 0000000..af3ce89 --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexContext.java @@ -0,0 +1,463 @@ +/******************************************************************************* + * Copyright (C) 2025, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.sdk.hybrid.kex; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.Key; +import java.security.PublicKey; +import java.util.Objects; +import java.util.logging.Level; +import java.util.logging.Logger; + +import zeroecho.core.CryptoAlgorithm; +import zeroecho.core.context.AgreementContext; +import zeroecho.core.context.MessageAgreementContext; +import zeroecho.sdk.util.Kdf; + +/** + * Hybrid key exchange (KEX) context that combines a classic agreement and a + * post-quantum KEM-style agreement. + * + *+ * This context composes two independent shared secrets: + *
+ *+ * This class implements {@link MessageAgreementContext} to provide a single + * hybrid message that can be transmitted between parties. The encoding is: + *
+ *{@code
+ * [int classicLen][classicBytes...][int pqcLen][pqcBytes...]
+ * }
+ *
+ * + * For typical pairings such as {@code X25519 + ML-KEM}, the classic part is + * empty because the classic leg is configured using the peer public key + * out-of-band. If the classic leg itself is message-capable, the classic part + * may be populated. + *
+ * + *+ * The final keying material is derived using HKDF-SHA256 (RFC 5869): + *
+ *{@code
+ * classicSS = classic.deriveSecret()
+ * pqcSS = pqc.deriveSecret()
+ * IKM = classicSS || pqcSS
+ * OKM = HKDF-SHA256(IKM, salt, info, outLen)
+ * }
+ *
+ * + * Intermediate raw secrets are treated as sensitive and are zeroized + * (best-effort) once HKDF completes. + *
+ * + *+ * 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. + *
+ * + *+ * Instances are mutable and not thread-safe; use one instance per + * handshake/session and thread. + *
+ * + * @since 1.0 + */ +public final class HybridKexContext implements MessageAgreementContext { + + private static final Logger LOG = Logger.getLogger(HybridKexContext.class.getName()); + + private final HybridKexProfile profile; + private final AgreementContext classic; + private final MessageAgreementContext pqc; + + private byte[] peerMessage; + + /** + * Creates a hybrid KEX context by composing two underlying contexts. + * + * @param profile hybrid profile defining HKDF binding and output length + * @param classic classic agreement context (must not be {@code null}) + * @param pqc PQC message agreement context (must not be {@code null}) + * @throws NullPointerException if any argument is {@code null} + * @since 1.0 + */ + public HybridKexContext(HybridKexProfile profile, AgreementContext classic, MessageAgreementContext pqc) { + this.profile = Objects.requireNonNull(profile, "profile"); + this.classic = Objects.requireNonNull(classic, "classic"); + this.pqc = Objects.requireNonNull(pqc, "pqc"); + } + + /** + * Returns the representative algorithm of this context. + * + *+ * This is currently delegated to the classic leg. Hybrid constructions bind + * multiple algorithms; callers that need full visibility should track both legs + * explicitly. + *
+ * + * @return representative algorithm (classic leg) + * @since 1.0 + */ + @Override + public CryptoAlgorithm algorithm() { + return classic.algorithm(); + } + + /** + * Returns the representative key of this context. + * + *+ * This is delegated to the classic leg to satisfy the {@code CryptoContext} + * contract. The hybrid KEX also depends on the PQC leg keys; callers should + * store those keys separately if needed by the application. + *
+ * + * @return representative key (classic leg) + * @since 1.0 + */ + @Override + public Key key() { + return classic.key(); + } + + /** + * Sets the peer public key for the classic leg. + * + *+ * This is the usual configuration step for classic DH-style agreements. The PQC + * leg typically does not use peer public keys through this method; however, the + * call is forwarded on a best-effort basis for implementations that accept it. + *
+ * + * @param peer peer public key for the classic agreement + * @since 1.0 + */ + @Override + public void setPeerPublic(PublicKey peer) { + classic.setPeerPublic(peer); + try { + pqc.setPeerPublic(peer); + } catch (RuntimeException ignore) { // NOPMD + // KEM-style agreements usually do not accept peer public here. + } + } + + /** + * Supplies the hybrid peer message received from the remote party. + * + *+ * The message is decoded into its classic and PQC parts. The PQC part is always + * forwarded to the PQC leg via + * {@link MessageAgreementContext#setPeerMessage(byte[])}. The classic part is + * forwarded only if the classic leg also implements + * {@link MessageAgreementContext}; otherwise it is ignored. + *
+ * + *+ * Passing {@code null} resets the peer-message related state of both legs + * (best-effort). + *
+ * + * @param message hybrid message, or {@code null} to reset + * @throws IllegalArgumentException if the provided message does not conform to + * the hybrid encoding + * @since 1.0 + */ + @Override + public void setPeerMessage(byte[] message) { + if (message == null) { + this.peerMessage = null; + try { + pqc.setPeerMessage(null); + } catch (RuntimeException ignore) { // NOPMD + } + try { + setClassicMessageIfSupported(null); + } catch (RuntimeException ignore) { // NOPMD + } + return; + } + + Parts parts; + try { + parts = decode(message); + } catch (IOException e) { + throw new IllegalArgumentException("Invalid hybrid peer message encoding", e); + } + + this.peerMessage = message.clone(); + + try { + setClassicMessageIfSupported(parts.classicPart()); + } catch (RuntimeException ignore) { // NOPMD + // classic leg may be non-message agreement; ignore + } + + byte[] pqcPart = parts.pqcPart(); + if (pqcPart != null && pqcPart.length > 0) { + pqc.setPeerMessage(pqcPart); + } + } + + /** + * Returns the hybrid message to be sent to the peer. + * + *+ * The PQC leg typically produces a non-empty message (encapsulation ciphertext) + * for initiator roles. The classic leg contributes an empty message unless it + * supports message mode. + *
+ * + * @return encoded hybrid peer message + * @throws IllegalStateException if encoding fails or the PQC leg cannot produce + * a message in the current role + * @since 1.0 + */ + @Override + public byte[] getPeerMessage() { + byte[] classicMsg = getClassicMessageIfSupported(); + byte[] pqcMsg; + try { + pqcMsg = pqc.getPeerMessage(); + } catch (RuntimeException e) { // NOPMD + // Responder-side KEM leg typically does not produce an outbound message. + pqcMsg = new byte[0]; + } + + try { + byte[] msg = encode(classicMsg, pqcMsg); + this.peerMessage = msg.clone(); + return msg; + } catch (IOException e) { + throw new IllegalStateException("Unable to encode hybrid peer message", e); + } + } + + /** + * Derives the final hybrid shared secret (OKM) using HKDF-SHA256. + * + *+ * This method derives both component secrets and then performs HKDF over their + * concatenation. Higher-level protocols should additionally bind + * transcript/context data through the HKDF {@code info} parameter + * ({@link HybridKexProfile#hkdfInfo()}). + *
+ * + * @return derived keying material (OKM) of length + * {@link HybridKexProfile#outLenBytes()} + * @throws IllegalStateException if HKDF fails or underlying contexts are not + * configured + * @since 1.0 + */ + @Override + public byte[] deriveSecret() { + byte[] classicSs = classic.deriveSecret(); + byte[] pqcSs = pqc.deriveSecret(); + + byte[] ikm = new byte[classicSs.length + pqcSs.length]; + System.arraycopy(classicSs, 0, ikm, 0, classicSs.length); + System.arraycopy(pqcSs, 0, ikm, classicSs.length, pqcSs.length); + + try { + byte[] out = Kdf.hkdfSha256(ikm, profile.hkdfSalt(), profile.hkdfInfo(), profile.outLenBytes()); + if (LOG.isLoggable(Level.FINE)) { + LOG.fine("HybridKexContext.deriveSecret(): derived OKM length=" + out.length); + } + return out; + } catch (GeneralSecurityException e) { + throw new IllegalStateException("HKDF-SHA256 failed", e); + } finally { + zeroize(classicSs); + zeroize(pqcSs); + zeroize(ikm); + } + } + + /** + * Closes both underlying contexts. + * + *+ * If closing the second context fails after the first one already failed, the + * second failure is added as a suppressed exception on the first one. + *
+ * + * @throws IOException if closing either leg fails + * @since 1.0 + */ + @Override + public void close() throws IOException { + IOException first = null; + + try { + classic.close(); + } catch (IOException e) { + first = e; + } + + try { + pqc.close(); + } catch (IOException e) { + if (first == null) { + first = e; + } else { + first.addSuppressed(e); + } + } + + if (first != null) { + throw first; + } + } + + /** + * Returns the last hybrid peer message that was produced or set on this + * instance. + * + *+ * This is intended for diagnostics and testing. The returned array is a + * defensive copy. + *
+ * + * @return last hybrid message, or {@code null} if none has been produced or set + * @since 1.0 + */ + public byte[] lastPeerMessageOrNull() { + return (peerMessage == null) ? null : peerMessage.clone(); + } + + // ------------------------------------------------------------------------- + // Encoding helpers + // ------------------------------------------------------------------------- + + private static byte[] encode(byte[] classicMsg, byte[] pqcMsg) throws IOException { + byte[] c = (classicMsg == null) ? new byte[0] : classicMsg; + byte[] p = (pqcMsg == null) ? new byte[0] : pqcMsg; + + ByteArrayOutputStream bout = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bout); + + out.writeInt(c.length); + out.write(c); + + out.writeInt(p.length); + out.write(p); + + out.flush(); + return bout.toByteArray(); + } + + private static Parts decode(byte[] msg) throws IOException { + DataInputStream in = new DataInputStream(new ByteArrayInputStream(msg)); + + int cLen = in.readInt(); + if (cLen < 0) { + throw new IOException("negative classic length"); + } + byte[] c = new byte[cLen]; + in.readFully(c); + + int pLen = in.readInt(); + if (pLen < 0) { + throw new IOException("negative pqc length"); + } + byte[] p = new byte[pLen]; + in.readFully(p); + + return new Parts(c, p); + } + + private static void zeroize(byte[] b) { + if (b == null) { + return; + } + for (int i = 0; i < b.length; i++) { + b[i] = 0; + } + } + + private byte[] getClassicMessageIfSupported() { + if (classic instanceof MessageAgreementContext) { + try { + return ((MessageAgreementContext) classic).getPeerMessage(); + } catch (RuntimeException ignore) { // NOPMD + return new byte[0]; + } + } + return new byte[0]; + } + + private void setClassicMessageIfSupported(byte[] msg) { + if (classic instanceof MessageAgreementContext) { + ((MessageAgreementContext) classic).setPeerMessage(msg); + } + } + + private record Parts(byte[] classicPart, byte[] pqcPart) { + } +} diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexContexts.java b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexContexts.java new file mode 100644 index 0000000..c1bc754 --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexContexts.java @@ -0,0 +1,314 @@ +/******************************************************************************* + * Copyright (C) 2025, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.sdk.hybrid.kex; + +import java.io.IOException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.util.Objects; + +import zeroecho.core.CryptoAlgorithms; +import zeroecho.core.KeyUsage; +import zeroecho.core.context.AgreementContext; +import zeroecho.core.context.MessageAgreementContext; +import zeroecho.core.spec.ContextSpec; + +/** + * Factory utilities for constructing hybrid key exchange (KEX) contexts. + * + *+ * This class implements the SDK-level composition layer for hybrid key + * exchange. It does not introduce new core contracts; instead, it composes + * existing core contexts: + *
+ *+ * The two legs have different wire semantics: + *
+ *+ * {@link HybridKexContext} unifies these semantics by emitting and consuming a + * single hybrid peer message that carries both legs (with the classic part + * typically empty for classic-only public-key exchange). + *
+ * + *+ * 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. + *
+ * + *+ * This factory is stateless and thread-safe. Produced contexts are mutable and + * not thread-safe. + *
+ * + * @since 1.0 + */ +public final class HybridKexContexts { + + private HybridKexContexts() { + // utility + } + + /** + * Creates an initiator-side hybrid KEX context. + * + *+ * This method constructs: + *
+ *+ * The returned {@link HybridKexContext} will typically produce a peer message + * whose PQC part is non-empty (e.g. KEM ciphertext), while the classic part is + * empty unless the chosen classic implementation itself supports message mode. + *
+ * + * @param profile hybrid profile defining HKDF binding and + * output length + * @param classicAlgId classic agreement algorithm identifier (for + * example {@code "X25519"}, {@code "ECDH"}, + * {@code "DH"}) + * @param classicInitiatorPrivate initiator private key for the classic leg + * @param classicPeerPublic peer public key for the classic leg + * @param classicSpec optional classic context spec (may be + * {@code null} if the algorithm supports a + * default) + * @param pqcAlgId post-quantum agreement algorithm identifier + * (for example {@code "ML-KEM"} exposed via + * {@code KeyUsage.AGREEMENT}) + * @param pqcPeerPublic peer public key for the PQC leg (encapsulation + * side) + * @param pqcSpec optional PQC context spec (may be {@code null} + * if the algorithm supports a default) + * @return initiator-side {@link HybridKexContext} + * @throws NullPointerException if any required argument is {@code null} + * @throws IOException if underlying context creation fails + * @since 1.0 + */ + public static HybridKexContext initiator(HybridKexProfile profile, String classicAlgId, + PrivateKey classicInitiatorPrivate, PublicKey classicPeerPublic, ContextSpec classicSpec, String pqcAlgId, + PublicKey pqcPeerPublic, ContextSpec pqcSpec) throws IOException { + + Objects.requireNonNull(profile, "profile"); + Objects.requireNonNull(classicAlgId, "classicAlgId"); + Objects.requireNonNull(classicInitiatorPrivate, "classicInitiatorPrivate"); + Objects.requireNonNull(classicPeerPublic, "classicPeerPublic"); + Objects.requireNonNull(pqcAlgId, "pqcAlgId"); + Objects.requireNonNull(pqcPeerPublic, "pqcPeerPublic"); + + AgreementContext classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT, classicInitiatorPrivate, + classicSpec); + classic.setPeerPublic(classicPeerPublic); + + MessageAgreementContext pqc = CryptoAlgorithms.create(pqcAlgId, KeyUsage.AGREEMENT, pqcPeerPublic, pqcSpec); + + return new HybridKexContext(profile, classic, pqc); + } + + /** + * Creates a responder-side hybrid KEX context. + * + *+ * This method constructs: + *
+ *+ * The returned {@link HybridKexContext} is typically used after receiving the + * initiator's hybrid peer message and passing it to + * {@link HybridKexContext#setPeerMessage(byte[])}. + *
+ * + * @param profile hybrid profile defining HKDF binding and + * output length + * @param classicAlgId classic agreement algorithm identifier + * @param classicResponderPrivate responder private key for the classic leg + * @param classicPeerPublic peer public key for the classic leg + * @param classicSpec optional classic context spec (may be + * {@code null}) + * @param pqcAlgId post-quantum agreement algorithm identifier + * @param pqcResponderPrivate responder private key for the PQC leg + * (decapsulation side) + * @param pqcSpec optional PQC context spec (may be + * {@code null}) + * @return responder-side {@link HybridKexContext} + * @throws NullPointerException if any required argument is {@code null} + * @throws IOException if underlying context creation fails + * @since 1.0 + */ + public static HybridKexContext responder(HybridKexProfile profile, String classicAlgId, + PrivateKey classicResponderPrivate, PublicKey classicPeerPublic, ContextSpec classicSpec, String pqcAlgId, + PrivateKey pqcResponderPrivate, ContextSpec pqcSpec) throws IOException { + + Objects.requireNonNull(profile, "profile"); + Objects.requireNonNull(classicAlgId, "classicAlgId"); + Objects.requireNonNull(classicResponderPrivate, "classicResponderPrivate"); + Objects.requireNonNull(classicPeerPublic, "classicPeerPublic"); + Objects.requireNonNull(pqcAlgId, "pqcAlgId"); + Objects.requireNonNull(pqcResponderPrivate, "pqcResponderPrivate"); + + AgreementContext classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT, classicResponderPrivate, + classicSpec); + classic.setPeerPublic(classicPeerPublic); + + MessageAgreementContext pqc = CryptoAlgorithms.create(pqcAlgId, KeyUsage.AGREEMENT, pqcResponderPrivate, + pqcSpec); + + return new HybridKexContext(profile, classic, pqc); + } + + /** + * Creates an initiator-side hybrid KEX where the classic leg is message-based + * (PAIR_MESSAGE). + * + *+ * The classic message is the SPKI encoding of the initiator's classic public + * key as produced by the underlying {@link MessageAgreementContext}. The PQC + * message is the PQC encapsulation payload (typically KEM ciphertext). + *
+ * + * @param profile hybrid profile defining HKDF binding and + * output length + * @param classicAlgId classic agreement algorithm identifier (e.g. + * "X25519", "ECDH", "DH") + * @param classicInitiatorKeyPair classic initiator key pair wrapped as + * {@code KeyPairKey} + * @param classicSpec classic context spec (may be {@code null} if + * supported) + * @param pqcAlgId post-quantum agreement algorithm identifier + * (e.g. "ML-KEM") + * @param pqcPeerPublic PQC peer public key (encapsulation side) + * @param pqcSpec optional PQC context spec (may be + * {@code null}) + * @return initiator-side {@link HybridKexContext} + * @throws NullPointerException if any required argument is {@code null} + * @throws IOException if underlying context creation fails + */ + public static HybridKexContext initiatorPairMessage(HybridKexProfile profile, String classicAlgId, + zeroecho.core.alg.common.agreement.KeyPairKey classicInitiatorKeyPair, ContextSpec classicSpec, + String pqcAlgId, PublicKey pqcPeerPublic, ContextSpec pqcSpec) throws IOException { + + Objects.requireNonNull(profile, "profile"); + Objects.requireNonNull(classicAlgId, "classicAlgId"); + Objects.requireNonNull(classicInitiatorKeyPair, "classicInitiatorKeyPair"); + Objects.requireNonNull(pqcAlgId, "pqcAlgId"); + Objects.requireNonNull(pqcPeerPublic, "pqcPeerPublic"); + + MessageAgreementContext classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT, + classicInitiatorKeyPair, classicSpec); + + MessageAgreementContext pqc = CryptoAlgorithms.create(pqcAlgId, KeyUsage.AGREEMENT, pqcPeerPublic, pqcSpec); + + return new HybridKexContext(profile, classic, pqc); + } + + /** + * Creates a responder-side hybrid KEX where the classic leg is message-based + * (PAIR_MESSAGE). + * + *+ * The responder must call {@link HybridKexContext#setPeerMessage(byte[])} with + * the initiator's hybrid message before calling + * {@link HybridKexContext#deriveSecret()}. + *
+ * + * @param profile hybrid profile defining HKDF binding and + * output length + * @param classicAlgId classic agreement algorithm identifier + * @param classicResponderKeyPair classic responder key pair wrapped as + * {@code KeyPairKey} + * @param classicSpec classic context spec (may be {@code null}) + * @param pqcAlgId post-quantum agreement algorithm identifier + * @param pqcResponderPrivate PQC responder private key (decapsulation side) + * @param pqcSpec optional PQC context spec (may be + * {@code null}) + * @return responder-side {@link HybridKexContext} + * @throws NullPointerException if any required argument is {@code null} + * @throws IOException if underlying context creation fails + */ + public static HybridKexContext responderPairMessage(HybridKexProfile profile, String classicAlgId, + zeroecho.core.alg.common.agreement.KeyPairKey classicResponderKeyPair, ContextSpec classicSpec, + String pqcAlgId, PrivateKey pqcResponderPrivate, ContextSpec pqcSpec) throws IOException { + + Objects.requireNonNull(profile, "profile"); + Objects.requireNonNull(classicAlgId, "classicAlgId"); + Objects.requireNonNull(classicResponderKeyPair, "classicResponderKeyPair"); + Objects.requireNonNull(pqcAlgId, "pqcAlgId"); + Objects.requireNonNull(pqcResponderPrivate, "pqcResponderPrivate"); + + MessageAgreementContext classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT, + classicResponderKeyPair, classicSpec); + + MessageAgreementContext pqc = CryptoAlgorithms.create(pqcAlgId, KeyUsage.AGREEMENT, pqcResponderPrivate, + pqcSpec); + + return new HybridKexContext(profile, classic, pqc); + } +} diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexExporter.java b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexExporter.java new file mode 100644 index 0000000..a6da588 --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexExporter.java @@ -0,0 +1,134 @@ +/******************************************************************************* + * Copyright (C) 2025, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.sdk.hybrid.kex; + +import java.security.GeneralSecurityException; +import java.util.Objects; + +import zeroecho.sdk.util.Kdf; + +/** + * Deterministic exporter for deriving multiple independent keys from a single + * hybrid KEX. + * + *+ * Many protocols require more than one key from a single handshake (for + * example, separate traffic keys for each direction, confirmation keys, or + * application exporters). This class provides a minimal key schedule API over + * HKDF-SHA256. + *
+ * + *+ * 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}. + *
+ * + *+ * This is intended for diagnostics/testing only. Applications should prefer + * {@link #export(String, byte[], int)}. + *
+ * + * @return copy of root secret + */ + public byte[] rootSecretCopy() { + return rootSecret.clone(); + } +} diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexPolicy.java b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexPolicy.java new file mode 100644 index 0000000..794901d --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexPolicy.java @@ -0,0 +1,124 @@ +/******************************************************************************* + * Copyright (C) 2025, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.sdk.hybrid.kex; + +import java.security.Key; +import java.util.Objects; + +import zeroecho.core.context.AgreementContext; +import zeroecho.core.context.MessageAgreementContext; +import zeroecho.core.policy.SecurityStrengthAdvisor; + +/** + * Hybrid KEX policy helper for minimum security strength enforcement. + * + *+ * This policy applies additional hybrid-specific checks beyond per-algorithm + * policy validation. It is intended to prevent accidental downgrade + * combinations (for example a strong PQC leg combined with a too-weak classic + * leg, or an undersized OKM output). + *
+ * + *+ * Strength estimates are provided by + * {@link SecurityStrengthAdvisor#estimateBits(String, Key)} and are + * conservative heuristics suitable for gating and coarse comparisons. + *
+ * + * @since 1.0 + */ +public final class HybridKexPolicy { + + private final int minClassicBits; + private final int minPqcBits; + private final int minOkmBytes; + + /** + * Creates a hybrid policy. + * + * @param minClassicBits minimum estimated bits for classic leg (for example 128 + * or 192) + * @param minPqcBits minimum estimated bits for PQC leg (for example 192) + * @param minOkmBytes minimum OKM output length in bytes (for example 32) + */ + public HybridKexPolicy(int minClassicBits, int minPqcBits, int minOkmBytes) { + if (minClassicBits < 0 || minPqcBits < 0) { + throw new IllegalArgumentException("min bits must be non-negative"); + } + if (minOkmBytes < 1) { // NOPMD + throw new IllegalArgumentException("minOkmBytes must be >= 1"); + } + this.minClassicBits = minClassicBits; + this.minPqcBits = minPqcBits; + this.minOkmBytes = minOkmBytes; + } + + /** + * Enforces the policy for a given hybrid configuration. + * + * @param profile hybrid profile + * @param classic classic agreement context + * @param pqc PQC message agreement context + * @throws NullPointerException if any argument is null + * @throws IllegalArgumentException if policy is violated + */ + public void enforce(HybridKexProfile profile, AgreementContext classic, MessageAgreementContext pqc) { + Objects.requireNonNull(profile, "profile"); + Objects.requireNonNull(classic, "classic"); + Objects.requireNonNull(pqc, "pqc"); + + if (profile.outLenBytes() < minOkmBytes) { + throw new IllegalArgumentException( + "Hybrid OKM length too small: " + profile.outLenBytes() + " < " + minOkmBytes); + } + + int classicBits = SecurityStrengthAdvisor.estimateBits(classic.algorithm().id(), classic.key()); + int pqcBits = SecurityStrengthAdvisor.estimateBits(pqc.algorithm().id(), pqc.key()); + + if (classicBits < minClassicBits) { + throw new IllegalArgumentException("Classic leg too weak: " + classicBits + " < " + minClassicBits); + } + if (pqcBits < minPqcBits) { + throw new IllegalArgumentException("PQC leg too weak: " + pqcBits + " < " + minPqcBits); + } + } +} diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexProfile.java b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexProfile.java new file mode 100644 index 0000000..f1772a9 --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexProfile.java @@ -0,0 +1,108 @@ +/******************************************************************************* + * Copyright (C) 2025, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.sdk.hybrid.kex; + +import java.nio.charset.StandardCharsets; + +/** + * Profile for a hybrid key exchange (KEX) composition. + * + *+ * A hybrid KEX combines two independently derived secrets: + *
+ *+ * The two secrets are combined using HKDF-SHA256 (RFC 5869) to produce a final + * keying material byte array of a caller-selected length. The default label is + * intended to make cross-protocol misuse harder by providing an explicit domain + * separator. + *
+ * + *+ * 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}. + *
+ * + *+ * The transcript is encoded as a sequence of TLV-like entries: + *
+ *{@code
+ * [u16 tagLen][tagBytes...][u32 valueLen][valueBytes...] ...
+ * }
+ *
+ * + * Tags are ASCII identifiers (for example {@code "suite"}, {@code "role"}, + * {@code "peerA"}, {@code "peerB"}, {@code "classicMsg"}, {@code "pqcMsg"}). + * Values are arbitrary bytes. The encoding is stable across JVMs and + * independent of locale. + *
+ * + *+ * Instances are mutable and not thread-safe. + *
+ * + * @since 1.0 + */ +public final class HybridKexTranscript { + + private final ByteArrayOutputStream buffer; + private final DataOutputStream out; + + /** + * Creates a new empty transcript. + */ + public HybridKexTranscript() { + this.buffer = new ByteArrayOutputStream(); + this.out = new DataOutputStream(buffer); + } + + /** + * Adds a UTF-8 string value under a tag. + * + * @param tag ASCII tag identifier + * @param value UTF-8 string value (must not be null) + * @return this transcript + * @throws NullPointerException if tag or value is null + * @throws IllegalArgumentException if tag is empty + */ + public HybridKexTranscript addUtf8(String tag, String value) { + Objects.requireNonNull(value, "value"); + return addBytes(tag, value.getBytes(StandardCharsets.UTF_8)); + } + + /** + * Adds a raw byte value under a tag. + * + *+ * The value is defensively copied by the caller if needed; this method does not + * retain a reference to the provided array. + *
+ * + * @param tag ASCII tag identifier + * @param value byte value (must not be null) + * @return this transcript + * @throws NullPointerException if tag or value is null + * @throws IllegalArgumentException if tag is empty + */ + public HybridKexTranscript addBytes(String tag, byte[] value) { + Objects.requireNonNull(tag, "tag"); + Objects.requireNonNull(value, "value"); + if (tag.isEmpty()) { + throw new IllegalArgumentException("tag must not be empty"); + } + + byte[] tagBytes = tag.getBytes(StandardCharsets.US_ASCII); + + try { + if (tagBytes.length > 65_535) { // NOPMD + throw new IllegalArgumentException("tag too long"); + } + out.writeShort(tagBytes.length); + out.write(tagBytes); + out.writeInt(value.length); + out.write(value); + out.flush(); + return this; + } catch (IOException e) { + // ByteArrayOutputStream should not throw, but keep failure explicit. + throw new IllegalStateException("Unable to encode transcript entry", e); + } + } + + /** + * Returns the canonical transcript bytes (defensive copy). + * + * @return transcript bytes + */ + public byte[] toByteArray() { + return buffer.toByteArray(); + } +} diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/kex/package-info.java b/lib/src/main/java/zeroecho/sdk/hybrid/kex/package-info.java new file mode 100644 index 0000000..42f8e9a --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/kex/package-info.java @@ -0,0 +1,81 @@ +/******************************************************************************* + * Copyright (C) 2025, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +/** + * Hybrid key exchange (KEX) utilities combining a classic agreement and a + * post-quantum KEM-style agreement into one derived shared secret. + * + *+ * This package provides an SDK-level composition layer over existing core + * contracts: + *
+ *+ * 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: + *
+ *+ * 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. + *
+ * + *+ * Use {@link zeroecho.sdk.hybrid.kex.HybridKexContexts} to build initiator and + * responder contexts and exchange + * {@link zeroecho.sdk.hybrid.kex.HybridKexContext#getPeerMessage()} between + * parties. + *
+ * + * @since 1.0 + */ +package zeroecho.sdk.hybrid.kex; diff --git a/lib/src/test/java/zeroecho/sdk/hybrid/kex/HybridKexTest.java b/lib/src/test/java/zeroecho/sdk/hybrid/kex/HybridKexTest.java new file mode 100644 index 0000000..fbee9ab --- /dev/null +++ b/lib/src/test/java/zeroecho/sdk/hybrid/kex/HybridKexTest.java @@ -0,0 +1,251 @@ +/******************************************************************************* + * Copyright (C) 2025, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.sdk.hybrid.kex; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.security.KeyPair; +import java.util.Arrays; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import zeroecho.core.CryptoAlgorithms; +import zeroecho.core.alg.common.agreement.KeyPairKey; +import zeroecho.core.alg.kyber.KyberKeyGenSpec; +import zeroecho.core.alg.xdh.XdhSpec; +import zeroecho.sdk.util.BouncyCastleActivator; + +/** + * Hybrid KEX tests. + */ +public class HybridKexTest { + + @BeforeAll + static void setup() { + // Optional: enable BC if you use BC-only modes in KEM payloads (EAX/OCB/CCM, + // etc.) + try { + BouncyCastleActivator.init(); + } catch (Throwable ignore) { + // keep tests runnable without BC if not present + } + } + + private static void logBegin(Object... params) { + String thisClass = HybridKexTest.class.getName(); + String method = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE) + .walk(frames -> frames + .dropWhile(f -> !f.getClassName().equals(thisClass) || f.getMethodName().equals("logBegin")) + .findFirst().map(StackWalker.StackFrame::getMethodName).orElse(">")); + System.out.println(method + "(" + Arrays.deepToString(params) + ")"); + } + + private static void logEnd() { + String thisClass = HybridKexTest.class.getName(); + String method = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE) + .walk(frames -> frames + .dropWhile(f -> !f.getClassName().equals(thisClass) || f.getMethodName().equals("logEnd")) + .findFirst().map(StackWalker.StackFrame::getMethodName).orElse(">")); + System.out.println(method + "...ok"); + } + + private static String hex(byte[] b) { + if (b == null) { + return "null"; + } + StringBuilder sb = new StringBuilder(b.length * 2); + for (byte v : b) { + if (sb.length() == 80) { + sb.append("..."); + break; + } + if ((v & 0xFF) < 16) { + sb.append('0'); + } + sb.append(Integer.toHexString(v & 0xFF)); + } + return sb.toString(); + } + + private static String lens(byte[] msg) { + if (msg == null || msg.length < 8) { + return "classicLen=?, pqcLen=?"; + } + try { + DataInputStream in = new DataInputStream(new ByteArrayInputStream(msg)); + int classicLen = in.readInt(); + int pqcLen = 0; + if (msg.length >= 8 + Math.max(0, classicLen)) { + if (classicLen > 0) { + in.skipBytes(classicLen); + } + pqcLen = in.readInt(); + } + return "classicLen=" + classicLen + ", pqcLen=" + pqcLen; + } catch (Exception e) { + return "classicLen=?, pqcLen=?"; + } + } + + @Test + void hybrid_x25519_mlkem_roundtrip() throws Exception { + logBegin("CLASSIC_AGREEMENT + KEM_ADAPTER", "Xdh/X25519 + ML-KEM(768)", "HKDF-SHA256", "32 bytes"); + + HybridKexProfile profile = HybridKexProfile.defaultProfile(32); + + // Classic: X25519 key pairs (Xdh + XdhSpec.X25519) + KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + + // PQC: ML-KEM key pair (Kyber variant) + KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768()); + + HybridKexContext alice = null; + HybridKexContext bob = null; + + try { + // Initiator: classic uses Alice private + Bob classic public; PQC uses Bob PQC + // public + alice = HybridKexContexts.initiator(profile, "Xdh", aliceClassic.getPrivate(), bobClassic.getPublic(), + XdhSpec.X25519, "ML-KEM", bobPqc.getPublic(), null); + + // Responder: classic uses Bob private + Alice classic public; PQC uses Bob PQC + // private + bob = HybridKexContexts.responder(profile, "Xdh", bobClassic.getPrivate(), aliceClassic.getPublic(), + XdhSpec.X25519, "ML-KEM", bobPqc.getPrivate(), null); + + // Alice produces message (contains PQC ciphertext; classic part is empty here) + byte[] aliceMsg = alice.getPeerMessage(); + System.out.println("...aliceMsg(" + lens(aliceMsg) + ")=" + hex(aliceMsg)); + + // Bob consumes message + bob.setPeerMessage(aliceMsg); + + byte[] kA = alice.deriveSecret(); + byte[] kB = bob.deriveSecret(); + + System.out.println("...kA=" + hex(kA)); + System.out.println("...kB=" + hex(kB)); + + assertNotNull(kA); + assertNotNull(kB); + assertArrayEquals(kA, kB); + } finally { + if (alice != null) { + try { + alice.close(); + } catch (Exception ignore) { + } + } + if (bob != null) { + try { + bob.close(); + } catch (Exception ignore) { + } + } + } + + logEnd(); + } + + @Test + void hybrid_x25519_pairmessage_mlkem_roundtrip() throws Exception { + logBegin("PAIR_MESSAGE + KEM_ADAPTER", "Xdh/X25519 + ML-KEM(768)", "HKDF-SHA256", "32 bytes"); + + HybridKexProfile profile = HybridKexProfile.defaultProfile(32); + + // Classic: X25519 key pairs (Xdh + XdhSpec.X25519) + KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + + // PQC: ML-KEM key pair (recipient/responder) + KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768()); + + HybridKexContext alice = null; + HybridKexContext bob = null; + + try { + // Classic leg is message-based on both sides (PAIR_MESSAGE capability: + // KeyPairKey + ContextSpec). + // PQC leg is KEM-style: initiator uses recipient public key; responder uses + // recipient private key. + alice = HybridKexContexts.initiatorPairMessage(profile, "Xdh", new KeyPairKey(aliceClassic), XdhSpec.X25519, + "ML-KEM", bobPqc.getPublic(), null); + + bob = HybridKexContexts.responderPairMessage(profile, "Xdh", new KeyPairKey(bobClassic), XdhSpec.X25519, + "ML-KEM", bobPqc.getPrivate(), null); + + // Step 1: Alice -> Bob (classic SPKI + PQC ciphertext) + byte[] msgA = alice.getPeerMessage(); + System.out.println("...msgA(" + lens(msgA) + ")=" + hex(msgA)); + bob.setPeerMessage(msgA); + + // Step 2: Bob -> Alice (classic SPKI only; PQC part is empty) + byte[] msgB = bob.getPeerMessage(); + System.out.println("...msgB(" + lens(msgB) + ")=" + hex(msgB)); + alice.setPeerMessage(msgB); + + // Both sides derive the final hybrid OKM. + byte[] kA = alice.deriveSecret(); + byte[] kB = bob.deriveSecret(); + + System.out.println("...kA=" + hex(kA)); + System.out.println("...kB=" + hex(kB)); + + assertNotNull(kA); + assertNotNull(kB); + assertArrayEquals(kA, kB); + } finally { + if (alice != null) { + try { + alice.close(); + } catch (Exception ignore) { + } + } + if (bob != null) { + try { + bob.close(); + } catch (Exception ignore) { + } + } + } + + logEnd(); + } +} diff --git a/samples/src/test/java/demo/HybridKexDemoTest.java b/samples/src/test/java/demo/HybridKexDemoTest.java new file mode 100644 index 0000000..19f1269 --- /dev/null +++ b/samples/src/test/java/demo/HybridKexDemoTest.java @@ -0,0 +1,536 @@ +/******************************************************************************* + * Copyright (C) 2025, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package demo; + +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.security.KeyPair; +import java.util.Arrays; +import java.util.logging.Logger; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import zeroecho.core.CryptoAlgorithms; +import zeroecho.core.alg.common.agreement.KeyPairKey; +import zeroecho.core.alg.kyber.KyberKeyGenSpec; +import zeroecho.core.alg.xdh.XdhSpec; +import zeroecho.sdk.hybrid.kex.HybridKexContext; +import zeroecho.sdk.hybrid.kex.HybridKexContexts; +import zeroecho.sdk.hybrid.kex.HybridKexProfile; +import zeroecho.sdk.util.BouncyCastleActivator; + +/** + * Demonstration of hybrid key exchange (KEX) usage in ZeroEcho. + * + *+ * Hybrid KEX in this project means: + *
+ *+ * The two independent secrets are combined using HKDF-SHA256 in the SDK hybrid + * layer. The application consumes only the final derived keying material (OKM), + * not the raw leg secrets. + *
+ * + *+ * The classic leg can be wired in two ways: + *
+ *+ * These examples intentionally avoid {@code try-with-resources}. Agreement + * contexts represent protocol-level state rather than traditional I/O + * resources; in real protocols their lifecycle often spans multiple + * send/receive steps and does not naturally fit a single lexical scope. Using + * explicit close blocks keeps the handshake lifecycle visible to the reader. + *
+ */ +class HybridKexDemoTest { + + private static final Logger LOG = Logger.getLogger(HybridKexDemoTest.class.getName()); + + @BeforeAll + static void setup() { + // Optional: enable BC/BCPQC if present. + try { + BouncyCastleActivator.init(); + } catch (Throwable ignore) { + // keep runnable without BC if not present + } + } + + @Test + void x25519_classicAgreement_plus_mlKem768_kemAdapter() throws Exception { + logBegin("CLASSIC_AGREEMENT + KEM_ADAPTER", "Xdh/X25519 + ML-KEM-768", "HKDF-SHA256", "OKM=32B"); + + // Classic leg keys (Xdh + XdhSpec.X25519). + KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + + // PQC leg: Bob is the KEM recipient (has ML-KEM keypair). + KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768()); + + // Hybrid profile: default HKDF label, 32-byte output suitable for symmetric + // keys. + HybridKexProfile profile = HybridKexProfile.defaultProfile(32); + + HybridKexContext alice = null; + HybridKexContext bob = null; + + try { + // Alice (initiator): classic uses Alice private + Bob classic public + // (out-of-band). + // ...PQC uses Bob PQC public and will produce a KEM ciphertext. + alice = HybridKexContexts.initiator(profile, "Xdh", aliceClassic.getPrivate(), bobClassic.getPublic(), + XdhSpec.X25519, "ML-KEM", bobPqc.getPublic(), null); + + // Bob (responder): classic uses Bob private + Alice classic public + // (out-of-band). + // ...PQC uses Bob PQC private and will consume Alice's ciphertext. + bob = HybridKexContexts.responder(profile, "Xdh", bobClassic.getPrivate(), aliceClassic.getPublic(), + XdhSpec.X25519, "ML-KEM", bobPqc.getPrivate(), null); + + // Alice -> Bob: hybrid message carries PQC ciphertext; classic part is empty. + byte[] msgA = alice.getPeerMessage(); + System.out.println("...msgA " + lens(msgA) + " " + shortHex(msgA)); + + bob.setPeerMessage(msgA); + + // Both derive the final hybrid OKM (HKDF output). + byte[] okmA = alice.deriveSecret(); + byte[] okmB = bob.deriveSecret(); + + System.out.println("...okmA " + shortHex(okmA)); + System.out.println("...okmB " + shortHex(okmB)); + System.out.println("...equal " + Arrays.equals(okmA, okmB)); + + // Application would now feed OKM into symmetric key schedule / AEAD keys / etc. + } finally { + closeQuiet(alice); + closeQuiet(bob); + } + + logEnd(); + } + + @Test + void x25519_pairMessage_plus_mlKem768_kemAdapter() throws Exception { + logBegin("PAIR_MESSAGE + KEM_ADAPTER", "Xdh/X25519 + ML-KEM-768", "HKDF-SHA256", "OKM=32B"); + + // Classic leg keys (Xdh + XdhSpec.X25519). + KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + + // PQC leg: Bob is the KEM recipient (has ML-KEM keypair). + KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768()); + + HybridKexProfile profile = HybridKexProfile.defaultProfile(32); + + HybridKexContext alice = null; + HybridKexContext bob = null; + + try { + // Alice classic leg is message-based (PAIR_MESSAGE): it will emit her public + // key as SPKI bytes. + // ...PQC leg (KEM initiator) will emit ciphertext. + alice = HybridKexContexts.initiatorPairMessage(profile, "Xdh", new KeyPairKey(aliceClassic), XdhSpec.X25519, + "ML-KEM", bobPqc.getPublic(), null); + + // Bob classic leg is message-based (PAIR_MESSAGE): it will emit his public key + // as SPKI bytes. + // ...PQC leg (KEM responder) consumes ciphertext and typically does not emit a + // PQC message. + bob = HybridKexContexts.responderPairMessage(profile, "Xdh", new KeyPairKey(bobClassic), XdhSpec.X25519, + "ML-KEM", bobPqc.getPrivate(), null); + + // Alice -> Bob: hybrid message carries classic SPKI + PQC ciphertext. + byte[] msgA = alice.getPeerMessage(); + System.out.println("...msgA " + lens(msgA) + " " + shortHex(msgA)); + bob.setPeerMessage(msgA); + + // Bob -> Alice: hybrid message carries classic SPKI; PQC part may be empty + // (depends on PQC role). + byte[] msgB = bob.getPeerMessage(); + System.out.println("...msgB " + lens(msgB) + " " + shortHex(msgB)); + alice.setPeerMessage(msgB); + + // Both derive the final hybrid OKM (HKDF output). + byte[] okmA = alice.deriveSecret(); + byte[] okmB = bob.deriveSecret(); + + System.out.println("...okmA " + shortHex(okmA)); + System.out.println("...okmB " + shortHex(okmB)); + System.out.println("...equal " + Arrays.equals(okmA, okmB)); + } finally { + closeQuiet(alice); + closeQuiet(bob); + } + + logEnd(); + } + + @Test + void builder_x25519_classicAgreement_plus_mlKem768() throws Exception { + logBegin("Builder", "CLASSIC_AGREEMENT + KEM_ADAPTER", "Xdh/X25519 + ML-KEM-768", "HKDF-SHA256", "OKM=32B"); + + // ...Generate classic leg keys (Xdh + XdhSpec.X25519). + java.security.KeyPair aliceClassic = zeroecho.core.CryptoAlgorithms.generateKeyPair("Xdh", + zeroecho.core.alg.xdh.XdhSpec.X25519); + java.security.KeyPair bobClassic = zeroecho.core.CryptoAlgorithms.generateKeyPair("Xdh", + zeroecho.core.alg.xdh.XdhSpec.X25519); + + // ...Generate PQC (recipient) keys (ML-KEM-768). + java.security.KeyPair bobPqc = zeroecho.core.CryptoAlgorithms.generateKeyPair("ML-KEM", + zeroecho.core.alg.kyber.KyberKeyGenSpec.kyber768()); + + // ...Create a profile for HKDF (output length 32 bytes). + zeroecho.sdk.hybrid.kex.HybridKexProfile profile = zeroecho.sdk.hybrid.kex.HybridKexProfile.defaultProfile(32); + + // ...Build a transcript to bind HKDF info to public handshake context. + zeroecho.sdk.hybrid.kex.HybridKexTranscript transcript = new zeroecho.sdk.hybrid.kex.HybridKexTranscript() + .addUtf8("suite", "X25519+MLKEM768").addUtf8("role", "demo"); + + // ...Define a minimum-strength policy (example: classic >= 128, PQC >= 192, OKM + // >= 32 bytes). + zeroecho.sdk.hybrid.kex.HybridKexPolicy policy = new zeroecho.sdk.hybrid.kex.HybridKexPolicy(128, 192, 32); + + // ...Start the builder. + zeroecho.sdk.builders.HybridKexBuilder b = zeroecho.sdk.builders.HybridKexBuilder.builder() + // ...Set HKDF profile (salt/info/outLen). + .profile(profile) + // ...Bind HKDF info to transcript (protocol context). + .transcript(transcript) + // ...Enable hybrid policy gating for this build. + .policy(policy); + + // ...Select classic mode where peer public key is known out-of-band. + zeroecho.sdk.builders.HybridKexBuilder.ClassicAgreement classicCfg = b.classicAgreement() + // ...Set classic algorithm id (X25519 is represented as "Xdh" with + // XdhSpec.X25519). + .algorithm("Xdh") + // ...Set classic parameters (X25519 curve spec). + .spec(zeroecho.core.alg.xdh.XdhSpec.X25519) + // ...Set local classic private key (Alice). + .privateKey(aliceClassic.getPrivate()) + // ...Set peer classic public key (Bob). + .peerPublic(bobClassic.getPublic()); + + // ...Continue with PQC KEM adapter configuration for initiator role. + zeroecho.sdk.hybrid.kex.HybridKexContext alice = classicCfg.pqcKem() + // ...Set PQC algorithm id (ML-KEM). + .algorithm("ML-KEM") + // ...Set PQC recipient public key (Bob). + .peerPublic(bobPqc.getPublic()) + // ...Build initiator-side hybrid context. + .buildInitiator(); + + // ...Build responder-side context (Bob) with symmetric configuration (note: PQC + // uses private key). + zeroecho.sdk.hybrid.kex.HybridKexContext bob = zeroecho.sdk.builders.HybridKexBuilder.builder() + // ...Set the same HKDF profile to derive the same OKM. + .profile(profile) + // ...Bind the same transcript to ensure both sides derive the same OKM. + .transcript(transcript) + // ...Enable the same policy gate. + .policy(policy) + // ...Select classic agreement mode (peer public is out-of-band). + .classicAgreement() + // ...Set classic algorithm id. + .algorithm("Xdh") + // ...Set classic parameters. + .spec(zeroecho.core.alg.xdh.XdhSpec.X25519) + // ...Set local classic private key (Bob). + .privateKey(bobClassic.getPrivate()) + // ...Set peer classic public key (Alice). + .peerPublic(aliceClassic.getPublic()) + // ...Continue to PQC configuration. + .pqcKem() + // ...Set PQC algorithm id. + .algorithm("ML-KEM") + // ...Set PQC recipient private key (Bob). + .privateKey(bobPqc.getPrivate()) + // ...Build responder-side hybrid context. + .buildResponder(); + + try { + // ...Alice produces the hybrid message (PQC ciphertext; classic part is empty + // in this mode). + byte[] msgA = alice.getPeerMessage(); + System.out.println("...msgA " + lens(msgA) + " " + shortHex(msgA)); + + // ...Bob consumes Alice message. + bob.setPeerMessage(msgA); + + // ...Both sides derive identical OKM. + byte[] okmA = alice.deriveSecret(); + byte[] okmB = bob.deriveSecret(); + + System.out.println("...okmA " + shortHex(okmA)); + System.out.println("...okmB " + shortHex(okmB)); + System.out.println("...equal " + java.util.Arrays.equals(okmA, okmB)); + + // ...Use exporter to derive purpose-specific keys bound to transcript. + zeroecho.sdk.hybrid.kex.HybridKexExporter exporterA = b.exporterFromOkm(okmA); + byte[] txA = exporterA.export("app/tx", transcript.toByteArray(), 32); + byte[] rxA = exporterA.export("app/rx", transcript.toByteArray(), 32); + + System.out.println("...txA " + shortHex(txA)); + System.out.println("...rxA " + shortHex(rxA)); + } finally { + closeQuiet(alice); + closeQuiet(bob); + } + + logEnd(); + } + + @Test + void builder_x25519_pairMessage_plus_mlKem768() throws Exception { + logBegin("Builder", "PAIR_MESSAGE + KEM_ADAPTER", "Xdh/X25519 + ML-KEM-768", "HKDF-SHA256", "OKM=32B"); + + // ...Generate classic leg keys (Xdh + XdhSpec.X25519). + KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + + // ...Generate PQC (recipient) keys (ML-KEM-768). + KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768()); + + // ...Create a profile for HKDF (output length 32 bytes). + HybridKexProfile profile = HybridKexProfile.defaultProfile(32); + + // ...Build a transcript to bind HKDF info to public handshake context. + // ...(Use identical transcript on both sides to derive the same OKM.) + zeroecho.sdk.hybrid.kex.HybridKexTranscript transcript = new zeroecho.sdk.hybrid.kex.HybridKexTranscript() + .addUtf8("suite", "X25519+MLKEM768").addUtf8("mode", "PAIR_MESSAGE").addUtf8("role", "demo"); + + // ...Define a minimum-strength policy (example: classic >= 128, PQC >= 192, OKM + // >= 32 bytes). + zeroecho.sdk.hybrid.kex.HybridKexPolicy policy = new zeroecho.sdk.hybrid.kex.HybridKexPolicy(128, 192, 32); + + // ...Start the initiator builder. + zeroecho.sdk.builders.HybridKexBuilder initBuilder = zeroecho.sdk.builders.HybridKexBuilder.builder() + // ...Set HKDF profile (salt/info/outLen). + .profile(profile) + // ...Bind HKDF info to transcript (protocol context). + .transcript(transcript) + // ...Enable hybrid policy gating for this build. + .policy(policy); + + // ...Select classic mode where the public key is carried in-band as a classic + // message (PAIR_MESSAGE). + zeroecho.sdk.hybrid.kex.HybridKexContext alice = initBuilder + // ...Switch classic leg to PAIR_MESSAGE mode. + .classicPairMessage() + // ...Set classic algorithm id (X25519 is represented as "Xdh" with + // XdhSpec.X25519). + .algorithm("Xdh") + // ...Set classic parameters (X25519 curve spec). + .spec(XdhSpec.X25519) + // ...Set local classic key pair wrapper (Alice). + .keyPair(new KeyPairKey(aliceClassic)) + // ...Continue with PQC KEM adapter configuration. + .pqcKem() + // ...Set PQC algorithm id (ML-KEM). + .algorithm("ML-KEM") + // ...Set PQC recipient public key (Bob). + .peerPublic(bobPqc.getPublic()) + // ...Build initiator-side hybrid context. + .buildInitiator(); + + // ...Start the responder builder. + zeroecho.sdk.builders.HybridKexBuilder respBuilder = zeroecho.sdk.builders.HybridKexBuilder.builder() + // ...Set the same HKDF profile to derive the same OKM. + .profile(profile) + // ...Bind the same transcript to ensure both sides derive the same OKM. + .transcript(transcript) + // ...Enable the same policy gate. + .policy(policy); + + // ...Build responder-side context (Bob). + zeroecho.sdk.hybrid.kex.HybridKexContext bob = respBuilder + // ...Switch classic leg to PAIR_MESSAGE mode. + .classicPairMessage() + // ...Set classic algorithm id. + .algorithm("Xdh") + // ...Set classic parameters. + .spec(XdhSpec.X25519) + // ...Set local classic key pair wrapper (Bob). + .keyPair(new KeyPairKey(bobClassic)) + // ...Continue with PQC KEM adapter configuration. + .pqcKem() + // ...Set PQC algorithm id. + .algorithm("ML-KEM") + // ...Set PQC recipient private key (Bob). + .privateKey(bobPqc.getPrivate()) + // ...Build responder-side hybrid context. + .buildResponder(); + + try { + // ...Alice produces the hybrid message: classic SPKI + PQC ciphertext. + byte[] msgA = alice.getPeerMessage(); + System.out.println("...msgA " + lens(msgA) + " " + shortHex(msgA)); + + // ...Bob consumes Alice message (learns Alice classic public + decapsulates PQC + // ciphertext). + bob.setPeerMessage(msgA); + + // ...Bob produces response message: classic SPKI; PQC part may be empty + // (role-dependent). + byte[] msgB = bob.getPeerMessage(); + System.out.println("...msgB " + lens(msgB) + " " + shortHex(msgB)); + + // ...Alice consumes Bob message (learns Bob classic public). + alice.setPeerMessage(msgB); + + // ...Both sides derive identical OKM. + byte[] okmA = alice.deriveSecret(); + byte[] okmB = bob.deriveSecret(); + + System.out.println("...okmA " + shortHex(okmA)); + System.out.println("...okmB " + shortHex(okmB)); + System.out.println("...equal " + Arrays.equals(okmA, okmB)); + + // ...Use exporter to derive purpose-specific keys bound to transcript. + zeroecho.sdk.hybrid.kex.HybridKexExporter exporterA = initBuilder.exporterFromOkm(okmA); + byte[] txA = exporterA.export("app/tx", transcript.toByteArray(), 32); + byte[] rxA = exporterA.export("app/rx", transcript.toByteArray(), 32); + + System.out.println("...txA " + shortHex(txA)); + System.out.println("...rxA " + shortHex(rxA)); + } finally { + closeQuiet(alice); + closeQuiet(bob); + } + + logEnd(); + } + + // ------------------------------------------------------------------------- + // helpers (JUnit output conventions) + // ------------------------------------------------------------------------- + + private static void logBegin(Object... params) { + String thisClass = HybridKexDemoTest.class.getName(); + String method = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE) + .walk(frames -> frames + .dropWhile(f -> !f.getClassName().equals(thisClass) || f.getMethodName().equals("logBegin")) + .findFirst().map(StackWalker.StackFrame::getMethodName).orElse(">")); + System.out.println(method + "(" + Arrays.deepToString(params) + ")"); + } + + private static void logEnd() { + String thisClass = HybridKexDemoTest.class.getName(); + String method = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE) + .walk(frames -> frames + .dropWhile(f -> !f.getClassName().equals(thisClass) || f.getMethodName().equals("logEnd")) + .findFirst().map(StackWalker.StackFrame::getMethodName).orElse(">")); + System.out.println(method + "...ok"); + } + + private static void closeQuiet(HybridKexContext ctx) { + if (ctx == null) { + return; + } + try { + ctx.close(); + } catch (Exception e) { + LOG.fine("close failed: " + e.getClass().getName()); + } + } + + private static String shortHex(byte[] b) { + if (b == null) { + return "null"; + } + StringBuilder sb = new StringBuilder(b.length * 2); + for (byte v : b) { + if (sb.length() == 80) { + sb.append("..."); + break; + } + if ((v & 0xFF) < 16) { + sb.append('0'); + } + sb.append(Integer.toHexString(v & 0xFF)); + } + return sb.toString(); + } + + private static String lens(byte[] msg) { + if (msg == null || msg.length < 8) { + return "[classicLen=?, pqcLen=?]"; + } + try { + DataInputStream in = new DataInputStream(new ByteArrayInputStream(msg)); + int classicLen = in.readInt(); + if (classicLen < 0) { + return "[classicLen=?, pqcLen=?]"; + } + if (classicLen > 0) { + in.skipBytes(classicLen); + } + int pqcLen = in.readInt(); + return "[classicLen=" + classicLen + ", pqcLen=" + pqcLen + "]"; + } catch (Exception e) { + return "[classicLen=?, pqcLen=?]"; + } + } +} From 300f40c2838a472480163f3c859a63b39f8b170c Mon Sep 17 00:00:00 2001 From: Leo Galambos+ * HMAC is defined for keys of arbitrary length; this method therefore does not + * express a strict requirement. It provides a conservative, + * interoperability-friendly recommendation intended for default key derivation + * and key generation paths, especially where the caller does not want to + * manually select a key size. + *
+ * + *+ * The recommendation follows common practice: use a key size at least equal to + * the underlying hash output length. For the built-in variants this yields: + *
+ *+ * If this spec uses an unrecognized {@link #macName()} value, the method + * returns {@code 256} bits as a safe default and to avoid failing existing + * applications that rely on custom provider names. Applications with strict + * requirements should enforce their own policy and/or explicitly specify a key + * size. + *
+ * + * @return recommended key size in bits (positive, multiple of 8) + * @since 1.0 + */ + public int recommendedKeyBits() { + return recommendedKeyBitsForMacName(macName); + } + + private static int recommendedKeyBitsForMacName(String macName) { + return switch (macName) { + case "HmacSHA256" -> 256; + case "HmacSHA384" -> 384; + case "HmacSHA512" -> 512; + default -> 256; + }; + } + } diff --git a/lib/src/main/java/zeroecho/sdk/builders/alg/HmacDataContentBuilder.java b/lib/src/main/java/zeroecho/sdk/builders/alg/HmacDataContentBuilder.java index 90a1c9b..664feed 100644 --- a/lib/src/main/java/zeroecho/sdk/builders/alg/HmacDataContentBuilder.java +++ b/lib/src/main/java/zeroecho/sdk/builders/alg/HmacDataContentBuilder.java @@ -253,6 +253,40 @@ public final class HmacDataContentBuilder implements DataContentBuilder+ * This is a convenience forwarding method to + * {@link HmacSpec#recommendedKeyBits()} and is intended as the default choice + * for derived-key integrations. Callers that intentionally need a non-default + * size may override it explicitly. + *
+ * + * @return recommended key size in bits (positive, multiple of 8) + * @since 1.0 + */ + public int recommendedKeyBits() { + return this.spec.recommendedKeyBits(); + } + /** * Switches the builder to MAC mode. * diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/derived/HybridDerived.java b/lib/src/main/java/zeroecho/sdk/hybrid/derived/HybridDerived.java new file mode 100644 index 0000000..34fcdf3 --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/derived/HybridDerived.java @@ -0,0 +1,378 @@ +/******************************************************************************* + * 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.derived; + +import java.util.Objects; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import zeroecho.core.alg.hmac.HmacSpec; +import zeroecho.sdk.builders.alg.AesDataContentBuilder; +import zeroecho.sdk.builders.alg.ChaChaDataContentBuilder; +import zeroecho.sdk.builders.alg.HmacDataContentBuilder; +import zeroecho.sdk.hybrid.kex.HybridKexExporter; + +/** + * Builder-style utility for deriving purpose-separated key material from a + * hybrid KEX exporter and applying it to streaming algorithm builders. + * + *+ * This class does not implement new cryptographic primitives. It derives keying + * bytes via HKDF labels (using {@link HybridKexExporter}) and injects them into + * existing builder instances. + *
+ * + *+ * Derivation uses a base label, plus fixed suffixes for individual fields: + *
+ *+ * A caller-supplied transcript binding (public handshake context) may be + * included and will be passed to the exporter as {@code info}. This improves + * cross-protocol separation and reduces configuration mistakes. + *
+ * + *+ * Instances are mutable and not thread-safe. + *
+ * + * @since 1.0 + */ +public final class HybridDerived { + + private final HybridKexExporter exporter; + + private String label; + private byte[] transcript; + + private byte[] aadExplicit; + private boolean aadDerive; + private int aadDeriveLen; + + /** + * Creates a new derived-material builder backed by an exporter. + * + * @param exporter exporter seeded from a hybrid KEX result (must not be null) + * @return new derived-material builder + * @throws NullPointerException if exporter is null + * @since 1.0 + */ + public static HybridDerived from(HybridKexExporter exporter) { + Objects.requireNonNull(exporter, "exporter"); + return new HybridDerived(exporter); + } + + private HybridDerived(HybridKexExporter exporter) { + this.exporter = exporter; + } + + /** + * Sets the base label used for purpose separation. + * + *+ * The label should identify the protocol purpose of the derived material, for + * example {@code "app/enc"} or {@code "handshake/confirm"}. + *
+ * + * @param label base label (must not be null or empty) + * @return this builder + * @throws NullPointerException if label is null + * @throws IllegalArgumentException if label is empty + * @since 1.0 + */ + public HybridDerived label(String label) { + Objects.requireNonNull(label, "label"); + if (label.isEmpty()) { + throw new IllegalArgumentException("label must not be empty"); + } + this.label = label; + return this; + } + + /** + * Sets transcript binding bytes used as exporter {@code info}. + * + *+ * The transcript should contain only public context (negotiated suite, public + * keys/messages, channel binding, etc.). It must not contain secrets. + *
+ * + * @param transcript transcript bytes (may be null to clear) + * @return this builder + * @since 1.0 + */ + public HybridDerived transcript(byte[] transcript) { + this.transcript = (transcript == null) ? null : transcript.clone(); + return this; + } + + /** + * Supplies explicit AAD bytes to be injected into AEAD builders. + * + *+ * If set, no AAD derivation is performed. + *
+ * + * @param aad AAD bytes (may be null to clear) + * @return this builder + * @since 1.0 + */ + public HybridDerived aad(byte[] aad) { + this.aadExplicit = (aad == null) ? null : aad.clone(); + this.aadDerive = false; + this.aadDeriveLen = 0; + return this; + } + + /** + * Requests deterministic derivation of AAD bytes from the exporter. + * + *+ * This is optional. Many applications prefer to keep AAD as an + * application-defined, already-available public context. If derived, the AAD is + * separated using {@code label + "/aad"}. + *
+ * + * @param aadLen number of bytes to derive (must be >= 1) + * @return this builder + * @throws IllegalArgumentException if aadLen < 1 + * @since 1.0 + */ + public HybridDerived deriveAad(int aadLen) { + if (aadLen < 1) { // NOPMD + throw new IllegalArgumentException("aadLen must be >= 1"); + } + this.aadDerive = true; + this.aadDeriveLen = aadLen; + this.aadExplicit = null; + return this; + } + + /** + * Derives an AES key and applies it (and optional IV/AAD) to the provided AES + * builder. + * + *+ * The returned value is the same builder instance to preserve fluent pipeline + * construction. + *
+ * + * @param aes AES builder to configure (must not be null) + * @param keyBits AES key size in bits (128/192/256) + * @param ivLenBytes if > 0, derive IV of this length and inject it via + * {@code withIv(...)}; if 0, do not set IV (header/ctx may + * generate it) + * @return the provided builder instance + * @throws NullPointerException if aes is null + * @throws IllegalArgumentException if keyBits is invalid + * @since 1.0 + */ + public AesDataContentBuilder applyToAesGcm(AesDataContentBuilder aes, int keyBits, int ivLenBytes) { + Objects.requireNonNull(aes, "aes"); + validateBase(); + + int keyLenBytes = bitsToBytesStrict(keyBits); + byte[] keyRaw = exportBytes(label + "/key", keyLenBytes); // NOPMD + SecretKey key = new SecretKeySpec(keyRaw, "AES"); + + aes.withKey(key); + + if (ivLenBytes > 0) { + byte[] iv = exportBytes(label + "/iv", ivLenBytes); + aes.withIv(iv); + } + + byte[] aad = resolveAad(); + if (aad != null) { + aes.withAad(aad); + } + + return aes; + } + + /** + * Derives a ChaCha key and applies it (and optional nonce/AAD) to the provided + * ChaCha builder. + * + *+ * The returned value is the same builder instance to preserve fluent pipeline + * construction. + *
+ * + * @param chacha ChaCha builder to configure (must not be null) + * @param keyBits key size in bits (typically 256) + * @param nonceLenBytes if > 0, derive nonce of this length and inject it via + * {@code withNonce(...)}; if 0, do not set nonce + * (header/ctx may generate it) + * @return the provided builder instance + * @throws NullPointerException if chacha is null + * @throws IllegalArgumentException if keyBits is invalid + * @since 1.0 + */ + public ChaChaDataContentBuilder applyToChaChaAead(ChaChaDataContentBuilder chacha, int keyBits, int nonceLenBytes) { + Objects.requireNonNull(chacha, "chacha"); + validateBase(); + + int keyLenBytes = bitsToBytesStrict(keyBits); + byte[] keyRaw = exportBytes(label + "/key", keyLenBytes); // NOPMD + SecretKey key = new SecretKeySpec(keyRaw, "ChaCha20"); + + chacha.withKey(key); + + if (nonceLenBytes > 0) { + byte[] nonce = exportBytes(label + "/nonce", nonceLenBytes); + chacha.withNonce(nonce); + } + + byte[] aad = resolveAad(); + if (aad != null) { + chacha.withAad(aad); + } + + return chacha; + } + + /** + * Derives a MAC key using the builder's recommended size and applies it to the + * provided HMAC builder. + * + *+ * This is the preferred integration method because it avoids duplicated + * configuration: the HMAC variant is chosen by the builder + * ({@link HmacDataContentBuilder#spec()}), and the key size recommendation is + * provided by {@link HmacDataContentBuilder#recommendedKeyBits()}. + *
+ * + *+ * The returned value is the same builder instance to preserve fluent pipeline + * construction. + *
+ * + * @param hmac HMAC builder to configure (must not be null) + * @return the provided builder instance + * @throws NullPointerException if {@code hmac} is null + * @throws IllegalStateException if this {@code HybridDerived} instance is + * missing required base configuration + * @since 1.0 + */ + public HmacDataContentBuilder applyToHmac(HmacDataContentBuilder hmac) { + Objects.requireNonNull(hmac, "hmac"); + validateBase(); + + int keyBits = hmac.recommendedKeyBits(); + return applyToHmac(hmac, keyBits); + } + + /** + * Derives a MAC key of an explicit size (override) and applies it to the + * provided HMAC builder. + * + *+ * This overload exists for advanced use-cases where the application + * intentionally chooses a key size different from + * {@link HmacSpec#recommendedKeyBits()}, for example to align a policy across + * different MAC functions or to satisfy interoperability constraints. + *
+ * + *+ * Because HMAC accepts arbitrary key lengths, this method does not attempt to + * validate semantic suitability of {@code keyBits}. Applications that require + * stricter controls should enforce them via policy (for example minimum bit + * strength) and use transcript-bound labels to guarantee key separation. + *
+ * + *+ * The returned value is the same builder instance to preserve fluent pipeline + * construction. + *
+ * + * @param hmac HMAC builder to configure (must not be null) + * @param keyBits key size in bits (must be a positive multiple of 8) + * @return the provided builder instance + * @throws NullPointerException if {@code hmac} is null + * @throws IllegalArgumentException if {@code keyBits} is invalid + * @throws IllegalStateException if this {@code HybridDerived} instance is + * missing required base configuration + * @since 1.0 + */ + public HmacDataContentBuilder applyToHmac(HmacDataContentBuilder hmac, int keyBits) { + Objects.requireNonNull(hmac, "hmac"); + validateBase(); + + int keyLenBytes = bitsToBytesStrict(keyBits); + byte[] keyRaw = exportBytes(label + "/key", keyLenBytes); + + // Prefer raw import to avoid duplicating MAC algorithm naming and to keep the + // builder as the source of truth. + return hmac.importKeyRaw(keyRaw); + } + + private void validateBase() { + if (label == null || label.isEmpty()) { + throw new IllegalStateException("label must be set"); + } + } + + private byte[] resolveAad() { + if (aadExplicit != null) { + return aadExplicit.clone(); + } + if (aadDerive) { + return exportBytes(label + "/aad", aadDeriveLen); + } + return null; // NOPMD + } + + private byte[] exportBytes(String subLabel, int len) { + byte[] info = transcript; + return exporter.export(subLabel, info, len); + } + + private static int bitsToBytesStrict(int bits) { + if (bits < 8 || (bits % 8) != 0) { + throw new IllegalArgumentException("bits must be a positive multiple of 8"); + } + return bits / 8; + } +} diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/HybridException.java b/lib/src/main/java/zeroecho/sdk/hybrid/derived/package-info.java similarity index 63% rename from lib/src/main/java/zeroecho/sdk/hybrid/HybridException.java rename to lib/src/main/java/zeroecho/sdk/hybrid/derived/package-info.java index 5c4c346..00bd95b 100644 --- a/lib/src/main/java/zeroecho/sdk/hybrid/HybridException.java +++ b/lib/src/main/java/zeroecho/sdk/hybrid/derived/package-info.java @@ -32,32 +32,36 @@ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE * POSSIBILITY OF SUCH DAMAGE. ******************************************************************************/ -package zeroecho.sdk.hybrid; - /** - * Base exception type for hybrid framework failures. + * Derived-key utilities for integrating hybrid KEX output with streaming + * builders. + * + *+ * This package provides a thin, SDK-level integration layer between hybrid key + * exchange ({@link zeroecho.sdk.hybrid.kex.HybridKexContext} / + * {@link zeroecho.sdk.hybrid.kex.HybridKexExporter}) and streaming data-content + * builders (for example AES/ChaCha/HMAC builders in + * {@link zeroecho.sdk.builders.alg}). + *
+ * + *+ * The central concept is derived material: purpose-separated keying + * bytes (key, optional IV/nonce, optional AAD) derived via HKDF labels. The + * material is then applied to an existing builder via {@code applyTo(...)} + * which returns the same builder instance to preserve fluent pipeline + * construction. + *
+ * + ** Underlying context construction uses - * {@link CryptoAlgorithms#create(String, KeyUsage, java.security.Key, Object)} + * {@link CryptoAlgorithms#create(String, KeyUsage, java.security.Key, ContextSpec)} * which may throw {@link IOException}. These factory methods propagate the * checked exception to keep failures explicit and auditable. *
diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/package-info.java b/lib/src/main/java/zeroecho/sdk/hybrid/package-info.java new file mode 100644 index 0000000..edaac66 --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/package-info.java @@ -0,0 +1,94 @@ +/******************************************************************************* + * 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. + ******************************************************************************/ +/** + * SDK-level hybrid cryptography utilities. + * + *+ * This package groups hybrid composition helpers that combine classical and + * post-quantum primitives at the SDK layer while keeping the underlying core + * contracts unchanged. Hybrid constructions are exposed as regular streaming + * contexts and builder integrations, so they can be used with existing pipeline + * APIs (for example {@link zeroecho.sdk.builders.core.DataContentChainBuilder} + * and trailer-oriented stages). + *
+ * + *+ * Hybrid contexts and builders are not thread-safe. Create a new instance per + * independent operation and do not share instances across concurrent pipeline + * executions. + *
+ * + * @since 1.0 + */ +package zeroecho.sdk.hybrid; diff --git a/lib/src/test/java/zeroecho/sdk/hybrid/derived/HybridDerivedTest.java b/lib/src/test/java/zeroecho/sdk/hybrid/derived/HybridDerivedTest.java new file mode 100644 index 0000000..b7b3e75 --- /dev/null +++ b/lib/src/test/java/zeroecho/sdk/hybrid/derived/HybridDerivedTest.java @@ -0,0 +1,341 @@ +/******************************************************************************* + * 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.derived; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Arrays; + +import org.junit.jupiter.api.Test; + +import zeroecho.sdk.builders.alg.AesDataContentBuilder; +import zeroecho.sdk.builders.alg.ChaChaDataContentBuilder; +import zeroecho.sdk.builders.alg.HmacDataContentBuilder; +import zeroecho.sdk.builders.core.DataContentBuilder; +import zeroecho.sdk.builders.core.DataContentChainBuilder; +import zeroecho.sdk.builders.core.PlainBytesBuilder; +import zeroecho.sdk.content.api.DataContent; +import zeroecho.sdk.hybrid.kex.HybridKexExporter; + +/** + * Coverage tests for {@link HybridDerived} derived-material application + * helpers. + * + *+ * The tests use deterministic exporter inputs (fixed OKM and salt) to ensure + * stable results. + *
+ */ +public class HybridDerivedTest { + + @Test + void aes_gcm_applyTo_roundtrip() throws Exception { + System.out.println("aes_gcm_applyTo_roundtrip()"); + HybridKexExporter exporter = testExporter(); + + byte[] transcript = "demo-transcript".getBytes(StandardCharsets.UTF_8); + byte[] aad = "aad".getBytes(StandardCharsets.UTF_8); + byte[] msg = fixedBytes(1024, (byte) 0x5A); + + AesDataContentBuilder encAes = AesDataContentBuilder.builder().withHeader().modeGcm(128); + + AesDataContentBuilder returnedEnc = HybridDerived.from(exporter).label("app/enc/aes").transcript(transcript) + .aad(aad).applyToAesGcm(encAes, 256, 12); + + System.out.println("...returnedEncSame=" + (returnedEnc == encAes)); + assertSame(encAes, returnedEnc); + + byte[] ciphertext = runEncrypt(encAes, msg); + System.out.println("...ciphertextLen=" + ciphertext.length); + System.out.println("...ciphertextPrefix=" + shortHex(ciphertext, 32)); + + AesDataContentBuilder decAes = AesDataContentBuilder.builder().withHeader().modeGcm(128); + + HybridDerived.from(exporter).label("app/enc/aes").transcript(transcript).aad(aad).applyToAesGcm(decAes, 256, + 12); + + byte[] out = runDecrypt(decAes, ciphertext); + System.out.println("...outPrefix=" + shortHex(out, 32)); + + assertArrayEquals(msg, out); + System.out.println("aes_gcm_applyTo_roundtrip...ok"); + } + + @Test + void aes_gcm_applyTo_negative_label_mismatch() throws Exception { + System.out.println("aes_gcm_applyTo_negative_label_mismatch()"); + HybridKexExporter exporter = testExporter(); + + byte[] transcript = "demo-transcript".getBytes(StandardCharsets.UTF_8); + byte[] aad = "aad".getBytes(StandardCharsets.UTF_8); + byte[] msg = fixedBytes(256, (byte) 0x1C); + + AesDataContentBuilder encAes = AesDataContentBuilder.builder().withHeader().modeGcm(128); + + HybridDerived.from(exporter).label("app/enc/aes").transcript(transcript).aad(aad).applyToAesGcm(encAes, 256, + 12); + + byte[] ciphertext = runEncrypt(encAes, msg); + System.out.println("...ciphertextLen=" + ciphertext.length); + + AesDataContentBuilder decAesWrong = AesDataContentBuilder.builder().withHeader().modeGcm(128); + + // ...label mismatch -> wrong key/iv/aad -> decryption must fail + HybridDerived.from(exporter).label("app/enc/aes_WRONG").transcript(transcript).aad(aad) + .applyToAesGcm(decAesWrong, 256, 12); + + assertThrows(Exception.class, () -> runDecrypt(decAesWrong, ciphertext)); + + System.out.println("aes_gcm_applyTo_negative_label_mismatch...ok"); + } + + @Test + void chacha_aead_applyTo_roundtrip() throws Exception { + System.out.println("chacha_aead_applyTo_roundtrip()"); + HybridKexExporter exporter = testExporter(); + + byte[] transcript = "demo-transcript".getBytes(StandardCharsets.UTF_8); + byte[] aad = "aad".getBytes(StandardCharsets.UTF_8); + byte[] msg = fixedBytes(777, (byte) 0x33); + + ChaChaDataContentBuilder encChaCha = ChaChaDataContentBuilder.builder().withHeader(); + + ChaChaDataContentBuilder returnedEnc = HybridDerived.from(exporter).label("app/enc/chacha") + .transcript(transcript).aad(aad).applyToChaChaAead(encChaCha, 256, 12); + + System.out.println("...returnedEncSame=" + (returnedEnc == encChaCha)); + assertSame(encChaCha, returnedEnc); + + byte[] ciphertext = runEncrypt(encChaCha, msg); + System.out.println("...ciphertextLen=" + ciphertext.length); + System.out.println("...ciphertextPrefix=" + shortHex(ciphertext, 32)); + + ChaChaDataContentBuilder decChaCha = ChaChaDataContentBuilder.builder().withHeader(); + + HybridDerived.from(exporter).label("app/enc/chacha").transcript(transcript).aad(aad) + .applyToChaChaAead(decChaCha, 256, 12); + + byte[] out = runDecrypt(decChaCha, ciphertext); + System.out.println("...outPrefix=" + shortHex(out, 32)); + + assertArrayEquals(msg, out); + System.out.println("chacha_aead_applyTo_roundtrip...ok"); + } + + @Test + void hmac_applyTo_default_and_override() throws Exception { + System.out.println("hmac_applyTo_default_and_override()"); + HybridKexExporter exporter = testExporter(); + + byte[] transcript = "demo-transcript".getBytes(StandardCharsets.UTF_8); + byte[] msg = fixedBytes(2048, (byte) 0x7E); + + // -------------------- + // Default key size path: applyToHmac(hmac) derives key using builder's + // recommended bits + // -------------------- + + HmacDataContentBuilder macBuilder = HmacDataContentBuilder.builder().sha256().emitHexTag(); + + int recommendedBits = macBuilder.recommendedKeyBits(); + System.out.println("...recommendedBits=" + recommendedBits); + + HybridDerived.from(exporter).label("app/mac/hmac-default").transcript(transcript).applyToHmac(macBuilder); + + String tagHex = runHmacHex(macBuilder, msg); + System.out.println("...tagHexPrefix=" + shortText(tagHex, 64)); + + HmacDataContentBuilder verifyBuilder = HmacDataContentBuilder.builder().sha256().expectedTagHex(tagHex) + .emitVerificationBoolean(); + + HybridDerived.from(exporter).label("app/mac/hmac-default").transcript(transcript).applyToHmac(verifyBuilder); + + String ok = runHmacVerifyBool(verifyBuilder, msg); + System.out.println("...verifyBool=" + ok); + assertEquals("true", ok); + + // -------------------- + // Override key size path: applyToHmac(hmac, keyBits) + // -------------------- + + HmacDataContentBuilder macBuilderOv = HmacDataContentBuilder.builder().sha256().emitHexTag(); + + // ...override to 512-bit keying material (still valid for HMAC; explicit expert + // choice) + HybridDerived.from(exporter).label("app/mac/hmac-override").transcript(transcript).applyToHmac(macBuilderOv, + 512); + + String tagHexOv = runHmacHex(macBuilderOv, msg); + System.out.println("...tagHexOvPrefix=" + shortText(tagHexOv, 64)); + + HmacDataContentBuilder verifyBuilderOv = HmacDataContentBuilder.builder().sha256().expectedTagHex(tagHexOv) + .emitVerificationBoolean(); + + HybridDerived.from(exporter).label("app/mac/hmac-override").transcript(transcript).applyToHmac(verifyBuilderOv, + 512); + + String okOv = runHmacVerifyBool(verifyBuilderOv, msg); + System.out.println("...verifyBoolOv=" + okOv); + assertEquals("true", okOv); + + // -------------------- + // Negative: wrong expected tag -> must emit "false" + // -------------------- + + HmacDataContentBuilder verifyBad = HmacDataContentBuilder.builder().sha256() + .expectedTagHex(tagHex.substring(0, Math.max(0, tagHex.length() - 2)) + "00").emitVerificationBoolean(); + + HybridDerived.from(exporter).label("app/mac/hmac-default").transcript(transcript).applyToHmac(verifyBad); + + String bad = runHmacVerifyBool(verifyBad, msg); + System.out.println("...verifyBoolBad=" + bad); + assertEquals("false", bad); + + // sanity: ensure the two tags differ (default vs override label/key schedule) + assertTrue(!tagHex.equals(tagHexOv)); + + System.out.println("hmac_applyTo_default_and_override...ok"); + } + + // -------------------- + // helpers + // -------------------- + + private static HybridKexExporter testExporter() { + byte[] okm = fixedBytes(32, (byte) 0x11); + byte[] salt = fixedBytes(32, (byte) 0x22); + return new HybridKexExporter(okm, salt); + } + + private static byte[] runEncrypt(DataContentBuilder+ * This sample is intentionally structured in two variants: + *
+ *+ * The hybrid combination (classic + PQC) happens in {@link HybridKexContext}. + * The derived layer ({@link HybridDerived}) consumes the exporter output (OKM + + * HKDF salt) and injects key/IV/AAD into existing streaming builders. + *
+ */ +class HybridDerivedAesDemoTest { + + private static final Logger LOG = Logger.getLogger(HybridDerivedAesDemoTest.class.getName()); + + @BeforeAll + static void setup() { + // Optional: enable BC if you use BC-only algorithms in the broader test suite. + try { + BouncyCastleActivator.init(); + } catch (Throwable ignore) { + // Keep samples runnable without BC if not present. + } + } + + @Test + void hybridDerived_aes_gcm_condensed() throws Exception { + System.out.println("hybridDerived_aes_gcm_condensed()"); + LOG.info("Hybrid-derived AES-GCM demo (condensed form)"); + + // ...Select a standard hybrid KEX profile (HKDF info/salt + OKM length). + HybridKexProfile profile = HybridKexProfile.defaultProfile(32); + + // ...Prepare plaintext. + byte[] msg = randomBytes(1024); + + // ...Prepare transcript (public context bound into HKDF info and derived + // labels). + HybridKexTranscript transcript = new HybridKexTranscript().addUtf8("suite", "X25519+MLKEM768").addUtf8("demo", + "hybrid-derived-aes-gcm-condensed"); + + // ...Generate classic key pairs for X25519 (Xdh + XdhSpec.X25519). + KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + + // ...Generate PQC key pair for ML-KEM-768 (recipient; used by Bob side to + // decapsulate). + KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768()); + + // ...Build Alice initiator: classic agreement (out-of-band peer pub) + PQC + // encapsulation. + HybridKexContext alice = HybridKexBuilder.builder() + // ...Set mandatory profile. + .profile(profile) + // ...Bind builder HKDF info to transcript. + .transcript(transcript) + // ...Select classic mode: peer public key is out-of-band. + .classicAgreement() + // ...Select classic algorithm id (Xdh). + .algorithm("Xdh") + // ...Select classic spec (X25519). + .spec(XdhSpec.X25519) + // ...Set Alice classic private key. + .privateKey(aliceClassic.getPrivate()) + // ...Set Bob classic public key. + .peerPublic(bobClassic.getPublic()) + // ...Switch to PQC KEM configuration. + .pqcKem() + // ...Select PQC algorithm id (ML-KEM). + .algorithm("ML-KEM") + // ...Set recipient PQC public key for encapsulation. + .peerPublic(bobPqc.getPublic()) + // ...Build initiator context. + .buildInitiator(); + + // ...Build Bob responder: classic agreement + PQC decapsulation. + HybridKexContext bob = HybridKexBuilder.builder() + // ...Set mandatory profile. + .profile(profile) + // ...Bind builder HKDF info to transcript. + .transcript(transcript) + // ...Select classic mode: peer public key is out-of-band. + .classicAgreement() + // ...Select classic algorithm id (Xdh). + .algorithm("Xdh") + // ...Select classic spec (X25519). + .spec(XdhSpec.X25519) + // ...Set Bob classic private key. + .privateKey(bobClassic.getPrivate()) + // ...Set Alice classic public key. + .peerPublic(aliceClassic.getPublic()) + // ...Switch to PQC KEM configuration. + .pqcKem() + // ...Select PQC algorithm id (ML-KEM). + .algorithm("ML-KEM") + // ...Set recipient PQC private key for decapsulation. + .privateKey(bobPqc.getPrivate()) + // ...Build responder context. + .buildResponder(); + + try { + // ...Alice produces peer message (PQC ciphertext; classic is out-of-band in + // this mode). + byte[] peerMsg = alice.getPeerMessage(); + System.out.println("...peerMsg " + lens(peerMsg) + " " + shortHex(peerMsg, 48)); + + // ...Bob consumes the peer message to complete the PQC leg. + bob.setPeerMessage(peerMsg); + + // ...Derive OKM on both sides (must match for a valid hybrid exchange). + byte[] okmA = alice.deriveSecret(); + byte[] okmB = bob.deriveSecret(); + System.out.println("...okmEqual " + Arrays.equals(okmA, okmB)); + if (!Arrays.equals(okmA, okmB)) { + throw new IllegalStateException("Hybrid KEX mismatch"); + } + + // ...Create exporter directly from OKM and profile salt (avoid exporterFromOkm + // validation requirements). + HybridKexExporter exporter = new HybridKexExporter(okmA, profile.hkdfSalt()); + + // ...Choose explicit AAD (public) for AEAD; must match on decrypt. + byte[] aad = "aad:demo".getBytes(StandardCharsets.UTF_8); + + // ...Encrypt: build pipeline in compact form with inline derived injection. + DataContent enc = DataContentChainBuilder.encrypt() + // ...Input: plaintext bytes. + .add(PlainBytesBuilder.builder().bytes(msg)) + // ...AEAD: derive key/IV/AAD and inject into AES-GCM builder. + .add(HybridDerived.from(exporter) + // ...Purpose separation label for AEAD encryption. + .label("app/enc/aes-gcm") + // ...Bind derivation to transcript bytes (public). + .transcript(transcript.toByteArray()) + // ...Inject explicit AAD. + .aad(aad) + // ...Apply derived key(256b) and IV(12B) to AES-GCM with header. + .applyToAesGcm(AesDataContentBuilder.builder() + // ...Store IV in header for decrypt side. + .withHeader() + // ...Use AES-GCM with 128-bit authentication tag. + .modeGcm(128), 256, 12)) + // ...Finalize pipeline. + .build(); + + byte[] ciphertext; + try (InputStream in = enc.getStream()) { + ciphertext = readAll(in); + } + System.out.println("...ciphertext " + lens(ciphertext) + " " + shortHex(ciphertext, 48)); + + // ...Decrypt: rebuild the same derived inputs and run decrypt pipeline. + DataContent dec = DataContentChainBuilder.decrypt() + // ...Input: ciphertext bytes. + .add(PlainBytesBuilder.builder().bytes(ciphertext)) + // ...AEAD: apply the same label/transcript/AAD to get identical key/IV. + .add(HybridDerived.from(exporter) + // ...Same purpose label as encryption. + .label("app/enc/aes-gcm") + // ...Same transcript binding as encryption. + .transcript(transcript.toByteArray()) + // ...Same explicit AAD as encryption. + .aad(aad) + // ...Apply derived key and IV to AES-GCM with header. + .applyToAesGcm(AesDataContentBuilder.builder() + // ...Parse IV from header. + .withHeader() + // ...Use AES-GCM with 128-bit authentication tag. + .modeGcm(128), 256, 12)) + // ...Finalize pipeline. + .build(); + + byte[] out; + try (InputStream in = dec.getStream()) { + out = readAll(in); + } + System.out.println("...plaintextEqual " + Arrays.equals(msg, out)); + if (!Arrays.equals(msg, out)) { + throw new IllegalStateException("Roundtrip mismatch"); + } + + System.out.println("hybridDerived_aes_gcm_condensed...ok"); + } finally { + closeQuiet(alice); + closeQuiet(bob); + } + } + + @Test + void hybridDerived_aes_gcm_expanded() throws Exception { + System.out.println("hybridDerived_aes_gcm_expanded()"); + LOG.info("Hybrid-derived AES-GCM demo (expanded form)"); + + // ...Select a standard hybrid KEX profile (HKDF info/salt + OKM length). + HybridKexProfile profile = HybridKexProfile.defaultProfile(32); + + // ...Prepare plaintext. + byte[] msg = randomBytes(1024); + + // ...Prepare transcript (public context bound into HKDF info and derived + // labels). + HybridKexTranscript transcript = new HybridKexTranscript().addUtf8("suite", "X25519+MLKEM768").addUtf8("demo", + "hybrid-derived-aes-gcm-expanded"); + + // ...Generate classic key pairs for X25519. + KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + + // ...Generate PQC key pair for ML-KEM-768. + KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768()); + + // ...Build Alice initiator in a step-by-step manner. + HybridKexBuilder aliceBuilder = HybridKexBuilder.builder(); + // ...Set mandatory profile. + aliceBuilder.profile(profile); + // ...Bind builder HKDF info to transcript. + aliceBuilder.transcript(transcript); + + // ...Select classic mode: peer public key is out-of-band. + HybridKexBuilder.ClassicAgreement aliceClassicCfg = aliceBuilder.classicAgreement(); + // ...Select classic algorithm id (Xdh). + aliceClassicCfg.algorithm("Xdh"); + // ...Select classic spec (X25519). + aliceClassicCfg.spec(XdhSpec.X25519); + // ...Set Alice classic private key. + aliceClassicCfg.privateKey(aliceClassic.getPrivate()); + // ...Set Bob classic public key. + aliceClassicCfg.peerPublic(bobClassic.getPublic()); + + // ...Switch to PQC KEM configuration. + HybridKexBuilder.PqcKem alicePqcCfg = aliceClassicCfg.pqcKem(); + // ...Select PQC algorithm id (ML-KEM). + alicePqcCfg.algorithm("ML-KEM"); + // ...Set recipient PQC public key for encapsulation. + alicePqcCfg.peerPublic(bobPqc.getPublic()); + + // ...Build initiator context. + HybridKexContext alice = alicePqcCfg.buildInitiator(); + + // ...Build Bob responder in a step-by-step manner. + HybridKexBuilder bobBuilder = HybridKexBuilder.builder(); + // ...Set mandatory profile. + bobBuilder.profile(profile); + // ...Bind builder HKDF info to transcript. + bobBuilder.transcript(transcript); + + // ...Select classic mode: peer public key is out-of-band. + HybridKexBuilder.ClassicAgreement bobClassicCfg = bobBuilder.classicAgreement(); + // ...Select classic algorithm id (Xdh). + bobClassicCfg.algorithm("Xdh"); + // ...Select classic spec (X25519). + bobClassicCfg.spec(XdhSpec.X25519); + // ...Set Bob classic private key. + bobClassicCfg.privateKey(bobClassic.getPrivate()); + // ...Set Alice classic public key. + bobClassicCfg.peerPublic(aliceClassic.getPublic()); + + // ...Switch to PQC KEM configuration. + HybridKexBuilder.PqcKem bobPqcCfg = bobClassicCfg.pqcKem(); + // ...Select PQC algorithm id (ML-KEM). + bobPqcCfg.algorithm("ML-KEM"); + // ...Set recipient PQC private key for decapsulation. + bobPqcCfg.privateKey(bobPqc.getPrivate()); + + // ...Build responder context. + HybridKexContext bob = bobPqcCfg.buildResponder(); + + try { + // ...Alice produces peer message (PQC ciphertext in this classic mode). + byte[] peerMsg = alice.getPeerMessage(); + System.out.println("...peerMsg " + lens(peerMsg) + " " + shortHex(peerMsg, 48)); + + // ...Bob consumes peer message to complete the PQC leg. + bob.setPeerMessage(peerMsg); + + // ...Derive OKM and ensure both sides match. + byte[] okmA = alice.deriveSecret(); + byte[] okmB = bob.deriveSecret(); + System.out.println("...okmEqual " + Arrays.equals(okmA, okmB)); + if (!Arrays.equals(okmA, okmB)) { + throw new IllegalStateException("Hybrid KEX mismatch"); + } + + // ...Create exporter directly from OKM and profile salt. + HybridKexExporter exporter = new HybridKexExporter(okmA, profile.hkdfSalt()); + + // ...Choose explicit AAD (public) for AEAD. + byte[] aad = "aad:demo:expanded".getBytes(StandardCharsets.UTF_8); + + // ...Prepare AES builder for encryption. + AesDataContentBuilder aesEnc = AesDataContentBuilder.builder(); + // ...Store IV in header. + aesEnc.withHeader(); + // ...Use AES-GCM with 128-bit authentication tag. + aesEnc.modeGcm(128); + + // ...Inject derived key/IV/AAD into AES builder. + HybridDerived.from(exporter) + // ...Purpose separation label for AEAD. + .label("app/enc/aes-gcm") + // ...Bind derivation to transcript bytes. + .transcript(transcript.toByteArray()) + // ...Inject explicit AAD. + .aad(aad) + // ...Apply derived key(256b) and IV(12B). + .applyToAesGcm(aesEnc, 256, 12); + + // ...Build encryption pipeline. + DataContent enc = DataContentChainBuilder.encrypt() + // ...Input: plaintext bytes. + .add(PlainBytesBuilder.builder().bytes(msg)) + // ...AES encryption stage. + .add(aesEnc) + // ...Finalize. + .build(); + + byte[] ciphertext; + try (InputStream in = enc.getStream()) { + ciphertext = readAll(in); + } + System.out.println("...ciphertext " + lens(ciphertext) + " " + shortHex(ciphertext, 48)); + + // ...Prepare AES builder for decryption. + AesDataContentBuilder aesDec = AesDataContentBuilder.builder(); + // ...Parse IV from header. + aesDec.withHeader(); + // ...Use AES-GCM with 128-bit authentication tag. + aesDec.modeGcm(128); + + // ...Inject the same derived key/IV/AAD into decryption builder. + HybridDerived.from(exporter) + // ...Same purpose label. + .label("app/enc/aes-gcm") + // ...Same transcript binding. + .transcript(transcript.toByteArray()) + // ...Same explicit AAD. + .aad(aad) + // ...Apply the same derived key and IV. + .applyToAesGcm(aesDec, 256, 12); + + // ...Build decryption pipeline. + DataContent dec = DataContentChainBuilder.decrypt() + // ...Input: ciphertext bytes. + .add(PlainBytesBuilder.builder().bytes(ciphertext)) + // ...AES decryption stage. + .add(aesDec) + // ...Finalize. + .build(); + + byte[] out; + try (InputStream in = dec.getStream()) { + out = readAll(in); + } + System.out.println("...plaintextEqual " + Arrays.equals(msg, out)); + if (!Arrays.equals(msg, out)) { + throw new IllegalStateException("Roundtrip mismatch"); + } + + System.out.println("hybridDerived_aes_gcm_expanded...ok"); + } finally { + closeQuiet(alice); + closeQuiet(bob); + } + } + + @Test + void hybridDerived_aes_gcm_local_self_recipient() throws Exception { + System.out.println("hybridDerived_aes_gcm_local_self_recipient()"); + LOG.info("Hybrid-derived AES-GCM demo (local self-recipient)"); + + // ...Select a standard hybrid KEX profile (HKDF info/salt + OKM length). + HybridKexProfile profile = HybridKexProfile.defaultProfile(32); + + // ...Prepare plaintext. + byte[] msg = randomBytes(1024); + + // ...Prepare transcript (public context bound into KDF and derived labels). + HybridKexTranscript transcript = new HybridKexTranscript() + // ...Identify the suite used by this envelope. + .addUtf8("suite", "X25519+MLKEM768") + // ...Identify that this is a local/self-recipient envelope. + .addUtf8("mode", "local-self"); + + // ...Choose explicit AAD (public) for AEAD; must match on decrypt. + byte[] aad = "aad:local-self".getBytes(StandardCharsets.UTF_8); + + // ...Generate classic identity keys (X25519). + KeyPair selfClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + + // ...Generate PQC identity keys (ML-KEM-768). + KeyPair selfPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768()); + + // ...Build local initiator (encapsulation) against our own public keys. + HybridKexContext encKex = HybridKexBuilder.builder() + // ...Set mandatory profile. + .profile(profile) + // ...Bind derivation to transcript. + .transcript(transcript) + // ...Select classic mode: peer public key is known out-of-band (here: our own + // public key). + .classicAgreement() + // ...Classic algorithm id (X25519). + .algorithm("Xdh") + // ...Classic spec (X25519). + .spec(XdhSpec.X25519) + // ...Use our private key. + .privateKey(selfClassic.getPrivate()) + // ...Use our public key as the peer public key (self-recipient). + .peerPublic(selfClassic.getPublic()) + // ...Switch to PQC KEM. + .pqcKem() + // ...PQC algorithm id (ML-KEM). + .algorithm("ML-KEM") + // ...Use our PQC public key as the recipient key for encapsulation. + .peerPublic(selfPqc.getPublic()) + // ...Build initiator. + .buildInitiator(); + + // ...Produce the envelope header (peer message); must be stored next to + // ciphertext. + byte[] peerMsg = encKex.getPeerMessage(); + System.out.println("...peerMsg " + lens(peerMsg) + " " + shortHex(peerMsg, 48)); + + // ...Derive OKM for this local envelope. + byte[] okm = encKex.deriveSecret(); + System.out.println("...okm " + shortHex(okm, 48)); + + // ...Create exporter directly from OKM and profile salt. + HybridKexExporter exporter = new HybridKexExporter(okm, profile.hkdfSalt()); + + // ...Encrypt: build pipeline; derived key/IV/AAD are injected into AES-GCM. + DataContent enc = DataContentChainBuilder.encrypt() + // ...Input: plaintext bytes. + .add(PlainBytesBuilder.builder().bytes(msg)) + // ...AEAD: inject derived material into AES-GCM builder. + .add(HybridDerived.from(exporter) + // ...Purpose separation label for AEAD encryption. + .label("app/local/aes-gcm") + // ...Bind derivation to transcript bytes. + .transcript(transcript.toByteArray()) + // ...Inject explicit AAD. + .aad(aad) + // ...Apply derived key(256b) and IV(12B) to AES-GCM with header. + .applyToAesGcm(AesDataContentBuilder.builder() + // ...Store IV in header for decrypt side. + .withHeader() + // ...Use AES-GCM with 128-bit authentication tag. + .modeGcm(128), 256, 12)) + // ...Finalize pipeline. + .build(); + + byte[] ciphertext; + try (InputStream in = enc.getStream()) { + ciphertext = readAll(in); + } + System.out.println("...ciphertext " + lens(ciphertext) + " " + shortHex(ciphertext, 48)); + + // ...Build local responder (decapsulation) using our own private keys and + // stored peer message. + HybridKexContext decKex = HybridKexBuilder.builder() + // ...Set mandatory profile. + .profile(profile) + // ...Bind derivation to transcript. + .transcript(transcript) + // ...Select classic mode: peer public key is known out-of-band (here: our own + // public key). + .classicAgreement() + // ...Classic algorithm id (X25519). + .algorithm("Xdh") + // ...Classic spec (X25519). + .spec(XdhSpec.X25519) + // ...Use our private key. + .privateKey(selfClassic.getPrivate()) + // ...Use our public key as the peer public key (self-recipient). + .peerPublic(selfClassic.getPublic()) + // ...Switch to PQC KEM. + .pqcKem() + // ...PQC algorithm id (ML-KEM). + .algorithm("ML-KEM") + // ...Use our PQC private key for decapsulation. + .privateKey(selfPqc.getPrivate()) + // ...Build responder. + .buildResponder(); + + try { + // ...Provide the stored peer message (envelope header) to complete + // decapsulation. + decKex.setPeerMessage(peerMsg); + + // ...Derive the same OKM and create the exporter. + byte[] okmDec = decKex.deriveSecret(); + System.out.println("...okmEqual " + Arrays.equals(okm, okmDec)); + if (!Arrays.equals(okm, okmDec)) { + throw new IllegalStateException("Local hybrid envelope mismatch"); + } + + HybridKexExporter exporterDec = new HybridKexExporter(okmDec, profile.hkdfSalt()); + + // ...Decrypt: rebuild the same derived inputs and run decrypt pipeline. + DataContent dec = DataContentChainBuilder.decrypt() + // ...Input: ciphertext bytes. + .add(PlainBytesBuilder.builder().bytes(ciphertext)) + // ...AEAD: apply the same label/transcript/AAD to get identical key/IV. + .add(HybridDerived.from(exporterDec) + // ...Same purpose label as encryption. + .label("app/local/aes-gcm") + // ...Same transcript binding. + .transcript(transcript.toByteArray()) + // ...Same explicit AAD. + .aad(aad) + // ...Apply derived key and IV to AES-GCM with header. + .applyToAesGcm(AesDataContentBuilder.builder() + // ...Parse IV from header. + .withHeader() + // ...Use AES-GCM with 128-bit authentication tag. + .modeGcm(128), 256, 12)) + // ...Finalize pipeline. + .build(); + + byte[] out; + try (InputStream in = dec.getStream()) { + out = readAll(in); + } + + System.out.println("...plaintextEqual " + Arrays.equals(msg, out)); + if (!Arrays.equals(msg, out)) { + throw new IllegalStateException("Roundtrip mismatch"); + } + + System.out.println("hybridDerived_aes_gcm_local_self_recipient...ok"); + } finally { + closeQuiet(encKex); + closeQuiet(decKex); + } + } + + // helpers + + private static byte[] randomBytes(int len) { + byte[] data = new byte[len]; + new SecureRandom().nextBytes(data); + return data; + } + + private static byte[] readAll(InputStream in) throws Exception { + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + in.transferTo(out); + out.flush(); + return out.toByteArray(); + } + } + + private static String lens(byte[] b) { + if (b == null) { + return "len=null"; + } + return "len=" + b.length; + } + + private static String shortHex(byte[] data, int maxBytes) { + if (data == null) { + return "null"; + } + int n = Math.min(data.length, Math.max(0, maxBytes)); + StringBuilder sb = new StringBuilder(n * 2 + 3); + for (int i = 0; i < n; i++) { + int v = data[i] & 0xFF; + sb.append(Character.forDigit((v >>> 4) & 0x0F, 16)); + sb.append(Character.forDigit(v & 0x0F, 16)); + } + if (data.length > n) { + sb.append("..."); + } + return sb.toString(); + } + + private static void closeQuiet(HybridKexContext ctx) { + if (ctx == null) { + return; + } + try { + ctx.close(); + } catch (Exception ignore) { + // ignore + } + } +}