feat: add hybrid-derived key injection
Extend HMAC metadata and builders to expose recommended key sizes and enable safe derived-key injection without duplicating algorithm configuration. Key changes: - Add HybridDerived utility for expanding hybrid KEX output and injecting purpose-separated keys, IVs/nonces and optional AAD into existing DataContent builders (AES-GCM, ChaCha, HMAC) - Improve HmacSpec and HmacDataContentBuilder to expose recommended key material characteristics for derived use - Refine HybridKexContexts to better support exporter-based derived workflows - Add comprehensive unit tests for hybrid-derived functionality - Add documented demo showing hybrid-derived AES-GCM encryption, including local (self-recipient) hybrid usage - Introduce top-level sdk.hybrid package documentation and derived subpackage Javadoc All changes are additive at the SDK layer; core cryptographic contracts remain unchanged. Signed-off-by: Leo Galambos <lg@hq.egothor.org>
This commit is contained in:
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>HmacSHA256 - 256 bits</li>
|
||||
* <li>HmacSHA384 - 384 bits</li>
|
||||
* <li>HmacSHA512 - 512 bits</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @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;
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -253,6 +253,40 @@ public final class HmacDataContentBuilder implements DataContentBuilder<PlainCon
|
||||
return new HmacDataContentBuilder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently configured HMAC specification.
|
||||
*
|
||||
* <p>
|
||||
* This accessor is intentionally read-only and exists to support safe
|
||||
* integrations (for example hybrid-derived key injection) without duplicating
|
||||
* the HMAC variant configuration outside of this builder.
|
||||
* </p>
|
||||
*
|
||||
* @return current HMAC spec (never null)
|
||||
* @since 1.0
|
||||
*/
|
||||
public HmacSpec spec() {
|
||||
return this.spec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a recommended HMAC key size (in bits) for the currently configured
|
||||
* {@link #spec()}.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
|
||||
378
lib/src/main/java/zeroecho/sdk/hybrid/derived/HybridDerived.java
Normal file
378
lib/src/main/java/zeroecho/sdk/hybrid/derived/HybridDerived.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Labeling</h2>
|
||||
* <p>
|
||||
* Derivation uses a base label, plus fixed suffixes for individual fields:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>{@code label + "/key"} for the secret key</li>
|
||||
* <li>{@code label + "/iv"} for AES IV</li>
|
||||
* <li>{@code label + "/nonce"} for ChaCha nonce</li>
|
||||
* <li>{@code label + "/aad"} for AEAD AAD (optional, if derived)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Thread safety</h2>
|
||||
* <p>
|
||||
* Instances are mutable and not thread-safe.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* The label should identify the protocol purpose of the derived material, for
|
||||
* example {@code "app/enc"} or {@code "handshake/confirm"}.
|
||||
* </p>
|
||||
*
|
||||
* @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}.
|
||||
*
|
||||
* <p>
|
||||
* The transcript should contain only public context (negotiated suite, public
|
||||
* keys/messages, channel binding, etc.). It must not contain secrets.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* If set, no AAD derivation is performed.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* 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"}.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* The returned value is the same builder instance to preserve fluent pipeline
|
||||
* construction.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* The returned value is the same builder instance to preserve fluent pipeline
|
||||
* construction.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* 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()}.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* The returned value is the same builder instance to preserve fluent pipeline
|
||||
* construction.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* The returned value is the same builder instance to preserve fluent pipeline
|
||||
* construction.
|
||||
* </p>
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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}).
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* The central concept is <b>derived material</b>: 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Design goals</h2>
|
||||
* <ul>
|
||||
* <li>Keep cryptographic primitives unchanged; only inject derived
|
||||
* parameters.</li>
|
||||
* <li>Provide safe-by-construction key separation using labels and transcript
|
||||
* binding.</li>
|
||||
* <li>Preserve fluent builder usage by returning the original builder from
|
||||
* {@code applyTo(...)}.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
package zeroecho.sdk.hybrid.derived;
|
||||
@@ -85,7 +85,7 @@ import zeroecho.core.spec.ContextSpec;
|
||||
* <h2>Error handling</h2>
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
|
||||
94
lib/src/main/java/zeroecho/sdk/hybrid/package-info.java
Normal file
94
lib/src/main/java/zeroecho/sdk/hybrid/package-info.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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).
|
||||
* </p>
|
||||
*
|
||||
* <h2>Subpackages</h2>
|
||||
* <ul>
|
||||
* <li>{@link zeroecho.sdk.hybrid.kex} - hybrid key exchange (KEX) that composes
|
||||
* a classic agreement and a message-based (KEM-style) agreement into a single
|
||||
* derived shared secret, and emits an explicit peer message suitable for
|
||||
* transport.</li>
|
||||
* <li>{@link zeroecho.sdk.hybrid.derived} - derived-key utilities that consume
|
||||
* hybrid KEX output and inject purpose-separated keying material (key, optional
|
||||
* IV/nonce, optional AAD) into streaming builders while preserving fluent
|
||||
* builder usage.</li>
|
||||
* <li>{@link zeroecho.sdk.hybrid.signature} - hybrid signature composition that
|
||||
* combines two independent signature schemes and exposes them as a single
|
||||
* streaming {@link zeroecho.core.context.SignatureContext} suitable for
|
||||
* trailer-style pipeline stages.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Design principles</h2>
|
||||
* <ul>
|
||||
* <li><b>Composition over modification</b>: hybrids are implemented as
|
||||
* SDK-level compositions over existing core contexts rather than by expanding
|
||||
* core API contracts.</li>
|
||||
* <li><b>Explicit messages where needed</b>: whenever a hybrid operation has
|
||||
* "to-be-sent" bytes (for example KEX peer messages), they are modeled as
|
||||
* explicit byte sequences rather than hidden side effects.</li>
|
||||
* <li><b>Key separation via KDF</b>: hybrid secrets are combined and expanded
|
||||
* using HKDF label separation and transcript binding; concatenation is avoided
|
||||
* as a primary combination method.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Security notes</h2>
|
||||
* <ul>
|
||||
* <li>Hybrid constructions increase protocol and implementation complexity.
|
||||
* Prefer clear profiles, stable transcript inputs, and explicit policy to avoid
|
||||
* ambiguous security expectations.</li>
|
||||
* <li>Do not log or otherwise expose sensitive material (private keys, seeds,
|
||||
* derived keying bytes, plaintexts, intermediate secrets).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Thread safety</h2>
|
||||
* <p>
|
||||
* Hybrid contexts and builders are not thread-safe. Create a new instance per
|
||||
* independent operation and do not share instances across concurrent pipeline
|
||||
* executions.
|
||||
* </p>
|
||||
*
|
||||
* @since 1.0
|
||||
*/
|
||||
package zeroecho.sdk.hybrid;
|
||||
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* The tests use deterministic exporter inputs (fixed OKM and salt) to ensure
|
||||
* stable results.
|
||||
* </p>
|
||||
*/
|
||||
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<DataContent> algorithmBuilder, byte[] plaintext)
|
||||
throws Exception {
|
||||
DataContent enc = DataContentChainBuilder.encrypt().add(PlainBytesBuilder.builder().bytes(plaintext))
|
||||
.add(algorithmBuilder).build();
|
||||
|
||||
try (InputStream in = enc.getStream()) {
|
||||
return readAll(in);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] runDecrypt(DataContentBuilder<DataContent> algorithmBuilder, byte[] ciphertext)
|
||||
throws Exception {
|
||||
DataContent dec = DataContentChainBuilder.decrypt().add(PlainBytesBuilder.builder().bytes(ciphertext))
|
||||
.add(algorithmBuilder).build();
|
||||
|
||||
try (InputStream in = dec.getStream()) {
|
||||
return readAll(in);
|
||||
}
|
||||
}
|
||||
|
||||
private static String runHmacHex(HmacDataContentBuilder macBuilder, byte[] msg) throws Exception {
|
||||
DataContent dc = DataContentChainBuilder.encrypt().add(PlainBytesBuilder.builder().bytes(msg)).add(macBuilder)
|
||||
.build();
|
||||
|
||||
byte[] out;
|
||||
try (InputStream in = dc.getStream()) {
|
||||
out = readAll(in);
|
||||
}
|
||||
String tagHex = new String(out, StandardCharsets.UTF_8).trim();
|
||||
return tagHex;
|
||||
}
|
||||
|
||||
private static String runHmacVerifyBool(HmacDataContentBuilder verifyBuilder, byte[] msg) throws Exception {
|
||||
DataContent dc = DataContentChainBuilder.decrypt().add(PlainBytesBuilder.builder().bytes(msg))
|
||||
.add(verifyBuilder).build();
|
||||
|
||||
byte[] out;
|
||||
try (InputStream in = dc.getStream()) {
|
||||
out = readAll(in);
|
||||
}
|
||||
String s = new String(out, StandardCharsets.UTF_8).trim();
|
||||
return s;
|
||||
}
|
||||
|
||||
private static byte[] fixedBytes(int len, byte v) {
|
||||
byte[] b = new byte[len];
|
||||
Arrays.fill(b, v);
|
||||
return b;
|
||||
}
|
||||
|
||||
private static byte[] readAll(InputStream in) throws Exception {
|
||||
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
in.transferTo(out);
|
||||
out.flush();
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
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, 16));
|
||||
sb.append(Character.forDigit(v & 0x0F, 16));
|
||||
}
|
||||
if (data.length > n) {
|
||||
sb.append("...");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String shortText(String s, int maxLen) {
|
||||
if (s == null) {
|
||||
return "null";
|
||||
}
|
||||
if (s.length() <= maxLen) {
|
||||
return s;
|
||||
}
|
||||
return s.substring(0, maxLen) + "...";
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static byte[] randomBytes(int len) {
|
||||
byte[] b = new byte[len];
|
||||
new SecureRandom().nextBytes(b);
|
||||
return b;
|
||||
}
|
||||
}
|
||||
656
samples/src/test/java/demo/HybridDerivedAesDemoTest.java
Normal file
656
samples/src/test/java/demo/HybridDerivedAesDemoTest.java
Normal file
@@ -0,0 +1,656 @@
|
||||
/*******************************************************************************
|
||||
* 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.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyPair;
|
||||
import java.security.SecureRandom;
|
||||
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.kyber.KyberKeyGenSpec;
|
||||
import zeroecho.core.alg.xdh.XdhSpec;
|
||||
import zeroecho.sdk.builders.HybridKexBuilder;
|
||||
import zeroecho.sdk.builders.alg.AesDataContentBuilder;
|
||||
import zeroecho.sdk.builders.core.DataContentChainBuilder;
|
||||
import zeroecho.sdk.builders.core.PlainBytesBuilder;
|
||||
import zeroecho.sdk.content.api.DataContent;
|
||||
import zeroecho.sdk.hybrid.derived.HybridDerived;
|
||||
import zeroecho.sdk.hybrid.kex.HybridKexContext;
|
||||
import zeroecho.sdk.hybrid.kex.HybridKexExporter;
|
||||
import zeroecho.sdk.hybrid.kex.HybridKexProfile;
|
||||
import zeroecho.sdk.hybrid.kex.HybridKexTranscript;
|
||||
import zeroecho.sdk.util.BouncyCastleActivator;
|
||||
|
||||
/**
|
||||
* Demonstration of hybrid-derived AEAD encryption and decryption.
|
||||
*
|
||||
* <p>
|
||||
* This sample is intentionally structured in two variants:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li><b>Condensed</b> - compact fluent chains suitable for everyday use.</li>
|
||||
* <li><b>Expanded</b> - the same operations, step-by-step, for explanatory
|
||||
* documentation.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user