diff --git a/lib/src/main/java/zeroecho/core/alg/hmac/HmacSpec.java b/lib/src/main/java/zeroecho/core/alg/hmac/HmacSpec.java index 144ef1f..a3797cb 100644 --- a/lib/src/main/java/zeroecho/core/alg/hmac/HmacSpec.java +++ b/lib/src/main/java/zeroecho/core/alg/hmac/HmacSpec.java @@ -137,4 +137,50 @@ public final class HmacSpec implements ContextSpec, Describable { public String description() { return macName; } + + /** + * Returns a recommended key size (in bits) for this HMAC variant. + * + *
+ * 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 + } + } +}