Merge hybrid cryptography support (KEX, derived keys, signatures)

This merge introduces the sdk.hybrid package with support for:
- hybrid key exchange (classic + post-quantum),
- hybrid-derived key injection for encryption and MAC builders,
- hybrid signature composition.

The implementation is additive at the SDK layer and does not modify
core cryptographic contracts.
This commit is contained in:
2025-12-26 21:08:31 +01:00
38 changed files with 8127 additions and 136 deletions

View File

@@ -76,7 +76,7 @@ import zeroecho.core.context.AgreementContext;
*
* @since 1.0
*/
public final class GenericJcaAgreementContext implements AgreementContext {
public class GenericJcaAgreementContext implements AgreementContext {
private final CryptoAlgorithm algorithm;
private final PrivateKey privateKey;
private final String jcaName; // e.g., "ECDH" or "XDH" (or "X25519"/"X448")

View File

@@ -0,0 +1,235 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement: This product includes software
* developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.alg.common.agreement;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.util.Objects;
import zeroecho.core.CryptoAlgorithm;
import zeroecho.core.context.MessageAgreementContext;
/**
* Message-oriented JCA key agreement context where the handshake message is a
* public key encoding.
*
* <p>
* This context provides a {@link MessageAgreementContext} view over a classical
* JCA DiffieHellman style agreement (e.g., ECDH, XDH). The protocol
* "to-be-sent" data for such agreements is the party's public key. This class
* therefore maps:
* </p>
* <ul>
* <li>{@link #getPeerMessage()} to the local public key encoding (X.509
* SubjectPublicKeyInfo),</li>
* <li>{@link #setPeerMessage(byte[])} to importing the peer public key and
* binding it as the peer key.</li>
* </ul>
*
* <h2>Encoding</h2>
* <p>
* The message format is the standard X.509 SubjectPublicKeyInfo encoding
* returned by {@link java.security.PublicKey#getEncoded()}. This format is
* stable, widely interoperable and avoids ad-hoc or algorithm-specific wire
* formats.
* </p>
*
* <h2>Algorithm and provider selection</h2>
* <p>
* The underlying key agreement algorithm name (for
* {@link javax.crypto.KeyAgreement}) and the key factory algorithm name (for
* {@link KeyFactory}) are supplied explicitly by the algorithm registration
* code. This keeps algorithm-specific knowledge in {@code *Algorithm} classes
* rather than embedding naming heuristics into shared code.
* </p>
*
* <h2>Lifecycle</h2>
* <ol>
* <li>Create the context with the local key pair wrapper.</li>
* <li>Send {@link #getPeerMessage()} to the remote party.</li>
* <li>Receive the remote party's message and call
* {@link #setPeerMessage(byte[])}.</li>
* <li>Derive the raw shared secret with {@link #deriveSecret()}.</li>
* </ol>
*
* <h2>Security considerations</h2>
* <ul>
* <li>The message returned by {@link #getPeerMessage()} contains only public
* key material.</li>
* <li>The output of {@link #deriveSecret()} is a raw shared secret and must be
* processed with a KDF by higher protocol layers before use as symmetric keying
* material.</li>
* <li>This class does not log, persist, or otherwise expose private key
* material.</li>
* </ul>
*
* <h2>Thread safety</h2>
* <p>
* Instances are not thread-safe and are intended for single-use,
* single-threaded protocol executions.
* </p>
*
* @since 1.0
*/
public final class GenericJcaMessageAgreementContext extends GenericJcaAgreementContext
implements MessageAgreementContext {
private final PublicKey localPublic;
private final String keyFactoryAlg;
private final String keyFactoryProvider;
/**
* Creates a message-oriented agreement context over a JCA key agreement.
*
* <p>
* The wrapped {@link KeyPairKey} supplies both local private and public key
* components: the private key is used for the underlying agreement computation,
* while the public key is exported as the handshake message via
* {@link #getPeerMessage()}.
* </p>
*
* @param alg algorithm descriptor that owns this context
* @param keyPairKey local key pair wrapper; must contain both a private
* and a public key
* @param jcaAgreementName JCA {@link javax.crypto.KeyAgreement} algorithm
* name (e.g., {@code "ECDH"}, {@code "X25519"})
* @param agreementProvider optional JCA provider name for
* {@link javax.crypto.KeyAgreement}, or {@code null}
* for default provider selection
* @param keyFactoryAlg JCA {@link KeyFactory} algorithm name used to
* import peer public keys (e.g., {@code "EC"},
* {@code "XDH"})
* @param keyFactoryProvider optional JCA provider name for {@link KeyFactory},
* or {@code null} for default provider selection
* @throws NullPointerException if any required argument is {@code null}
* @since 1.0
*/
public GenericJcaMessageAgreementContext(CryptoAlgorithm alg, KeyPairKey keyPairKey, String jcaAgreementName,
String agreementProvider, String keyFactoryAlg, String keyFactoryProvider) {
super(Objects.requireNonNull(alg, "alg"), Objects.requireNonNull(keyPairKey, "keyPairKey").privateKey(),
Objects.requireNonNull(jcaAgreementName, "jcaAgreementName"), agreementProvider);
this.localPublic = Objects.requireNonNull(keyPairKey.publicKey(), "keyPairKey.public");
this.keyFactoryAlg = Objects.requireNonNull(keyFactoryAlg, "keyFactoryAlg");
this.keyFactoryProvider = keyFactoryProvider;
}
/**
* Returns the local party's handshake message.
*
* <p>
* For DH/XDH-style agreements, the handshake message is the local public key
* encoding. The returned array is a defensive copy and may be safely
* transmitted over untrusted channels.
* </p>
*
* <p>
* The encoding is the standard X.509 SubjectPublicKeyInfo bytes returned by
* {@link PublicKey#getEncoded()}.
* </p>
*
* @return a defensive copy of the local public key encoding (never
* {@code null})
* @throws IllegalStateException if the local public key does not provide an
* encoding
* @since 1.0
*/
@Override
public byte[] getPeerMessage() {
byte[] encoded = localPublic.getEncoded();
if (encoded == null) {
throw new IllegalStateException("Local public key does not provide an encoding");
}
return encoded.clone();
}
/**
* Supplies the peer party's handshake message.
*
* <p>
* The provided message is interpreted as an X.509 SubjectPublicKeyInfo encoding
* of the peer public key. The key is imported using {@link KeyFactory} and then
* assigned as the peer key for the underlying agreement computation.
* </p>
*
* <p>
* Passing {@code null} resets the peer binding and makes
* {@link #deriveSecret()} unusable until a new peer message is provided.
* </p>
*
* @param message peer public key encoding (SPKI), or {@code null} to reset the
* peer state
* @throws IllegalArgumentException if the message cannot be imported as a
* public key using the configured
* {@link KeyFactory} algorithm/provider
* @since 1.0
*/
@Override
public void setPeerMessage(byte[] message) {
if (message == null) {
setPeerPublic(null);
return;
}
PublicKey peerPublic = importPeerPublic(message);
setPeerPublic(peerPublic);
}
/**
* Imports a peer public key from an X.509 SubjectPublicKeyInfo encoding.
*
* <p>
* This method performs no caching and always imports the key anew. The caller
* is responsible for ensuring that the protocol-layer validation requirements
* are met (e.g., checking that the received public key belongs to an expected
* identity, or that the agreement mode requires ephemeral vs. static keys).
* </p>
*
* @param spkiEncoded peer public key encoding (SPKI); must not be {@code null}
* @return imported peer {@link PublicKey}
* @throws IllegalArgumentException if the key cannot be imported
*/
private PublicKey importPeerPublic(byte[] spkiEncoded) {
try {
KeyFactory keyFactory = (keyFactoryProvider == null) ? KeyFactory.getInstance(keyFactoryAlg)
: KeyFactory.getInstance(keyFactoryAlg, keyFactoryProvider);
X509EncodedKeySpec spec = new X509EncodedKeySpec(spkiEncoded);
return keyFactory.generatePublic(spec);
} catch (GeneralSecurityException e) {
throw new IllegalArgumentException("Failed to import peer public key using KeyFactory " + keyFactoryAlg, e);
}
}
}

View File

@@ -0,0 +1,147 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.alg.common.agreement;
import java.io.Serial;
import java.io.Serializable;
import java.security.Key;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.Objects;
/**
* A {@link Key} wrapper that carries a {@link KeyPair} through APIs that are
* constrained to {@link Key} types.
*
* <p>
* This type exists to support capabilities that require both private and public
* components (e.g., message-oriented key agreement contexts), while preserving
* backward-compatible capabilities that accept only a {@link PrivateKey}.
* </p>
*
* <p>
* The wrapper does not expose an encoding via {@link #getEncoded()} because
* serializing private key material implicitly is dangerous and not required for
* capability dispatch.
* </p>
*
* @since 1.0
*/
public final class KeyPairKey implements Key, Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final KeyPair keyPair;
/**
* Creates a wrapper around a {@link KeyPair}.
*
* @param keyPair key pair to wrap (must not be {@code null})
* @throws NullPointerException if {@code keyPair} is {@code null}
* @since 1.0
*/
public KeyPairKey(KeyPair keyPair) {
this.keyPair = Objects.requireNonNull(keyPair, "keyPair");
Objects.requireNonNull(keyPair.getPrivate(), "keyPair.private");
Objects.requireNonNull(keyPair.getPublic(), "keyPair.public");
}
/**
* Returns the wrapped {@link KeyPair}.
*
* @return key pair
* @since 1.0
*/
public KeyPair keyPair() {
return keyPair;
}
/**
* Returns the wrapped private key.
*
* @return private key
* @since 1.0
*/
public PrivateKey privateKey() {
return keyPair.getPrivate();
}
/**
* Returns the wrapped public key.
*
* @return public key
* @since 1.0
*/
public PublicKey publicKey() {
return keyPair.getPublic();
}
/**
* Returns the algorithm name of the wrapped private key.
*
* @return algorithm name
* @since 1.0
*/
@Override
public String getAlgorithm() {
return privateKey().getAlgorithm();
}
/**
* Returns {@code null}. This wrapper intentionally does not define a standard
* encoding format.
*
* @return {@code null}
* @since 1.0
*/
@Override
public String getFormat() {
return null;
}
/**
* Returns {@code null}. This wrapper intentionally does not expose a combined
* encoding.
*
* @return {@code null}
* @since 1.0
*/
@Override
public byte[] getEncoded() {
return null; // NOPMD
}
}

View File

@@ -46,7 +46,10 @@ import zeroecho.core.AlgorithmFamily;
import zeroecho.core.KeyUsage;
import zeroecho.core.alg.AbstractCryptoAlgorithm;
import zeroecho.core.alg.common.agreement.GenericJcaAgreementContext;
import zeroecho.core.alg.common.agreement.GenericJcaMessageAgreementContext;
import zeroecho.core.alg.common.agreement.KeyPairKey;
import zeroecho.core.context.AgreementContext;
import zeroecho.core.context.MessageAgreementContext;
import zeroecho.core.spi.AsymmetricKeyBuilder;
/**
@@ -133,6 +136,10 @@ public final class DhAlgorithm extends AbstractCryptoAlgorithm {
DhSpec.class,
(PrivateKey k, DhSpec s) -> new GenericJcaAgreementContext(this, k, "DiffieHellman", null),
DhSpec::ffdhe2048);
capability(AlgorithmFamily.AGREEMENT, KeyUsage.AGREEMENT, MessageAgreementContext.class, KeyPairKey.class,
DhSpec.class, (KeyPairKey k, DhSpec s) -> new GenericJcaMessageAgreementContext(this, k,
"DiffieHellman", null, "DH", null),
DhSpec::ffdhe2048);
registerAsymmetricKeyBuilder(DhSpec.class, new DhKeyGenBuilder(), DhSpec::ffdhe2048);
registerAsymmetricKeyBuilder(DhPublicKeySpec.class, new AsymmetricKeyBuilder<>() {

View File

@@ -40,12 +40,15 @@ import zeroecho.core.AlgorithmFamily;
import zeroecho.core.KeyUsage;
import zeroecho.core.alg.AbstractCryptoAlgorithm;
import zeroecho.core.alg.common.agreement.GenericJcaAgreementContext;
import zeroecho.core.alg.common.agreement.GenericJcaMessageAgreementContext;
import zeroecho.core.alg.common.agreement.KeyPairKey;
import zeroecho.core.alg.ecdsa.EcdsaCurveSpec;
import zeroecho.core.alg.ecdsa.EcdsaPrivateKeyBuilder;
import zeroecho.core.alg.ecdsa.EcdsaPrivateKeySpec;
import zeroecho.core.alg.ecdsa.EcdsaPublicKeyBuilder;
import zeroecho.core.alg.ecdsa.EcdsaPublicKeySpec;
import zeroecho.core.context.AgreementContext;
import zeroecho.core.context.MessageAgreementContext;
/**
* <h2>Elliptic Curve Diffie-Hellman (ECDH) Algorithm</h2>
@@ -152,6 +155,10 @@ public final class EcdhAlgorithm extends AbstractCryptoAlgorithm {
EcdsaCurveSpec.class,
(PrivateKey k, EcdsaCurveSpec s) -> new GenericJcaAgreementContext(this, k, "ECDH", null),
() -> EcdsaCurveSpec.P256); // XXX spec is not used at all ?!?!
capability(AlgorithmFamily.AGREEMENT, KeyUsage.AGREEMENT, MessageAgreementContext.class, KeyPairKey.class,
EcdsaCurveSpec.class, (KeyPairKey k, EcdsaCurveSpec s) -> new GenericJcaMessageAgreementContext(this, k,
"ECDH", null, "EC", null),
() -> EcdsaCurveSpec.P256);
// Reuse EC builders/importers
registerAsymmetricKeyBuilder(EcdhCurveSpec.class, new EcdhKeyGenBuilder(), () -> EcdhCurveSpec.P256);

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

@@ -46,7 +46,10 @@ import zeroecho.core.AlgorithmFamily;
import zeroecho.core.KeyUsage;
import zeroecho.core.alg.AbstractCryptoAlgorithm;
import zeroecho.core.alg.common.agreement.GenericJcaAgreementContext;
import zeroecho.core.alg.common.agreement.GenericJcaMessageAgreementContext;
import zeroecho.core.alg.common.agreement.KeyPairKey;
import zeroecho.core.context.AgreementContext;
import zeroecho.core.context.MessageAgreementContext;
import zeroecho.core.spi.AsymmetricKeyBuilder;
/**
@@ -145,6 +148,11 @@ public final class XdhAlgorithm extends AbstractCryptoAlgorithm {
XdhSpec.class,
(PrivateKey k, XdhSpec s) -> new GenericJcaAgreementContext(this, k, s.keyAgreementName(), null),
() -> XdhSpec.X25519);
// New capability: MessageAgreementContext over KeyPair
capability(AlgorithmFamily.AGREEMENT, KeyUsage.AGREEMENT, MessageAgreementContext.class, KeyPairKey.class,
XdhSpec.class, (KeyPairKey k, XdhSpec s) -> new GenericJcaMessageAgreementContext(this, k,
s.keyAgreementName(), null, "XDH", null),
() -> XdhSpec.X25519);
registerAsymmetricKeyBuilder(XdhSpec.class, new XdhKeyGenBuilder(), () -> XdhSpec.X25519);
registerAsymmetricKeyBuilder(XdhPublicKeySpec.class, new AsymmetricKeyBuilder<>() {

View File

@@ -89,6 +89,14 @@ import zeroecho.core.spec.VoidSpec;
* @since 1.0
*/
public final class TagEngineBuilder<T> implements Supplier<TagEngine<T>> {
/**
*
*/
private static final String PUBLIC_KEY = "publicKey";
/**
*
*/
private static final String PRIVATE_KEY = "privateKey";
private final Supplier<TagEngine<T>> factory;
private TagEngineBuilder(Supplier<TagEngine<T>> factory) {
@@ -205,7 +213,7 @@ public final class TagEngineBuilder<T> implements Supplier<TagEngine<T>> {
* @throws NullPointerException if {@code privateKey} is {@code null}
*/
public static TagEngineBuilder<Signature> ed25519Sign(final PrivateKey privateKey) {
Objects.requireNonNull(privateKey, "privateKey");
Objects.requireNonNull(privateKey, PRIVATE_KEY);
return signature("Ed25519", privateKey, VoidSpec.INSTANCE);
}
@@ -217,7 +225,7 @@ public final class TagEngineBuilder<T> implements Supplier<TagEngine<T>> {
* @throws NullPointerException if {@code publicKey} is {@code null}
*/
public static TagEngineBuilder<Signature> ed25519Verify(final PublicKey publicKey) {
Objects.requireNonNull(publicKey, "publicKey");
Objects.requireNonNull(publicKey, PUBLIC_KEY);
return signature("Ed25519", publicKey, VoidSpec.INSTANCE);
}
@@ -236,7 +244,7 @@ public final class TagEngineBuilder<T> implements Supplier<TagEngine<T>> {
* @throws NullPointerException if {@code privateKey} is {@code null}
*/
public static TagEngineBuilder<Signature> rsaSign(final PrivateKey privateKey, final RsaSigSpec spec) {
Objects.requireNonNull(privateKey, "privateKey");
Objects.requireNonNull(privateKey, PRIVATE_KEY);
return signature("RSA", privateKey, spec == null ? RsaSigSpec.pss(RsaSigSpec.Hash.SHA256, 32) : spec);
}
@@ -255,7 +263,7 @@ public final class TagEngineBuilder<T> implements Supplier<TagEngine<T>> {
* @throws NullPointerException if {@code publicKey} is {@code null}
*/
public static TagEngineBuilder<Signature> rsaVerify(final PublicKey publicKey, final RsaSigSpec spec) {
Objects.requireNonNull(publicKey, "publicKey");
Objects.requireNonNull(publicKey, PUBLIC_KEY);
return signature("RSA", publicKey, spec == null ? RsaSigSpec.pss(RsaSigSpec.Hash.SHA256, 32) : spec);
}
@@ -273,7 +281,7 @@ public final class TagEngineBuilder<T> implements Supplier<TagEngine<T>> {
* @throws NullPointerException if {@code privateKey} is {@code null}
*/
public static TagEngineBuilder<Signature> ecdsaSign(final PrivateKey privateKey, final EcdsaCurveSpec spec) {
Objects.requireNonNull(privateKey, "privateKey");
Objects.requireNonNull(privateKey, PRIVATE_KEY);
final EcdsaCurveSpec s = spec == null ? EcdsaCurveSpec.P256 : spec;
return signature("ECDSA", privateKey, s);
}
@@ -292,7 +300,7 @@ public final class TagEngineBuilder<T> implements Supplier<TagEngine<T>> {
* @throws NullPointerException if {@code publicKey} is {@code null}
*/
public static TagEngineBuilder<Signature> ecdsaVerify(final PublicKey publicKey, final EcdsaCurveSpec spec) {
Objects.requireNonNull(publicKey, "publicKey");
Objects.requireNonNull(publicKey, PUBLIC_KEY);
final EcdsaCurveSpec s = spec == null ? EcdsaCurveSpec.P256 : spec;
return signature("ECDSA", publicKey, s);
}
@@ -305,7 +313,7 @@ public final class TagEngineBuilder<T> implements Supplier<TagEngine<T>> {
* @throws NullPointerException if {@code privateKey} is {@code null}
*/
public static TagEngineBuilder<Signature> ecdsaP256Sign(final PrivateKey privateKey) {
Objects.requireNonNull(privateKey, "privateKey");
Objects.requireNonNull(privateKey, PRIVATE_KEY);
return signature("ECDSA", privateKey, EcdsaCurveSpec.P256);
}
@@ -317,7 +325,7 @@ public final class TagEngineBuilder<T> implements Supplier<TagEngine<T>> {
* @throws NullPointerException if {@code publicKey} is {@code null}
*/
public static TagEngineBuilder<Signature> ecdsaP256Verify(final PublicKey publicKey) {
Objects.requireNonNull(publicKey, "publicKey");
Objects.requireNonNull(publicKey, PUBLIC_KEY);
return signature("ECDSA", publicKey, EcdsaCurveSpec.P256);
}
@@ -334,7 +342,7 @@ public final class TagEngineBuilder<T> implements Supplier<TagEngine<T>> {
* @throws NullPointerException if {@code privateKey} is {@code null}
*/
public static TagEngineBuilder<Signature> sphincsPlusSign(final PrivateKey privateKey) {
Objects.requireNonNull(privateKey, "privateKey");
Objects.requireNonNull(privateKey, PRIVATE_KEY);
return signature("SPHINCS+", privateKey, VoidSpec.INSTANCE);
}
@@ -351,7 +359,81 @@ public final class TagEngineBuilder<T> implements Supplier<TagEngine<T>> {
* @throws NullPointerException if {@code publicKey} is {@code null}
*/
public static TagEngineBuilder<Signature> sphincsPlusVerify(final PublicKey publicKey) {
Objects.requireNonNull(publicKey, "publicKey");
Objects.requireNonNull(publicKey, PUBLIC_KEY);
return signature("SPHINCS+", publicKey, VoidSpec.INSTANCE);
}
/**
* Creates a builder for an SLH-DSA signing engine.
*
* <p>
* SLH-DSA is the NIST-standardized hash-based signature scheme (FIPS 205). The
* concrete parameter set is encoded in the key material and interpreted by the
* underlying {@link CryptoAlgorithms} implementation.
* </p>
*
* @param privateKey private signing key; must not be {@code null}
* @return a builder that produces SLH-DSA signature engines in SIGN mode
* @throws NullPointerException if {@code privateKey} is {@code null}
*/
public static TagEngineBuilder<Signature> slhDsaSign(final PrivateKey privateKey) {
Objects.requireNonNull(privateKey, PRIVATE_KEY);
return signature("SLH-DSA", privateKey, VoidSpec.INSTANCE);
}
/**
* Creates a builder for an SLH-DSA verification engine.
*
* <p>
* SLH-DSA is the NIST-standardized hash-based signature scheme (FIPS 205). The
* concrete parameter set is encoded in the key material and interpreted by the
* underlying {@link CryptoAlgorithms} implementation.
* </p>
*
* @param publicKey public verification key; must not be {@code null}
* @return a builder that produces SLH-DSA signature engines in VERIFY mode
* @throws NullPointerException if {@code publicKey} is {@code null}
*/
public static TagEngineBuilder<Signature> slhDsaVerify(final PublicKey publicKey) {
Objects.requireNonNull(publicKey, PUBLIC_KEY);
return signature("SLH-DSA", publicKey, VoidSpec.INSTANCE);
}
/**
* Creates a builder for an ML-DSA signing engine.
*
* <p>
* ML-DSA is the NIST-standardized module-lattice signature scheme (FIPS 204).
* The concrete parameter set and any pre-hash variant is encoded in the key
* material and interpreted by the underlying {@link CryptoAlgorithms}
* implementation.
* </p>
*
* @param privateKey private signing key; must not be {@code null}
* @return a builder that produces ML-DSA signature engines in SIGN mode
* @throws NullPointerException if {@code privateKey} is {@code null}
*/
public static TagEngineBuilder<Signature> mldsaSign(final PrivateKey privateKey) {
Objects.requireNonNull(privateKey, PRIVATE_KEY);
return signature("ML-DSA", privateKey, VoidSpec.INSTANCE);
}
/**
* Creates a builder for an ML-DSA verification engine.
*
* <p>
* ML-DSA is the NIST-standardized module-lattice signature scheme (FIPS 204).
* The concrete parameter set and any pre-hash variant is encoded in the key
* material and interpreted by the underlying {@link CryptoAlgorithms}
* implementation.
* </p>
*
* @param publicKey public verification key; must not be {@code null}
* @return a builder that produces ML-DSA signature engines in VERIFY mode
* @throws NullPointerException if {@code publicKey} is {@code null}
*/
public static TagEngineBuilder<Signature> mldsaVerify(final PublicKey publicKey) {
Objects.requireNonNull(publicKey, PUBLIC_KEY);
return signature("ML-DSA", publicKey, VoidSpec.INSTANCE);
}
}

View File

@@ -0,0 +1,714 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.sdk.builders;
import java.io.IOException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.Objects;
import zeroecho.core.CryptoAlgorithms;
import zeroecho.core.KeyUsage;
import zeroecho.core.alg.common.agreement.KeyPairKey;
import zeroecho.core.context.AgreementContext;
import zeroecho.core.context.MessageAgreementContext;
import zeroecho.core.spec.ContextSpec;
import zeroecho.sdk.hybrid.kex.HybridKexContext;
import zeroecho.sdk.hybrid.kex.HybridKexExporter;
import zeroecho.sdk.hybrid.kex.HybridKexPolicy;
import zeroecho.sdk.hybrid.kex.HybridKexProfile;
import zeroecho.sdk.hybrid.kex.HybridKexTranscript;
/**
* Fluent builder for constructing hybrid KEX contexts.
*
* <p>
* The builder supports the two practical hybrid variants:
* </p>
* <ul>
* <li><b>CLASSIC_AGREEMENT + KEM_ADAPTER</b> (classic peer public is provided
* out-of-band)</li>
* <li><b>PAIR_MESSAGE + KEM_ADAPTER</b> (classic public key is carried in the
* hybrid message)</li>
* </ul>
*
* <p>
* The builder also supports transcript binding and optional policy enforcement
* before returning the context.
* </p>
*
* <h2>Usage sketch</h2> <pre>{@code
* HybridKexContext ctx = HybridKexBuilder.builder()
* .profile(HybridKexProfile.defaultProfile(32))
* .transcript(new HybridKexTranscript().addUtf8("suite", "X25519+MLKEM768"))
* .policy(new HybridKexPolicy(128, 192, 32))
* .classicAgreement()
* .algorithm("Xdh").spec(XdhSpec.X25519)
* .privateKey(alicePriv).peerPublic(bobPub)
* .pqcKem()
* .algorithm("ML-KEM").peerPublic(bobPqcPub)
* .buildInitiator();
* }</pre>
*
* <p>
* Instances are mutable and not thread-safe.
* </p>
*
* @since 1.0
*/
public final class HybridKexBuilder {
private HybridKexProfile profile;
private HybridKexTranscript transcript;
private HybridKexPolicy policy;
private ClassicMode classicMode;
private String classicAlgId;
private ContextSpec classicSpec;
private PrivateKey classicPrivate;
private PublicKey classicPeerPublic;
private KeyPairKey classicKeyPair;
private String pqcAlgId;
private ContextSpec pqcSpec;
private PublicKey pqcPeerPublic;
private PrivateKey pqcPrivate;
private HybridKexBuilder() {
// builder
}
/**
* Creates a new builder instance.
*
* @return new builder
*/
public static HybridKexBuilder builder() {
return new HybridKexBuilder();
}
/**
* Sets the hybrid profile (HKDF salt/info/output length).
*
* @param profile profile (must not be null)
* @return this builder
*/
public HybridKexBuilder profile(HybridKexProfile profile) {
this.profile = Objects.requireNonNull(profile, "profile");
return this;
}
/**
* Optional transcript used to bind HKDF {@code info}.
*
* <p>
* If provided, its bytes are concatenated to the profile {@code hkdfInfo} with
* a single zero byte separator. This preserves the profile label as a domain
* separator while incorporating handshake context.
* </p>
*
* @param transcript transcript (may be null to clear)
* @return this builder
*/
public HybridKexBuilder transcript(HybridKexTranscript transcript) {
this.transcript = transcript;
return this;
}
/**
* Optional hybrid policy enforcement.
*
* @param policy policy (may be null to clear)
* @return this builder
*/
public HybridKexBuilder policy(HybridKexPolicy policy) {
this.policy = policy;
return this;
}
/**
* Selects the classic leg in {@link ClassicMode#CLASSIC_AGREEMENT} mode.
*
* <p>
* In this mode, the classic peer public key is assumed to be available
* out-of-band (for example via a certificate, a directory, or a higher-level
* handshake message). The hybrid wire message therefore typically carries only
* the PQC payload (KEM ciphertext), while the classic leg is configured via
* {@link AgreementContext#setPeerPublic(PublicKey)}.
* </p>
*
* <p>
* Calling this method resets any previously configured {@code PAIR_MESSAGE}
* inputs ({@link #classicKeyPair}) to prevent ambiguous configuration.
* </p>
*
* @return classic agreement configurator
* @since 1.0
*/
public ClassicAgreement classicAgreement() {
this.classicMode = ClassicMode.CLASSIC_AGREEMENT;
this.classicKeyPair = null;
return new ClassicAgreement(this);
}
/**
* Selects the classic leg in {@link ClassicMode#PAIR_MESSAGE} mode.
*
* <p>
* In this mode, the classic leg is message-capable: the public key is carried
* as an explicit classic message (typically an SPKI encoding) and becomes part
* of the hybrid peer message. This enables a fully message-oriented handshake
* where both legs contribute to the wire payload (classic public-key message +
* PQC ciphertext).
* </p>
*
* <p>
* Calling this method resets any previously configured
* {@code CLASSIC_AGREEMENT} inputs ({@link #classicPrivate} and
* {@link #classicPeerPublic}) to prevent ambiguous configuration.
* </p>
*
* @return classic pair-message configurator
* @since 1.0
*/
public ClassicPairMessage classicPairMessage() {
this.classicMode = ClassicMode.PAIR_MESSAGE;
this.classicPrivate = null;
this.classicPeerPublic = null;
return new ClassicPairMessage(this);
}
/**
* Selects configuration of the post-quantum leg (KEM adapter).
*
* <p>
* The PQC leg is always treated as a message-based agreement:
* </p>
* <ul>
* <li>Initiator is created from the recipient's PQC {@link PublicKey} and
* produces a message (typically a ciphertext) via
* {@link MessageAgreementContext#getPeerMessage()}.</li>
* <li>Responder is created from the recipient's PQC {@link PrivateKey} and
* consumes the peer message via
* {@link MessageAgreementContext#setPeerMessage(byte[])}.</li>
* </ul>
*
* @return PQC KEM configurator
* @since 1.0
*/
public PqcKem pqcKem() {
return new PqcKem(this);
}
/**
* Builds initiator-side context.
*
* @return initiator context
* @throws IOException if underlying context creation fails
*/
public HybridKexContext buildInitiator() throws IOException {
validateCommon();
AgreementContext classic;
if (classicMode == ClassicMode.CLASSIC_AGREEMENT) {
if (classicPrivate == null || classicPeerPublic == null) {
throw new IllegalStateException(
"classic private key and peer public must be set for CLASSIC_AGREEMENT");
}
classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT, classicPrivate, classicSpec);
classic.setPeerPublic(classicPeerPublic);
} else if (classicMode == ClassicMode.PAIR_MESSAGE) {
if (classicKeyPair == null) {
throw new IllegalStateException("classic key pair must be set for PAIR_MESSAGE");
}
classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT, classicKeyPair, classicSpec);
} else {
throw new IllegalStateException("classic mode must be selected");
}
if (pqcPeerPublic == null) {
throw new IllegalStateException("pqc peer public must be set for initiator");
}
MessageAgreementContext pqc = CryptoAlgorithms.create(pqcAlgId, KeyUsage.AGREEMENT, pqcPeerPublic, pqcSpec);
HybridKexProfile effective = effectiveProfile();
if (policy != null) {
policy.enforce(effective, classic, pqc);
}
return new HybridKexContext(effective, classic, pqc);
}
/**
* Builds responder-side context.
*
* @return responder context
* @throws IOException if underlying context creation fails
*/
public HybridKexContext buildResponder() throws IOException {
validateCommon();
AgreementContext classic;
if (classicMode == ClassicMode.CLASSIC_AGREEMENT) {
if (classicPrivate == null || classicPeerPublic == null) {
throw new IllegalStateException(
"classic private key and peer public must be set for CLASSIC_AGREEMENT");
}
classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT, classicPrivate, classicSpec);
classic.setPeerPublic(classicPeerPublic);
} else if (classicMode == ClassicMode.PAIR_MESSAGE) {
if (classicKeyPair == null) {
throw new IllegalStateException("classic key pair must be set for PAIR_MESSAGE");
}
classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT, classicKeyPair, classicSpec);
} else {
throw new IllegalStateException("classic mode must be selected");
}
if (pqcPrivate == null) {
throw new IllegalStateException("pqc private key must be set for responder");
}
MessageAgreementContext pqc = CryptoAlgorithms.create(pqcAlgId, KeyUsage.AGREEMENT, pqcPrivate, pqcSpec);
HybridKexProfile effective = effectiveProfile();
if (policy != null) {
policy.enforce(effective, classic, pqc);
}
return new HybridKexContext(effective, classic, pqc);
}
/**
* Creates an exporter seeded from a derived OKM value.
*
* @param okm derived OKM (for example {@link HybridKexContext#deriveSecret()})
* @return exporter
*/
public HybridKexExporter exporterFromOkm(byte[] okm) {
validateCommon();
HybridKexProfile effective = effectiveProfile();
return new HybridKexExporter(okm, effective.hkdfSalt());
}
private void validateCommon() {
if (profile == null) {
throw new IllegalStateException("profile must be set");
}
if (classicMode == null) {
throw new IllegalStateException("classic mode must be selected");
}
if (classicAlgId == null) {
throw new IllegalStateException("classic algorithm id must be set");
}
if (pqcAlgId == null) {
throw new IllegalStateException("pqc algorithm id must be set");
}
}
private HybridKexProfile effectiveProfile() {
byte[] info0 = profile.hkdfInfo();
byte[] t = (transcript == null) ? null : transcript.toByteArray();
if (t == null || t.length == 0) {
return profile;
}
byte[] base = (info0 == null) ? new byte[0] : info0.clone();
byte[] merged = new byte[base.length + 1 + t.length];
System.arraycopy(base, 0, merged, 0, base.length);
merged[base.length] = 0;
System.arraycopy(t, 0, merged, base.length + 1, t.length);
return new HybridKexProfile(profile.hkdfSalt(), merged, profile.outLenBytes());
}
/**
* Determines how the classic (pre-quantum) agreement leg is wired into the
* hybrid handshake.
*
* <p>
* The classic leg is always an {@link AgreementContext}, but there are two
* operational models:
* </p>
* <ul>
* <li>{@link #CLASSIC_AGREEMENT}: the peer public key is supplied out-of-band
* (not transported by the hybrid message). This corresponds to the traditional
* DH/ECDH/XDH usage pattern where the protocol already has a mechanism to
* convey or authenticate the peer public key (certificate, static key, or
* separate handshake structure).</li>
* <li>{@link #PAIR_MESSAGE}: the classic leg is message-capable and
* emits/consumes a public-key message (typically SPKI) through
* {@link MessageAgreementContext}. In this model, the classic public key
* travels inside the hybrid peer message alongside the PQC payload, enabling a
* fully message-oriented exchange.</li>
* </ul>
*
* <p>
* This enum is internal to the builder but is documented because it defines the
* wire semantics of the resulting {@link HybridKexContext} and determines which
* builder inputs are required.
* </p>
*
* @since 1.0
*/
private enum ClassicMode {
/**
* Classic agreement where the peer public key is provided out-of-band and
* configured via {@link AgreementContext#setPeerPublic(PublicKey)}.
*
* @since 1.0
*/
CLASSIC_AGREEMENT,
/**
* Classic agreement where the classic public key is carried in-band as a
* message produced/consumed via
* {@link MessageAgreementContext#getPeerMessage()} and
* {@link MessageAgreementContext#setPeerMessage(byte[])}.
*
* @since 1.0
*/
PAIR_MESSAGE
}
/**
* Configurator for the classic leg in {@link ClassicMode#CLASSIC_AGREEMENT}
* mode.
*
* <p>
* Required inputs before building:
* </p>
* <ul>
* <li>{@link #algorithm(String)} - classic algorithm id (for example
* {@code "Xdh"})</li>
* <li>{@link #privateKey(PrivateKey)} - local classic private key</li>
* <li>{@link #peerPublic(PublicKey)} - peer classic public key
* (out-of-band)</li>
* </ul>
*
* <p>
* Optional inputs:
* </p>
* <ul>
* <li>{@link #spec(ContextSpec)} - algorithm-specific context spec (for example
* {@code XdhSpec.X25519})</li>
* </ul>
*
* <p>
* After configuring the classic leg, continue with {@link #pqcKem()} to
* configure the PQC leg.
* </p>
*
* @since 1.0
*/
public static final class ClassicAgreement {
private final HybridKexBuilder parent;
private ClassicAgreement(HybridKexBuilder parent) {
this.parent = parent;
}
/**
* Sets the classic agreement algorithm identifier.
*
* <p>
* Example for X25519 in this project: use {@code "Xdh"} with
* {@code XdhSpec.X25519}.
* </p>
*
* @param algId algorithm id (must not be null)
* @return this configurator
* @throws NullPointerException if {@code algId} is null
* @since 1.0
*/
public ClassicAgreement algorithm(String algId) {
parent.classicAlgId = Objects.requireNonNull(algId, "algId");
return this;
}
/**
* Sets the classic context spec (algorithm parameters).
*
* @param spec spec (may be null if the algorithm provides a default)
* @return this configurator
* @since 1.0
*/
public ClassicAgreement spec(ContextSpec spec) {
parent.classicSpec = spec;
return this;
}
/**
* Sets the local private key for the classic leg.
*
* @param key private key (must not be null)
* @return this configurator
* @throws NullPointerException if {@code key} is null
* @since 1.0
*/
public ClassicAgreement privateKey(PrivateKey key) {
parent.classicPrivate = Objects.requireNonNull(key, "key");
return this;
}
/**
* Sets the peer public key for the classic leg.
*
* <p>
* In {@link ClassicMode#CLASSIC_AGREEMENT} this key is assumed to be obtained
* out-of-band.
* </p>
*
* @param key peer public key (must not be null)
* @return this configurator
* @throws NullPointerException if {@code key} is null
* @since 1.0
*/
public ClassicAgreement peerPublic(PublicKey key) {
parent.classicPeerPublic = Objects.requireNonNull(key, "key");
return this;
}
/**
* Continues with PQC (KEM adapter) leg configuration.
*
* @return PQC configurator
* @since 1.0
*/
public PqcKem pqcKem() {
return parent.pqcKem();
}
}
/**
* Configurator for the classic leg in {@link ClassicMode#PAIR_MESSAGE} mode.
*
* <p>
* Required inputs before building:
* </p>
* <ul>
* <li>{@link #algorithm(String)} - classic algorithm id (for example
* {@code "Xdh"})</li>
* <li>{@link #keyPair(KeyPairKey)} - local classic key pair wrapped as
* {@link KeyPairKey}</li>
* </ul>
*
* <p>
* Optional inputs:
* </p>
* <ul>
* <li>{@link #spec(ContextSpec)} - algorithm-specific context spec (for example
* {@code XdhSpec.X25519})</li>
* </ul>
*
* <p>
* In this mode, the classic leg contributes a public-key message (typically
* SPKI bytes) to the hybrid peer message. The peer public key is therefore
* learned from {@link HybridKexContext#setPeerMessage(byte[])} rather than
* being supplied out-of-band.
* </p>
*
* @since 1.0
*/
public static final class ClassicPairMessage {
private final HybridKexBuilder parent;
private ClassicPairMessage(HybridKexBuilder parent) {
this.parent = parent;
}
/**
* Sets the classic agreement algorithm identifier.
*
* @param algId algorithm id (must not be null)
* @return this configurator
* @throws NullPointerException if {@code algId} is null
* @since 1.0
*/
public ClassicPairMessage algorithm(String algId) {
parent.classicAlgId = Objects.requireNonNull(algId, "algId");
return this;
}
/**
* Sets the classic context spec (algorithm parameters).
*
* @param spec spec (may be null if the algorithm provides a default)
* @return this configurator
* @since 1.0
*/
public ClassicPairMessage spec(ContextSpec spec) {
parent.classicSpec = spec;
return this;
}
/**
* Sets the local classic key pair.
*
* <p>
* The key pair is wrapped into {@link KeyPairKey} to match the
* {@code PAIR_MESSAGE} capability registered in core.
* </p>
*
* @param keyPair key pair wrapper (must not be null)
* @return this configurator
* @throws NullPointerException if {@code keyPair} is null
* @since 1.0
*/
public ClassicPairMessage keyPair(KeyPairKey keyPair) {
parent.classicKeyPair = Objects.requireNonNull(keyPair, "keyPair");
return this;
}
/**
* Continues with PQC (KEM adapter) leg configuration.
*
* @return PQC configurator
* @since 1.0
*/
public PqcKem pqcKem() {
return parent.pqcKem();
}
}
/**
* Configurator for the PQC leg (KEM adapter).
*
* <p>
* Required inputs differ by role:
* </p>
* <ul>
* <li><b>Initiator</b> requires {@link #peerPublic(PublicKey)} (recipient PQC
* public key).</li>
* <li><b>Responder</b> requires {@link #privateKey(PrivateKey)} (recipient PQC
* private key).</li>
* </ul>
*
* <p>
* The PQC leg is always message-based: initiator produces a peer message
* (ciphertext) and responder consumes it. The hybrid context transports this
* payload as the PQC part of the hybrid message.
* </p>
*
* @since 1.0
*/
public static final class PqcKem {
private final HybridKexBuilder parent;
private PqcKem(HybridKexBuilder parent) {
this.parent = parent;
}
/**
* Sets the PQC algorithm identifier.
*
* @param algId algorithm id (must not be null)
* @return this configurator
* @throws NullPointerException if {@code algId} is null
* @since 1.0
*/
public PqcKem algorithm(String algId) {
parent.pqcAlgId = Objects.requireNonNull(algId, "algId");
return this;
}
/**
* Sets the PQC context spec (algorithm parameters).
*
* @param spec spec (may be null if the algorithm provides a default)
* @return this configurator
* @since 1.0
*/
public PqcKem spec(ContextSpec spec) {
parent.pqcSpec = spec;
return this;
}
/**
* Sets the recipient PQC public key for initiator-side construction.
*
* @param key recipient public key (must not be null)
* @return this configurator
* @throws NullPointerException if {@code key} is null
* @since 1.0
*/
public PqcKem peerPublic(PublicKey key) {
parent.pqcPeerPublic = Objects.requireNonNull(key, "key");
return this;
}
/**
* Sets the recipient PQC private key for responder-side construction.
*
* @param key recipient private key (must not be null)
* @return this configurator
* @throws NullPointerException if {@code key} is null
* @since 1.0
*/
public PqcKem privateKey(PrivateKey key) {
parent.pqcPrivate = Objects.requireNonNull(key, "key");
return this;
}
/**
* Builds an initiator-side {@link HybridKexContext} using the current builder
* configuration.
*
* @return initiator context
* @throws IOException if underlying context creation fails
* @throws IllegalStateException if required configuration for initiator role is
* missing
* @since 1.0
*/
public HybridKexContext buildInitiator() throws IOException {
return parent.buildInitiator();
}
/**
* Builds a responder-side {@link HybridKexContext} using the current builder
* configuration.
*
* @return responder context
* @throws IOException if underlying context creation fails
* @throws IllegalStateException if required configuration for responder role is
* missing
* @since 1.0
*/
public HybridKexContext buildResponder() throws IOException {
return parent.buildResponder();
}
}
}

View File

@@ -0,0 +1,464 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.sdk.builders;
import java.io.IOException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.Signature;
import java.util.Objects;
import java.util.function.Supplier;
import conflux.CtxInterface;
import conflux.Key;
import zeroecho.core.CryptoAlgorithms;
import zeroecho.core.KeyUsage;
import zeroecho.core.spec.ContextSpec;
import zeroecho.core.tag.TagEngine;
import zeroecho.sdk.builders.core.DataContentBuilder;
import zeroecho.sdk.content.api.DataContent;
import zeroecho.sdk.hybrid.signature.HybridSignatureContexts;
import zeroecho.sdk.hybrid.signature.HybridSignatureProfile;
/**
* Signature-specific trailer builder for {@link DataContent} pipelines.
*
* <p>
* This class is intended as a convenient, signature-specialized replacement
* for:
* </p>
*
* <pre>
* new TagTrailerDataContentBuilder&lt;Signature&gt;(engine).bufferSize(8192)
* new TagTrailerDataContentBuilder&lt;Signature&gt;(engine).bufferSize(8192).throwOnMismatch()
* </pre>
*
* <p>
* It keeps {@link TagTrailerDataContentBuilder} as the generic implementation
* while providing a compact API for {@code Signature} usage, including
* construction of {@code SignatureContext} for both single-algorithm and hybrid
* signatures.
* </p>
*
* <h2>Mode selection</h2>
* <ul>
* <li>{@link #core(TagEngine)} / {@link #core(Supplier)}: wraps a ready engine
* (same parameters as {@link TagTrailerDataContentBuilder}).</li>
* <li>{@link #single()}: constructs a non-hybrid {@code SignatureContext} via
* {@link CryptoAlgorithms}.</li>
* <li>{@link #hybrid()}: constructs a hybrid {@code SignatureContext} via
* {@link HybridSignatureContexts}.</li>
* </ul>
*
* <h2>Checked exceptions</h2>
* <p>
* Context construction may involve I/O (e.g., catalog/provider loading) and
* therefore throw {@link IOException}. This builder converts such failures to
* {@link IllegalStateException} because fluent builder APIs are expected to be
* used in configuration code without mandatory checked-exception plumbing.
* </p>
*
* @since 1.0
*/
public final class SignatureTrailerDataContentBuilder implements DataContentBuilder<DataContent> {
private final TagTrailerDataContentBuilder<Signature> delegate;
private SignatureTrailerDataContentBuilder(TagEngine<Signature> engine) {
this.delegate = new TagTrailerDataContentBuilder<>(engine);
}
private SignatureTrailerDataContentBuilder(Supplier<? extends TagEngine<Signature>> engineFactory) {
this.delegate = new TagTrailerDataContentBuilder<>(engineFactory);
}
/**
* Core mode: wraps a fixed engine instance.
*
* <p>
* This is the direct signature-specialized equivalent of:
* {@code new TagTrailerDataContentBuilder<Signature>(engine)}.
* </p>
*
* @param engine signature engine (typically a {@code SignatureContext}); must
* not be {@code null}
* @return builder instance
* @throws NullPointerException if {@code engine} is {@code null}
* @since 1.0
*/
public static SignatureTrailerDataContentBuilder core(TagEngine<Signature> engine) {
Objects.requireNonNull(engine, "engine");
return new SignatureTrailerDataContentBuilder(engine);
}
/**
* Core mode: wraps an engine factory.
*
* <p>
* This is the direct signature-specialized equivalent of:
* {@code new TagTrailerDataContentBuilder<Signature>(engineFactory)}.
* </p>
*
* @param engineFactory engine factory; must not be {@code null}
* @return builder instance
* @throws NullPointerException if {@code engineFactory} is {@code null}
* @since 1.0
*/
public static SignatureTrailerDataContentBuilder core(Supplier<? extends TagEngine<Signature>> engineFactory) {
Objects.requireNonNull(engineFactory, "engineFactory");
return new SignatureTrailerDataContentBuilder(engineFactory);
}
/**
* Enters single-algorithm (non-hybrid) construction helpers.
*
* @return selector for creating signing/verifying builders
* @since 1.0
*/
public static SingleSelector single() {
return new SingleSelector();
}
/**
* Enters hybrid signature construction helpers.
*
* @return selector for creating signing/verifying builders
* @since 1.0
*/
public static HybridSelector hybrid() {
return new HybridSelector();
}
/**
* Sets the internal I/O buffer size used when stripping the trailer.
*
* @param bytes buffer size in bytes; must be at least 1
* @return this builder for chaining
* @throws IllegalArgumentException if {@code bytes < 1}
* @since 1.0
*/
public SignatureTrailerDataContentBuilder bufferSize(int bytes) {
delegate.bufferSize(bytes);
return this;
}
/**
* Configures verification to throw on mismatch (default verification behavior).
*
* @return this builder for chaining
* @since 1.0
*/
public SignatureTrailerDataContentBuilder throwOnMismatch() {
delegate.throwOnMismatch();
return this;
}
/**
* Instead of throwing on mismatch, records the verification outcome into a
* context flag.
*
* @param ctx context instance to receive the flag; must not be
* {@code null}
* @param verifyKey key under which the {@code Boolean} result is recorded; must
* not be {@code null}
* @return this builder for chaining
* @throws NullPointerException if {@code ctx} or {@code verifyKey} is
* {@code null}
* @since 1.0
*/
public SignatureTrailerDataContentBuilder flagInContext(CtxInterface ctx, Key<Boolean> verifyKey) {
delegate.flagInContext(Objects.requireNonNull(ctx, "ctx"), Objects.requireNonNull(verifyKey, "verifyKey"));
return this;
}
@Override
public DataContent build(boolean encrypt) {
return delegate.build(encrypt);
}
// ======================================================================
// Single-algorithm helpers
// ======================================================================
/**
* Helper entry for single-algorithm signature construction.
*
* @since 1.0
*/
public static final class SingleSelector {
private SingleSelector() {
}
/**
* Creates a signing trailer for a single signature algorithm (no spec).
*
* @param algorithmId signature algorithm id
* @param privateKey private key for signing
* @return builder ready to be added to a pipeline
* @throws NullPointerException if {@code algorithmId} or {@code privateKey} is
* {@code null}
* @throws IllegalStateException if the signature context cannot be created
* (e.g., I/O/provider issues)
* @since 1.0
*/
public SignatureTrailerDataContentBuilder sign(String algorithmId, PrivateKey privateKey) {
return sign(algorithmId, privateKey, null);
}
/**
* Creates a signing trailer for a single signature algorithm with an optional
* spec.
*
* @param algorithmId signature algorithm id
* @param privateKey private key for signing
* @param spec optional context spec (may be {@code null})
* @return builder ready to be added to a pipeline
* @throws NullPointerException if {@code algorithmId} or {@code privateKey} is
* {@code null}
* @throws IllegalStateException if the signature context cannot be created
* (e.g., I/O/provider issues)
* @since 1.0
*/
public SignatureTrailerDataContentBuilder sign(String algorithmId, PrivateKey privateKey, ContextSpec spec) {
Objects.requireNonNull(algorithmId, "algorithmId");
Objects.requireNonNull(privateKey, "privateKey");
Supplier<TagEngine<Signature>> factory = () -> {
try {
return CryptoAlgorithms.create(algorithmId, KeyUsage.SIGN, privateKey, spec);
} catch (IOException e) {
throw new IllegalStateException("Failed to create SIGN SignatureContext for: " + algorithmId, e);
}
};
return core(factory);
}
/**
* Creates a verification trailer for a single signature algorithm (no spec).
*
* @param algorithmId signature algorithm id
* @param publicKey public key for verification
* @return builder ready to be added to a pipeline
* @throws NullPointerException if {@code algorithmId} or {@code publicKey} is
* {@code null}
* @throws IllegalStateException if the signature context cannot be created
* (e.g., I/O/provider issues)
* @since 1.0
*/
public SignatureTrailerDataContentBuilder verify(String algorithmId, PublicKey publicKey) {
return verify(algorithmId, publicKey, null);
}
/**
* Creates a verification trailer for a single signature algorithm with an
* optional spec.
*
* @param algorithmId signature algorithm id
* @param publicKey public key for verification
* @param spec optional context spec (may be {@code null})
* @return builder ready to be added to a pipeline
* @throws NullPointerException if {@code algorithmId} or {@code publicKey} is
* {@code null}
* @throws IllegalStateException if the signature context cannot be created
* (e.g., I/O/provider issues)
* @since 1.0
*/
public SignatureTrailerDataContentBuilder verify(String algorithmId, PublicKey publicKey, ContextSpec spec) {
Objects.requireNonNull(algorithmId, "algorithmId");
Objects.requireNonNull(publicKey, "publicKey");
Supplier<TagEngine<Signature>> factory = () -> {
try {
return CryptoAlgorithms.create(algorithmId, KeyUsage.VERIFY, publicKey, spec);
} catch (IOException e) {
throw new IllegalStateException("Failed to create VERIFY SignatureContext for: " + algorithmId, e);
}
};
return core(factory);
}
}
// ======================================================================
// Hybrid helpers
// ======================================================================
/**
* Helper entry for hybrid signature construction.
*
* @since 1.0
*/
public static final class HybridSelector {
private static final int DEFAULT_MAX_BODY_BYTES = 2 * 1024 * 1024;
private HybridSelector() {
}
/**
* Creates a hybrid signing trailer for the common case where both specs are
* {@code null}.
*
* @param classicSigId classic signature algorithm id
* @param pqcSigId post-quantum signature algorithm id
* @param rule aggregation rule
* @param classicPrivate classic private key
* @param pqcPrivate PQC private key
* @return builder ready to be added to a pipeline
* @throws NullPointerException if any required argument is {@code null}
* @throws IllegalStateException if the hybrid context cannot be created
* @since 1.0
*/
public SignatureTrailerDataContentBuilder sign(String classicSigId, String pqcSigId,
HybridSignatureProfile.VerifyRule rule, PrivateKey classicPrivate, PrivateKey pqcPrivate) {
return sign(classicSigId, pqcSigId, null, null, rule, classicPrivate, pqcPrivate, DEFAULT_MAX_BODY_BYTES);
}
/**
* Creates a hybrid verification trailer for the common case where both specs
* are {@code null}.
*
* @param classicSigId classic signature algorithm id
* @param pqcSigId post-quantum signature algorithm id
* @param rule aggregation rule
* @param classicPublic classic public key
* @param pqcPublic PQC public key
* @return builder ready to be added to a pipeline
* @throws NullPointerException if any required argument is {@code null}
* @throws IllegalStateException if the hybrid context cannot be created
* @since 1.0
*/
public SignatureTrailerDataContentBuilder verify(String classicSigId, String pqcSigId,
HybridSignatureProfile.VerifyRule rule, PublicKey classicPublic, PublicKey pqcPublic) {
return verify(classicSigId, pqcSigId, null, null, rule, classicPublic, pqcPublic, DEFAULT_MAX_BODY_BYTES);
}
/**
* Creates a hybrid signing trailer with optional specs and explicit
* {@code maxBodyBytes}.
*
* @param classicSigId classic signature algorithm id
* @param pqcSigId post-quantum signature algorithm id
* @param classicSpec optional classic spec (may be {@code null})
* @param pqcSpec optional PQC spec (may be {@code null})
* @param rule aggregation rule
* @param classicPrivate classic private key
* @param pqcPrivate PQC private key
* @param maxBodyBytes maximum body size accepted by the hybrid context; must
* be at least 1
* @return builder ready to be added to a pipeline
* @throws NullPointerException if any required argument is {@code null}
* @throws IllegalArgumentException if {@code maxBodyBytes < 1}
* @throws IllegalStateException if the hybrid context cannot be created
* @since 1.0
*/
public SignatureTrailerDataContentBuilder sign(String classicSigId, String pqcSigId, ContextSpec classicSpec,
ContextSpec pqcSpec, HybridSignatureProfile.VerifyRule rule, PrivateKey classicPrivate,
PrivateKey pqcPrivate, int maxBodyBytes) {
Objects.requireNonNull(classicSigId, "classicSigId");
Objects.requireNonNull(pqcSigId, "pqcSigId");
Objects.requireNonNull(rule, "rule");
Objects.requireNonNull(classicPrivate, "classicPrivate");
Objects.requireNonNull(pqcPrivate, "pqcPrivate");
if (maxBodyBytes < 1) { // NOPMD
throw new IllegalArgumentException("maxBodyBytes must be >= 1");
}
HybridSignatureProfile profile = new HybridSignatureProfile(classicSigId, pqcSigId, classicSpec, pqcSpec,
rule);
Supplier<TagEngine<Signature>> factory = () -> {
try {
return HybridSignatureContexts.sign(profile, classicPrivate, pqcPrivate, maxBodyBytes);
} catch (RuntimeException e) { // NOPMD
throw e;
} catch (Exception e) {
throw new IllegalStateException("Failed to create hybrid SIGN SignatureContext", e);
}
};
return core(factory);
}
/**
* Creates a hybrid verification trailer with optional specs and explicit
* {@code maxBodyBytes}.
*
* @param classicSigId classic signature algorithm id
* @param pqcSigId post-quantum signature algorithm id
* @param classicSpec optional classic spec (may be {@code null})
* @param pqcSpec optional PQC spec (may be {@code null})
* @param rule aggregation rule
* @param classicPublic classic public key
* @param pqcPublic PQC public key
* @param maxBodyBytes maximum body size accepted by the hybrid context; must
* be at least 1
* @return builder ready to be added to a pipeline
* @throws NullPointerException if any required argument is {@code null}
* @throws IllegalArgumentException if {@code maxBodyBytes < 1}
* @throws IllegalStateException if the hybrid context cannot be created
* @since 1.0
*/
public SignatureTrailerDataContentBuilder verify(String classicSigId, String pqcSigId, ContextSpec classicSpec,
ContextSpec pqcSpec, HybridSignatureProfile.VerifyRule rule, PublicKey classicPublic,
PublicKey pqcPublic, int maxBodyBytes) {
Objects.requireNonNull(classicSigId, "classicSigId");
Objects.requireNonNull(pqcSigId, "pqcSigId");
Objects.requireNonNull(rule, "rule");
Objects.requireNonNull(classicPublic, "classicPublic");
Objects.requireNonNull(pqcPublic, "pqcPublic");
if (maxBodyBytes < 1) { // NOPMD
throw new IllegalArgumentException("maxBodyBytes must be >= 1");
}
HybridSignatureProfile profile = new HybridSignatureProfile(classicSigId, pqcSigId, classicSpec, pqcSpec,
rule);
Supplier<TagEngine<Signature>> factory = () -> {
try {
return HybridSignatureContexts.verify(profile, classicPublic, pqcPublic, maxBodyBytes);
} catch (RuntimeException e) { // NOPMD
throw e;
} catch (Exception e) {
throw new IllegalStateException("Failed to create hybrid VERIFY SignatureContext", e);
}
};
return core(factory);
}
}
}

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

@@ -73,7 +73,9 @@
* {@link zeroecho.sdk.builders.alg.EcdsaDataContentBuilder},
* {@link zeroecho.sdk.builders.alg.Ed25519DataContentBuilder},
* {@link zeroecho.sdk.builders.alg.Ed448DataContentBuilder},
* {@link zeroecho.sdk.builders.alg.SphincsPlusDataContentBuilder}.</li>
* {@link zeroecho.sdk.builders.alg.SphincsPlusDataContentBuilder},
* {@link zeroecho.sdk.builders.alg.MldsaDataContentBuilder},
* {@link zeroecho.sdk.builders.alg.SlhDsaDataContentBuilder}.</li>
* <li>MAC and digest: {@link zeroecho.sdk.builders.alg.HmacDataContentBuilder},
* {@link zeroecho.sdk.builders.alg.DigestDataContentBuilder}.</li>
* <li>KEM envelopes: {@link zeroecho.sdk.builders.alg.KemDataContentBuilder}
@@ -86,6 +88,11 @@
* <li>{@link TagTrailerDataContentBuilder} - appends or verifies an
* authentication tag carried as an input trailer using a
* {@link zeroecho.core.tag.TagEngine}.</li>
* <li>{@link SignatureTrailerDataContentBuilder} - signature-specialized
* trailer builder intended to replace
* {@code TagTrailerDataContentBuilder<Signature>} in most signature use cases.
* It can wrap existing signature engines and construct single-algorithm and
* hybrid signature contexts.</li>
* </ul>
* </li>
* </ul>
@@ -107,6 +114,9 @@
* signatures or tags.</li>
* <li>{@link TagTrailerDataContentBuilder} focuses on trailer-style tags with
* explicit verify policies.</li>
* <li>{@link SignatureTrailerDataContentBuilder} provides the corresponding
* trailer functionality specialized for digital signatures, including hybrid
* signature construction.</li>
* </ul>
*
* <h2>Typical usage</h2> <pre>{@code

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

@@ -0,0 +1,67 @@
/*******************************************************************************
* 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.
******************************************************************************/
/**
* 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
*/
package zeroecho.sdk.hybrid.derived;

View File

@@ -0,0 +1,463 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.sdk.hybrid.kex;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.Key;
import java.security.PublicKey;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
import zeroecho.core.CryptoAlgorithm;
import zeroecho.core.context.AgreementContext;
import zeroecho.core.context.MessageAgreementContext;
import zeroecho.sdk.util.Kdf;
/**
* Hybrid key exchange (KEX) context that combines a classic agreement and a
* post-quantum KEM-style agreement.
*
* <p>
* This context composes two independent shared secrets:
* </p>
* <ul>
* <li><b>Classic leg</b>: a {@link AgreementContext} (e.g. X25519, ECDH, DH)
* that derives a raw shared secret after the peer public key is
* configured.</li>
* <li><b>PQC leg</b>: a {@link MessageAgreementContext} (typically ML-KEM
* exposed via core capabilities) whose "peer message" represents the
* encapsulation/decapsulation payload (e.g. KEM ciphertext).</li>
* </ul>
*
* <h2>Wire format</h2>
* <p>
* This class implements {@link MessageAgreementContext} to provide a single
* hybrid message that can be transmitted between parties. The encoding is:
* </p>
* <pre>{@code
* [int classicLen][classicBytes...][int pqcLen][pqcBytes...]
* }</pre>
*
* <p>
* For typical pairings such as {@code X25519 + ML-KEM}, the classic part is
* empty because the classic leg is configured using the peer public key
* out-of-band. If the classic leg itself is message-capable, the classic part
* may be populated.
* </p>
*
* <h2>Key derivation</h2>
* <p>
* The final keying material is derived using HKDF-SHA256 (RFC 5869):
* </p>
* <pre>{@code
* classicSS = classic.deriveSecret()
* pqcSS = pqc.deriveSecret()
* IKM = classicSS || pqcSS
* OKM = HKDF-SHA256(IKM, salt, info, outLen)
* }</pre>
*
* <p>
* Intermediate raw secrets are treated as sensitive and are zeroized
* (best-effort) once HKDF completes.
* </p>
*
* <h2>CryptoContext identity</h2>
* <p>
* A hybrid exchange is inherently bound to multiple algorithms and keys. The
* {@link #algorithm()} and {@link #key()} methods return representative values
* from the classic leg to satisfy the
* {@link zeroecho.core.context.CryptoContext} contract. Callers that require
* full introspection should retain references to the underlying component
* contexts.
* </p>
*
* <h2>Error handling</h2>
* <ul>
* <li>Malformed hybrid messages cause {@link IllegalArgumentException} in
* {@link #setPeerMessage(byte[])}.</li>
* <li>HKDF failures are surfaced as {@link IllegalStateException} in
* {@link #deriveSecret()}.</li>
* <li>{@link #close()} closes both component contexts and aggregates
* {@link IOException}s via suppressed exceptions.</li>
* </ul>
*
* <h2>Thread safety</h2>
* <p>
* Instances are mutable and not thread-safe; use one instance per
* handshake/session and thread.
* </p>
*
* @since 1.0
*/
public final class HybridKexContext implements MessageAgreementContext {
private static final Logger LOG = Logger.getLogger(HybridKexContext.class.getName());
private final HybridKexProfile profile;
private final AgreementContext classic;
private final MessageAgreementContext pqc;
private byte[] peerMessage;
/**
* Creates a hybrid KEX context by composing two underlying contexts.
*
* @param profile hybrid profile defining HKDF binding and output length
* @param classic classic agreement context (must not be {@code null})
* @param pqc PQC message agreement context (must not be {@code null})
* @throws NullPointerException if any argument is {@code null}
* @since 1.0
*/
public HybridKexContext(HybridKexProfile profile, AgreementContext classic, MessageAgreementContext pqc) {
this.profile = Objects.requireNonNull(profile, "profile");
this.classic = Objects.requireNonNull(classic, "classic");
this.pqc = Objects.requireNonNull(pqc, "pqc");
}
/**
* Returns the representative algorithm of this context.
*
* <p>
* This is currently delegated to the classic leg. Hybrid constructions bind
* multiple algorithms; callers that need full visibility should track both legs
* explicitly.
* </p>
*
* @return representative algorithm (classic leg)
* @since 1.0
*/
@Override
public CryptoAlgorithm algorithm() {
return classic.algorithm();
}
/**
* Returns the representative key of this context.
*
* <p>
* This is delegated to the classic leg to satisfy the {@code CryptoContext}
* contract. The hybrid KEX also depends on the PQC leg keys; callers should
* store those keys separately if needed by the application.
* </p>
*
* @return representative key (classic leg)
* @since 1.0
*/
@Override
public Key key() {
return classic.key();
}
/**
* Sets the peer public key for the classic leg.
*
* <p>
* This is the usual configuration step for classic DH-style agreements. The PQC
* leg typically does not use peer public keys through this method; however, the
* call is forwarded on a best-effort basis for implementations that accept it.
* </p>
*
* @param peer peer public key for the classic agreement
* @since 1.0
*/
@Override
public void setPeerPublic(PublicKey peer) {
classic.setPeerPublic(peer);
try {
pqc.setPeerPublic(peer);
} catch (RuntimeException ignore) { // NOPMD
// KEM-style agreements usually do not accept peer public here.
}
}
/**
* Supplies the hybrid peer message received from the remote party.
*
* <p>
* The message is decoded into its classic and PQC parts. The PQC part is always
* forwarded to the PQC leg via
* {@link MessageAgreementContext#setPeerMessage(byte[])}. The classic part is
* forwarded only if the classic leg also implements
* {@link MessageAgreementContext}; otherwise it is ignored.
* </p>
*
* <p>
* Passing {@code null} resets the peer-message related state of both legs
* (best-effort).
* </p>
*
* @param message hybrid message, or {@code null} to reset
* @throws IllegalArgumentException if the provided message does not conform to
* the hybrid encoding
* @since 1.0
*/
@Override
public void setPeerMessage(byte[] message) {
if (message == null) {
this.peerMessage = null;
try {
pqc.setPeerMessage(null);
} catch (RuntimeException ignore) { // NOPMD
}
try {
setClassicMessageIfSupported(null);
} catch (RuntimeException ignore) { // NOPMD
}
return;
}
Parts parts;
try {
parts = decode(message);
} catch (IOException e) {
throw new IllegalArgumentException("Invalid hybrid peer message encoding", e);
}
this.peerMessage = message.clone();
try {
setClassicMessageIfSupported(parts.classicPart());
} catch (RuntimeException ignore) { // NOPMD
// classic leg may be non-message agreement; ignore
}
byte[] pqcPart = parts.pqcPart();
if (pqcPart != null && pqcPart.length > 0) {
pqc.setPeerMessage(pqcPart);
}
}
/**
* Returns the hybrid message to be sent to the peer.
*
* <p>
* The PQC leg typically produces a non-empty message (encapsulation ciphertext)
* for initiator roles. The classic leg contributes an empty message unless it
* supports message mode.
* </p>
*
* @return encoded hybrid peer message
* @throws IllegalStateException if encoding fails or the PQC leg cannot produce
* a message in the current role
* @since 1.0
*/
@Override
public byte[] getPeerMessage() {
byte[] classicMsg = getClassicMessageIfSupported();
byte[] pqcMsg;
try {
pqcMsg = pqc.getPeerMessage();
} catch (RuntimeException e) { // NOPMD
// Responder-side KEM leg typically does not produce an outbound message.
pqcMsg = new byte[0];
}
try {
byte[] msg = encode(classicMsg, pqcMsg);
this.peerMessage = msg.clone();
return msg;
} catch (IOException e) {
throw new IllegalStateException("Unable to encode hybrid peer message", e);
}
}
/**
* Derives the final hybrid shared secret (OKM) using HKDF-SHA256.
*
* <p>
* This method derives both component secrets and then performs HKDF over their
* concatenation. Higher-level protocols should additionally bind
* transcript/context data through the HKDF {@code info} parameter
* ({@link HybridKexProfile#hkdfInfo()}).
* </p>
*
* @return derived keying material (OKM) of length
* {@link HybridKexProfile#outLenBytes()}
* @throws IllegalStateException if HKDF fails or underlying contexts are not
* configured
* @since 1.0
*/
@Override
public byte[] deriveSecret() {
byte[] classicSs = classic.deriveSecret();
byte[] pqcSs = pqc.deriveSecret();
byte[] ikm = new byte[classicSs.length + pqcSs.length];
System.arraycopy(classicSs, 0, ikm, 0, classicSs.length);
System.arraycopy(pqcSs, 0, ikm, classicSs.length, pqcSs.length);
try {
byte[] out = Kdf.hkdfSha256(ikm, profile.hkdfSalt(), profile.hkdfInfo(), profile.outLenBytes());
if (LOG.isLoggable(Level.FINE)) {
LOG.fine("HybridKexContext.deriveSecret(): derived OKM length=" + out.length);
}
return out;
} catch (GeneralSecurityException e) {
throw new IllegalStateException("HKDF-SHA256 failed", e);
} finally {
zeroize(classicSs);
zeroize(pqcSs);
zeroize(ikm);
}
}
/**
* Closes both underlying contexts.
*
* <p>
* If closing the second context fails after the first one already failed, the
* second failure is added as a suppressed exception on the first one.
* </p>
*
* @throws IOException if closing either leg fails
* @since 1.0
*/
@Override
public void close() throws IOException {
IOException first = null;
try {
classic.close();
} catch (IOException e) {
first = e;
}
try {
pqc.close();
} catch (IOException e) {
if (first == null) {
first = e;
} else {
first.addSuppressed(e);
}
}
if (first != null) {
throw first;
}
}
/**
* Returns the last hybrid peer message that was produced or set on this
* instance.
*
* <p>
* This is intended for diagnostics and testing. The returned array is a
* defensive copy.
* </p>
*
* @return last hybrid message, or {@code null} if none has been produced or set
* @since 1.0
*/
public byte[] lastPeerMessageOrNull() {
return (peerMessage == null) ? null : peerMessage.clone();
}
// -------------------------------------------------------------------------
// Encoding helpers
// -------------------------------------------------------------------------
private static byte[] encode(byte[] classicMsg, byte[] pqcMsg) throws IOException {
byte[] c = (classicMsg == null) ? new byte[0] : classicMsg;
byte[] p = (pqcMsg == null) ? new byte[0] : pqcMsg;
ByteArrayOutputStream bout = new ByteArrayOutputStream();
DataOutputStream out = new DataOutputStream(bout);
out.writeInt(c.length);
out.write(c);
out.writeInt(p.length);
out.write(p);
out.flush();
return bout.toByteArray();
}
private static Parts decode(byte[] msg) throws IOException {
DataInputStream in = new DataInputStream(new ByteArrayInputStream(msg));
int cLen = in.readInt();
if (cLen < 0) {
throw new IOException("negative classic length");
}
byte[] c = new byte[cLen];
in.readFully(c);
int pLen = in.readInt();
if (pLen < 0) {
throw new IOException("negative pqc length");
}
byte[] p = new byte[pLen];
in.readFully(p);
return new Parts(c, p);
}
private static void zeroize(byte[] b) {
if (b == null) {
return;
}
for (int i = 0; i < b.length; i++) {
b[i] = 0;
}
}
private byte[] getClassicMessageIfSupported() {
if (classic instanceof MessageAgreementContext) {
try {
return ((MessageAgreementContext) classic).getPeerMessage();
} catch (RuntimeException ignore) { // NOPMD
return new byte[0];
}
}
return new byte[0];
}
private void setClassicMessageIfSupported(byte[] msg) {
if (classic instanceof MessageAgreementContext) {
((MessageAgreementContext) classic).setPeerMessage(msg);
}
}
private record Parts(byte[] classicPart, byte[] pqcPart) {
}
}

View File

@@ -0,0 +1,314 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.sdk.hybrid.kex;
import java.io.IOException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.Objects;
import zeroecho.core.CryptoAlgorithms;
import zeroecho.core.KeyUsage;
import zeroecho.core.context.AgreementContext;
import zeroecho.core.context.MessageAgreementContext;
import zeroecho.core.spec.ContextSpec;
/**
* Factory utilities for constructing hybrid key exchange (KEX) contexts.
*
* <p>
* This class implements the SDK-level composition layer for hybrid key
* exchange. It does not introduce new core contracts; instead, it composes
* existing core contexts:
* </p>
* <ul>
* <li>{@link AgreementContext} for the classic (pre-quantum) leg (e.g. X25519,
* ECDH, DH), and</li>
* <li>{@link MessageAgreementContext} for the post-quantum leg (typically a
* KEM-style agreement, e.g. ML-KEM exposed as {@code KeyUsage.AGREEMENT}).</li>
* </ul>
*
* <h2>Handshake model</h2>
* <p>
* The two legs have different wire semantics:
* </p>
* <ul>
* <li><b>Classic leg</b> generally uses a peer public key supplied out-of-band
* (certificate, directory, or a higher-level handshake message). This is
* represented by
* {@link AgreementContext#setPeerPublic(java.security.PublicKey)} and does not
* necessarily produce any explicit "to-be-sent" bytes.</li>
* <li><b>PQC leg</b> is message-based: the initiator produces an encapsulation
* message (e.g. KEM ciphertext) via
* {@link MessageAgreementContext#getPeerMessage()}, and the responder consumes
* it via {@link MessageAgreementContext#setPeerMessage(byte[])}.</li>
* </ul>
*
* <p>
* {@link HybridKexContext} unifies these semantics by emitting and consuming a
* single hybrid peer message that carries both legs (with the classic part
* typically empty for classic-only public-key exchange).
* </p>
*
* <h2>Error handling</h2>
* <p>
* Underlying context construction uses
* {@link CryptoAlgorithms#create(String, KeyUsage, java.security.Key, ContextSpec)}
* which may throw {@link IOException}. These factory methods propagate the
* checked exception to keep failures explicit and auditable.
* </p>
*
* <h2>Thread safety</h2>
* <p>
* This factory is stateless and thread-safe. Produced contexts are mutable and
* not thread-safe.
* </p>
*
* @since 1.0
*/
public final class HybridKexContexts {
private HybridKexContexts() {
// utility
}
/**
* Creates an initiator-side hybrid KEX context.
*
* <p>
* This method constructs:
* </p>
* <ul>
* <li>a classic {@link AgreementContext} from the initiator's classic
* {@link PrivateKey} and configures it with the peer's classic
* {@link PublicKey}, and</li>
* <li>a PQC {@link MessageAgreementContext} from the peer's PQC
* {@link PublicKey} (encapsulation side).</li>
* </ul>
*
* <p>
* The returned {@link HybridKexContext} will typically produce a peer message
* whose PQC part is non-empty (e.g. KEM ciphertext), while the classic part is
* empty unless the chosen classic implementation itself supports message mode.
* </p>
*
* @param profile hybrid profile defining HKDF binding and
* output length
* @param classicAlgId classic agreement algorithm identifier (for
* example {@code "X25519"}, {@code "ECDH"},
* {@code "DH"})
* @param classicInitiatorPrivate initiator private key for the classic leg
* @param classicPeerPublic peer public key for the classic leg
* @param classicSpec optional classic context spec (may be
* {@code null} if the algorithm supports a
* default)
* @param pqcAlgId post-quantum agreement algorithm identifier
* (for example {@code "ML-KEM"} exposed via
* {@code KeyUsage.AGREEMENT})
* @param pqcPeerPublic peer public key for the PQC leg (encapsulation
* side)
* @param pqcSpec optional PQC context spec (may be {@code null}
* if the algorithm supports a default)
* @return initiator-side {@link HybridKexContext}
* @throws NullPointerException if any required argument is {@code null}
* @throws IOException if underlying context creation fails
* @since 1.0
*/
public static HybridKexContext initiator(HybridKexProfile profile, String classicAlgId,
PrivateKey classicInitiatorPrivate, PublicKey classicPeerPublic, ContextSpec classicSpec, String pqcAlgId,
PublicKey pqcPeerPublic, ContextSpec pqcSpec) throws IOException {
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(classicAlgId, "classicAlgId");
Objects.requireNonNull(classicInitiatorPrivate, "classicInitiatorPrivate");
Objects.requireNonNull(classicPeerPublic, "classicPeerPublic");
Objects.requireNonNull(pqcAlgId, "pqcAlgId");
Objects.requireNonNull(pqcPeerPublic, "pqcPeerPublic");
AgreementContext classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT, classicInitiatorPrivate,
classicSpec);
classic.setPeerPublic(classicPeerPublic);
MessageAgreementContext pqc = CryptoAlgorithms.create(pqcAlgId, KeyUsage.AGREEMENT, pqcPeerPublic, pqcSpec);
return new HybridKexContext(profile, classic, pqc);
}
/**
* Creates a responder-side hybrid KEX context.
*
* <p>
* This method constructs:
* </p>
* <ul>
* <li>a classic {@link AgreementContext} from the responder's classic
* {@link PrivateKey} and configures it with the peer's classic
* {@link PublicKey}, and</li>
* <li>a PQC {@link MessageAgreementContext} from the responder's PQC
* {@link PrivateKey} (decapsulation side).</li>
* </ul>
*
* <p>
* The returned {@link HybridKexContext} is typically used after receiving the
* initiator's hybrid peer message and passing it to
* {@link HybridKexContext#setPeerMessage(byte[])}.
* </p>
*
* @param profile hybrid profile defining HKDF binding and
* output length
* @param classicAlgId classic agreement algorithm identifier
* @param classicResponderPrivate responder private key for the classic leg
* @param classicPeerPublic peer public key for the classic leg
* @param classicSpec optional classic context spec (may be
* {@code null})
* @param pqcAlgId post-quantum agreement algorithm identifier
* @param pqcResponderPrivate responder private key for the PQC leg
* (decapsulation side)
* @param pqcSpec optional PQC context spec (may be
* {@code null})
* @return responder-side {@link HybridKexContext}
* @throws NullPointerException if any required argument is {@code null}
* @throws IOException if underlying context creation fails
* @since 1.0
*/
public static HybridKexContext responder(HybridKexProfile profile, String classicAlgId,
PrivateKey classicResponderPrivate, PublicKey classicPeerPublic, ContextSpec classicSpec, String pqcAlgId,
PrivateKey pqcResponderPrivate, ContextSpec pqcSpec) throws IOException {
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(classicAlgId, "classicAlgId");
Objects.requireNonNull(classicResponderPrivate, "classicResponderPrivate");
Objects.requireNonNull(classicPeerPublic, "classicPeerPublic");
Objects.requireNonNull(pqcAlgId, "pqcAlgId");
Objects.requireNonNull(pqcResponderPrivate, "pqcResponderPrivate");
AgreementContext classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT, classicResponderPrivate,
classicSpec);
classic.setPeerPublic(classicPeerPublic);
MessageAgreementContext pqc = CryptoAlgorithms.create(pqcAlgId, KeyUsage.AGREEMENT, pqcResponderPrivate,
pqcSpec);
return new HybridKexContext(profile, classic, pqc);
}
/**
* Creates an initiator-side hybrid KEX where the classic leg is message-based
* (PAIR_MESSAGE).
*
* <p>
* The classic message is the SPKI encoding of the initiator's classic public
* key as produced by the underlying {@link MessageAgreementContext}. The PQC
* message is the PQC encapsulation payload (typically KEM ciphertext).
* </p>
*
* @param profile hybrid profile defining HKDF binding and
* output length
* @param classicAlgId classic agreement algorithm identifier (e.g.
* "X25519", "ECDH", "DH")
* @param classicInitiatorKeyPair classic initiator key pair wrapped as
* {@code KeyPairKey}
* @param classicSpec classic context spec (may be {@code null} if
* supported)
* @param pqcAlgId post-quantum agreement algorithm identifier
* (e.g. "ML-KEM")
* @param pqcPeerPublic PQC peer public key (encapsulation side)
* @param pqcSpec optional PQC context spec (may be
* {@code null})
* @return initiator-side {@link HybridKexContext}
* @throws NullPointerException if any required argument is {@code null}
* @throws IOException if underlying context creation fails
*/
public static HybridKexContext initiatorPairMessage(HybridKexProfile profile, String classicAlgId,
zeroecho.core.alg.common.agreement.KeyPairKey classicInitiatorKeyPair, ContextSpec classicSpec,
String pqcAlgId, PublicKey pqcPeerPublic, ContextSpec pqcSpec) throws IOException {
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(classicAlgId, "classicAlgId");
Objects.requireNonNull(classicInitiatorKeyPair, "classicInitiatorKeyPair");
Objects.requireNonNull(pqcAlgId, "pqcAlgId");
Objects.requireNonNull(pqcPeerPublic, "pqcPeerPublic");
MessageAgreementContext classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT,
classicInitiatorKeyPair, classicSpec);
MessageAgreementContext pqc = CryptoAlgorithms.create(pqcAlgId, KeyUsage.AGREEMENT, pqcPeerPublic, pqcSpec);
return new HybridKexContext(profile, classic, pqc);
}
/**
* Creates a responder-side hybrid KEX where the classic leg is message-based
* (PAIR_MESSAGE).
*
* <p>
* The responder must call {@link HybridKexContext#setPeerMessage(byte[])} with
* the initiator's hybrid message before calling
* {@link HybridKexContext#deriveSecret()}.
* </p>
*
* @param profile hybrid profile defining HKDF binding and
* output length
* @param classicAlgId classic agreement algorithm identifier
* @param classicResponderKeyPair classic responder key pair wrapped as
* {@code KeyPairKey}
* @param classicSpec classic context spec (may be {@code null})
* @param pqcAlgId post-quantum agreement algorithm identifier
* @param pqcResponderPrivate PQC responder private key (decapsulation side)
* @param pqcSpec optional PQC context spec (may be
* {@code null})
* @return responder-side {@link HybridKexContext}
* @throws NullPointerException if any required argument is {@code null}
* @throws IOException if underlying context creation fails
*/
public static HybridKexContext responderPairMessage(HybridKexProfile profile, String classicAlgId,
zeroecho.core.alg.common.agreement.KeyPairKey classicResponderKeyPair, ContextSpec classicSpec,
String pqcAlgId, PrivateKey pqcResponderPrivate, ContextSpec pqcSpec) throws IOException {
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(classicAlgId, "classicAlgId");
Objects.requireNonNull(classicResponderKeyPair, "classicResponderKeyPair");
Objects.requireNonNull(pqcAlgId, "pqcAlgId");
Objects.requireNonNull(pqcResponderPrivate, "pqcResponderPrivate");
MessageAgreementContext classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT,
classicResponderKeyPair, classicSpec);
MessageAgreementContext pqc = CryptoAlgorithms.create(pqcAlgId, KeyUsage.AGREEMENT, pqcResponderPrivate,
pqcSpec);
return new HybridKexContext(profile, classic, pqc);
}
}

View File

@@ -0,0 +1,134 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.sdk.hybrid.kex;
import java.security.GeneralSecurityException;
import java.util.Objects;
import zeroecho.sdk.util.Kdf;
/**
* Deterministic exporter for deriving multiple independent keys from a single
* hybrid KEX.
*
* <p>
* Many protocols require more than one key from a single handshake (for
* example, separate traffic keys for each direction, confirmation keys, or
* application exporters). This class provides a minimal key schedule API over
* HKDF-SHA256.
* </p>
*
* <h2>Construction</h2>
* <p>
* The exporter is seeded by a caller-supplied secret (typically the output of
* {@link HybridKexContext#deriveSecret()}). The exporter then derives sub-keys
* by varying HKDF {@code info}.
* </p>
*
* <h2>Security notes</h2>
* <ul>
* <li>Use distinct labels per purpose (for example {@code "app/tx"} vs
* {@code "app/rx"}).</li>
* <li>Bind transcript/context by including it in the {@code info} bytes.</li>
* </ul>
*
* @since 1.0
*/
public final class HybridKexExporter {
private final byte[] rootSecret;
private final byte[] salt;
/**
* Creates an exporter seeded from a root secret.
*
* @param rootSecret root secret (must not be null)
* @param salt optional HKDF salt (may be null)
*/
public HybridKexExporter(byte[] rootSecret, byte[] salt) {
Objects.requireNonNull(rootSecret, "rootSecret");
this.rootSecret = rootSecret.clone();
this.salt = (salt == null) ? null : salt.clone();
}
/**
* Derives {@code outLenBytes} bytes for a specific purpose.
*
* @param label ASCII/UTF-8 label identifying the purpose (must not be
* null)
* @param info optional additional binding info (may be null)
* @param outLenBytes output length in bytes (1..8160)
* @return derived bytes
* @throws IllegalArgumentException if outLenBytes is out of range
*/
public byte[] export(String label, byte[] info, int outLenBytes) {
Objects.requireNonNull(label, "label");
if (outLenBytes < 1 || outLenBytes > 255 * 32) {
throw new IllegalArgumentException("outLenBytes must be in range 1.." + (255 * 32));
}
byte[] labelBytes = label.getBytes(java.nio.charset.StandardCharsets.UTF_8);
byte[] infoUse;
if (info == null || info.length == 0) {
infoUse = labelBytes;
} else {
infoUse = new byte[labelBytes.length + 1 + info.length];
System.arraycopy(labelBytes, 0, infoUse, 0, labelBytes.length);
infoUse[labelBytes.length] = 0;
System.arraycopy(info, 0, infoUse, labelBytes.length + 1, info.length);
}
try {
return Kdf.hkdfSha256(rootSecret, salt, infoUse, outLenBytes);
} catch (GeneralSecurityException e) {
throw new IllegalStateException("HKDF-SHA256 failed", e);
}
}
/**
* Returns a defensive copy of the exporter root secret.
*
* <p>
* This is intended for diagnostics/testing only. Applications should prefer
* {@link #export(String, byte[], int)}.
* </p>
*
* @return copy of root secret
*/
public byte[] rootSecretCopy() {
return rootSecret.clone();
}
}

View File

@@ -0,0 +1,124 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.sdk.hybrid.kex;
import java.security.Key;
import java.util.Objects;
import zeroecho.core.context.AgreementContext;
import zeroecho.core.context.MessageAgreementContext;
import zeroecho.core.policy.SecurityStrengthAdvisor;
/**
* Hybrid KEX policy helper for minimum security strength enforcement.
*
* <p>
* This policy applies additional hybrid-specific checks beyond per-algorithm
* policy validation. It is intended to prevent accidental downgrade
* combinations (for example a strong PQC leg combined with a too-weak classic
* leg, or an undersized OKM output).
* </p>
*
* <h2>What is checked</h2>
* <ul>
* <li>Classic leg estimated strength in bits (algorithm id + key)</li>
* <li>PQC leg estimated strength in bits (algorithm id + key)</li>
* <li>Minimum OKM length (in bytes) for the intended usage</li>
* </ul>
*
* <p>
* Strength estimates are provided by
* {@link SecurityStrengthAdvisor#estimateBits(String, Key)} and are
* conservative heuristics suitable for gating and coarse comparisons.
* </p>
*
* @since 1.0
*/
public final class HybridKexPolicy {
private final int minClassicBits;
private final int minPqcBits;
private final int minOkmBytes;
/**
* Creates a hybrid policy.
*
* @param minClassicBits minimum estimated bits for classic leg (for example 128
* or 192)
* @param minPqcBits minimum estimated bits for PQC leg (for example 192)
* @param minOkmBytes minimum OKM output length in bytes (for example 32)
*/
public HybridKexPolicy(int minClassicBits, int minPqcBits, int minOkmBytes) {
if (minClassicBits < 0 || minPqcBits < 0) {
throw new IllegalArgumentException("min bits must be non-negative");
}
if (minOkmBytes < 1) { // NOPMD
throw new IllegalArgumentException("minOkmBytes must be >= 1");
}
this.minClassicBits = minClassicBits;
this.minPqcBits = minPqcBits;
this.minOkmBytes = minOkmBytes;
}
/**
* Enforces the policy for a given hybrid configuration.
*
* @param profile hybrid profile
* @param classic classic agreement context
* @param pqc PQC message agreement context
* @throws NullPointerException if any argument is null
* @throws IllegalArgumentException if policy is violated
*/
public void enforce(HybridKexProfile profile, AgreementContext classic, MessageAgreementContext pqc) {
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(classic, "classic");
Objects.requireNonNull(pqc, "pqc");
if (profile.outLenBytes() < minOkmBytes) {
throw new IllegalArgumentException(
"Hybrid OKM length too small: " + profile.outLenBytes() + " < " + minOkmBytes);
}
int classicBits = SecurityStrengthAdvisor.estimateBits(classic.algorithm().id(), classic.key());
int pqcBits = SecurityStrengthAdvisor.estimateBits(pqc.algorithm().id(), pqc.key());
if (classicBits < minClassicBits) {
throw new IllegalArgumentException("Classic leg too weak: " + classicBits + " < " + minClassicBits);
}
if (pqcBits < minPqcBits) {
throw new IllegalArgumentException("PQC leg too weak: " + pqcBits + " < " + minPqcBits);
}
}
}

View File

@@ -0,0 +1,108 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.sdk.hybrid.kex;
import java.nio.charset.StandardCharsets;
/**
* Profile for a hybrid key exchange (KEX) composition.
*
* <p>
* A hybrid KEX combines two independently derived secrets:
* </p>
* <ul>
* <li>a classic (pre-quantum) agreement secret produced by an
* {@code AgreementContext} (e.g. X25519, ECDH, DH), and</li>
* <li>a post-quantum agreement secret produced by a
* {@code MessageAgreementContext} (typically a KEM-style agreement such as
* ML-KEM).</li>
* </ul>
*
* <p>
* The two secrets are combined using HKDF-SHA256 (RFC 5869) to produce a final
* keying material byte array of a caller-selected length. The default label is
* intended to make cross-protocol misuse harder by providing an explicit domain
* separator.
* </p>
*
* <h2>Notes</h2>
* <ul>
* <li>This profile does not store algorithm identifiers on purpose; it focuses
* on KDF binding and output size. Algorithm selection happens in higher layers
* when constructing the two underlying contexts.</li>
* <li>{@code salt} is optional; if null/empty, HKDF uses a zero-filled salt as
* per RFC 5869 and {@link zeroecho.sdk.util.Kdf}.</li>
* </ul>
*
* @param hkdfSalt optional HKDF salt (defensively copied), may be
* {@code null}
* @param hkdfInfo optional HKDF info/label (defensively copied), may be
* {@code null}
* @param outLenBytes length of derived keying material in bytes (1..8160)
*
* @since 1.0
*/
public record HybridKexProfile(byte[] hkdfSalt, byte[] hkdfInfo, int outLenBytes) {
/**
* Default HKDF label used when the caller does not provide an explicit one.
*/
public static final byte[] DEFAULT_INFO = "ZeroEcho-HybridKEX".getBytes(StandardCharsets.US_ASCII);
/**
* Constructs a profile with a default HKDF info label.
*
* @param outLenBytes output length in bytes
* @return profile with default HKDF info label and no explicit salt
*/
public static HybridKexProfile defaultProfile(int outLenBytes) {
return new HybridKexProfile(null, DEFAULT_INFO, outLenBytes);
}
/**
* Canonical constructor with validation and defensive copies.
*
* @param hkdfSalt optional HKDF salt
* @param hkdfInfo optional HKDF info/label
* @param outLenBytes output length in bytes
*/
public HybridKexProfile {
if (outLenBytes < 1 || outLenBytes > 255 * 32) {
throw new IllegalArgumentException("outLenBytes must be in range 1.." + (255 * 32));
}
hkdfSalt = (hkdfSalt == null) ? null : hkdfSalt.clone();
hkdfInfo = (hkdfInfo == null) ? null : hkdfInfo.clone();
}
}

View File

@@ -0,0 +1,150 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.sdk.hybrid.kex;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
/**
* Canonical transcript builder for binding hybrid KEX derivation to public
* handshake context.
*
* <p>
* The derived hybrid keying material should be bound to protocol context to
* reduce risk of cross-protocol key reuse and "unknown key-share" style
* mistakes. This builder provides a deterministic and self-delimiting binary
* encoding intended to be used as HKDF {@code info}.
* </p>
*
* <h2>Encoding</h2>
* <p>
* The transcript is encoded as a sequence of TLV-like entries:
* </p>
* <pre>{@code
* [u16 tagLen][tagBytes...][u32 valueLen][valueBytes...] ...
* }</pre>
*
* <p>
* Tags are ASCII identifiers (for example {@code "suite"}, {@code "role"},
* {@code "peerA"}, {@code "peerB"}, {@code "classicMsg"}, {@code "pqcMsg"}).
* Values are arbitrary bytes. The encoding is stable across JVMs and
* independent of locale.
* </p>
*
* <h2>Thread safety</h2>
* <p>
* Instances are mutable and not thread-safe.
* </p>
*
* @since 1.0
*/
public final class HybridKexTranscript {
private final ByteArrayOutputStream buffer;
private final DataOutputStream out;
/**
* Creates a new empty transcript.
*/
public HybridKexTranscript() {
this.buffer = new ByteArrayOutputStream();
this.out = new DataOutputStream(buffer);
}
/**
* Adds a UTF-8 string value under a tag.
*
* @param tag ASCII tag identifier
* @param value UTF-8 string value (must not be null)
* @return this transcript
* @throws NullPointerException if tag or value is null
* @throws IllegalArgumentException if tag is empty
*/
public HybridKexTranscript addUtf8(String tag, String value) {
Objects.requireNonNull(value, "value");
return addBytes(tag, value.getBytes(StandardCharsets.UTF_8));
}
/**
* Adds a raw byte value under a tag.
*
* <p>
* The value is defensively copied by the caller if needed; this method does not
* retain a reference to the provided array.
* </p>
*
* @param tag ASCII tag identifier
* @param value byte value (must not be null)
* @return this transcript
* @throws NullPointerException if tag or value is null
* @throws IllegalArgumentException if tag is empty
*/
public HybridKexTranscript addBytes(String tag, byte[] value) {
Objects.requireNonNull(tag, "tag");
Objects.requireNonNull(value, "value");
if (tag.isEmpty()) {
throw new IllegalArgumentException("tag must not be empty");
}
byte[] tagBytes = tag.getBytes(StandardCharsets.US_ASCII);
try {
if (tagBytes.length > 65_535) { // NOPMD
throw new IllegalArgumentException("tag too long");
}
out.writeShort(tagBytes.length);
out.write(tagBytes);
out.writeInt(value.length);
out.write(value);
out.flush();
return this;
} catch (IOException e) {
// ByteArrayOutputStream should not throw, but keep failure explicit.
throw new IllegalStateException("Unable to encode transcript entry", e);
}
}
/**
* Returns the canonical transcript bytes (defensive copy).
*
* @return transcript bytes
*/
public byte[] toByteArray() {
return buffer.toByteArray();
}
}

View File

@@ -0,0 +1,81 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
/**
* Hybrid key exchange (KEX) utilities combining a classic agreement and a
* post-quantum KEM-style agreement into one derived shared secret.
*
* <p>
* This package provides an SDK-level composition layer over existing core
* contracts:
* </p>
* <ul>
* <li>{@link zeroecho.core.context.AgreementContext} for classic DH-style
* agreements (e.g. X25519, ECDH, DH), and</li>
* <li>{@link zeroecho.core.context.MessageAgreementContext} for message-based
* agreements (typically PQC KEM-style flows such as ML-KEM exposed via
* {@code KeyUsage.AGREEMENT}).</li>
* </ul>
*
* <h2>Wire model</h2>
* <p>
* A hybrid exchange needs explicit "to-be-sent" bytes. This package unifies the
* model by emitting a single peer message containing two length-prefixed parts:
* </p>
* <ol>
* <li>classic message (often empty for classic agreements that only require a
* peer public key),</li>
* <li>PQC message (typically KEM ciphertext produced by
* {@link zeroecho.core.context.MessageAgreementContext}).</li>
* </ol>
*
* <h2>Key derivation</h2>
* <p>
* The two underlying secrets are combined using HKDF-SHA256 (RFC 5869) via
* {@link zeroecho.sdk.util.Kdf#hkdfSha256(byte[], byte[], byte[], int)} rather
* than concatenation. Callers should treat raw intermediate secrets as
* sensitive and clear them as soon as possible.
* </p>
*
* <h2>Intended usage</h2>
* <p>
* Use {@link zeroecho.sdk.hybrid.kex.HybridKexContexts} to build initiator and
* responder contexts and exchange
* {@link zeroecho.sdk.hybrid.kex.HybridKexContext#getPeerMessage()} between
* parties.
* </p>
*
* @since 1.0
*/
package zeroecho.sdk.hybrid.kex;

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;

View File

@@ -0,0 +1,212 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.sdk.hybrid.signature;
import java.security.Signature;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level;
import java.util.logging.Logger;
import zeroecho.core.err.VerificationException;
import zeroecho.core.tag.ThrowingBiPredicate.VerificationBiPredicate;
/**
* Verification predicate wrapper that captures the boolean verification result
* and never propagates {@link VerificationException} to its caller.
*
* <p>
* This wrapper delegates verification to a supplied
* {@link VerificationBiPredicate} and records the boolean outcome into a shared
* {@link AtomicBoolean}. If the delegate throws {@link VerificationException},
* the exception is suppressed and the verification is treated as a failure
* (returns {@code false}).
* </p>
*
* <p>
* This behavior is required for robust hybrid verification semantics, most
* notably for logical OR composition: individual component verifiers may throw
* on malformed or structurally invalid signatures (for example, certain Ed25519
* invalid point encodings). Translating such exceptions into a boolean failure
* allows the hybrid engine to continue evaluating alternative verification
* paths and to aggregate the final decision deterministically.
* </p>
*
* <h2>Logging</h2>
* <p>
* For diagnostic purposes, a {@link Level#FINE} log entry is emitted on each
* invocation containing:
* </p>
* <ul>
* <li>a short hexadecimal prefix of {@code expectedTag} (bounded and
* truncated), and</li>
* <li>the resulting verification decision ({@code true}/{@code false}).</li>
* </ul>
*
* <p>
* The logged tag prefix is intentionally truncated to reduce the risk of
* leaking sensitive material. The log message is produced using a JUL
* formatting string (no string concatenation in the hot path) and is only
* constructed when {@code FINE} is enabled.
* </p>
*
* <h2>Threading and side effects</h2>
* <ul>
* <li>This class is immutable with respect to its own fields.</li>
* <li>The {@code ok} flag is updated exactly once per invocation with the
* result returned by this method (including failures caused by suppressed
* exceptions).</li>
* <li>Correctness depends on coordinated usage of the shared
* {@link AtomicBoolean} when used concurrently.</li>
* </ul>
*
* <p>
* Security note: this class must not log keys, plaintext, shared secrets, full
* tags, or other sensitive material. Only a bounded prefix is logged at
* {@code FINE} level.
* </p>
*
* @since 1.0
*/
final class CapturePredicate extends VerificationBiPredicate<Signature> {
private static final Logger LOGGER = Logger.getLogger(CapturePredicate.class.getName());
/**
* Maximum number of bytes from {@code expectedTag} included in log output.
*/
private static final int TAG_LOG_PREFIX_BYTES = 8;
/**
* Underlying verification predicate performing the actual cryptographic check.
*/
private final VerificationBiPredicate<Signature> delegate;
/**
* Shared atomic flag capturing the result of the most recent verification.
*/
private final AtomicBoolean ok;
/**
* Creates a new capturing predicate.
*
* @param delegate the underlying verification predicate to invoke
* @param ok shared atomic flag receiving the verification outcome
* @throws NullPointerException if {@code delegate} or {@code ok} is
* {@code null}
*/
protected CapturePredicate(VerificationBiPredicate<Signature> delegate, AtomicBoolean ok) {
super();
this.delegate = Objects.requireNonNull(delegate, "delegate");
this.ok = Objects.requireNonNull(ok, "ok");
}
/**
* Invokes the delegate predicate, captures its boolean result, and never
* propagates {@link VerificationException}.
*
* <p>
* If the delegate completes normally, its return value is recorded into
* {@link #ok} and returned. If the delegate throws
* {@link VerificationException}, the exception is suppressed, {@code false} is
* recorded into {@link #ok}, and {@code false} is returned.
* </p>
*
* <p>
* A {@link Level#FINE} log entry is emitted with a truncated hexadecimal prefix
* of {@code expectedTag} and the resulting decision.
* </p>
*
* @param signature the signature object to verify
* @param expectedTag the expected signature tag (may be {@code null}, in which
* case {@code "<null>"} is logged)
* @return {@code true} if verification succeeded, {@code false} otherwise
* @throws VerificationException never thrown by this implementation, but
* declared to satisfy the overridden contract
*/
@Override
public boolean verify(Signature signature, byte[] expectedTag) throws VerificationException {
boolean result;
try {
result = delegate.verify(signature, expectedTag);
} catch (VerificationException ex) {
result = false;
}
ok.set(result);
if (LOGGER.isLoggable(Level.FINE)) {
LOGGER.log(Level.FINE, "Hybrid verify: expectedTagPrefix={0}, result={1}",
new Object[] { formatTagPrefix(expectedTag), result });
}
return result;
}
/**
* Formats a short hexadecimal prefix of the provided tag for logging.
*
* <p>
* At most {@link #TAG_LOG_PREFIX_BYTES} bytes are included. If the tag is
* longer, the output is suffixed with {@code "..."}.
* </p>
*
* <p>
* The output format uses lowercase hexadecimal digits without separators.
* </p>
*
* @param tag tag bytes to format (may be {@code null})
* @return a bounded, log-safe hexadecimal prefix, or {@code "<null>"} if
* {@code tag} is {@code null}
*/
private static String formatTagPrefix(byte[] tag) {
if (tag == null) {
return "<null>";
}
int n = Math.min(tag.length, TAG_LOG_PREFIX_BYTES);
StringBuilder sb = new StringBuilder(n * 2 + 3);
for (int i = 0; i < n; i++) {
sb.append(Character.forDigit((tag[i] >>> 4) & 0x0f, 16)).append(Character.forDigit(tag[i] & 0x0f, 16));
}
if (tag.length > n) {
sb.append("...");
}
return sb.toString();
}
}

View File

@@ -0,0 +1,131 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.sdk.hybrid.signature;
import java.security.Signature;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level;
import java.util.logging.Logger;
import zeroecho.core.err.VerificationException;
import zeroecho.core.tag.ThrowingBiPredicate.VerificationBiPredicate;
/**
* Hybrid verification core predicate delegating the final decision to the
* hybrid engine's end-of-stream (EOF) aggregation.
*
* <p>
* This predicate does not perform any cryptographic verification on its own.
* Instead, it acts as a bridge between the generic verification pipeline and
* the hybrid streaming engine, where the actual verification of classic and PQC
* signatures is performed once the complete payload has been consumed.
* </p>
*
* <p>
* The boolean result returned by {@link #verify(Signature, byte[])} reflects
* the outcome computed during EOF processing and stored in the shared
* {@link AtomicBoolean} instance. This allows the verification decision to be
* made exactly once, based on the fully buffered message body and the hybrid
* verification rule.
* </p>
*
* <h2>Design notes</h2>
* <ul>
* <li>The {@code signature} and {@code expectedTag} parameters are
* intentionally ignored, as the hybrid model defers verification until all data
* has been read from the stream.</li>
* <li>This class is immutable and thread-safe with respect to its own state;
* however, correctness depends on coordinated usage with the associated hybrid
* stream.</li>
* <li>The predicate must be used only in conjunction with a hybrid signature
* stream that updates the shared {@code lastOk} flag.</li>
* </ul>
*
* <p>
* Security note: this predicate must not leak any sensitive material. It merely
* returns a boolean decision computed elsewhere and does not inspect keys,
* signatures, or plaintext data.
* </p>
*
* @since 1.0
*/
final class HybridCorePredicate extends VerificationBiPredicate<Signature> {
private static final Logger LOGGER = Logger.getLogger(HybridCorePredicate.class.getName());
/**
* Holds the final hybrid verification result computed at end-of-stream.
*/
private final AtomicBoolean lastOk;
/**
* Creates a new hybrid core predicate bound to the given verification result
* flag.
*
* @param lastOk shared atomic flag holding the final hybrid verification result
* @throws NullPointerException if {@code lastOk} is {@code null}
*/
protected HybridCorePredicate(AtomicBoolean lastOk) {
super();
this.lastOk = Objects.requireNonNull(lastOk, "lastOk");
}
/**
* Returns the hybrid verification result computed during EOF processing.
*
* <p>
* This method performs no validation of the provided {@code signature} or
* {@code expectedTag}. All cryptographic checks have already been executed by
* the hybrid stream once the complete payload was available.
* </p>
*
* @param signature ignored; present to satisfy the predicate contract
* @param expectedTag ignored; present to satisfy the predicate contract
* @return {@code true} if the hybrid verification succeeded according to the
* configured hybrid verification rule, {@code false} otherwise
* @throws VerificationException never thrown by this implementation, but
* declared to satisfy the overridden contract
*/
@Override
public boolean verify(Signature signature, byte[] expectedTag) throws VerificationException {
boolean result = lastOk.get();
if (LOGGER.isLoggable(Level.FINE)) {
LOGGER.log(Level.FINE, "Hybrid core verification result={0}", result);
}
return result;
}
}

View File

@@ -0,0 +1,611 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.sdk.hybrid.signature;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.security.Key;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.Signature;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import zeroecho.core.CryptoAlgorithm;
import zeroecho.core.CryptoAlgorithms;
import zeroecho.core.KeyUsage;
import zeroecho.core.context.SignatureContext;
import zeroecho.core.err.VerificationException;
import zeroecho.core.io.TailStrippingInputStream;
import zeroecho.core.spec.ContextSpec;
import zeroecho.core.tag.ThrowingBiPredicate.VerificationBiPredicate;
/**
* Package-private {@link SignatureContext} that composes two signature engines.
*
* <p>
* The signature trailer is {@code sigClassic || sigPqc} in the fixed order
* defined by the {@link HybridSignatureProfile}. The trailer carries no
* algorithm identifiers; the profile is the source of truth.
* </p>
*
* <h3>Streaming semantics</h3>
* <p>
* This context is compatible with the existing ZeroEcho signing/verification
* pipelines: callers wrap an {@link InputStream} and read until EOF. At EOF,
* the underlying engines produce/verify the trailer.
* </p>
*
* <p>
* Implementation note: the wrapper buffers the message body and runs the
* component engines at EOF. This avoids any need for changes in core contracts
* while keeping the same external streaming behavior.
* </p>
*
* <h3>Verification aggregation</h3>
* <p>
* Verification is performed for both component signatures and aggregated by the
* profile rule (AND / OR). The final decision is applied via this context's
* {@link #setVerificationApproach(VerificationBiPredicate)} predicate, enabling
* the standard ZeroEcho behavior (throw-on-mismatch, flagging, etc.).
* </p>
*
* @since 1.0
*/
final class HybridSignatureContext implements SignatureContext {
private final HybridSignatureProfile profile;
private final boolean produceMode;
private final PrivateKey classicPrivate;
private final PrivateKey pqcPrivate;
private final PublicKey classicPublic;
private final PublicKey pqcPublic;
private final int maxBufferedBytes;
private final AtomicBoolean lastOk;
private final VerificationBiPredicate<Signature> hybridCore;
private VerificationBiPredicate<Signature> verificationApproach;
private byte[] expectedTag;
/**
* Creates a signing hybrid signature context.
*
* @param profile hybrid signature profile
* @param classicPrivate private key for classical engine
* @param pqcPrivate private key for PQC engine
* @param maxBufferedBytes maximum buffered bytes (DoS guard)
* @throws NullPointerException if {@code profile}, {@code classicPrivate},
* or {@code pqcPrivate} is {@code null}
* @throws IllegalArgumentException if {@code maxBufferedBytes <= 0}
*/
protected HybridSignatureContext(HybridSignatureProfile profile, PrivateKey classicPrivate, PrivateKey pqcPrivate,
int maxBufferedBytes) {
this.profile = Objects.requireNonNull(profile, "profile");
this.classicPrivate = Objects.requireNonNull(classicPrivate, "classicPrivate");
this.pqcPrivate = Objects.requireNonNull(pqcPrivate, "pqcPrivate");
this.classicPublic = null;
this.pqcPublic = null;
this.produceMode = true;
if (maxBufferedBytes <= 0) {
throw new IllegalArgumentException("maxBufferedBytes must be positive");
}
this.maxBufferedBytes = maxBufferedBytes;
this.lastOk = new AtomicBoolean(false);
this.hybridCore = new HybridCorePredicate(this.lastOk);
this.verificationApproach = this.hybridCore;
this.expectedTag = null;
}
/**
* Creates a verifying hybrid signature context.
*
* @param profile hybrid signature profile
* @param classicPublic public key for classical engine
* @param pqcPublic public key for PQC engine
* @param maxBufferedBytes maximum buffered bytes (DoS guard)
* @throws NullPointerException if {@code profile}, {@code classicPublic},
* or {@code pqcPublic} is {@code null}
* @throws IllegalArgumentException if {@code maxBufferedBytes <= 0}
*/
protected HybridSignatureContext(HybridSignatureProfile profile, PublicKey classicPublic, PublicKey pqcPublic,
int maxBufferedBytes) {
this.profile = Objects.requireNonNull(profile, "profile");
this.classicPublic = Objects.requireNonNull(classicPublic, "classicPublic");
this.pqcPublic = Objects.requireNonNull(pqcPublic, "pqcPublic");
this.classicPrivate = null;
this.pqcPrivate = null;
this.produceMode = false;
if (maxBufferedBytes <= 0) {
throw new IllegalArgumentException("maxBufferedBytes must be positive");
}
this.maxBufferedBytes = maxBufferedBytes;
this.lastOk = new AtomicBoolean(false);
this.hybridCore = new HybridCorePredicate(this.lastOk);
this.verificationApproach = this.hybridCore;
this.expectedTag = null;
}
@Override
public InputStream wrap(InputStream upstream) throws IOException {
Objects.requireNonNull(upstream, "upstream");
return new HybridStream(upstream);
}
@Override
public int tagLength() {
try (SignatureContext classic = createClassic(produceMode ? KeyUsage.SIGN : KeyUsage.VERIFY);
SignatureContext pqc = createPqc(produceMode ? KeyUsage.SIGN : KeyUsage.VERIFY)) {
return classic.tagLength() + pqc.tagLength();
} catch (IOException e) {
throw new IllegalStateException("Failed to determine hybrid tag length", e);
}
}
@Override
public void setExpectedTag(byte[] expected) {
if (produceMode) {
throw new UnsupportedOperationException("Expected tag is not used in sign mode");
}
Objects.requireNonNull(expected, "expected");
this.expectedTag = expected.clone();
}
@Override
public void setVerificationApproach(VerificationBiPredicate<Signature> approach) {
this.verificationApproach = Objects.requireNonNull(approach, "approach");
}
@Override
public VerificationBiPredicate<Signature> getVerificationCore() {
return hybridCore;
}
@Override
public CryptoAlgorithm algorithm() {
return CryptoAlgorithms.require(profile.classicSigId());
}
@Override
public Key key() {
return produceMode ? classicPrivate : classicPublic;
}
@Override
public void close() {
// Stateless at context level; per-stream resources are closed by the stream.
}
private SignatureContext createClassic(KeyUsage usage) throws IOException {
ContextSpec spec = profile.classicSpec();
if (usage == KeyUsage.SIGN) {
return CryptoAlgorithms.create(profile.classicSigId(), usage, classicPrivate, spec);
}
return CryptoAlgorithms.create(profile.classicSigId(), usage, classicPublic, spec);
}
private SignatureContext createPqc(KeyUsage usage) throws IOException {
ContextSpec spec = profile.pqcSpec();
if (usage == KeyUsage.SIGN) {
return CryptoAlgorithms.create(profile.pqcSigId(), usage, pqcPrivate, spec);
}
return CryptoAlgorithms.create(profile.pqcSigId(), usage, pqcPublic, spec);
}
/**
* Internal {@link InputStream} wrapper that transparently appends or validates
* a hybrid-signature trailer at end-of-stream.
*
* <p>
* The stream proxies reads from an underlying upstream {@link InputStream}.
* While reading, it buffers the payload body (bounded by
* {@code maxBufferedBytes}) to enable hybrid signature processing at EOF:
* </p>
*
* <ul>
* <li><b>Produce mode</b> ({@code produceMode == true}): on upstream EOF, two
* signatures are computed (classic + PQC) over the buffered body and then
* emitted as a trailer appended to the stream.</li>
* <li><b>Verify mode</b> ({@code produceMode == false}): on upstream EOF, the
* expected tag is split into classic/PQC components based on tag lengths, both
* signatures are verified against the buffered body, and the final verification
* result is stored into {@code lastOk}. No trailer bytes are emitted in verify
* mode.</li>
* </ul>
*
* <h2>Lifecycle and invariants</h2>
* <ul>
* <li>EOF finalization ({@link #finishAtEof()}) is executed at most once,
* guarded by {@link #eofSeen}.</li>
* <li>In produce mode, the trailer is emitted strictly after all upstream
* bytes.</li>
* <li>In verify mode, the stream ends immediately after the upstream ends.</li>
* <li>This class is not thread-safe and assumes sequential consumption.</li>
* </ul>
*
* <p>
* Security note: this implementation must not expose sensitive materials (keys,
* seeds, plaintext, signatures, or intermediate state) via logging or exception
* messages. Exceptions raised by this stream are limited to generic error
* descriptions and non-sensitive metadata (e.g., length and limits).
* </p>
*/
private final class HybridStream extends InputStream {
/** The wrapped upstream stream providing the primary data. */
private final InputStream upstream;
/**
* Buffer accumulating upstream bytes required for hybrid signature processing.
* The buffer is bounded to mitigate unbounded memory growth.
*/
private final ByteArrayOutputStream buffer;
/**
* Indicates whether end-of-stream has already been observed on the upstream.
* Ensures EOF finalization logic executes exactly once.
*/
private boolean eofSeen;
/**
* Trailer bytes to be emitted after upstream exhaustion in produce mode, or
* {@code null} when no trailer is present (e.g., verify mode).
*/
private byte[] trailer;
/**
* Current emission position within {@link #trailer}.
*/
private int trailerPos;
/**
* Creates a new hybrid stream wrapping the provided upstream.
*
* @param upstream the underlying input stream supplying the primary data; must
* not be {@code null}
*/
private HybridStream(InputStream upstream) {
super();
this.upstream = upstream;
this.buffer = new ByteArrayOutputStream(Math.min(8192, maxBufferedBytes));
this.eofSeen = false;
this.trailer = null;
this.trailerPos = 0;
}
/**
* Reads a single byte from this stream.
*
* <p>
* Delegates to {@link #read(byte[], int, int)} and follows the standard
* {@link InputStream} contract.
* </p>
*
* @return the next byte of data as an unsigned value in the range
* {@code 0255}, or {@code -1} if the stream is exhausted (including
* any trailer)
* @throws IOException if an I/O error occurs or EOF finalization fails
*/
@Override
public int read() throws IOException {
byte[] one = new byte[1];
int r = read(one, 0, 1);
if (r == -1) {
return -1;
}
return one[0] & 0xff;
}
/**
* Reads up to {@code len} bytes of data into {@code b}, starting at
* {@code off}.
*
* <p>
* The method first serves any pending trailer bytes (produce mode). Otherwise
* it reads from the upstream stream, buffering all successfully read bytes for
* later hybrid signature processing at EOF. When the upstream stream returns
* {@code -1} for the first time, {@link #finishAtEof()} is invoked and may
* either prepare a trailer for emission (produce mode) or perform verification
* (verify mode).
* </p>
*
* @param b destination buffer
* @param off offset at which to start storing bytes
* @param len maximum number of bytes to read
* @return the number of bytes read, or {@code -1} if the stream is exhausted
* @throws IOException if an I/O error occurs, the internal buffer
* limit is exceeded, or EOF finalization
* (signature creation/verification) fails
* @throws IndexOutOfBoundsException if {@code off} or {@code len} are invalid
*/
@Override
public int read(byte[] b, int off, int len) throws IOException {
Objects.checkFromIndexSize(off, len, b.length);
if (trailer != null) {
if (trailerPos >= trailer.length) {
return -1;
}
int n = Math.min(len, trailer.length - trailerPos);
System.arraycopy(trailer, trailerPos, b, off, n);
trailerPos += n;
return n;
}
int r = upstream.read(b, off, len);
if (r == -1) {
if (!eofSeen) {
eofSeen = true;
finishAtEof();
}
if (trailer != null && trailer.length > 0) {
return read(b, off, len);
}
return -1;
}
if (r > 0) {
writeToBuffer(b, off, r);
}
return r;
}
/**
* Closes this stream and releases the underlying upstream resource.
*
* <p>
* Note: closing this stream closes the wrapped {@code upstream} stream.
* </p>
*
* @throws IOException if closing the upstream fails
*/
@Override
public void close() throws IOException {
upstream.close();
}
/**
* Appends the specified slice of bytes into the internal body buffer.
*
* <p>
* The buffer is bounded by {@code maxBufferedBytes}. If appending would exceed
* the configured limit, the method fails fast with an {@link IOException}.
* </p>
*
* @param b source buffer
* @param off offset within {@code b}
* @param len number of bytes to append
* @throws IOException if the buffer limit would be exceeded
*/
private void writeToBuffer(byte[] b, int off, int len) throws IOException {
if (buffer.size() + len > maxBufferedBytes) {
throw new IOException("Hybrid signature buffer limit exceeded: " + maxBufferedBytes);
}
buffer.write(b, off, len);
}
/**
* Finalizes processing when upstream EOF is reached.
*
* <p>
* This method is invoked exactly once upon the first observation of upstream
* EOF. It uses the buffered body to either:
* </p>
*
* <ul>
* <li><b>Produce mode</b>: compute the classic and PQC signatures over the
* body, concatenate them, and expose them as a trailer to be emitted by
* subsequent {@code read(...)} calls.</li>
* <li><b>Verify mode</b>: split the expected tag into classic/PQC signature
* components, verify each against the body, combine results using the profile
* verify rule, update {@code lastOk}, and delegate to
* {@code verificationApproach} for any additional policy enforcement. No
* trailer is produced.</li>
* </ul>
*
* @throws IOException if required inputs are missing, if signature
* creation/verification fails, if the expected tag has an
* invalid length, or if the verification approach rejects
* the tag
*/
private void finishAtEof() throws IOException {
byte[] body = buffer.toByteArray();
if (produceMode) {
byte[] sigClassic = signOne(profile.classicSigId(), classicPrivate, profile.classicSpec(), body);
byte[] sigPqc = signOne(profile.pqcSigId(), pqcPrivate, profile.pqcSpec(), body);
trailer = concat(sigClassic, sigPqc);
trailerPos = 0;
return;
}
byte[] exp = expectedTag;
if (exp == null) {
throw new IOException("Expected tag not set");
}
VerificationSplit split = splitExpected(exp);
boolean okClassic = verifyOne(profile.classicSigId(), classicPublic, profile.classicSpec(), body,
split.expectedClassic);
boolean okPqc = verifyOne(profile.pqcSigId(), pqcPublic, profile.pqcSpec(), body, split.expectedPqc);
boolean finalOk = (profile.verifyRule() == HybridSignatureProfile.VerifyRule.OR) ? (okClassic || okPqc)
: (okClassic && okPqc);
lastOk.set(finalOk);
try {
verificationApproach.verify(null, exp);
} catch (VerificationException e) {
throw new IOException("Hybrid signature verification failed", e);
}
trailer = null;
trailerPos = 0;
}
/**
* Splits the expected hybrid tag into classic and PQC signature components.
*
* <p>
* The split is determined by querying tag lengths from freshly created
* verification contexts (classic and PQC). The expected tag must match the
* exact combined length {@code classicLen + pqcLen}; otherwise an
* {@link IOException} is thrown.
* </p>
*
* @param exp expected hybrid tag bytes containing concatenated classic and PQC
* signatures
* @return a value object holding the classic and PQC expected signatures
* @throws IOException if context initialization fails or {@code exp} has an
* invalid length
*/
private VerificationSplit splitExpected(byte[] exp) throws IOException {
int classicLen;
int pqcLen;
try (SignatureContext classic = createClassic(KeyUsage.VERIFY);
SignatureContext pqc = createPqc(KeyUsage.VERIFY)) {
classicLen = classic.tagLength();
pqcLen = pqc.tagLength();
}
int total = classicLen + pqcLen;
if (exp.length != total) {
throw new IOException("Invalid expected tag length: " + exp.length + ", expected " + total);
}
byte[] eClassic = new byte[classicLen];
byte[] ePqc = new byte[pqcLen];
System.arraycopy(exp, 0, eClassic, 0, classicLen);
System.arraycopy(exp, classicLen, ePqc, 0, pqcLen);
return new VerificationSplit(eClassic, ePqc);
}
}
/**
* Simple value object holding the classic and PQC portions of an expected
* hybrid signature tag.
*
* <p>
* Instances are created only after the expected tag length is validated and
* then used to feed the per-algorithm verification routines.
* </p>
*
* <p>
* Security note: this structure stores signature bytes; it must not be logged
* or exposed outside the narrow verification flow.
* </p>
*/
private static final class VerificationSplit {
/** Expected signature bytes for the classic algorithm. */
private final byte[] expectedClassic;
/** Expected signature bytes for the PQC algorithm. */
private final byte[] expectedPqc;
/**
* Constructs the split expected-tag view.
*
* @param expectedClassic expected classic signature bytes; must not be
* {@code null}
* @param expectedPqc expected PQC signature bytes; must not be {@code null}
*/
private VerificationSplit(byte[] expectedClassic, byte[] expectedPqc) {
this.expectedClassic = expectedClassic;
this.expectedPqc = expectedPqc;
}
}
private static byte[] signOne(String id, PrivateKey key, ContextSpec spec, byte[] body) throws IOException {
final byte[][] sigHolder = new byte[1][];
try (SignatureContext signer = CryptoAlgorithms.create(id, KeyUsage.SIGN, key, spec);
InputStream in = new TailStrippingInputStream(signer.wrap(new ByteArrayInputStream(body)),
signer.tagLength(), 8192) {
@Override
protected void processTail(byte[] tail) throws IOException {
if (tail == null || tail.length == 0) {
throw new IOException("Empty signature trailer for " + id);
}
sigHolder[0] = tail.clone();
}
}) {
in.transferTo(OutputStream.nullOutputStream());
byte[] sig = sigHolder[0];
if (sig == null) {
throw new IOException("Signature trailer missing for " + id);
}
return sig;
}
}
private static boolean verifyOne(String id, PublicKey key, ContextSpec spec, byte[] body, byte[] expected)
throws IOException {
AtomicBoolean ok = new AtomicBoolean(false);
try (SignatureContext verifier = CryptoAlgorithms.create(id, KeyUsage.VERIFY, key, spec)) {
verifier.setVerificationApproach(new CapturePredicate(verifier.getVerificationCore(), ok));
verifier.setExpectedTag(expected);
try (InputStream in = verifier.wrap(new ByteArrayInputStream(body))) {
in.transferTo(OutputStream.nullOutputStream());
}
}
return ok.get();
}
private static byte[] concat(byte[] a, byte[] b) {
byte[] out = new byte[a.length + b.length];
System.arraycopy(a, 0, out, 0, a.length);
System.arraycopy(b, 0, out, a.length, b.length);
return out;
}
}

View File

@@ -0,0 +1,99 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.sdk.hybrid.signature;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.Objects;
import zeroecho.core.context.SignatureContext;
/**
* Factory for {@link SignatureContext}-compatible hybrid signature contexts.
*
* <p>
* The returned contexts implement the standard ZeroEcho streaming contract:
* wrapping a stream produces/verifies a signature trailer at EOF.
* </p>
*
* @since 1.0
*/
public final class HybridSignatureContexts {
private HybridSignatureContexts() {
}
/**
* Creates a signing hybrid signature context.
*
* @param profile hybrid signature profile
* @param classicPrivate private key for the classical signature engine
* @param pqcPrivate private key for the PQC signature engine
* @param maxBufferedBytes maximum number of bytes buffered from the wrapped
* stream (DoS guard)
* @return signing signature context
* @throws NullPointerException if any mandatory argument is {@code null}
* @throws IllegalArgumentException if {@code maxBufferedBytes <= 0}
* @since 1.0
*/
public static SignatureContext sign(HybridSignatureProfile profile, PrivateKey classicPrivate,
PrivateKey pqcPrivate, int maxBufferedBytes) {
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(classicPrivate, "classicPrivate");
Objects.requireNonNull(pqcPrivate, "pqcPrivate");
return new HybridSignatureContext(profile, classicPrivate, pqcPrivate, maxBufferedBytes);
}
/**
* Creates a verification hybrid signature context.
*
* @param profile hybrid signature profile
* @param classicPublic public key for the classical signature engine
* @param pqcPublic public key for the PQC signature engine
* @param maxBufferedBytes maximum number of bytes buffered from the wrapped
* stream (DoS guard)
* @return verifying signature context
* @throws NullPointerException if any mandatory argument is {@code null}
* @throws IllegalArgumentException if {@code maxBufferedBytes <= 0}
* @since 1.0
*/
public static SignatureContext verify(HybridSignatureProfile profile, PublicKey classicPublic, PublicKey pqcPublic,
int maxBufferedBytes) {
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(classicPublic, "classicPublic");
Objects.requireNonNull(pqcPublic, "pqcPublic");
return new HybridSignatureContext(profile, classicPublic, pqcPublic, maxBufferedBytes);
}
}

View File

@@ -0,0 +1,112 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.sdk.hybrid.signature;
import java.util.Objects;
import zeroecho.core.spec.ContextSpec;
/**
* Immutable definition of a hybrid signature composition.
*
* {@code HybridSignatureProfile} is a pure configuration object that defines:
* <ul>
* <li>which two signature algorithms participate in the hybrid
* composition,</li>
* <li>their fixed ordering within the produced signature trailer,</li>
* <li>the verification aggregation rule applied to their results.</li>
* </ul>
*
* <p>
* The profile is the <em>single source of truth</em> for hybrid signature
* processing. No algorithm identifiers or metadata are embedded in the
* signature trailer itself; both signing and verification sides must use the
* same profile.
* </p>
*
* <p>
* Instances of this record are immutable and thread-safe.
* </p>
*
* @param classicSigId canonical identifier of the classical signature algorithm
* (for example {@code "Ed25519"})
* @param pqcSigId canonical identifier of the post-quantum signature
* algorithm (for example {@code "ML-DSA-65"})
* @param classicSpec optional {@link ContextSpec} for the classical signature
* engine; may be {@code null}
* @param pqcSpec optional {@link ContextSpec} for the post-quantum
* signature engine; may be {@code null}
* @param verifyRule aggregation rule applied during verification
*
* @since 1.0
*/
public record HybridSignatureProfile(String classicSigId, String pqcSigId, ContextSpec classicSpec, ContextSpec pqcSpec,
VerifyRule verifyRule) {
/**
* Verification aggregation rule for hybrid signatures.
*
* @since 1.0
*/
public enum VerifyRule {
/**
* Both component signatures must verify successfully.
*/
AND,
/**
* At least one component signature must verify successfully.
*/
OR
}
/**
* Canonical constructor with invariant checks.
*
* <p>
* Uses {@link Objects#requireNonNull(Object, String)} to enforce mandatory
* components while keeping validation idiomatic and consistent with the rest of
* the ZeroEcho codebase.
* </p>
*
* @throws NullPointerException if {@code classicSigId}, {@code pqcSigId}, or
* {@code verifyRule} is {@code null}
*/
public HybridSignatureProfile {
Objects.requireNonNull(classicSigId, "classicSigId");
Objects.requireNonNull(pqcSigId, "pqcSigId");
Objects.requireNonNull(verifyRule, "verifyRule");
}
}

View File

@@ -0,0 +1,118 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
/**
* Hybrid signature composition for streaming pipelines.
*
* <p>
* This package provides SDK-level hybrid signatures that combine two
* independent signature schemes (typically a classical and a post-quantum
* algorithm) and expose them as a single streaming signature engine suitable
* for {@link zeroecho.sdk.builders.TagTrailerDataContentBuilder} and related
* pipeline stages.
* </p>
*
* <h2>Concept</h2>
* <p>
* A hybrid signature computes two component signatures over the same message
* stream and then aggregates verification according to a configured rule:
* </p>
* <ul>
* <li><b>AND</b> - verification succeeds only if both component signatures
* verify.</li>
* <li><b>OR</b> - verification succeeds if at least one component signature
* verifies.</li>
* </ul>
*
* <p>
* The AND rule is intended for security-hardening scenarios (both schemes must
* hold). The OR rule is intended for migration/fallback scenarios (accept if at
* least one scheme verifies), and should be used with clear policy and
* operational intent.
* </p>
*
* <h2>Main types</h2>
* <ul>
* <li>{@link zeroecho.sdk.hybrid.signature.HybridSignatureProfile} - immutable
* configuration describing the two component algorithms, optional per-algorithm
* specs, and the verification rule.</li>
* <li>{@link zeroecho.sdk.hybrid.signature.HybridSignatureContexts} - factory
* methods for creating hybrid signing and verification contexts from keys and a
* {@link zeroecho.sdk.hybrid.signature.HybridSignatureProfile}.</li>
* <li>{@link zeroecho.sdk.hybrid.signature.HybridSignatureContext} - the
* streaming hybrid {@link zeroecho.core.context.SignatureContext}
* implementation that computes/verifies two signatures in a single pass.</li>
* </ul>
*
* <h2>Integration with pipeline builders</h2>
* <p>
* The hybrid signature contexts created by this package are intended to be used
* as engines in trailer-oriented pipeline stages. In particular:
* </p>
* <ul>
* <li>{@link zeroecho.sdk.builders.SignatureTrailerDataContentBuilder} can
* construct hybrid contexts directly and is the preferred signature-specialized
* builder API.</li>
* <li>{@link zeroecho.sdk.builders.TagTrailerDataContentBuilder} can also be
* used with a hybrid {@link zeroecho.core.context.SignatureContext} when
* generic tag handling is desired.</li>
* </ul>
*
* <h2>Streaming and resource management</h2>
* <p>
* Hybrid signature computation and verification are streaming operations. The
* resulting contexts and streams must be closed to release resources and to
* finalize tag/signature production or verification.
* </p>
*
* <h2>Security notes</h2>
* <ul>
* <li>Hybrid verification should be configured explicitly for mismatch handling
* (throw-on-mismatch vs. capture/flag) at the pipeline layer. This package
* focuses on computing/verifying the two component signatures and returning an
* aggregated result.</li>
* <li>Implementations must not log or otherwise expose sensitive material
* (private keys, seeds, message contents, intermediate state).</li>
* </ul>
*
* <h2>Thread safety</h2>
* <p>
* Context instances are not thread-safe and are intended for single-use in a
* single pipeline execution. Create a new context instance for each independent
* signing or verification operation.
* </p>
*
* @since 1.0
*/
package zeroecho.sdk.hybrid.signature;

View File

@@ -3,6 +3,7 @@ handlers = java.util.logging.ConsoleHandler
zeroecho.core.tag.ByteVerificationStrategy.level = FINE
zeroecho.core.tag.SignatureVerificationStrategy.level = FINE
zeroecho.sdk.hybrid.signature.level = FINE
# Console handler uses our one-line formatter
java.util.logging.ConsoleHandler.level = ALL

View File

@@ -36,7 +36,6 @@ package zeroecho.core.alg.common.agreement;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import java.io.IOException;
import java.math.BigInteger;
import java.security.GeneralSecurityException;
import java.security.InvalidKeyException;
@@ -88,20 +87,26 @@ public class AgreementAlgorithmsRoundTripTest {
System.out.println(method + "...ok");
}
private static void printCapabilityHeader(String algId, String branch, Capability cap) {
System.out.println("...algId=" + algId + ", branch=" + branch + ", ctxType=" + cap.contextType().getSimpleName()
+ ", keyType=" + cap.keyType().getSimpleName() + ", specType=" + cap.specType().getSimpleName());
}
private static String hex(byte[] b) {
if (b == null) {
return "null";
}
StringBuilder sb = new StringBuilder(b.length * 2);
for (byte v : b) {
for (int i = 0; i < b.length; i++) {
if (sb.length() == 80) {
sb.append("...");
break;
}
if ((v & 0xFF) < 16) {
int v = b[i] & 0xFF;
if (v < 16) {
sb.append('0');
}
sb.append(Integer.toHexString(v & 0xFF));
sb.append(Integer.toHexString(v));
}
return sb.toString();
}
@@ -118,8 +123,13 @@ public class AgreementAlgorithmsRoundTripTest {
@BeforeAll
static void setup() {
// If needed, install/activate providers (e.g., BouncyCastlePQCProvider) here.
BouncyCastleActivator.init();
// Optional: activate providers (e.g., BC/BCPQC) if present.
// Keep tests runnable even if BC is absent.
try {
BouncyCastleActivator.init();
} catch (Throwable ignore) {
// ignore
}
}
@Test
@@ -135,131 +145,254 @@ public class AgreementAlgorithmsRoundTripTest {
}
final List<Capability> caps = alg.listCapabilities();
for (Capability cap : caps) {
if (cap.role() != KeyUsage.AGREEMENT) {
continue;
}
System.out.printf(" ...%s - AGREEMENT capability found", id);
System.out.println("...algId=" + id + " - AGREEMENT capability found");
final Class<?> ctxType = cap.contextType();
final Class<?> keyType = cap.keyType();
final Class<?> specType = cap.specType();
@SuppressWarnings("unused")
final ContextSpec defVal = cap.defaultSpec().get();
// System.out.printf(" ......type=[%s] key=[%s] spec=[%s] default=[%s]%n",
// ctxType, keyType, specType, defVal);
// --------------------------------------------------------------------
// MessageAgreementContext branch A: PAIR_MESSAGE (DH/XDH/ECDH-style)
//
// New extension:
// - contextType: MessageAgreementContext
// - keyType: KeyPairKey (wrapper for KeyPair, because capabilities require Key)
// - specType: ContextSpec (algorithm-specific agreement spec)
//
// Semantics:
// - getPeerMessage() returns local public key encoding (SPKI)
// - setPeerMessage(...) imports peer public key encoding
// --------------------------------------------------------------------
if (MessageAgreementContext.class.isAssignableFrom(ctxType) && keyType == KeyPairKey.class
&& ContextSpec.class.isAssignableFrom(specType)) {
// AGREEMENT (KEM-style) via MessageAgreementContext adapter
if (MessageAgreementContext.class.isAssignableFrom(ctxType)) {
printCapabilityHeader(alg.id(), "PAIR_MESSAGE", cap);
KeyPair bob = generateKeyPair(alg);
if (bob == null) {
System.out.println(" ...bob=null");
ContextSpec spec = null;
try {
spec = cap.defaultSpec().get();
} catch (Throwable ignore) {
spec = tryExtractContextSpec(alg);
}
if (spec == null) {
System.out.println("...spec=null (skip)");
continue;
}
System.out.println("runAgreement(" + alg.id() + ", VoidSpec)");
System.out.println(" Bob.public " + keyInfo("key", bob.getPublic()));
System.out.println(" Bob.private " + keyInfo("key", bob.getPrivate()));
// Alice (initiator): has Bob's public key
MessageAgreementContext aliceCtx = CryptoAlgorithms.create(alg.id(), KeyUsage.AGREEMENT,
bob.getPublic(), VoidSpec.INSTANCE);
// Bob (responder): has his private key
MessageAgreementContext bobCtx = CryptoAlgorithms.create(alg.id(), KeyUsage.AGREEMENT,
bob.getPrivate(), VoidSpec.INSTANCE);
// Initiator produces encapsulation message (ciphertext) to send
byte[] enc = aliceCtx.getPeerMessage();
// Responder consumes it
bobCtx.setPeerMessage(enc);
// Both derive the same shared secret
byte[] kA = aliceCtx.deriveSecret();
byte[] kB = bobCtx.deriveSecret();
System.out.println(" KEM.ciphertext=" + hex(enc));
System.out.println(" Alice.secret =" + hex(kA));
System.out.println(" Bob.secret =" + hex(kB));
assertArrayEquals(kA, kB, alg.id() + ": agreement secrets mismatch");
System.out.println("...ok");
try {
aliceCtx.close();
} catch (Exception ignored) {
KeyPair alice = generateKeyPair(alg);
KeyPair bob = generateKeyPair(alg);
if (alice == null || bob == null) {
System.out.println("...keypair=null (skip)");
continue;
}
System.out.println("...pair-message agreement roundtrip: algId=" + alg.id() + ", spec="
+ spec.getClass().getSimpleName());
System.out.println("...Alice.public " + keyInfo("key", alice.getPublic()));
System.out.println("...Alice.private " + keyInfo("key", alice.getPrivate()));
System.out.println("...Bob.public " + keyInfo("key", bob.getPublic()));
System.out.println("...Bob.private " + keyInfo("key", bob.getPrivate()));
KeyPairKey aliceKey = new KeyPairKey(alice);
KeyPairKey bobKey = new KeyPairKey(bob);
MessageAgreementContext aCtx = null;
MessageAgreementContext bCtx = null;
try {
bobCtx.close();
} catch (Exception ignored) {
aCtx = CryptoAlgorithms.create(alg.id(), KeyUsage.AGREEMENT, aliceKey, spec);
bCtx = CryptoAlgorithms.create(alg.id(), KeyUsage.AGREEMENT, bobKey, spec);
byte[] aMsg = aCtx.getPeerMessage();
byte[] bMsg = bCtx.getPeerMessage();
aCtx.setPeerMessage(bMsg);
bCtx.setPeerMessage(aMsg);
byte[] zA = aCtx.deriveSecret();
byte[] zB = bCtx.deriveSecret();
System.out.println("...Alice.msg =" + hex(aMsg));
System.out.println("...Bob.msg =" + hex(bMsg));
System.out.println("...Alice.secret =" + hex(zA));
System.out.println("...Bob.secret =" + hex(zB));
assertArrayEquals(zA, zB, alg.id() + ": PAIR_MESSAGE secrets mismatch");
System.out.println("...PAIR_MESSAGE...ok");
} finally {
if (aCtx != null) {
try {
aCtx.close();
} catch (Exception ignored) {
// ignore
}
}
if (bCtx != null) {
try {
bCtx.close();
} catch (Exception ignored) {
// ignore
}
}
}
// For a given algorithm, one AGREEMENT capability is sufficient for the sweep.
break;
}
// --------------------------------------------------------------------
// MessageAgreementContext branch B: KEM_ADAPTER (KEM-style adapter)
//
// Existing behavior:
// - initiator has recipient PublicKey (encapsulation)
// - responder has PrivateKey (decapsulation)
// - spec is VoidSpec
// --------------------------------------------------------------------
if (MessageAgreementContext.class.isAssignableFrom(ctxType)
&& (keyType == PublicKey.class || keyType == PrivateKey.class)) {
printCapabilityHeader(alg.id(), "KEM_ADAPTER", cap);
KeyPair bob = generateKeyPair(alg);
if (bob == null) {
System.out.println("...keypair=null (skip)");
continue;
}
System.out.println("...kem-adapter agreement roundtrip: algId=" + alg.id() + ", spec=VoidSpec");
System.out.println("...Bob.public " + keyInfo("key", bob.getPublic()));
System.out.println("...Bob.private " + keyInfo("key", bob.getPrivate()));
MessageAgreementContext aliceCtx = null;
MessageAgreementContext bobCtx = null;
try {
// Alice (initiator): has Bob's public key
aliceCtx = CryptoAlgorithms.create(alg.id(), KeyUsage.AGREEMENT, bob.getPublic(),
VoidSpec.INSTANCE);
// Bob (responder): has his private key
bobCtx = CryptoAlgorithms.create(alg.id(), KeyUsage.AGREEMENT, bob.getPrivate(),
VoidSpec.INSTANCE);
// Initiator produces encapsulation message (ciphertext) to send
byte[] enc = aliceCtx.getPeerMessage();
// Responder consumes it
bobCtx.setPeerMessage(enc);
// Both derive the same shared secret
byte[] kA = aliceCtx.deriveSecret();
byte[] kB = bobCtx.deriveSecret();
System.out.println("...KEM.ciphertext=" + hex(enc));
System.out.println("...Alice.secret =" + hex(kA));
System.out.println("...Bob.secret =" + hex(kB));
assertArrayEquals(kA, kB, alg.id() + ": KEM_ADAPTER secrets mismatch");
System.out.println("...KEM_ADAPTER...ok");
} finally {
if (aliceCtx != null) {
try {
aliceCtx.close();
} catch (Exception ignored) {
// ignore
}
}
if (bobCtx != null) {
try {
bobCtx.close();
} catch (Exception ignored) {
// ignore
}
}
}
break;
}
// -------- Classic DH/XDH: AgreementContext + real ContextSpec --------
else if (AgreementContext.class.isAssignableFrom(ctxType)
&& ContextSpec.class.isAssignableFrom(specType) && keyType == PrivateKey.class) {
// --------------------------------------------------------------------
// AgreementContext branch C: CLASSIC_AGREEMENT (PrivateKey + ContextSpec)
// --------------------------------------------------------------------
if (AgreementContext.class.isAssignableFrom(ctxType) && keyType == PrivateKey.class
&& ContextSpec.class.isAssignableFrom(specType)) {
KeyPair alice;
KeyPair bob;
printCapabilityHeader(alg.id(), "CLASSIC_AGREEMENT", cap);
alice = generateKeyPair(alg);
bob = generateKeyPair(alg);
if (alice == null || bob == null) {
continue;
}
// Prefer the capability's default ContextSpec if provided
ContextSpec spec = null;
try {
ContextSpec def = cap.defaultSpec().get();
spec = def;
spec = cap.defaultSpec().get();
} catch (Throwable ignore) {
spec = tryExtractContextSpec(alg);
}
if (spec == null) {
System.out.println("...spec=null (skip)");
continue;
}
System.out.println("runAgreement(" + alg.id() + ", " + spec.getClass().getSimpleName() + ")");
System.out.println(" Alice.public " + keyInfo("key", alice.getPublic()));
System.out.println(" Alice.private " + keyInfo("key", alice.getPrivate()));
System.out.println(" Bob.public " + keyInfo("key", bob.getPublic()));
System.out.println(" Bob.private " + keyInfo("key", bob.getPrivate()));
KeyPair alice = generateKeyPair(alg);
KeyPair bob = generateKeyPair(alg);
// assertDhCompatible(alice.getPrivate(), bob.getPublic());
// assertDhCompatible(bob.getPrivate(), alice.getPublic());
if (alice == null || bob == null) {
System.out.println("...keypair=null (skip)");
continue;
}
AgreementContext aCtx = CryptoAlgorithms.create(alg.id(), KeyUsage.AGREEMENT, alice.getPrivate(),
spec);
AgreementContext bCtx = CryptoAlgorithms.create(alg.id(), KeyUsage.AGREEMENT, bob.getPrivate(),
spec);
aCtx.setPeerPublic(bob.getPublic());
bCtx.setPeerPublic(alice.getPublic());
System.out.println("...classic agreement roundtrip: algId=" + alg.id() + ", spec="
+ spec.getClass().getSimpleName());
System.out.println("...Alice.public " + keyInfo("key", alice.getPublic()));
System.out.println("...Alice.private " + keyInfo("key", alice.getPrivate()));
System.out.println("...Bob.public " + keyInfo("key", bob.getPublic()));
System.out.println("...Bob.private " + keyInfo("key", bob.getPrivate()));
byte[] zA = aCtx.deriveSecret();
byte[] zB = bCtx.deriveSecret();
// Optional DH sanity check (only when both sides are DH keys).
try {
assertDhCompatible(alice.getPrivate(), bob.getPublic());
assertDhCompatible(bob.getPrivate(), alice.getPublic());
} catch (Throwable ignore) {
// not DH, or provider-specific checks not applicable; continue anyway
}
System.out.println(" Alice.secret =" + hex(zA));
System.out.println(" Bob.secret =" + hex(zB));
assertArrayEquals(zA, zB, alg.id() + ": DH/XDH secrets mismatch");
System.out.println("...ok");
AgreementContext aCtx = null;
AgreementContext bCtx = null;
try {
aCtx.close();
} catch (IOException ignore) {
}
try {
bCtx.close();
} catch (IOException ignore) {
aCtx = CryptoAlgorithms.create(alg.id(), KeyUsage.AGREEMENT, alice.getPrivate(), spec);
bCtx = CryptoAlgorithms.create(alg.id(), KeyUsage.AGREEMENT, bob.getPrivate(), spec);
aCtx.setPeerPublic(bob.getPublic());
bCtx.setPeerPublic(alice.getPublic());
byte[] zA = aCtx.deriveSecret();
byte[] zB = bCtx.deriveSecret();
System.out.println("...Alice.secret =" + hex(zA));
System.out.println("...Bob.secret =" + hex(zB));
assertArrayEquals(zA, zB, alg.id() + ": CLASSIC_AGREEMENT secrets mismatch");
System.out.println("...CLASSIC_AGREEMENT...ok");
} finally {
if (aCtx != null) {
try {
aCtx.close();
} catch (Exception ignored) {
// ignore
}
}
if (bCtx != null) {
try {
bCtx.close();
} catch (Exception ignored) {
// ignore
}
}
}
// do not break: a single algorithm may have multiple agreement capabilities
}
}
}
@@ -271,19 +404,17 @@ public class AgreementAlgorithmsRoundTripTest {
private static KeyPair generateKeyPair(CryptoAlgorithm alg) {
try {
for (CryptoAlgorithm.AsymBuilderInfo bi : alg.asymmetricBuildersInfo()) {
// System.out.println(" ...keyPair " + bi.getClass().getName());
if (bi.defaultKeySpec == null) {
// System.out.println(" ......skip default = null --> it was for keyImport");
continue;
}
@SuppressWarnings("unchecked")
Class<AlgorithmKeySpec> specType = (Class<AlgorithmKeySpec>) bi.specType;
AsymmetricKeyBuilder<AlgorithmKeySpec> b = alg.asymmetricKeyBuilder(specType);
AsymmetricKeyBuilder<AlgorithmKeySpec> builder = alg.asymmetricKeyBuilder(specType);
AlgorithmKeySpec spec = (AlgorithmKeySpec) bi.defaultKeySpec;
// System.out.println(" ......generated from " + spec);
return b.generateKeyPair(spec);
return builder.generateKeyPair(spec);
}
} catch (Throwable ignore) {
// ignore
}
return null;
}
@@ -300,6 +431,7 @@ public class AgreementAlgorithmsRoundTripTest {
}
}
} catch (Throwable ignore) {
// ignore
}
return null;
}
@@ -315,15 +447,13 @@ public class AgreementAlgorithmsRoundTripTest {
DHPrivateKey dhPriv = (DHPrivateKey) priv;
DHPublicKey dhPub = (DHPublicKey) pub;
// 1) Parameter compatibility: same p,g and l-compatible (0 means "unspecified")
DHParameterSpec a = dhPriv.getParams();
DHParameterSpec b = dhPub.getParams();
if (a == null || b == null) {
throw new InvalidKeyException("Missing DH parameters on one of the keys");
}
if (!a.getP().equals(b.getP()) || !a.getG().equals(b.getG())) {
System.out.printf(" ...a.p=%s%n ...b.p=%s%n ...a.g=%s%n ...b.g=%s%n", a.getP(), b.getP(), a.getG(),
b.getG());
System.out.printf("...a.p=%s%n...b.p=%s%n...a.g=%s%n...b.g=%s%n", a.getP(), b.getP(), a.getG(), b.getG());
throw new InvalidKeyException("Incompatible DH parameters: (p,g) differ");
}
int la = a.getL();
@@ -333,8 +463,6 @@ public class AgreementAlgorithmsRoundTripTest {
"Incompatible DH parameters: private value length (l) differs: " + la + " vs " + lb);
}
// 2) Public value Y sanity check: 2 <= Y <= p-2 (reject trivial/small subgroup
// values)
BigInteger p = a.getP();
BigInteger y = dhPub.getY();
if (y == null) {
@@ -345,12 +473,10 @@ public class AgreementAlgorithmsRoundTripTest {
throw new InvalidKeyException("DH public value Y out of range");
}
// 3) Trial doPhase to prove the pair actually works with the provider
KeyAgreement ka = KeyAgreement.getInstance("DiffieHellman");
try {
ka.init(priv);
ka.doPhase(pub, true); // if this throws, they are not operationally compatible
// (we don't need the secret here; just proving doPhase succeeds)
ka.doPhase(pub, true);
} catch (InvalidKeyException e) {
throw new InvalidKeyException("KeyAgreement.doPhase failed: " + e.getMessage(), e);
}

View File

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

View File

@@ -0,0 +1,251 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.sdk.hybrid.kex;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.security.KeyPair;
import java.util.Arrays;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import zeroecho.core.CryptoAlgorithms;
import zeroecho.core.alg.common.agreement.KeyPairKey;
import zeroecho.core.alg.kyber.KyberKeyGenSpec;
import zeroecho.core.alg.xdh.XdhSpec;
import zeroecho.sdk.util.BouncyCastleActivator;
/**
* Hybrid KEX tests.
*/
public class HybridKexTest {
@BeforeAll
static void setup() {
// Optional: enable BC if you use BC-only modes in KEM payloads (EAX/OCB/CCM,
// etc.)
try {
BouncyCastleActivator.init();
} catch (Throwable ignore) {
// keep tests runnable without BC if not present
}
}
private static void logBegin(Object... params) {
String thisClass = HybridKexTest.class.getName();
String method = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE)
.walk(frames -> frames
.dropWhile(f -> !f.getClassName().equals(thisClass) || f.getMethodName().equals("logBegin"))
.findFirst().map(StackWalker.StackFrame::getMethodName).orElse("<?>"));
System.out.println(method + "(" + Arrays.deepToString(params) + ")");
}
private static void logEnd() {
String thisClass = HybridKexTest.class.getName();
String method = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE)
.walk(frames -> frames
.dropWhile(f -> !f.getClassName().equals(thisClass) || f.getMethodName().equals("logEnd"))
.findFirst().map(StackWalker.StackFrame::getMethodName).orElse("<?>"));
System.out.println(method + "...ok");
}
private static String hex(byte[] b) {
if (b == null) {
return "null";
}
StringBuilder sb = new StringBuilder(b.length * 2);
for (byte v : b) {
if (sb.length() == 80) {
sb.append("...");
break;
}
if ((v & 0xFF) < 16) {
sb.append('0');
}
sb.append(Integer.toHexString(v & 0xFF));
}
return sb.toString();
}
private static String lens(byte[] msg) {
if (msg == null || msg.length < 8) {
return "classicLen=?, pqcLen=?";
}
try {
DataInputStream in = new DataInputStream(new ByteArrayInputStream(msg));
int classicLen = in.readInt();
int pqcLen = 0;
if (msg.length >= 8 + Math.max(0, classicLen)) {
if (classicLen > 0) {
in.skipBytes(classicLen);
}
pqcLen = in.readInt();
}
return "classicLen=" + classicLen + ", pqcLen=" + pqcLen;
} catch (Exception e) {
return "classicLen=?, pqcLen=?";
}
}
@Test
void hybrid_x25519_mlkem_roundtrip() throws Exception {
logBegin("CLASSIC_AGREEMENT + KEM_ADAPTER", "Xdh/X25519 + ML-KEM(768)", "HKDF-SHA256", "32 bytes");
HybridKexProfile profile = HybridKexProfile.defaultProfile(32);
// Classic: X25519 key pairs (Xdh + XdhSpec.X25519)
KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
// PQC: ML-KEM key pair (Kyber variant)
KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
HybridKexContext alice = null;
HybridKexContext bob = null;
try {
// Initiator: classic uses Alice private + Bob classic public; PQC uses Bob PQC
// public
alice = HybridKexContexts.initiator(profile, "Xdh", aliceClassic.getPrivate(), bobClassic.getPublic(),
XdhSpec.X25519, "ML-KEM", bobPqc.getPublic(), null);
// Responder: classic uses Bob private + Alice classic public; PQC uses Bob PQC
// private
bob = HybridKexContexts.responder(profile, "Xdh", bobClassic.getPrivate(), aliceClassic.getPublic(),
XdhSpec.X25519, "ML-KEM", bobPqc.getPrivate(), null);
// Alice produces message (contains PQC ciphertext; classic part is empty here)
byte[] aliceMsg = alice.getPeerMessage();
System.out.println("...aliceMsg(" + lens(aliceMsg) + ")=" + hex(aliceMsg));
// Bob consumes message
bob.setPeerMessage(aliceMsg);
byte[] kA = alice.deriveSecret();
byte[] kB = bob.deriveSecret();
System.out.println("...kA=" + hex(kA));
System.out.println("...kB=" + hex(kB));
assertNotNull(kA);
assertNotNull(kB);
assertArrayEquals(kA, kB);
} finally {
if (alice != null) {
try {
alice.close();
} catch (Exception ignore) {
}
}
if (bob != null) {
try {
bob.close();
} catch (Exception ignore) {
}
}
}
logEnd();
}
@Test
void hybrid_x25519_pairmessage_mlkem_roundtrip() throws Exception {
logBegin("PAIR_MESSAGE + KEM_ADAPTER", "Xdh/X25519 + ML-KEM(768)", "HKDF-SHA256", "32 bytes");
HybridKexProfile profile = HybridKexProfile.defaultProfile(32);
// Classic: X25519 key pairs (Xdh + XdhSpec.X25519)
KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
// PQC: ML-KEM key pair (recipient/responder)
KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
HybridKexContext alice = null;
HybridKexContext bob = null;
try {
// Classic leg is message-based on both sides (PAIR_MESSAGE capability:
// KeyPairKey + ContextSpec).
// PQC leg is KEM-style: initiator uses recipient public key; responder uses
// recipient private key.
alice = HybridKexContexts.initiatorPairMessage(profile, "Xdh", new KeyPairKey(aliceClassic), XdhSpec.X25519,
"ML-KEM", bobPqc.getPublic(), null);
bob = HybridKexContexts.responderPairMessage(profile, "Xdh", new KeyPairKey(bobClassic), XdhSpec.X25519,
"ML-KEM", bobPqc.getPrivate(), null);
// Step 1: Alice -> Bob (classic SPKI + PQC ciphertext)
byte[] msgA = alice.getPeerMessage();
System.out.println("...msgA(" + lens(msgA) + ")=" + hex(msgA));
bob.setPeerMessage(msgA);
// Step 2: Bob -> Alice (classic SPKI only; PQC part is empty)
byte[] msgB = bob.getPeerMessage();
System.out.println("...msgB(" + lens(msgB) + ")=" + hex(msgB));
alice.setPeerMessage(msgB);
// Both sides derive the final hybrid OKM.
byte[] kA = alice.deriveSecret();
byte[] kB = bob.deriveSecret();
System.out.println("...kA=" + hex(kA));
System.out.println("...kB=" + hex(kB));
assertNotNull(kA);
assertNotNull(kB);
assertArrayEquals(kA, kB);
} finally {
if (alice != null) {
try {
alice.close();
} catch (Exception ignore) {
}
}
if (bob != null) {
try {
bob.close();
} catch (Exception ignore) {
}
}
}
logEnd();
}
}

View File

@@ -0,0 +1,601 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.sdk.hybrid.signature;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.security.KeyPair;
import java.security.Signature;
import java.util.Arrays;
import java.util.Random;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import zeroecho.core.CryptoAlgorithms;
import zeroecho.core.KeyUsage;
import zeroecho.core.context.SignatureContext;
import zeroecho.core.io.TailStrippingInputStream;
import zeroecho.core.spec.ContextSpec;
import zeroecho.sdk.builders.TagTrailerDataContentBuilder;
import zeroecho.sdk.builders.core.DataContentBuilder;
import zeroecho.sdk.builders.core.DataContentChainBuilder;
import zeroecho.sdk.content.api.DataContent;
import zeroecho.sdk.content.api.PlainContent;
import zeroecho.sdk.util.BouncyCastleActivator;
/**
* End-to-end tests for hybrid signatures (classic + PQC) across:
* <ul>
* <li>{@link HybridSignatureProfile.VerifyRule#AND} and
* {@link HybridSignatureProfile.VerifyRule#OR}</li>
* <li>direct streaming use via {@link SignatureContext#wrap(InputStream)}</li>
* <li>integration via {@link TagTrailerDataContentBuilder} and
* {@link DataContentChainBuilder}</li>
* </ul>
*
* <p>
* Tests focus on practical combinations: Ed25519 + SPHINCS+, and
* RSA-PSS(SHA-256) + SPHINCS+ (if registered).
* </p>
*/
public class HybridSignatureTest {
@BeforeAll
static void setup() {
// Optional: enable BC if you use BC-only modes in KEM payloads (EAX/OCB/CCM,
// etc.)
try {
BouncyCastleActivator.init();
} catch (Throwable ignore) {
// keep tests runnable without BC if not present
}
}
// ---------- boilerplate logging ----------
private static void logBegin(Object... params) {
String thisClass = HybridSignatureTest.class.getName();
String method = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE)
.walk(frames -> frames
.dropWhile(f -> !f.getClassName().equals(thisClass) || f.getMethodName().equals("logBegin"))
.findFirst().map(StackWalker.StackFrame::getMethodName).orElse("<?>"));
System.out.println(method + "(" + Arrays.deepToString(params) + ")");
}
private static void logEnd() {
String thisClass = HybridSignatureTest.class.getName();
String method = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE)
.walk(frames -> frames
.dropWhile(f -> !f.getClassName().equals(thisClass) || f.getMethodName().equals("logEnd"))
.findFirst().map(StackWalker.StackFrame::getMethodName).orElse("<?>"));
System.out.println(method + "...ok");
}
private static void requireAlgOrSkip(String id) {
if (!CryptoAlgorithms.available().contains(id)) {
System.out.println("...*** SKIP *** " + id + " not registered");
Assumptions.assumeTrue(false);
}
}
private static byte[] randomBytes(int n) {
byte[] b = new byte[n];
Random r = new Random(123456789L); // deterministic
r.nextBytes(b);
return b;
}
private static byte[] readAll(InputStream in) throws Exception {
try (InputStream closeMe = in) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
closeMe.transferTo(out);
return out.toByteArray();
}
}
private static String hexShort(byte[] b) {
if (b == null) {
return "null";
}
int max = Math.min(b.length, 24);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < max; i++) {
sb.append(String.format("%02x", Integer.valueOf(b[i] & 0xff)));
}
if (b.length > max) {
sb.append("...");
}
return sb.toString();
}
private static byte[] flipOneBit(byte[] in, int index) {
byte[] out = in.clone();
out[index] = (byte) (out[index] ^ 0x01);
return out;
}
private static byte[] sub(byte[] b, int off, int len) {
byte[] out = new byte[len];
System.arraycopy(b, off, out, 0, len);
return out;
}
private static byte[] concat(byte[] a, byte[] b) {
byte[] out = new byte[a.length + b.length];
System.arraycopy(a, 0, out, 0, a.length);
System.arraycopy(b, 0, out, a.length, b.length);
return out;
}
private static int tagLen(String algoId, KeyUsage role, Key key, ContextSpec specOrNull) throws Exception {
try (SignatureContext ctx = CryptoAlgorithms.create(algoId, role, key, specOrNull)) {
return ctx.tagLength();
}
}
private static byte[] signTrailer(SignatureContext signer, byte[] body) throws Exception {
int tagLen = signer.tagLength();
final byte[][] holder = new byte[1][];
try (InputStream in = new TailStrippingInputStream(signer.wrap(new ByteArrayInputStream(body)), tagLen, 8192) {
@Override
protected void processTail(byte[] tail) {
holder[0] = (tail == null ? null : tail.clone());
}
}) {
byte[] pt = readAll(in);
assertArrayEquals(body, pt, "sign passthrough mismatch");
}
if (holder[0] == null) {
throw new IllegalStateException("Signature trailer missing");
}
return holder[0];
}
/**
* Minimal source builder for DataContent chains (same shape as other tests).
*/
private static final class BytesSourceBuilder implements DataContentBuilder<PlainContent> {
private final byte[] data;
private BytesSourceBuilder(byte[] data) {
this.data = data.clone();
}
static BytesSourceBuilder of(byte[] data) {
return new BytesSourceBuilder(data);
}
@Override
public PlainContent build(boolean encrypt) {
return new PlainContent() {
@Override
public void setInput(DataContent input) {
/* no upstream for sources */
}
@Override
public InputStream getStream() {
return new ByteArrayInputStream(data);
}
};
}
}
// ======================================================================
// 1) DIRECT streaming tests (SignatureContext.wrap + transferTo)
// ======================================================================
@Test
void hybrid_ed25519_sphincsplus_direct_and_or_negative() throws Exception {
final int size = 64 * 1024 + 13;
logBegin("direct", "Ed25519+SPHINCS+", Integer.valueOf(size));
requireAlgOrSkip("Ed25519");
requireAlgOrSkip("SPHINCS+");
byte[] msg = randomBytes(size);
System.out.println("...msg=" + msg.length + " bytes");
KeyPair ed = CryptoAlgorithms.require("Ed25519").generateKeyPair();
KeyPair spx = CryptoAlgorithms.require("SPHINCS+").generateKeyPair();
int edLen = tagLen("Ed25519", KeyUsage.SIGN, ed.getPrivate(), null);
int spxLen = tagLen("SPHINCS+", KeyUsage.SIGN, spx.getPrivate(), null);
System.out.println("...classicTagLen=" + edLen + ", pqcTagLen=" + spxLen);
// ---- AND ----
HybridSignatureProfile andProfile = new HybridSignatureProfile("Ed25519", "SPHINCS+", null, null,
HybridSignatureProfile.VerifyRule.AND);
byte[] sigAnd;
try (SignatureContext signer = HybridSignatureContexts.sign(andProfile, ed.getPrivate(), spx.getPrivate(),
2 * 1024 * 1024)) {
sigAnd = signTrailer(signer, msg);
}
System.out.println("...sig(AND).len=" + sigAnd.length + ", head=" + hexShort(sigAnd));
// verify OK
try (SignatureContext verifier = HybridSignatureContexts.verify(andProfile, ed.getPublic(), spx.getPublic(),
2 * 1024 * 1024)) {
verifier.setVerificationApproach(verifier.getVerificationCore().getThrowOnMismatch());
verifier.setExpectedTag(sigAnd);
try (InputStream in = verifier.wrap(new ByteArrayInputStream(msg))) {
in.transferTo(OutputStream.nullOutputStream());
}
}
System.out.println("...verify(AND)=ok");
// corrupt classic => must fail
byte[] badClassic = concat(flipOneBit(sub(sigAnd, 0, edLen), 0), sub(sigAnd, edLen, spxLen));
try (SignatureContext verifier = HybridSignatureContexts.verify(andProfile, ed.getPublic(), spx.getPublic(),
2 * 1024 * 1024)) {
verifier.setVerificationApproach(verifier.getVerificationCore().getThrowOnMismatch());
verifier.setExpectedTag(badClassic);
assertThrows(java.io.IOException.class, () -> {
try (InputStream in = verifier.wrap(new ByteArrayInputStream(msg))) {
in.transferTo(OutputStream.nullOutputStream());
}
});
}
System.out.println("...verify(AND) bad classic -> throws");
// corrupt pqc => must fail
byte[] badPqc = concat(sub(sigAnd, 0, edLen), flipOneBit(sub(sigAnd, edLen, spxLen), 0));
try (SignatureContext verifier = HybridSignatureContexts.verify(andProfile, ed.getPublic(), spx.getPublic(),
2 * 1024 * 1024)) {
verifier.setVerificationApproach(verifier.getVerificationCore().getThrowOnMismatch());
verifier.setExpectedTag(badPqc);
assertThrows(java.io.IOException.class, () -> {
try (InputStream in = verifier.wrap(new ByteArrayInputStream(msg))) {
in.transferTo(OutputStream.nullOutputStream());
}
});
}
System.out.println("...verify(AND) bad pqc -> throws");
// ---- OR ----
HybridSignatureProfile orProfile = new HybridSignatureProfile("Ed25519", "SPHINCS+", null, null,
HybridSignatureProfile.VerifyRule.OR);
byte[] sigOr;
try (SignatureContext signer = HybridSignatureContexts.sign(orProfile, ed.getPrivate(), spx.getPrivate(),
2 * 1024 * 1024)) {
sigOr = signTrailer(signer, msg);
}
System.out.println("...sig(OR).len=" + sigOr.length + ", head=" + hexShort(sigOr));
// corrupt classic => OR must pass
byte[] orBadClassic = concat(flipOneBit(sub(sigOr, 0, edLen), 0), sub(sigOr, edLen, spxLen));
try (SignatureContext verifier = HybridSignatureContexts.verify(orProfile, ed.getPublic(), spx.getPublic(),
2 * 1024 * 1024)) {
verifier.setVerificationApproach(verifier.getVerificationCore().getThrowOnMismatch());
verifier.setExpectedTag(orBadClassic);
try (InputStream in = verifier.wrap(new ByteArrayInputStream(msg))) {
in.transferTo(OutputStream.nullOutputStream());
}
}
System.out.println("...verify(OR) bad classic -> ok");
// corrupt pqc => OR must pass
byte[] orBadPqc = concat(sub(sigOr, 0, edLen), flipOneBit(sub(sigOr, edLen, spxLen), 0));
try (SignatureContext verifier = HybridSignatureContexts.verify(orProfile, ed.getPublic(), spx.getPublic(),
2 * 1024 * 1024)) {
verifier.setVerificationApproach(verifier.getVerificationCore().getThrowOnMismatch());
verifier.setExpectedTag(orBadPqc);
try (InputStream in = verifier.wrap(new ByteArrayInputStream(msg))) {
in.transferTo(OutputStream.nullOutputStream());
}
}
System.out.println("...verify(OR) bad pqc -> ok");
// corrupt both => OR must fail
byte[] orBadBoth = concat(flipOneBit(sub(sigOr, 0, edLen), 0), flipOneBit(sub(sigOr, edLen, spxLen), 0));
try (SignatureContext verifier = HybridSignatureContexts.verify(orProfile, ed.getPublic(), spx.getPublic(),
2 * 1024 * 1024)) {
verifier.setVerificationApproach(verifier.getVerificationCore().getThrowOnMismatch());
verifier.setExpectedTag(orBadBoth);
assertThrows(java.io.IOException.class, () -> {
try (InputStream in = verifier.wrap(new ByteArrayInputStream(msg))) {
in.transferTo(OutputStream.nullOutputStream());
}
});
}
System.out.println("...verify(OR) bad both -> throws");
logEnd();
}
@Test
void hybrid_rsa_sphincsplus_direct_and_roundtrip() throws Exception {
final int size = 96 * 1024 + 3;
logBegin("direct", "RSA+SPHINCS+", Integer.valueOf(size));
requireAlgOrSkip("RSA");
requireAlgOrSkip("SPHINCS+");
byte[] msg = randomBytes(size);
System.out.println("...msg=" + msg.length + " bytes");
KeyPair rsa = CryptoAlgorithms.require("RSA").generateKeyPair();
KeyPair spx = CryptoAlgorithms.require("SPHINCS+").generateKeyPair();
int rsaLen = tagLen("RSA", KeyUsage.SIGN, rsa.getPrivate(), null);
int spxLen = tagLen("SPHINCS+", KeyUsage.SIGN, spx.getPrivate(), null);
System.out.println("...classicTagLen=" + rsaLen + ", pqcTagLen=" + spxLen);
HybridSignatureProfile profile = new HybridSignatureProfile("RSA", "SPHINCS+", null, null,
HybridSignatureProfile.VerifyRule.AND);
byte[] sig;
try (SignatureContext signer = HybridSignatureContexts.sign(profile, rsa.getPrivate(), spx.getPrivate(),
2 * 1024 * 1024)) {
sig = signTrailer(signer, msg);
}
System.out.println("...sig.len=" + sig.length + ", head=" + hexShort(sig));
try (SignatureContext verifier = HybridSignatureContexts.verify(profile, rsa.getPublic(), spx.getPublic(),
2 * 1024 * 1024)) {
verifier.setVerificationApproach(verifier.getVerificationCore().getThrowOnMismatch());
verifier.setExpectedTag(sig);
try (InputStream in = verifier.wrap(new ByteArrayInputStream(msg))) {
in.transferTo(OutputStream.nullOutputStream());
}
}
System.out.println("...verify(AND)=ok");
// negative sanity: corrupt classic => must fail (AND)
byte[] badClassic = concat(flipOneBit(sub(sig, 0, rsaLen), 0), sub(sig, rsaLen, spxLen));
try (SignatureContext verifier = HybridSignatureContexts.verify(profile, rsa.getPublic(), spx.getPublic(),
2 * 1024 * 1024)) {
verifier.setVerificationApproach(verifier.getVerificationCore().getThrowOnMismatch());
verifier.setExpectedTag(badClassic);
assertThrows(java.io.IOException.class, () -> {
try (InputStream in = verifier.wrap(new ByteArrayInputStream(msg))) {
in.transferTo(OutputStream.nullOutputStream());
}
});
}
System.out.println("...verify(AND) bad classic -> throws");
logEnd();
}
// ======================================================================
// 2) TagTrailer integration tests (DataContentChainBuilder + TagTrailer)
// ======================================================================
@Test
void hybrid_ed25519_sphincsplus_via_tagtrailer_and_roundtrip() throws Exception {
final int size = 32 * 1024 + 7;
logBegin("TagTrailer", "AND", "Ed25519+SPHINCS+", Integer.valueOf(size));
requireAlgOrSkip("Ed25519");
requireAlgOrSkip("SPHINCS+");
byte[] msg = randomBytes(size);
System.out.println("...msg=" + msg.length + " bytes");
KeyPair ed = CryptoAlgorithms.require("Ed25519").generateKeyPair();
KeyPair spx = CryptoAlgorithms.require("SPHINCS+").generateKeyPair();
HybridSignatureProfile profile = new HybridSignatureProfile("Ed25519", "SPHINCS+", null, null,
HybridSignatureProfile.VerifyRule.AND);
byte[] out;
int tagLen;
try (SignatureContext tagEnc = HybridSignatureContexts.sign(profile, ed.getPrivate(), spx.getPrivate(),
2 * 1024 * 1024)) {
DataContent enc = DataContentChainBuilder.encrypt().add(BytesSourceBuilder.of(msg))
.add(new TagTrailerDataContentBuilder<Signature>(tagEnc).bufferSize(8192)).build();
out = readAll(enc.getStream());
tagLen = tagEnc.tagLength();
}
System.out.println("...out=" + out.length + " bytes");
try (SignatureContext tagDec = HybridSignatureContexts.verify(profile, ed.getPublic(), spx.getPublic(),
2 * 1024 * 1024)) {
tagDec.setVerificationApproach(tagDec.getVerificationCore().getThrowOnMismatch());
// IMPORTANT: TagTrailerDataContentBuilder supplies expectedTag internally
// during streaming.
// HybridSignatureContext.wrap must NOT require expectedTag pre-set.
DataContent dec = DataContentChainBuilder.decrypt().add(BytesSourceBuilder.of(out))
.add(new TagTrailerDataContentBuilder<Signature>(tagDec).bufferSize(8192).throwOnMismatch())
.build();
byte[] pt = readAll(dec.getStream());
assertArrayEquals(msg, pt, "hybrid TagTrailer AND roundtrip mismatch");
}
System.out.println("...tagLen=" + tagLen);
logEnd();
}
@Test
void hybrid_ed25519_sphincsplus_via_tagtrailer_or_negative() throws Exception {
final int size = 24 * 1024 + 9;
logBegin("TagTrailer", "OR", "Ed25519+SPHINCS+", Integer.valueOf(size));
requireAlgOrSkip("Ed25519");
requireAlgOrSkip("SPHINCS+");
byte[] msg = ("zeroecho-hybrid-or-negative-").getBytes(StandardCharsets.UTF_8);
msg = Arrays.copyOf(msg, size);
System.out.println("...msg=" + msg.length + " bytes");
KeyPair ed = CryptoAlgorithms.require("Ed25519").generateKeyPair();
KeyPair spx = CryptoAlgorithms.require("SPHINCS+").generateKeyPair();
int edLen = tagLen("Ed25519", KeyUsage.SIGN, ed.getPrivate(), null);
int spxLen = tagLen("SPHINCS+", KeyUsage.SIGN, spx.getPrivate(), null);
System.out.println("...classicTagLen=" + edLen + ", pqcTagLen=" + spxLen);
HybridSignatureProfile profile = new HybridSignatureProfile("Ed25519", "SPHINCS+", null, null,
HybridSignatureProfile.VerifyRule.OR);
byte[] out;
int tagLen;
try (SignatureContext tagEnc = HybridSignatureContexts.sign(profile, ed.getPrivate(), spx.getPrivate(),
2 * 1024 * 1024)) {
DataContent enc = DataContentChainBuilder.encrypt().add(BytesSourceBuilder.of(msg))
.add(new TagTrailerDataContentBuilder<Signature>(tagEnc).bufferSize(8192)).build();
out = readAll(enc.getStream());
tagLen = tagEnc.tagLength();
}
byte[] body = sub(out, 0, out.length - tagLen);
byte[] tag = sub(out, out.length - tagLen, tagLen);
System.out.println("...tag.len=" + tag.length + ", head=" + hexShort(tag));
// Corrupt ONLY classic part => OR must still PASS
byte[] badClassic = concat(flipOneBit(sub(tag, 0, edLen), 0), sub(tag, edLen, spxLen));
byte[] outBadClassic = concat(body, badClassic);
try (SignatureContext tagDec = HybridSignatureContexts.verify(profile, ed.getPublic(), spx.getPublic(),
2 * 1024 * 1024)) {
tagDec.setVerificationApproach(tagDec.getVerificationCore().getThrowOnMismatch());
DataContent dec = DataContentChainBuilder.decrypt().add(BytesSourceBuilder.of(outBadClassic))
.add(new TagTrailerDataContentBuilder<Signature>(tagDec).bufferSize(8192).throwOnMismatch())
.build();
byte[] pt = readAll(dec.getStream());
assertArrayEquals(msg, pt, "OR should accept when only classic signature is corrupted");
}
System.out.println("...OR verify with bad classic -> ok");
// Corrupt ONLY pqc part => OR must still PASS
byte[] badPqc = concat(sub(tag, 0, edLen), flipOneBit(sub(tag, edLen, spxLen), 0));
byte[] outBadPqc = concat(body, badPqc);
try (SignatureContext tagDec = HybridSignatureContexts.verify(profile, ed.getPublic(), spx.getPublic(),
2 * 1024 * 1024)) {
tagDec.setVerificationApproach(tagDec.getVerificationCore().getThrowOnMismatch());
DataContent dec = DataContentChainBuilder.decrypt().add(BytesSourceBuilder.of(outBadPqc))
.add(new TagTrailerDataContentBuilder<Signature>(tagDec).bufferSize(8192).throwOnMismatch())
.build();
byte[] pt = readAll(dec.getStream());
assertArrayEquals(msg, pt, "OR should accept when only PQC signature is corrupted");
}
System.out.println("...OR verify with bad pqc -> ok");
// Corrupt BOTH => OR must FAIL
byte[] badBoth = concat(flipOneBit(sub(tag, 0, edLen), 0), flipOneBit(sub(tag, edLen, spxLen), 0));
byte[] outBadBoth = concat(body, badBoth);
try (SignatureContext tagDec = HybridSignatureContexts.verify(profile, ed.getPublic(), spx.getPublic(),
2 * 1024 * 1024)) {
tagDec.setVerificationApproach(tagDec.getVerificationCore().getThrowOnMismatch());
DataContent dec = DataContentChainBuilder.decrypt().add(BytesSourceBuilder.of(outBadBoth))
.add(new TagTrailerDataContentBuilder<Signature>(tagDec).bufferSize(8192).throwOnMismatch())
.build();
assertThrows(java.io.IOException.class, () -> readAll(dec.getStream()));
}
System.out.println("...OR verify with bad both -> throws");
System.out.println("...tagLen=" + tagLen);
logEnd();
}
@Test
void hybrid_rsa_sphincsplus_via_tagtrailer_and_roundtrip() throws Exception {
final int size = 40 * 1024 + 1;
logBegin("TagTrailer", "AND", "RSA+SPHINCS+", Integer.valueOf(size));
requireAlgOrSkip("RSA");
requireAlgOrSkip("SPHINCS+");
byte[] msg = randomBytes(size);
System.out.println("...msg=" + msg.length + " bytes");
KeyPair rsa = CryptoAlgorithms.require("RSA").generateKeyPair();
KeyPair spx = CryptoAlgorithms.require("SPHINCS+").generateKeyPair();
HybridSignatureProfile profile = new HybridSignatureProfile("RSA", "SPHINCS+", null, null,
HybridSignatureProfile.VerifyRule.AND);
byte[] out;
try (SignatureContext tagEnc = HybridSignatureContexts.sign(profile, rsa.getPrivate(), spx.getPrivate(),
2 * 1024 * 1024)) {
DataContent enc = DataContentChainBuilder.encrypt().add(BytesSourceBuilder.of(msg))
.add(new TagTrailerDataContentBuilder<Signature>(tagEnc).bufferSize(8192)).build();
out = readAll(enc.getStream());
}
System.out.println("...out=" + out.length + " bytes");
try (SignatureContext tagDec = HybridSignatureContexts.verify(profile, rsa.getPublic(), spx.getPublic(),
2 * 1024 * 1024)) {
tagDec.setVerificationApproach(tagDec.getVerificationCore().getThrowOnMismatch());
DataContent dec = DataContentChainBuilder.decrypt().add(BytesSourceBuilder.of(out))
.add(new TagTrailerDataContentBuilder<Signature>(tagDec).bufferSize(8192).throwOnMismatch())
.build();
byte[] pt = readAll(dec.getStream());
assertArrayEquals(msg, pt, "hybrid TagTrailer AND (RSA+SPHINCS+) roundtrip mismatch");
}
logEnd();
}
}

View File

@@ -0,0 +1,308 @@
/*******************************************************************************
* 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.security.KeyPair;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.Arrays;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import zeroecho.core.CryptoAlgorithm;
import zeroecho.core.CryptoAlgorithms;
import zeroecho.core.KeyUsage;
import zeroecho.core.alg.common.agreement.KeyPairKey;
import zeroecho.core.alg.kyber.KyberKeyGenSpec;
import zeroecho.core.alg.xdh.XdhSpec;
import zeroecho.core.context.AgreementContext;
import zeroecho.core.context.MessageAgreementContext;
import zeroecho.core.spec.VoidSpec;
import zeroecho.core.util.Strings;
import zeroecho.sdk.util.BouncyCastleActivator;
/**
* Demonstration of agreement usage variants in ZeroEcho.
*
* <p>
* This sample illustrates three complementary models used in practice:
* </p>
* <ul>
* <li><b>KEM_ADAPTER</b> (example: ML-KEM / Kyber): the initiator is
* constructed with the recipient's {@link PublicKey} and produces an outbound
* encapsulation message; the responder is constructed with the matching
* {@link PrivateKey} and consumes that message.</li>
* <li><b>CLASSIC_AGREEMENT</b> (example: XDH / X25519): both parties hold their
* own {@link PrivateKey}, set the peer {@link PublicKey} explicitly, and derive
* the same raw shared secret.</li>
* <li><b>PAIR_MESSAGE</b> (example: XDH / X25519): both parties hold a key pair
* and exchange messages that are simply the X.509 SPKI encodings of their
* public keys. This yields a message-oriented handshake without changing the
* underlying agreement primitive.</li>
* </ul>
*
* <h2>Important note</h2>
* <p>
* All examples below produce a <em>raw</em> agreement secret (the direct output
* of KEM decapsulation or DiffieHellman agreement). Real protocols should feed
* the raw secret into a suitable KDF (typically HKDF) together with
* transcript/context info before using it as key material.
* </p>
*
* <h2>Note on resource management</h2>
* <p>
* The examples in this class intentionally do <em>not</em> use the
* {@code try-with-resources} construct when working with
* {@link zeroecho.core.context.AgreementContext} and
* {@link zeroecho.core.context.MessageAgreementContext}.
* </p>
*
* <p>
* Agreement contexts represent protocol-level state rather than traditional I/O
* resources. In real-world applications their lifecycle often spans multiple
* protocol steps (message send, receive, validation, key derivation) and may
* cross method or thread boundaries. Using explicit {@code try/finally} blocks
* in the examples makes this lifecycle visible and closer to how agreement
* contexts are typically managed in production code.
* </p>
*
* <p>
* In short-lived, fully synchronous scenarios (such as unit tests),
* {@code try-with-resources} is perfectly acceptable. It is omitted here purely
* for didactic reasons.
* </p>
*/
class AgreementVariantsTest {
private static final Logger LOG = Logger.getLogger(AgreementVariantsTest.class.getName());
@BeforeAll
static void setup() {
// Optional: activate BC/BCPQC if present.
// Keeps tests runnable even when providers are missing.
try {
BouncyCastleActivator.init();
} catch (Throwable ignore) {
// ignore
}
}
/**
* KEM_ADAPTER example for ML-KEM (Kyber):
*
* <p>
* This models a common "send one message, derive shared secret" pattern:
* </p>
* <ul>
* <li>Initiator uses recipient {@link PublicKey} and produces an encapsulation
* message.</li>
* <li>Responder uses recipient {@link PrivateKey}, consumes the message, and
* derives the same secret.</li>
* </ul>
*/
@Test
void kemAdapter_mlKem_roundTrip() throws Exception {
LOG.info("kemAdapter_mlKem_roundTrip - KEM_ADAPTER (ML-KEM)");
KeyPair recipient = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber1024());
MessageAgreementContext initiator = null;
MessageAgreementContext responder = null;
try {
// Initiator: constructed with recipient's public key (encapsulation side).
initiator = CryptoAlgorithms.create("ML-KEM", KeyUsage.AGREEMENT, recipient.getPublic(), VoidSpec.INSTANCE);
// Responder: constructed with recipient's private key (decapsulation side).
responder = CryptoAlgorithms.create("ML-KEM", KeyUsage.AGREEMENT, recipient.getPrivate(),
VoidSpec.INSTANCE);
// One-shot outbound message: KEM ciphertext / encapsulation payload.
byte[] enc = initiator.getPeerMessage();
// Responder consumes ciphertext to derive its secret.
responder.setPeerMessage(enc);
byte[] s1 = initiator.deriveSecret();
byte[] s2 = responder.deriveSecret();
LOG.log(Level.INFO, "KEM_ADAPTER: ciphertext={0}", Strings.toShortHexString(enc));
LOG.log(Level.INFO, "KEM_ADAPTER: initiatorSecret={0}", Strings.toShortHexString(s1));
LOG.log(Level.INFO, "KEM_ADAPTER: responderSecret={0}", Strings.toShortHexString(s2));
LOG.log(Level.INFO, "KEM_ADAPTER: secretsEqual={0}", Boolean.valueOf(Arrays.equals(s1, s2)));
} finally {
if (initiator != null) {
try {
initiator.close();
} catch (Exception ignore) {
// ignore
}
}
if (responder != null) {
try {
responder.close();
} catch (Exception ignore) {
// ignore
}
}
}
}
/**
* CLASSIC_AGREEMENT example for XDH/X25519:
*
* <p>
* This is the traditional DiffieHellman model: both parties generate their own
* key pair, each side keeps a private key, and the peer public key is provided
* out-of-band (protocol message or session state).
* </p>
*/
@Test
void classicAgreement_x25519_roundTrip() throws Exception {
LOG.info("classicAgreement_x25519_roundTrip - CLASSIC_AGREEMENT (X25519)");
CryptoAlgorithm xdh = CryptoAlgorithms.require("Xdh");
KeyPair alice = xdh.generateKeyPair();
KeyPair bob = xdh.generateKeyPair();
AgreementContext aCtx = null;
AgreementContext bCtx = null;
try {
// Both contexts are built from local private keys.
aCtx = CryptoAlgorithms.create("Xdh", KeyUsage.AGREEMENT, alice.getPrivate(), XdhSpec.X25519);
bCtx = CryptoAlgorithms.create("Xdh", KeyUsage.AGREEMENT, bob.getPrivate(), XdhSpec.X25519);
// The protocol layer provides peer public keys (here we use in-memory
// exchange).
aCtx.setPeerPublic(bob.getPublic());
bCtx.setPeerPublic(alice.getPublic());
byte[] s1 = aCtx.deriveSecret();
byte[] s2 = bCtx.deriveSecret();
LOG.log(Level.INFO, "CLASSIC_AGREEMENT: aliceSecret={0}", Strings.toShortHexString(s1));
LOG.log(Level.INFO, "CLASSIC_AGREEMENT: bobSecret={0}", Strings.toShortHexString(s2));
LOG.log(Level.INFO, "CLASSIC_AGREEMENT: secretsEqual={0}", Boolean.valueOf(Arrays.equals(s1, s2)));
} finally {
if (aCtx != null) {
try {
aCtx.close();
} catch (Exception ignore) {
// ignore
}
}
if (bCtx != null) {
try {
bCtx.close();
} catch (Exception ignore) {
// ignore
}
}
}
}
/**
* PAIR_MESSAGE example for XDH/X25519:
*
* <p>
* This demonstrates the "message-oriented" handshake for DH-style agreements.
* Each party holds a key pair and the outbound message is simply the local
* public key encoding (SPKI). The receiver imports the encoding and binds it as
* the peer key.
* </p>
*
* <p>
* This model is particularly practical for protocol implementations because it
* makes the "to-be-sent" artifact explicit (a byte array message), similarly to
* KEM ciphertexts.
* </p>
*/
@Test
void pairMessage_x25519_roundTrip() throws Exception {
LOG.info("pairMessage_x25519_roundTrip - PAIR_MESSAGE (X25519)");
CryptoAlgorithm xdh = CryptoAlgorithms.require("Xdh");
KeyPair alice = xdh.generateKeyPair();
KeyPair bob = xdh.generateKeyPair();
// Wrapper is required because ZeroEcho capability dispatch uses Key (KeyPair is
// not a Key).
KeyPairKey aliceKey = new KeyPairKey(alice);
KeyPairKey bobKey = new KeyPairKey(bob);
MessageAgreementContext aCtx = null;
MessageAgreementContext bCtx = null;
try {
aCtx = CryptoAlgorithms.create("Xdh", KeyUsage.AGREEMENT, aliceKey, XdhSpec.X25519);
bCtx = CryptoAlgorithms.create("Xdh", KeyUsage.AGREEMENT, bobKey, XdhSpec.X25519);
// Outbound messages: SPKI encodings of local public keys.
byte[] aMsg = aCtx.getPeerMessage();
byte[] bMsg = bCtx.getPeerMessage();
LOG.log(Level.INFO, "PAIR_MESSAGE: aliceMsg={0}", Strings.toShortHexString(aMsg));
LOG.log(Level.INFO, "PAIR_MESSAGE: bobMsg={0}", Strings.toShortHexString(bMsg));
// Each side imports peer public key from message.
aCtx.setPeerMessage(bMsg);
bCtx.setPeerMessage(aMsg);
byte[] s1 = aCtx.deriveSecret();
byte[] s2 = bCtx.deriveSecret();
LOG.log(Level.INFO, "PAIR_MESSAGE: aliceSecret={0}", Strings.toShortHexString(s1));
LOG.log(Level.INFO, "PAIR_MESSAGE: bobSecret={0}", Strings.toShortHexString(s2));
LOG.log(Level.INFO, "PAIR_MESSAGE: secretsEqual={0}", Boolean.valueOf(Arrays.equals(s1, s2)));
} finally {
if (aCtx != null) {
try {
aCtx.close();
} catch (Exception ignore) {
// ignore
}
}
if (bCtx != null) {
try {
bCtx.close();
} catch (Exception ignore) {
// ignore
}
}
}
}
}

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

View File

@@ -0,0 +1,536 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package demo;
import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.security.KeyPair;
import java.util.Arrays;
import java.util.logging.Logger;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import zeroecho.core.CryptoAlgorithms;
import zeroecho.core.alg.common.agreement.KeyPairKey;
import zeroecho.core.alg.kyber.KyberKeyGenSpec;
import zeroecho.core.alg.xdh.XdhSpec;
import zeroecho.sdk.hybrid.kex.HybridKexContext;
import zeroecho.sdk.hybrid.kex.HybridKexContexts;
import zeroecho.sdk.hybrid.kex.HybridKexProfile;
import zeroecho.sdk.util.BouncyCastleActivator;
/**
* Demonstration of hybrid key exchange (KEX) usage in ZeroEcho.
*
* <p>
* Hybrid KEX in this project means:
* </p>
* <ul>
* <li>A <b>classic agreement</b> leg (DH/ECDH/XDH), and</li>
* <li>A <b>post-quantum</b> leg implemented as a message-based agreement (KEM
* adapter, e.g. ML-KEM).</li>
* </ul>
*
* <p>
* The two independent secrets are combined using HKDF-SHA256 in the SDK hybrid
* layer. The application consumes only the final derived keying material (OKM),
* not the raw leg secrets.
* </p>
*
* <h2>Available hybrid variants</h2>
* <p>
* The classic leg can be wired in two ways:
* </p>
* <ul>
* <li><b>CLASSIC_AGREEMENT + KEM_ADAPTER</b> (most common in practice):
* <ul>
* <li>Classic peer public key is supplied out-of-band
* (certificate/directory/session state).</li>
* <li>The hybrid message carries only the PQC payload (KEM ciphertext).</li>
* </ul>
* </li>
* <li><b>PAIR_MESSAGE + KEM_ADAPTER</b> (fully message-oriented hybrid):
* <ul>
* <li>Classic public keys are carried explicitly as messages (SPKI
* encodings).</li>
* <li>The hybrid message carries both: classic public-key message and PQC
* ciphertext.</li>
* <li>Responder may reply with a classic message only (PQC part empty),
* depending on PQC role.</li>
* </ul>
* </li>
* </ul>
*
* <h2>Note on resource management</h2>
* <p>
* These examples intentionally avoid {@code try-with-resources}. Agreement
* contexts represent protocol-level state rather than traditional I/O
* resources; in real protocols their lifecycle often spans multiple
* send/receive steps and does not naturally fit a single lexical scope. Using
* explicit close blocks keeps the handshake lifecycle visible to the reader.
* </p>
*/
class HybridKexDemoTest {
private static final Logger LOG = Logger.getLogger(HybridKexDemoTest.class.getName());
@BeforeAll
static void setup() {
// Optional: enable BC/BCPQC if present.
try {
BouncyCastleActivator.init();
} catch (Throwable ignore) {
// keep runnable without BC if not present
}
}
@Test
void x25519_classicAgreement_plus_mlKem768_kemAdapter() throws Exception {
logBegin("CLASSIC_AGREEMENT + KEM_ADAPTER", "Xdh/X25519 + ML-KEM-768", "HKDF-SHA256", "OKM=32B");
// Classic leg keys (Xdh + XdhSpec.X25519).
KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
// PQC leg: Bob is the KEM recipient (has ML-KEM keypair).
KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
// Hybrid profile: default HKDF label, 32-byte output suitable for symmetric
// keys.
HybridKexProfile profile = HybridKexProfile.defaultProfile(32);
HybridKexContext alice = null;
HybridKexContext bob = null;
try {
// Alice (initiator): classic uses Alice private + Bob classic public
// (out-of-band).
// ...PQC uses Bob PQC public and will produce a KEM ciphertext.
alice = HybridKexContexts.initiator(profile, "Xdh", aliceClassic.getPrivate(), bobClassic.getPublic(),
XdhSpec.X25519, "ML-KEM", bobPqc.getPublic(), null);
// Bob (responder): classic uses Bob private + Alice classic public
// (out-of-band).
// ...PQC uses Bob PQC private and will consume Alice's ciphertext.
bob = HybridKexContexts.responder(profile, "Xdh", bobClassic.getPrivate(), aliceClassic.getPublic(),
XdhSpec.X25519, "ML-KEM", bobPqc.getPrivate(), null);
// Alice -> Bob: hybrid message carries PQC ciphertext; classic part is empty.
byte[] msgA = alice.getPeerMessage();
System.out.println("...msgA " + lens(msgA) + " " + shortHex(msgA));
bob.setPeerMessage(msgA);
// Both derive the final hybrid OKM (HKDF output).
byte[] okmA = alice.deriveSecret();
byte[] okmB = bob.deriveSecret();
System.out.println("...okmA " + shortHex(okmA));
System.out.println("...okmB " + shortHex(okmB));
System.out.println("...equal " + Arrays.equals(okmA, okmB));
// Application would now feed OKM into symmetric key schedule / AEAD keys / etc.
} finally {
closeQuiet(alice);
closeQuiet(bob);
}
logEnd();
}
@Test
void x25519_pairMessage_plus_mlKem768_kemAdapter() throws Exception {
logBegin("PAIR_MESSAGE + KEM_ADAPTER", "Xdh/X25519 + ML-KEM-768", "HKDF-SHA256", "OKM=32B");
// Classic leg keys (Xdh + XdhSpec.X25519).
KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
// PQC leg: Bob is the KEM recipient (has ML-KEM keypair).
KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
HybridKexProfile profile = HybridKexProfile.defaultProfile(32);
HybridKexContext alice = null;
HybridKexContext bob = null;
try {
// Alice classic leg is message-based (PAIR_MESSAGE): it will emit her public
// key as SPKI bytes.
// ...PQC leg (KEM initiator) will emit ciphertext.
alice = HybridKexContexts.initiatorPairMessage(profile, "Xdh", new KeyPairKey(aliceClassic), XdhSpec.X25519,
"ML-KEM", bobPqc.getPublic(), null);
// Bob classic leg is message-based (PAIR_MESSAGE): it will emit his public key
// as SPKI bytes.
// ...PQC leg (KEM responder) consumes ciphertext and typically does not emit a
// PQC message.
bob = HybridKexContexts.responderPairMessage(profile, "Xdh", new KeyPairKey(bobClassic), XdhSpec.X25519,
"ML-KEM", bobPqc.getPrivate(), null);
// Alice -> Bob: hybrid message carries classic SPKI + PQC ciphertext.
byte[] msgA = alice.getPeerMessage();
System.out.println("...msgA " + lens(msgA) + " " + shortHex(msgA));
bob.setPeerMessage(msgA);
// Bob -> Alice: hybrid message carries classic SPKI; PQC part may be empty
// (depends on PQC role).
byte[] msgB = bob.getPeerMessage();
System.out.println("...msgB " + lens(msgB) + " " + shortHex(msgB));
alice.setPeerMessage(msgB);
// Both derive the final hybrid OKM (HKDF output).
byte[] okmA = alice.deriveSecret();
byte[] okmB = bob.deriveSecret();
System.out.println("...okmA " + shortHex(okmA));
System.out.println("...okmB " + shortHex(okmB));
System.out.println("...equal " + Arrays.equals(okmA, okmB));
} finally {
closeQuiet(alice);
closeQuiet(bob);
}
logEnd();
}
@Test
void builder_x25519_classicAgreement_plus_mlKem768() throws Exception {
logBegin("Builder", "CLASSIC_AGREEMENT + KEM_ADAPTER", "Xdh/X25519 + ML-KEM-768", "HKDF-SHA256", "OKM=32B");
// ...Generate classic leg keys (Xdh + XdhSpec.X25519).
java.security.KeyPair aliceClassic = zeroecho.core.CryptoAlgorithms.generateKeyPair("Xdh",
zeroecho.core.alg.xdh.XdhSpec.X25519);
java.security.KeyPair bobClassic = zeroecho.core.CryptoAlgorithms.generateKeyPair("Xdh",
zeroecho.core.alg.xdh.XdhSpec.X25519);
// ...Generate PQC (recipient) keys (ML-KEM-768).
java.security.KeyPair bobPqc = zeroecho.core.CryptoAlgorithms.generateKeyPair("ML-KEM",
zeroecho.core.alg.kyber.KyberKeyGenSpec.kyber768());
// ...Create a profile for HKDF (output length 32 bytes).
zeroecho.sdk.hybrid.kex.HybridKexProfile profile = zeroecho.sdk.hybrid.kex.HybridKexProfile.defaultProfile(32);
// ...Build a transcript to bind HKDF info to public handshake context.
zeroecho.sdk.hybrid.kex.HybridKexTranscript transcript = new zeroecho.sdk.hybrid.kex.HybridKexTranscript()
.addUtf8("suite", "X25519+MLKEM768").addUtf8("role", "demo");
// ...Define a minimum-strength policy (example: classic >= 128, PQC >= 192, OKM
// >= 32 bytes).
zeroecho.sdk.hybrid.kex.HybridKexPolicy policy = new zeroecho.sdk.hybrid.kex.HybridKexPolicy(128, 192, 32);
// ...Start the builder.
zeroecho.sdk.builders.HybridKexBuilder b = zeroecho.sdk.builders.HybridKexBuilder.builder()
// ...Set HKDF profile (salt/info/outLen).
.profile(profile)
// ...Bind HKDF info to transcript (protocol context).
.transcript(transcript)
// ...Enable hybrid policy gating for this build.
.policy(policy);
// ...Select classic mode where peer public key is known out-of-band.
zeroecho.sdk.builders.HybridKexBuilder.ClassicAgreement classicCfg = b.classicAgreement()
// ...Set classic algorithm id (X25519 is represented as "Xdh" with
// XdhSpec.X25519).
.algorithm("Xdh")
// ...Set classic parameters (X25519 curve spec).
.spec(zeroecho.core.alg.xdh.XdhSpec.X25519)
// ...Set local classic private key (Alice).
.privateKey(aliceClassic.getPrivate())
// ...Set peer classic public key (Bob).
.peerPublic(bobClassic.getPublic());
// ...Continue with PQC KEM adapter configuration for initiator role.
zeroecho.sdk.hybrid.kex.HybridKexContext alice = classicCfg.pqcKem()
// ...Set PQC algorithm id (ML-KEM).
.algorithm("ML-KEM")
// ...Set PQC recipient public key (Bob).
.peerPublic(bobPqc.getPublic())
// ...Build initiator-side hybrid context.
.buildInitiator();
// ...Build responder-side context (Bob) with symmetric configuration (note: PQC
// uses private key).
zeroecho.sdk.hybrid.kex.HybridKexContext bob = zeroecho.sdk.builders.HybridKexBuilder.builder()
// ...Set the same HKDF profile to derive the same OKM.
.profile(profile)
// ...Bind the same transcript to ensure both sides derive the same OKM.
.transcript(transcript)
// ...Enable the same policy gate.
.policy(policy)
// ...Select classic agreement mode (peer public is out-of-band).
.classicAgreement()
// ...Set classic algorithm id.
.algorithm("Xdh")
// ...Set classic parameters.
.spec(zeroecho.core.alg.xdh.XdhSpec.X25519)
// ...Set local classic private key (Bob).
.privateKey(bobClassic.getPrivate())
// ...Set peer classic public key (Alice).
.peerPublic(aliceClassic.getPublic())
// ...Continue to PQC configuration.
.pqcKem()
// ...Set PQC algorithm id.
.algorithm("ML-KEM")
// ...Set PQC recipient private key (Bob).
.privateKey(bobPqc.getPrivate())
// ...Build responder-side hybrid context.
.buildResponder();
try {
// ...Alice produces the hybrid message (PQC ciphertext; classic part is empty
// in this mode).
byte[] msgA = alice.getPeerMessage();
System.out.println("...msgA " + lens(msgA) + " " + shortHex(msgA));
// ...Bob consumes Alice message.
bob.setPeerMessage(msgA);
// ...Both sides derive identical OKM.
byte[] okmA = alice.deriveSecret();
byte[] okmB = bob.deriveSecret();
System.out.println("...okmA " + shortHex(okmA));
System.out.println("...okmB " + shortHex(okmB));
System.out.println("...equal " + java.util.Arrays.equals(okmA, okmB));
// ...Use exporter to derive purpose-specific keys bound to transcript.
zeroecho.sdk.hybrid.kex.HybridKexExporter exporterA = b.exporterFromOkm(okmA);
byte[] txA = exporterA.export("app/tx", transcript.toByteArray(), 32);
byte[] rxA = exporterA.export("app/rx", transcript.toByteArray(), 32);
System.out.println("...txA " + shortHex(txA));
System.out.println("...rxA " + shortHex(rxA));
} finally {
closeQuiet(alice);
closeQuiet(bob);
}
logEnd();
}
@Test
void builder_x25519_pairMessage_plus_mlKem768() throws Exception {
logBegin("Builder", "PAIR_MESSAGE + KEM_ADAPTER", "Xdh/X25519 + ML-KEM-768", "HKDF-SHA256", "OKM=32B");
// ...Generate classic leg keys (Xdh + XdhSpec.X25519).
KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519);
// ...Generate PQC (recipient) keys (ML-KEM-768).
KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768());
// ...Create a profile for HKDF (output length 32 bytes).
HybridKexProfile profile = HybridKexProfile.defaultProfile(32);
// ...Build a transcript to bind HKDF info to public handshake context.
// ...(Use identical transcript on both sides to derive the same OKM.)
zeroecho.sdk.hybrid.kex.HybridKexTranscript transcript = new zeroecho.sdk.hybrid.kex.HybridKexTranscript()
.addUtf8("suite", "X25519+MLKEM768").addUtf8("mode", "PAIR_MESSAGE").addUtf8("role", "demo");
// ...Define a minimum-strength policy (example: classic >= 128, PQC >= 192, OKM
// >= 32 bytes).
zeroecho.sdk.hybrid.kex.HybridKexPolicy policy = new zeroecho.sdk.hybrid.kex.HybridKexPolicy(128, 192, 32);
// ...Start the initiator builder.
zeroecho.sdk.builders.HybridKexBuilder initBuilder = zeroecho.sdk.builders.HybridKexBuilder.builder()
// ...Set HKDF profile (salt/info/outLen).
.profile(profile)
// ...Bind HKDF info to transcript (protocol context).
.transcript(transcript)
// ...Enable hybrid policy gating for this build.
.policy(policy);
// ...Select classic mode where the public key is carried in-band as a classic
// message (PAIR_MESSAGE).
zeroecho.sdk.hybrid.kex.HybridKexContext alice = initBuilder
// ...Switch classic leg to PAIR_MESSAGE mode.
.classicPairMessage()
// ...Set classic algorithm id (X25519 is represented as "Xdh" with
// XdhSpec.X25519).
.algorithm("Xdh")
// ...Set classic parameters (X25519 curve spec).
.spec(XdhSpec.X25519)
// ...Set local classic key pair wrapper (Alice).
.keyPair(new KeyPairKey(aliceClassic))
// ...Continue with PQC KEM adapter configuration.
.pqcKem()
// ...Set PQC algorithm id (ML-KEM).
.algorithm("ML-KEM")
// ...Set PQC recipient public key (Bob).
.peerPublic(bobPqc.getPublic())
// ...Build initiator-side hybrid context.
.buildInitiator();
// ...Start the responder builder.
zeroecho.sdk.builders.HybridKexBuilder respBuilder = zeroecho.sdk.builders.HybridKexBuilder.builder()
// ...Set the same HKDF profile to derive the same OKM.
.profile(profile)
// ...Bind the same transcript to ensure both sides derive the same OKM.
.transcript(transcript)
// ...Enable the same policy gate.
.policy(policy);
// ...Build responder-side context (Bob).
zeroecho.sdk.hybrid.kex.HybridKexContext bob = respBuilder
// ...Switch classic leg to PAIR_MESSAGE mode.
.classicPairMessage()
// ...Set classic algorithm id.
.algorithm("Xdh")
// ...Set classic parameters.
.spec(XdhSpec.X25519)
// ...Set local classic key pair wrapper (Bob).
.keyPair(new KeyPairKey(bobClassic))
// ...Continue with PQC KEM adapter configuration.
.pqcKem()
// ...Set PQC algorithm id.
.algorithm("ML-KEM")
// ...Set PQC recipient private key (Bob).
.privateKey(bobPqc.getPrivate())
// ...Build responder-side hybrid context.
.buildResponder();
try {
// ...Alice produces the hybrid message: classic SPKI + PQC ciphertext.
byte[] msgA = alice.getPeerMessage();
System.out.println("...msgA " + lens(msgA) + " " + shortHex(msgA));
// ...Bob consumes Alice message (learns Alice classic public + decapsulates PQC
// ciphertext).
bob.setPeerMessage(msgA);
// ...Bob produces response message: classic SPKI; PQC part may be empty
// (role-dependent).
byte[] msgB = bob.getPeerMessage();
System.out.println("...msgB " + lens(msgB) + " " + shortHex(msgB));
// ...Alice consumes Bob message (learns Bob classic public).
alice.setPeerMessage(msgB);
// ...Both sides derive identical OKM.
byte[] okmA = alice.deriveSecret();
byte[] okmB = bob.deriveSecret();
System.out.println("...okmA " + shortHex(okmA));
System.out.println("...okmB " + shortHex(okmB));
System.out.println("...equal " + Arrays.equals(okmA, okmB));
// ...Use exporter to derive purpose-specific keys bound to transcript.
zeroecho.sdk.hybrid.kex.HybridKexExporter exporterA = initBuilder.exporterFromOkm(okmA);
byte[] txA = exporterA.export("app/tx", transcript.toByteArray(), 32);
byte[] rxA = exporterA.export("app/rx", transcript.toByteArray(), 32);
System.out.println("...txA " + shortHex(txA));
System.out.println("...rxA " + shortHex(rxA));
} finally {
closeQuiet(alice);
closeQuiet(bob);
}
logEnd();
}
// -------------------------------------------------------------------------
// helpers (JUnit output conventions)
// -------------------------------------------------------------------------
private static void logBegin(Object... params) {
String thisClass = HybridKexDemoTest.class.getName();
String method = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE)
.walk(frames -> frames
.dropWhile(f -> !f.getClassName().equals(thisClass) || f.getMethodName().equals("logBegin"))
.findFirst().map(StackWalker.StackFrame::getMethodName).orElse("<?>"));
System.out.println(method + "(" + Arrays.deepToString(params) + ")");
}
private static void logEnd() {
String thisClass = HybridKexDemoTest.class.getName();
String method = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE)
.walk(frames -> frames
.dropWhile(f -> !f.getClassName().equals(thisClass) || f.getMethodName().equals("logEnd"))
.findFirst().map(StackWalker.StackFrame::getMethodName).orElse("<?>"));
System.out.println(method + "...ok");
}
private static void closeQuiet(HybridKexContext ctx) {
if (ctx == null) {
return;
}
try {
ctx.close();
} catch (Exception e) {
LOG.fine("close failed: " + e.getClass().getName());
}
}
private static String shortHex(byte[] b) {
if (b == null) {
return "null";
}
StringBuilder sb = new StringBuilder(b.length * 2);
for (byte v : b) {
if (sb.length() == 80) {
sb.append("...");
break;
}
if ((v & 0xFF) < 16) {
sb.append('0');
}
sb.append(Integer.toHexString(v & 0xFF));
}
return sb.toString();
}
private static String lens(byte[] msg) {
if (msg == null || msg.length < 8) {
return "[classicLen=?, pqcLen=?]";
}
try {
DataInputStream in = new DataInputStream(new ByteArrayInputStream(msg));
int classicLen = in.readInt();
if (classicLen < 0) {
return "[classicLen=?, pqcLen=?]";
}
if (classicLen > 0) {
in.skipBytes(classicLen);
}
int pqcLen = in.readInt();
return "[classicLen=" + classicLen + ", pqcLen=" + pqcLen + "]";
} catch (Exception e) {
return "[classicLen=?, pqcLen=?]";
}
}
}

View File

@@ -0,0 +1,240 @@
/*******************************************************************************
* 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.IOException;
import java.io.InputStream;
import java.security.GeneralSecurityException;
import java.security.KeyPair;
import java.security.SecureRandom;
import java.security.Signature;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.crypto.SecretKey;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import zeroecho.core.CryptoAlgorithm;
import zeroecho.core.CryptoAlgorithms;
import zeroecho.core.context.SignatureContext;
import zeroecho.sdk.builders.TagTrailerDataContentBuilder;
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.signature.HybridSignatureContexts;
import zeroecho.sdk.hybrid.signature.HybridSignatureProfile;
import zeroecho.sdk.util.BouncyCastleActivator;
/**
* Demonstration of hybrid signing combined with AES-GCM encryption.
*
* <p>
* This sample shows both canonical compositions:
* <ul>
* <li>StE: Sign-then-Encrypt</li>
* <li>EtS: Encrypt-then-Sign</li>
* </ul>
* </p>
*
* <p>
* Hybrid signature used here (popular practical choice): Ed25519 + SPHINCS+
* with AND verification.
* </p>
*/
class HybridSigningAesTest {
private static final Logger LOG = Logger.getLogger(HybridSigningAesTest.class.getName());
@BeforeAll
static void setup() {
// Optional: enable BC if you use BC-only modes in KEM payloads (EAX/OCB/CCM,
// etc.)
try {
BouncyCastleActivator.init();
} catch (Throwable ignore) {
// keep tests runnable without BC if not present
}
}
@Test
void aesRoundStE_withHybridSignature() throws GeneralSecurityException, IOException {
LOG.info("aesRoundStE_withHybridSignature - Sign then Encrypt (Hybrid signature)");
// Prepare plaintext
byte[] msg = randomBytes(100);
// AES-GCM with header, runtime params are stored in header
AesDataContentBuilder aesBuilder = AesDataContentBuilder.builder().generateKey(256).modeGcm(128).withHeader();
// Hybrid signature: Ed25519 + SPHINCS+ (AND)
HybridSignatureProfile profile = new HybridSignatureProfile("Ed25519", "SPHINCS+", null, null,
HybridSignatureProfile.VerifyRule.AND);
KeyPair ed = generateKeyPair("Ed25519");
KeyPair spx = generateKeyPair("SPHINCS+");
SignatureContext tagEnc = HybridSignatureContexts.sign(profile, ed.getPrivate(), spx.getPrivate(),
2 * 1024 * 1024);
SignatureContext tagDec = HybridSignatureContexts.verify(profile, ed.getPublic(), spx.getPublic(),
2 * 1024 * 1024);
// For verification, make mismatch behavior explicit (builder also supports
// throwOnMismatch()).
tagDec.setVerificationApproach(tagDec.getVerificationCore().getThrowOnMismatch());
// Build StE pipeline: PLAIN -> SIGN(trailer) -> ENCRYPT
DataContent dccb = DataContentChainBuilder.encrypt()
// plaintext source
.add(PlainBytesBuilder.builder().bytes(msg))
// hybrid signature trailer
.add(new TagTrailerDataContentBuilder<Signature>(tagEnc).bufferSize(8192))
// AES-GCM encryption
.add(aesBuilder).build();
SecretKey aesKey = aesBuilder.generatedKey();
LOG.log(Level.INFO, "StE: produced ciphertext, aesKeySizeBits={0}",
Integer.valueOf(aesKey.getEncoded().length * 8));
byte[] ciphertext;
try (InputStream in = dccb.getStream()) {
ciphertext = readAll(in);
}
LOG.log(Level.INFO, "StE: ciphertextSize={0}", Integer.valueOf(ciphertext.length));
// Build decrypt pipeline: ENCRYPTED -> DECRYPT -> VERIFY(trailer)
dccb = DataContentChainBuilder.decrypt()
// encrypted input
.add(PlainBytesBuilder.builder().bytes(ciphertext))
// AES-GCM decryption
.add(AesDataContentBuilder.builder().importKeyRaw(aesKey.getEncoded()).modeGcm(128).withHeader())
// hybrid signature verification
.add(new TagTrailerDataContentBuilder<Signature>(tagDec).bufferSize(8192).throwOnMismatch()).build();
byte[] decrypted;
try (InputStream in = dccb.getStream()) {
decrypted = readAll(in);
}
LOG.log(Level.INFO, "StE: roundtrip ok={0}", Boolean.valueOf(java.util.Arrays.equals(msg, decrypted)));
}
@Test
void aesRoundEtS_withHybridSignature() throws GeneralSecurityException, IOException {
LOG.info("aesRoundEtS_withHybridSignature - Encrypt then Sign (Hybrid signature)");
// Prepare plaintext
byte[] msg = randomBytes(100);
// AES-GCM with header, runtime params are stored in header
AesDataContentBuilder aesBuilder = AesDataContentBuilder.builder().generateKey(256).modeGcm(128).withHeader();
// Hybrid signature: Ed25519 + SPHINCS+ (AND)
HybridSignatureProfile profile = new HybridSignatureProfile("Ed25519", "SPHINCS+", null, null,
HybridSignatureProfile.VerifyRule.AND);
KeyPair ed = generateKeyPair("Ed25519");
KeyPair spx = generateKeyPair("SPHINCS+");
SignatureContext tagEnc = HybridSignatureContexts.sign(profile, ed.getPrivate(), spx.getPrivate(),
2 * 1024 * 1024);
SignatureContext tagDec = HybridSignatureContexts.verify(profile, ed.getPublic(), spx.getPublic(),
2 * 1024 * 1024);
tagDec.setVerificationApproach(tagDec.getVerificationCore().getThrowOnMismatch());
// Build EtS pipeline: PLAIN -> ENCRYPT -> SIGN(trailer)
DataContent dccb = DataContentChainBuilder.encrypt()
// plaintext source
.add(PlainBytesBuilder.builder().bytes(msg))
// AES-GCM encryption
.add(aesBuilder)
// hybrid signature trailer
.add(new TagTrailerDataContentBuilder<Signature>(tagEnc).bufferSize(8192)).build();
SecretKey aesKey = aesBuilder.generatedKey();
LOG.log(Level.INFO, "EtS: produced ciphertext, aesKeySizeBits={0}",
Integer.valueOf(aesKey.getEncoded().length * 8));
byte[] ciphertext;
try (InputStream in = dccb.getStream()) {
ciphertext = readAll(in);
}
LOG.log(Level.INFO, "EtS: ciphertextSize={0}", Integer.valueOf(ciphertext.length));
// Build decrypt pipeline: ENCRYPTED -> VERIFY(trailer) -> DECRYPT
// Verification runs during streaming; consumer gets plaintext only if signature
// matches.
dccb = DataContentChainBuilder.decrypt()
// encrypted input
.add(PlainBytesBuilder.builder().bytes(ciphertext))
// hybrid signature verification
.add(new TagTrailerDataContentBuilder<Signature>(tagDec).bufferSize(8192).throwOnMismatch())
// AES-GCM decryption
.add(AesDataContentBuilder.builder().importKeyRaw(aesKey.getEncoded()).modeGcm(128).withHeader())
.build();
byte[] decrypted;
try (InputStream in = dccb.getStream()) {
decrypted = readAll(in);
}
LOG.log(Level.INFO, "EtS: roundtrip ok={0}", Boolean.valueOf(java.util.Arrays.equals(msg, decrypted)));
}
// helpers
private static KeyPair generateKeyPair(String algId) throws GeneralSecurityException {
CryptoAlgorithm alg = CryptoAlgorithms.require(algId);
return alg.generateKeyPair();
}
private static byte[] randomBytes(int len) {
byte[] data = new byte[len];
new SecureRandom().nextBytes(data);
return data;
}
private static byte[] readAll(InputStream in) throws IOException {
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
in.transferTo(out);
out.flush();
return out.toByteArray();
}
}
}

View File

@@ -48,9 +48,7 @@ import javax.crypto.SecretKey;
import org.junit.jupiter.api.Test;
import zeroecho.core.CryptoAlgorithm;
import zeroecho.core.CryptoAlgorithms;
import zeroecho.core.alg.aes.AesKeyGenSpec;
import zeroecho.core.alg.rsa.RsaKeyGenSpec;
import zeroecho.core.alg.rsa.RsaSigSpec;
import zeroecho.core.tag.TagEngine;
@@ -65,23 +63,6 @@ import zeroecho.sdk.content.api.DataContent;
class SigningAesTest {
private static final Logger LOG = Logger.getLogger(SigningAesTest.class.getName());
SecretKey generateAesKey() throws GeneralSecurityException {
// Locate the AES algorithm in the catalog
CryptoAlgorithm aes = CryptoAlgorithms.require("AES");
SecretKey key = aes
// Retrieve the builder that works with AesKeyGenSpec - the specification for
// AES key generation
.symmetricKeyBuilder(AesKeyGenSpec.class)
// Generate a secret key according to the AES256 specification
.generateSecret(AesKeyGenSpec.aes256());
// Log the generated key (truncated to short hex for readability)
LOG.log(Level.INFO, "AES256 key generated: {0}", Strings.toShortHexString(key.getEncoded()));
// or just: CryptoAlgorithms.generateSecret("AES", AesKeyGenSpec.aes256())
return key;
}
KeyPair generateRsaKeys() throws GeneralSecurityException {
KeyPair kp = CryptoAlgorithms.generateKeyPair("RSA", RsaKeyGenSpec.rsa4096());