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:
2025-12-26 21:00:01 +01:00
parent 55da24735f
commit 300f40c283
8 changed files with 1579 additions and 26 deletions

View File

@@ -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;
};
}
}

View File

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

View 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 &gt;= 1)
* @return this builder
* @throws IllegalArgumentException if aadLen &lt; 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 &gt; 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 &gt; 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;
}
}

View File

@@ -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;

View File

@@ -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>

View 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;