diff --git a/lib/src/main/java/zeroecho/core/alg/common/agreement/GenericJcaAgreementContext.java b/lib/src/main/java/zeroecho/core/alg/common/agreement/GenericJcaAgreementContext.java index 5faa5dc..b2cd805 100644 --- a/lib/src/main/java/zeroecho/core/alg/common/agreement/GenericJcaAgreementContext.java +++ b/lib/src/main/java/zeroecho/core/alg/common/agreement/GenericJcaAgreementContext.java @@ -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") diff --git a/lib/src/main/java/zeroecho/core/alg/common/agreement/GenericJcaMessageAgreementContext.java b/lib/src/main/java/zeroecho/core/alg/common/agreement/GenericJcaMessageAgreementContext.java new file mode 100644 index 0000000..8625e7b --- /dev/null +++ b/lib/src/main/java/zeroecho/core/alg/common/agreement/GenericJcaMessageAgreementContext.java @@ -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. + * + *

+ * This context provides a {@link MessageAgreementContext} view over a classical + * JCA Diffie–Hellman style agreement (e.g., ECDH, XDH). The protocol + * "to-be-sent" data for such agreements is the party's public key. This class + * therefore maps: + *

+ * + * + *

Encoding

+ *

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

+ * + *

Algorithm and provider selection

+ *

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

+ * + *

Lifecycle

+ *
    + *
  1. Create the context with the local key pair wrapper.
  2. + *
  3. Send {@link #getPeerMessage()} to the remote party.
  4. + *
  5. Receive the remote party's message and call + * {@link #setPeerMessage(byte[])}.
  6. + *
  7. Derive the raw shared secret with {@link #deriveSecret()}.
  8. + *
+ * + *

Security considerations

+ * + * + *

Thread safety

+ *

+ * Instances are not thread-safe and are intended for single-use, + * single-threaded protocol executions. + *

+ * + * @since 1.0 + */ +public final class GenericJcaMessageAgreementContext extends GenericJcaAgreementContext + implements MessageAgreementContext { + + private final PublicKey localPublic; + private final String keyFactoryAlg; + private final String keyFactoryProvider; + + /** + * Creates a message-oriented agreement context over a JCA key agreement. + * + *

+ * The wrapped {@link KeyPairKey} supplies both local private and public key + * components: the private key is used for the underlying agreement computation, + * while the public key is exported as the handshake message via + * {@link #getPeerMessage()}. + *

+ * + * @param alg algorithm descriptor that owns this context + * @param keyPairKey local key pair wrapper; must contain both a private + * and a public key + * @param jcaAgreementName JCA {@link javax.crypto.KeyAgreement} algorithm + * name (e.g., {@code "ECDH"}, {@code "X25519"}) + * @param agreementProvider optional JCA provider name for + * {@link javax.crypto.KeyAgreement}, or {@code null} + * for default provider selection + * @param keyFactoryAlg JCA {@link KeyFactory} algorithm name used to + * import peer public keys (e.g., {@code "EC"}, + * {@code "XDH"}) + * @param keyFactoryProvider optional JCA provider name for {@link KeyFactory}, + * or {@code null} for default provider selection + * @throws NullPointerException if any required argument is {@code null} + * @since 1.0 + */ + public GenericJcaMessageAgreementContext(CryptoAlgorithm alg, KeyPairKey keyPairKey, String jcaAgreementName, + String agreementProvider, String keyFactoryAlg, String keyFactoryProvider) { + super(Objects.requireNonNull(alg, "alg"), Objects.requireNonNull(keyPairKey, "keyPairKey").privateKey(), + Objects.requireNonNull(jcaAgreementName, "jcaAgreementName"), agreementProvider); + this.localPublic = Objects.requireNonNull(keyPairKey.publicKey(), "keyPairKey.public"); + this.keyFactoryAlg = Objects.requireNonNull(keyFactoryAlg, "keyFactoryAlg"); + this.keyFactoryProvider = keyFactoryProvider; + } + + /** + * Returns the local party's handshake message. + * + *

+ * For DH/XDH-style agreements, the handshake message is the local public key + * encoding. The returned array is a defensive copy and may be safely + * transmitted over untrusted channels. + *

+ * + *

+ * The encoding is the standard X.509 SubjectPublicKeyInfo bytes returned by + * {@link PublicKey#getEncoded()}. + *

+ * + * @return a defensive copy of the local public key encoding (never + * {@code null}) + * @throws IllegalStateException if the local public key does not provide an + * encoding + * @since 1.0 + */ + @Override + public byte[] getPeerMessage() { + byte[] encoded = localPublic.getEncoded(); + if (encoded == null) { + throw new IllegalStateException("Local public key does not provide an encoding"); + } + return encoded.clone(); + } + + /** + * Supplies the peer party's handshake message. + * + *

+ * The provided message is interpreted as an X.509 SubjectPublicKeyInfo encoding + * of the peer public key. The key is imported using {@link KeyFactory} and then + * assigned as the peer key for the underlying agreement computation. + *

+ * + *

+ * Passing {@code null} resets the peer binding and makes + * {@link #deriveSecret()} unusable until a new peer message is provided. + *

+ * + * @param message peer public key encoding (SPKI), or {@code null} to reset the + * peer state + * @throws IllegalArgumentException if the message cannot be imported as a + * public key using the configured + * {@link KeyFactory} algorithm/provider + * @since 1.0 + */ + @Override + public void setPeerMessage(byte[] message) { + if (message == null) { + setPeerPublic(null); + return; + } + + PublicKey peerPublic = importPeerPublic(message); + setPeerPublic(peerPublic); + } + + /** + * Imports a peer public key from an X.509 SubjectPublicKeyInfo encoding. + * + *

+ * This method performs no caching and always imports the key anew. The caller + * is responsible for ensuring that the protocol-layer validation requirements + * are met (e.g., checking that the received public key belongs to an expected + * identity, or that the agreement mode requires ephemeral vs. static keys). + *

+ * + * @param spkiEncoded peer public key encoding (SPKI); must not be {@code null} + * @return imported peer {@link PublicKey} + * @throws IllegalArgumentException if the key cannot be imported + */ + private PublicKey importPeerPublic(byte[] spkiEncoded) { + try { + KeyFactory keyFactory = (keyFactoryProvider == null) ? KeyFactory.getInstance(keyFactoryAlg) + : KeyFactory.getInstance(keyFactoryAlg, keyFactoryProvider); + + X509EncodedKeySpec spec = new X509EncodedKeySpec(spkiEncoded); + return keyFactory.generatePublic(spec); + } catch (GeneralSecurityException e) { + throw new IllegalArgumentException("Failed to import peer public key using KeyFactory " + keyFactoryAlg, e); + } + } +} diff --git a/lib/src/main/java/zeroecho/core/alg/common/agreement/KeyPairKey.java b/lib/src/main/java/zeroecho/core/alg/common/agreement/KeyPairKey.java new file mode 100644 index 0000000..e9e87d3 --- /dev/null +++ b/lib/src/main/java/zeroecho/core/alg/common/agreement/KeyPairKey.java @@ -0,0 +1,147 @@ +/******************************************************************************* + * Copyright (C) 2025, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.core.alg.common.agreement; + +import java.io.Serial; +import java.io.Serializable; +import java.security.Key; +import java.security.KeyPair; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.util.Objects; + +/** + * A {@link Key} wrapper that carries a {@link KeyPair} through APIs that are + * constrained to {@link Key} types. + * + *

+ * This type exists to support capabilities that require both private and public + * components (e.g., message-oriented key agreement contexts), while preserving + * backward-compatible capabilities that accept only a {@link PrivateKey}. + *

+ * + *

+ * The wrapper does not expose an encoding via {@link #getEncoded()} because + * serializing private key material implicitly is dangerous and not required for + * capability dispatch. + *

+ * + * @since 1.0 + */ +public final class KeyPairKey implements Key, Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private final KeyPair keyPair; + + /** + * Creates a wrapper around a {@link KeyPair}. + * + * @param keyPair key pair to wrap (must not be {@code null}) + * @throws NullPointerException if {@code keyPair} is {@code null} + * @since 1.0 + */ + public KeyPairKey(KeyPair keyPair) { + this.keyPair = Objects.requireNonNull(keyPair, "keyPair"); + Objects.requireNonNull(keyPair.getPrivate(), "keyPair.private"); + Objects.requireNonNull(keyPair.getPublic(), "keyPair.public"); + } + + /** + * Returns the wrapped {@link KeyPair}. + * + * @return key pair + * @since 1.0 + */ + public KeyPair keyPair() { + return keyPair; + } + + /** + * Returns the wrapped private key. + * + * @return private key + * @since 1.0 + */ + public PrivateKey privateKey() { + return keyPair.getPrivate(); + } + + /** + * Returns the wrapped public key. + * + * @return public key + * @since 1.0 + */ + public PublicKey publicKey() { + return keyPair.getPublic(); + } + + /** + * Returns the algorithm name of the wrapped private key. + * + * @return algorithm name + * @since 1.0 + */ + @Override + public String getAlgorithm() { + return privateKey().getAlgorithm(); + } + + /** + * Returns {@code null}. This wrapper intentionally does not define a standard + * encoding format. + * + * @return {@code null} + * @since 1.0 + */ + @Override + public String getFormat() { + return null; + } + + /** + * Returns {@code null}. This wrapper intentionally does not expose a combined + * encoding. + * + * @return {@code null} + * @since 1.0 + */ + @Override + public byte[] getEncoded() { + return null; // NOPMD + } +} diff --git a/lib/src/main/java/zeroecho/core/alg/dh/DhAlgorithm.java b/lib/src/main/java/zeroecho/core/alg/dh/DhAlgorithm.java index 6df5525..8fce875 100644 --- a/lib/src/main/java/zeroecho/core/alg/dh/DhAlgorithm.java +++ b/lib/src/main/java/zeroecho/core/alg/dh/DhAlgorithm.java @@ -46,7 +46,10 @@ import zeroecho.core.AlgorithmFamily; import zeroecho.core.KeyUsage; import zeroecho.core.alg.AbstractCryptoAlgorithm; import zeroecho.core.alg.common.agreement.GenericJcaAgreementContext; +import zeroecho.core.alg.common.agreement.GenericJcaMessageAgreementContext; +import zeroecho.core.alg.common.agreement.KeyPairKey; import zeroecho.core.context.AgreementContext; +import zeroecho.core.context.MessageAgreementContext; import zeroecho.core.spi.AsymmetricKeyBuilder; /** @@ -133,6 +136,10 @@ public final class DhAlgorithm extends AbstractCryptoAlgorithm { DhSpec.class, (PrivateKey k, DhSpec s) -> new GenericJcaAgreementContext(this, k, "DiffieHellman", null), DhSpec::ffdhe2048); + capability(AlgorithmFamily.AGREEMENT, KeyUsage.AGREEMENT, MessageAgreementContext.class, KeyPairKey.class, + DhSpec.class, (KeyPairKey k, DhSpec s) -> new GenericJcaMessageAgreementContext(this, k, + "DiffieHellman", null, "DH", null), + DhSpec::ffdhe2048); registerAsymmetricKeyBuilder(DhSpec.class, new DhKeyGenBuilder(), DhSpec::ffdhe2048); registerAsymmetricKeyBuilder(DhPublicKeySpec.class, new AsymmetricKeyBuilder<>() { diff --git a/lib/src/main/java/zeroecho/core/alg/ecdh/EcdhAlgorithm.java b/lib/src/main/java/zeroecho/core/alg/ecdh/EcdhAlgorithm.java index 9e0457d..afcfa19 100644 --- a/lib/src/main/java/zeroecho/core/alg/ecdh/EcdhAlgorithm.java +++ b/lib/src/main/java/zeroecho/core/alg/ecdh/EcdhAlgorithm.java @@ -40,12 +40,15 @@ import zeroecho.core.AlgorithmFamily; import zeroecho.core.KeyUsage; import zeroecho.core.alg.AbstractCryptoAlgorithm; import zeroecho.core.alg.common.agreement.GenericJcaAgreementContext; +import zeroecho.core.alg.common.agreement.GenericJcaMessageAgreementContext; +import zeroecho.core.alg.common.agreement.KeyPairKey; import zeroecho.core.alg.ecdsa.EcdsaCurveSpec; import zeroecho.core.alg.ecdsa.EcdsaPrivateKeyBuilder; import zeroecho.core.alg.ecdsa.EcdsaPrivateKeySpec; import zeroecho.core.alg.ecdsa.EcdsaPublicKeyBuilder; import zeroecho.core.alg.ecdsa.EcdsaPublicKeySpec; import zeroecho.core.context.AgreementContext; +import zeroecho.core.context.MessageAgreementContext; /** *

Elliptic Curve Diffie-Hellman (ECDH) Algorithm

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

+ * HMAC is defined for keys of arbitrary length; this method therefore does not + * express a strict requirement. It provides a conservative, + * interoperability-friendly recommendation intended for default key derivation + * and key generation paths, especially where the caller does not want to + * manually select a key size. + *

+ * + *

+ * The recommendation follows common practice: use a key size at least equal to + * the underlying hash output length. For the built-in variants this yields: + *

+ * + * + *

+ * If this spec uses an unrecognized {@link #macName()} value, the method + * returns {@code 256} bits as a safe default and to avoid failing existing + * applications that rely on custom provider names. Applications with strict + * requirements should enforce their own policy and/or explicitly specify a key + * size. + *

+ * + * @return recommended key size in bits (positive, multiple of 8) + * @since 1.0 + */ + public int recommendedKeyBits() { + return recommendedKeyBitsForMacName(macName); + } + + private static int recommendedKeyBitsForMacName(String macName) { + return switch (macName) { + case "HmacSHA256" -> 256; + case "HmacSHA384" -> 384; + case "HmacSHA512" -> 512; + default -> 256; + }; + } + } diff --git a/lib/src/main/java/zeroecho/core/alg/xdh/XdhAlgorithm.java b/lib/src/main/java/zeroecho/core/alg/xdh/XdhAlgorithm.java index 346f372..bd542b5 100644 --- a/lib/src/main/java/zeroecho/core/alg/xdh/XdhAlgorithm.java +++ b/lib/src/main/java/zeroecho/core/alg/xdh/XdhAlgorithm.java @@ -46,7 +46,10 @@ import zeroecho.core.AlgorithmFamily; import zeroecho.core.KeyUsage; import zeroecho.core.alg.AbstractCryptoAlgorithm; import zeroecho.core.alg.common.agreement.GenericJcaAgreementContext; +import zeroecho.core.alg.common.agreement.GenericJcaMessageAgreementContext; +import zeroecho.core.alg.common.agreement.KeyPairKey; import zeroecho.core.context.AgreementContext; +import zeroecho.core.context.MessageAgreementContext; import zeroecho.core.spi.AsymmetricKeyBuilder; /** @@ -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<>() { diff --git a/lib/src/main/java/zeroecho/core/tag/TagEngineBuilder.java b/lib/src/main/java/zeroecho/core/tag/TagEngineBuilder.java index 57aa534..abd2dcf 100644 --- a/lib/src/main/java/zeroecho/core/tag/TagEngineBuilder.java +++ b/lib/src/main/java/zeroecho/core/tag/TagEngineBuilder.java @@ -89,6 +89,14 @@ import zeroecho.core.spec.VoidSpec; * @since 1.0 */ public final class TagEngineBuilder implements Supplier> { + /** + * + */ + private static final String PUBLIC_KEY = "publicKey"; + /** + * + */ + private static final String PRIVATE_KEY = "privateKey"; private final Supplier> factory; private TagEngineBuilder(Supplier> factory) { @@ -205,7 +213,7 @@ public final class TagEngineBuilder implements Supplier> { * @throws NullPointerException if {@code privateKey} is {@code null} */ public static TagEngineBuilder 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 implements Supplier> { * @throws NullPointerException if {@code publicKey} is {@code null} */ public static TagEngineBuilder 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 implements Supplier> { * @throws NullPointerException if {@code privateKey} is {@code null} */ public static TagEngineBuilder 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 implements Supplier> { * @throws NullPointerException if {@code publicKey} is {@code null} */ public static TagEngineBuilder 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 implements Supplier> { * @throws NullPointerException if {@code privateKey} is {@code null} */ public static TagEngineBuilder 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 implements Supplier> { * @throws NullPointerException if {@code publicKey} is {@code null} */ public static TagEngineBuilder 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 implements Supplier> { * @throws NullPointerException if {@code privateKey} is {@code null} */ public static TagEngineBuilder 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 implements Supplier> { * @throws NullPointerException if {@code publicKey} is {@code null} */ public static TagEngineBuilder 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 implements Supplier> { * @throws NullPointerException if {@code privateKey} is {@code null} */ public static TagEngineBuilder 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 implements Supplier> { * @throws NullPointerException if {@code publicKey} is {@code null} */ public static TagEngineBuilder 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. + * + *

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

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

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

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

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

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

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

+ * + * @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 mldsaVerify(final PublicKey publicKey) { + Objects.requireNonNull(publicKey, PUBLIC_KEY); + return signature("ML-DSA", publicKey, VoidSpec.INSTANCE); + } } diff --git a/lib/src/main/java/zeroecho/sdk/builders/HybridKexBuilder.java b/lib/src/main/java/zeroecho/sdk/builders/HybridKexBuilder.java new file mode 100644 index 0000000..520037d --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/builders/HybridKexBuilder.java @@ -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. + * + *

+ * The builder supports the two practical hybrid variants: + *

+ *
    + *
  • CLASSIC_AGREEMENT + KEM_ADAPTER (classic peer public is provided + * out-of-band)
  • + *
  • PAIR_MESSAGE + KEM_ADAPTER (classic public key is carried in the + * hybrid message)
  • + *
+ * + *

+ * The builder also supports transcript binding and optional policy enforcement + * before returning the context. + *

+ * + *

Usage sketch

{@code
+ * HybridKexContext ctx = HybridKexBuilder.builder()
+ *     .profile(HybridKexProfile.defaultProfile(32))
+ *     .transcript(new HybridKexTranscript().addUtf8("suite", "X25519+MLKEM768"))
+ *     .policy(new HybridKexPolicy(128, 192, 32))
+ *     .classicAgreement()
+ *         .algorithm("Xdh").spec(XdhSpec.X25519)
+ *         .privateKey(alicePriv).peerPublic(bobPub)
+ *     .pqcKem()
+ *         .algorithm("ML-KEM").peerPublic(bobPqcPub)
+ *     .buildInitiator();
+ * }
+ * + *

+ * Instances are mutable and not thread-safe. + *

+ * + * @since 1.0 + */ +public final class HybridKexBuilder { + + private HybridKexProfile profile; + private HybridKexTranscript transcript; + private HybridKexPolicy policy; + + private ClassicMode classicMode; + + private String classicAlgId; + private ContextSpec classicSpec; + private PrivateKey classicPrivate; + private PublicKey classicPeerPublic; + private KeyPairKey classicKeyPair; + + private String pqcAlgId; + private ContextSpec pqcSpec; + private PublicKey pqcPeerPublic; + private PrivateKey pqcPrivate; + + private HybridKexBuilder() { + // builder + } + + /** + * Creates a new builder instance. + * + * @return new builder + */ + public static HybridKexBuilder builder() { + return new HybridKexBuilder(); + } + + /** + * Sets the hybrid profile (HKDF salt/info/output length). + * + * @param profile profile (must not be null) + * @return this builder + */ + public HybridKexBuilder profile(HybridKexProfile profile) { + this.profile = Objects.requireNonNull(profile, "profile"); + return this; + } + + /** + * Optional transcript used to bind HKDF {@code info}. + * + *

+ * If provided, its bytes are concatenated to the profile {@code hkdfInfo} with + * a single zero byte separator. This preserves the profile label as a domain + * separator while incorporating handshake context. + *

+ * + * @param transcript transcript (may be null to clear) + * @return this builder + */ + public HybridKexBuilder transcript(HybridKexTranscript transcript) { + this.transcript = transcript; + return this; + } + + /** + * Optional hybrid policy enforcement. + * + * @param policy policy (may be null to clear) + * @return this builder + */ + public HybridKexBuilder policy(HybridKexPolicy policy) { + this.policy = policy; + return this; + } + + /** + * Selects the classic leg in {@link ClassicMode#CLASSIC_AGREEMENT} mode. + * + *

+ * In this mode, the classic peer public key is assumed to be available + * out-of-band (for example via a certificate, a directory, or a higher-level + * handshake message). The hybrid wire message therefore typically carries only + * the PQC payload (KEM ciphertext), while the classic leg is configured via + * {@link AgreementContext#setPeerPublic(PublicKey)}. + *

+ * + *

+ * Calling this method resets any previously configured {@code PAIR_MESSAGE} + * inputs ({@link #classicKeyPair}) to prevent ambiguous configuration. + *

+ * + * @return classic agreement configurator + * @since 1.0 + */ + public ClassicAgreement classicAgreement() { + this.classicMode = ClassicMode.CLASSIC_AGREEMENT; + this.classicKeyPair = null; + return new ClassicAgreement(this); + } + + /** + * Selects the classic leg in {@link ClassicMode#PAIR_MESSAGE} mode. + * + *

+ * In this mode, the classic leg is message-capable: the public key is carried + * as an explicit classic message (typically an SPKI encoding) and becomes part + * of the hybrid peer message. This enables a fully message-oriented handshake + * where both legs contribute to the wire payload (classic public-key message + + * PQC ciphertext). + *

+ * + *

+ * Calling this method resets any previously configured + * {@code CLASSIC_AGREEMENT} inputs ({@link #classicPrivate} and + * {@link #classicPeerPublic}) to prevent ambiguous configuration. + *

+ * + * @return classic pair-message configurator + * @since 1.0 + */ + public ClassicPairMessage classicPairMessage() { + this.classicMode = ClassicMode.PAIR_MESSAGE; + this.classicPrivate = null; + this.classicPeerPublic = null; + return new ClassicPairMessage(this); + } + + /** + * Selects configuration of the post-quantum leg (KEM adapter). + * + *

+ * The PQC leg is always treated as a message-based agreement: + *

+ *
    + *
  • Initiator is created from the recipient's PQC {@link PublicKey} and + * produces a message (typically a ciphertext) via + * {@link MessageAgreementContext#getPeerMessage()}.
  • + *
  • Responder is created from the recipient's PQC {@link PrivateKey} and + * consumes the peer message via + * {@link MessageAgreementContext#setPeerMessage(byte[])}.
  • + *
+ * + * @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. + * + *

+ * The classic leg is always an {@link AgreementContext}, but there are two + * operational models: + *

+ *
    + *
  • {@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).
  • + *
  • {@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.
  • + *
+ * + *

+ * This enum is internal to the builder but is documented because it defines the + * wire semantics of the resulting {@link HybridKexContext} and determines which + * builder inputs are required. + *

+ * + * @since 1.0 + */ + private enum ClassicMode { + + /** + * Classic agreement where the peer public key is provided out-of-band and + * configured via {@link AgreementContext#setPeerPublic(PublicKey)}. + * + * @since 1.0 + */ + CLASSIC_AGREEMENT, + + /** + * Classic agreement where the classic public key is carried in-band as a + * message produced/consumed via + * {@link MessageAgreementContext#getPeerMessage()} and + * {@link MessageAgreementContext#setPeerMessage(byte[])}. + * + * @since 1.0 + */ + PAIR_MESSAGE + } + + /** + * Configurator for the classic leg in {@link ClassicMode#CLASSIC_AGREEMENT} + * mode. + * + *

+ * Required inputs before building: + *

+ *
    + *
  • {@link #algorithm(String)} - classic algorithm id (for example + * {@code "Xdh"})
  • + *
  • {@link #privateKey(PrivateKey)} - local classic private key
  • + *
  • {@link #peerPublic(PublicKey)} - peer classic public key + * (out-of-band)
  • + *
+ * + *

+ * Optional inputs: + *

+ *
    + *
  • {@link #spec(ContextSpec)} - algorithm-specific context spec (for example + * {@code XdhSpec.X25519})
  • + *
+ * + *

+ * After configuring the classic leg, continue with {@link #pqcKem()} to + * configure the PQC leg. + *

+ * + * @since 1.0 + */ + public static final class ClassicAgreement { + private final HybridKexBuilder parent; + + private ClassicAgreement(HybridKexBuilder parent) { + this.parent = parent; + } + + /** + * Sets the classic agreement algorithm identifier. + * + *

+ * Example for X25519 in this project: use {@code "Xdh"} with + * {@code XdhSpec.X25519}. + *

+ * + * @param algId algorithm id (must not be null) + * @return this configurator + * @throws NullPointerException if {@code algId} is null + * @since 1.0 + */ + public ClassicAgreement algorithm(String algId) { + parent.classicAlgId = Objects.requireNonNull(algId, "algId"); + return this; + } + + /** + * Sets the classic context spec (algorithm parameters). + * + * @param spec spec (may be null if the algorithm provides a default) + * @return this configurator + * @since 1.0 + */ + public ClassicAgreement spec(ContextSpec spec) { + parent.classicSpec = spec; + return this; + } + + /** + * Sets the local private key for the classic leg. + * + * @param key private key (must not be null) + * @return this configurator + * @throws NullPointerException if {@code key} is null + * @since 1.0 + */ + public ClassicAgreement privateKey(PrivateKey key) { + parent.classicPrivate = Objects.requireNonNull(key, "key"); + return this; + } + + /** + * Sets the peer public key for the classic leg. + * + *

+ * In {@link ClassicMode#CLASSIC_AGREEMENT} this key is assumed to be obtained + * out-of-band. + *

+ * + * @param key peer public key (must not be null) + * @return this configurator + * @throws NullPointerException if {@code key} is null + * @since 1.0 + */ + public ClassicAgreement peerPublic(PublicKey key) { + parent.classicPeerPublic = Objects.requireNonNull(key, "key"); + return this; + } + + /** + * Continues with PQC (KEM adapter) leg configuration. + * + * @return PQC configurator + * @since 1.0 + */ + public PqcKem pqcKem() { + return parent.pqcKem(); + } + } + + /** + * Configurator for the classic leg in {@link ClassicMode#PAIR_MESSAGE} mode. + * + *

+ * Required inputs before building: + *

+ *
    + *
  • {@link #algorithm(String)} - classic algorithm id (for example + * {@code "Xdh"})
  • + *
  • {@link #keyPair(KeyPairKey)} - local classic key pair wrapped as + * {@link KeyPairKey}
  • + *
+ * + *

+ * Optional inputs: + *

+ *
    + *
  • {@link #spec(ContextSpec)} - algorithm-specific context spec (for example + * {@code XdhSpec.X25519})
  • + *
+ * + *

+ * In this mode, the classic leg contributes a public-key message (typically + * SPKI bytes) to the hybrid peer message. The peer public key is therefore + * learned from {@link HybridKexContext#setPeerMessage(byte[])} rather than + * being supplied out-of-band. + *

+ * + * @since 1.0 + */ + public static final class ClassicPairMessage { + private final HybridKexBuilder parent; + + private ClassicPairMessage(HybridKexBuilder parent) { + this.parent = parent; + } + + /** + * Sets the classic agreement algorithm identifier. + * + * @param algId algorithm id (must not be null) + * @return this configurator + * @throws NullPointerException if {@code algId} is null + * @since 1.0 + */ + public ClassicPairMessage algorithm(String algId) { + parent.classicAlgId = Objects.requireNonNull(algId, "algId"); + return this; + } + + /** + * Sets the classic context spec (algorithm parameters). + * + * @param spec spec (may be null if the algorithm provides a default) + * @return this configurator + * @since 1.0 + */ + public ClassicPairMessage spec(ContextSpec spec) { + parent.classicSpec = spec; + return this; + } + + /** + * Sets the local classic key pair. + * + *

+ * The key pair is wrapped into {@link KeyPairKey} to match the + * {@code PAIR_MESSAGE} capability registered in core. + *

+ * + * @param keyPair key pair wrapper (must not be null) + * @return this configurator + * @throws NullPointerException if {@code keyPair} is null + * @since 1.0 + */ + public ClassicPairMessage keyPair(KeyPairKey keyPair) { + parent.classicKeyPair = Objects.requireNonNull(keyPair, "keyPair"); + return this; + } + + /** + * Continues with PQC (KEM adapter) leg configuration. + * + * @return PQC configurator + * @since 1.0 + */ + public PqcKem pqcKem() { + return parent.pqcKem(); + } + } + + /** + * Configurator for the PQC leg (KEM adapter). + * + *

+ * Required inputs differ by role: + *

+ *
    + *
  • Initiator requires {@link #peerPublic(PublicKey)} (recipient PQC + * public key).
  • + *
  • Responder requires {@link #privateKey(PrivateKey)} (recipient PQC + * private key).
  • + *
+ * + *

+ * The PQC leg is always message-based: initiator produces a peer message + * (ciphertext) and responder consumes it. The hybrid context transports this + * payload as the PQC part of the hybrid message. + *

+ * + * @since 1.0 + */ + public static final class PqcKem { + private final HybridKexBuilder parent; + + private PqcKem(HybridKexBuilder parent) { + this.parent = parent; + } + + /** + * Sets the PQC algorithm identifier. + * + * @param algId algorithm id (must not be null) + * @return this configurator + * @throws NullPointerException if {@code algId} is null + * @since 1.0 + */ + public PqcKem algorithm(String algId) { + parent.pqcAlgId = Objects.requireNonNull(algId, "algId"); + return this; + } + + /** + * Sets the PQC context spec (algorithm parameters). + * + * @param spec spec (may be null if the algorithm provides a default) + * @return this configurator + * @since 1.0 + */ + public PqcKem spec(ContextSpec spec) { + parent.pqcSpec = spec; + return this; + } + + /** + * Sets the recipient PQC public key for initiator-side construction. + * + * @param key recipient public key (must not be null) + * @return this configurator + * @throws NullPointerException if {@code key} is null + * @since 1.0 + */ + public PqcKem peerPublic(PublicKey key) { + parent.pqcPeerPublic = Objects.requireNonNull(key, "key"); + return this; + } + + /** + * Sets the recipient PQC private key for responder-side construction. + * + * @param key recipient private key (must not be null) + * @return this configurator + * @throws NullPointerException if {@code key} is null + * @since 1.0 + */ + public PqcKem privateKey(PrivateKey key) { + parent.pqcPrivate = Objects.requireNonNull(key, "key"); + return this; + } + + /** + * Builds an initiator-side {@link HybridKexContext} using the current builder + * configuration. + * + * @return initiator context + * @throws IOException if underlying context creation fails + * @throws IllegalStateException if required configuration for initiator role is + * missing + * @since 1.0 + */ + public HybridKexContext buildInitiator() throws IOException { + return parent.buildInitiator(); + } + + /** + * Builds a responder-side {@link HybridKexContext} using the current builder + * configuration. + * + * @return responder context + * @throws IOException if underlying context creation fails + * @throws IllegalStateException if required configuration for responder role is + * missing + * @since 1.0 + */ + public HybridKexContext buildResponder() throws IOException { + return parent.buildResponder(); + } + } + +} diff --git a/lib/src/main/java/zeroecho/sdk/builders/SignatureTrailerDataContentBuilder.java b/lib/src/main/java/zeroecho/sdk/builders/SignatureTrailerDataContentBuilder.java new file mode 100644 index 0000000..4e46265 --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/builders/SignatureTrailerDataContentBuilder.java @@ -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. + * + *

+ * This class is intended as a convenient, signature-specialized replacement + * for: + *

+ * + *
+ * new TagTrailerDataContentBuilder<Signature>(engine).bufferSize(8192)
+ * new TagTrailerDataContentBuilder<Signature>(engine).bufferSize(8192).throwOnMismatch()
+ * 
+ * + *

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

+ * + *

Mode selection

+ *
    + *
  • {@link #core(TagEngine)} / {@link #core(Supplier)}: wraps a ready engine + * (same parameters as {@link TagTrailerDataContentBuilder}).
  • + *
  • {@link #single()}: constructs a non-hybrid {@code SignatureContext} via + * {@link CryptoAlgorithms}.
  • + *
  • {@link #hybrid()}: constructs a hybrid {@code SignatureContext} via + * {@link HybridSignatureContexts}.
  • + *
+ * + *

Checked exceptions

+ *

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

+ * + * @since 1.0 + */ +public final class SignatureTrailerDataContentBuilder implements DataContentBuilder { + + private final TagTrailerDataContentBuilder delegate; + + private SignatureTrailerDataContentBuilder(TagEngine engine) { + this.delegate = new TagTrailerDataContentBuilder<>(engine); + } + + private SignatureTrailerDataContentBuilder(Supplier> engineFactory) { + this.delegate = new TagTrailerDataContentBuilder<>(engineFactory); + } + + /** + * Core mode: wraps a fixed engine instance. + * + *

+ * This is the direct signature-specialized equivalent of: + * {@code new TagTrailerDataContentBuilder(engine)}. + *

+ * + * @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 engine) { + Objects.requireNonNull(engine, "engine"); + return new SignatureTrailerDataContentBuilder(engine); + } + + /** + * Core mode: wraps an engine factory. + * + *

+ * This is the direct signature-specialized equivalent of: + * {@code new TagTrailerDataContentBuilder(engineFactory)}. + *

+ * + * @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> 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 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> 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> 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> 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> 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); + } + } +} diff --git a/lib/src/main/java/zeroecho/sdk/builders/alg/HmacDataContentBuilder.java b/lib/src/main/java/zeroecho/sdk/builders/alg/HmacDataContentBuilder.java index 90a1c9b..664feed 100644 --- a/lib/src/main/java/zeroecho/sdk/builders/alg/HmacDataContentBuilder.java +++ b/lib/src/main/java/zeroecho/sdk/builders/alg/HmacDataContentBuilder.java @@ -253,6 +253,40 @@ public final class HmacDataContentBuilder implements DataContentBuilder + * This 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. + *

+ * + * @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()}. + * + *

+ * This is a convenience forwarding method to + * {@link HmacSpec#recommendedKeyBits()} and is intended as the default choice + * for derived-key integrations. Callers that intentionally need a non-default + * size may override it explicitly. + *

+ * + * @return recommended key size in bits (positive, multiple of 8) + * @since 1.0 + */ + public int recommendedKeyBits() { + return this.spec.recommendedKeyBits(); + } + /** * Switches the builder to MAC mode. * diff --git a/lib/src/main/java/zeroecho/sdk/builders/package-info.java b/lib/src/main/java/zeroecho/sdk/builders/package-info.java index 8ccb9fb..6430dd3 100644 --- a/lib/src/main/java/zeroecho/sdk/builders/package-info.java +++ b/lib/src/main/java/zeroecho/sdk/builders/package-info.java @@ -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}. + * {@link zeroecho.sdk.builders.alg.SphincsPlusDataContentBuilder}, + * {@link zeroecho.sdk.builders.alg.MldsaDataContentBuilder}, + * {@link zeroecho.sdk.builders.alg.SlhDsaDataContentBuilder}. *
  • MAC and digest: {@link zeroecho.sdk.builders.alg.HmacDataContentBuilder}, * {@link zeroecho.sdk.builders.alg.DigestDataContentBuilder}.
  • *
  • KEM envelopes: {@link zeroecho.sdk.builders.alg.KemDataContentBuilder} @@ -86,6 +88,11 @@ *
  • {@link TagTrailerDataContentBuilder} - appends or verifies an * authentication tag carried as an input trailer using a * {@link zeroecho.core.tag.TagEngine}.
  • + *
  • {@link SignatureTrailerDataContentBuilder} - signature-specialized + * trailer builder intended to replace + * {@code TagTrailerDataContentBuilder} in most signature use cases. + * It can wrap existing signature engines and construct single-algorithm and + * hybrid signature contexts.
  • * * * @@ -107,6 +114,9 @@ * signatures or tags. *
  • {@link TagTrailerDataContentBuilder} focuses on trailer-style tags with * explicit verify policies.
  • + *
  • {@link SignatureTrailerDataContentBuilder} provides the corresponding + * trailer functionality specialized for digital signatures, including hybrid + * signature construction.
  • * * *

    Typical usage

    {@code
    @@ -136,4 +146,4 @@
      *
      * @since 1.0
      */
    -package zeroecho.sdk.builders;
    \ No newline at end of file
    +package zeroecho.sdk.builders;
    diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/derived/HybridDerived.java b/lib/src/main/java/zeroecho/sdk/hybrid/derived/HybridDerived.java
    new file mode 100644
    index 0000000..34fcdf3
    --- /dev/null
    +++ b/lib/src/main/java/zeroecho/sdk/hybrid/derived/HybridDerived.java
    @@ -0,0 +1,378 @@
    +/*******************************************************************************
    + * Copyright (C) 2025, Leo Galambos
    + * All rights reserved.
    + *
    + * Redistribution and use in source and binary forms, with or without
    + * modification, are permitted provided that the following conditions are met:
    + *
    + * 1. Redistributions of source code must retain the above copyright notice,
    + *    this list of conditions and the following disclaimer.
    + *
    + * 2. Redistributions in binary form must reproduce the above copyright notice,
    + *    this list of conditions and the following disclaimer in the documentation
    + *    and/or other materials provided with the distribution.
    + *
    + * 3. All advertising materials mentioning features or use of this software must
    + *    display the following acknowledgement:
    + *    This product includes software developed by the Egothor project.
    + *
    + * 4. Neither the name of the copyright holder nor the names of its contributors
    + *    may be used to endorse or promote products derived from this software
    + *    without specific prior written permission.
    + *
    + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
    + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
    + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
    + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
    + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
    + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
    + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
    + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
    + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
    + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
    + * POSSIBILITY OF SUCH DAMAGE.
    + ******************************************************************************/
    +package zeroecho.sdk.hybrid.derived;
    +
    +import java.util.Objects;
    +
    +import javax.crypto.SecretKey;
    +import javax.crypto.spec.SecretKeySpec;
    +
    +import zeroecho.core.alg.hmac.HmacSpec;
    +import zeroecho.sdk.builders.alg.AesDataContentBuilder;
    +import zeroecho.sdk.builders.alg.ChaChaDataContentBuilder;
    +import zeroecho.sdk.builders.alg.HmacDataContentBuilder;
    +import zeroecho.sdk.hybrid.kex.HybridKexExporter;
    +
    +/**
    + * Builder-style utility for deriving purpose-separated key material from a
    + * hybrid KEX exporter and applying it to streaming algorithm builders.
    + *
    + * 

    + * This class does not implement new cryptographic primitives. It derives keying + * bytes via HKDF labels (using {@link HybridKexExporter}) and injects them into + * existing builder instances. + *

    + * + *

    Labeling

    + *

    + * Derivation uses a base label, plus fixed suffixes for individual fields: + *

    + *
      + *
    • {@code label + "/key"} for the secret key
    • + *
    • {@code label + "/iv"} for AES IV
    • + *
    • {@code label + "/nonce"} for ChaCha nonce
    • + *
    • {@code label + "/aad"} for AEAD AAD (optional, if derived)
    • + *
    + * + *

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

    + * + *

    Thread safety

    + *

    + * Instances are mutable and not thread-safe. + *

    + * + * @since 1.0 + */ +public final class HybridDerived { + + private final HybridKexExporter exporter; + + private String label; + private byte[] transcript; + + private byte[] aadExplicit; + private boolean aadDerive; + private int aadDeriveLen; + + /** + * Creates a new derived-material builder backed by an exporter. + * + * @param exporter exporter seeded from a hybrid KEX result (must not be null) + * @return new derived-material builder + * @throws NullPointerException if exporter is null + * @since 1.0 + */ + public static HybridDerived from(HybridKexExporter exporter) { + Objects.requireNonNull(exporter, "exporter"); + return new HybridDerived(exporter); + } + + private HybridDerived(HybridKexExporter exporter) { + this.exporter = exporter; + } + + /** + * Sets the base label used for purpose separation. + * + *

    + * The label should identify the protocol purpose of the derived material, for + * example {@code "app/enc"} or {@code "handshake/confirm"}. + *

    + * + * @param label base label (must not be null or empty) + * @return this builder + * @throws NullPointerException if label is null + * @throws IllegalArgumentException if label is empty + * @since 1.0 + */ + public HybridDerived label(String label) { + Objects.requireNonNull(label, "label"); + if (label.isEmpty()) { + throw new IllegalArgumentException("label must not be empty"); + } + this.label = label; + return this; + } + + /** + * Sets transcript binding bytes used as exporter {@code info}. + * + *

    + * The transcript should contain only public context (negotiated suite, public + * keys/messages, channel binding, etc.). It must not contain secrets. + *

    + * + * @param transcript transcript bytes (may be null to clear) + * @return this builder + * @since 1.0 + */ + public HybridDerived transcript(byte[] transcript) { + this.transcript = (transcript == null) ? null : transcript.clone(); + return this; + } + + /** + * Supplies explicit AAD bytes to be injected into AEAD builders. + * + *

    + * If set, no AAD derivation is performed. + *

    + * + * @param aad AAD bytes (may be null to clear) + * @return this builder + * @since 1.0 + */ + public HybridDerived aad(byte[] aad) { + this.aadExplicit = (aad == null) ? null : aad.clone(); + this.aadDerive = false; + this.aadDeriveLen = 0; + return this; + } + + /** + * Requests deterministic derivation of AAD bytes from the exporter. + * + *

    + * This is optional. Many applications prefer to keep AAD as an + * application-defined, already-available public context. If derived, the AAD is + * separated using {@code label + "/aad"}. + *

    + * + * @param aadLen number of bytes to derive (must be >= 1) + * @return this builder + * @throws IllegalArgumentException if aadLen < 1 + * @since 1.0 + */ + public HybridDerived deriveAad(int aadLen) { + if (aadLen < 1) { // NOPMD + throw new IllegalArgumentException("aadLen must be >= 1"); + } + this.aadDerive = true; + this.aadDeriveLen = aadLen; + this.aadExplicit = null; + return this; + } + + /** + * Derives an AES key and applies it (and optional IV/AAD) to the provided AES + * builder. + * + *

    + * The returned value is the same builder instance to preserve fluent pipeline + * construction. + *

    + * + * @param aes AES builder to configure (must not be null) + * @param keyBits AES key size in bits (128/192/256) + * @param ivLenBytes if > 0, derive IV of this length and inject it via + * {@code withIv(...)}; if 0, do not set IV (header/ctx may + * generate it) + * @return the provided builder instance + * @throws NullPointerException if aes is null + * @throws IllegalArgumentException if keyBits is invalid + * @since 1.0 + */ + public AesDataContentBuilder applyToAesGcm(AesDataContentBuilder aes, int keyBits, int ivLenBytes) { + Objects.requireNonNull(aes, "aes"); + validateBase(); + + int keyLenBytes = bitsToBytesStrict(keyBits); + byte[] keyRaw = exportBytes(label + "/key", keyLenBytes); // NOPMD + SecretKey key = new SecretKeySpec(keyRaw, "AES"); + + aes.withKey(key); + + if (ivLenBytes > 0) { + byte[] iv = exportBytes(label + "/iv", ivLenBytes); + aes.withIv(iv); + } + + byte[] aad = resolveAad(); + if (aad != null) { + aes.withAad(aad); + } + + return aes; + } + + /** + * Derives a ChaCha key and applies it (and optional nonce/AAD) to the provided + * ChaCha builder. + * + *

    + * The returned value is the same builder instance to preserve fluent pipeline + * construction. + *

    + * + * @param chacha ChaCha builder to configure (must not be null) + * @param keyBits key size in bits (typically 256) + * @param nonceLenBytes if > 0, derive nonce of this length and inject it via + * {@code withNonce(...)}; if 0, do not set nonce + * (header/ctx may generate it) + * @return the provided builder instance + * @throws NullPointerException if chacha is null + * @throws IllegalArgumentException if keyBits is invalid + * @since 1.0 + */ + public ChaChaDataContentBuilder applyToChaChaAead(ChaChaDataContentBuilder chacha, int keyBits, int nonceLenBytes) { + Objects.requireNonNull(chacha, "chacha"); + validateBase(); + + int keyLenBytes = bitsToBytesStrict(keyBits); + byte[] keyRaw = exportBytes(label + "/key", keyLenBytes); // NOPMD + SecretKey key = new SecretKeySpec(keyRaw, "ChaCha20"); + + chacha.withKey(key); + + if (nonceLenBytes > 0) { + byte[] nonce = exportBytes(label + "/nonce", nonceLenBytes); + chacha.withNonce(nonce); + } + + byte[] aad = resolveAad(); + if (aad != null) { + chacha.withAad(aad); + } + + return chacha; + } + + /** + * Derives a MAC key using the builder's recommended size and applies it to the + * provided HMAC builder. + * + *

    + * This is the preferred integration method because it avoids duplicated + * configuration: the HMAC variant is chosen by the builder + * ({@link HmacDataContentBuilder#spec()}), and the key size recommendation is + * provided by {@link HmacDataContentBuilder#recommendedKeyBits()}. + *

    + * + *

    + * The returned value is the same builder instance to preserve fluent pipeline + * construction. + *

    + * + * @param hmac HMAC builder to configure (must not be null) + * @return the provided builder instance + * @throws NullPointerException if {@code hmac} is null + * @throws IllegalStateException if this {@code HybridDerived} instance is + * missing required base configuration + * @since 1.0 + */ + public HmacDataContentBuilder applyToHmac(HmacDataContentBuilder hmac) { + Objects.requireNonNull(hmac, "hmac"); + validateBase(); + + int keyBits = hmac.recommendedKeyBits(); + return applyToHmac(hmac, keyBits); + } + + /** + * Derives a MAC key of an explicit size (override) and applies it to the + * provided HMAC builder. + * + *

    + * This overload exists for advanced use-cases where the application + * intentionally chooses a key size different from + * {@link HmacSpec#recommendedKeyBits()}, for example to align a policy across + * different MAC functions or to satisfy interoperability constraints. + *

    + * + *

    + * Because HMAC accepts arbitrary key lengths, this method does not attempt to + * validate semantic suitability of {@code keyBits}. Applications that require + * stricter controls should enforce them via policy (for example minimum bit + * strength) and use transcript-bound labels to guarantee key separation. + *

    + * + *

    + * The returned value is the same builder instance to preserve fluent pipeline + * construction. + *

    + * + * @param hmac HMAC builder to configure (must not be null) + * @param keyBits key size in bits (must be a positive multiple of 8) + * @return the provided builder instance + * @throws NullPointerException if {@code hmac} is null + * @throws IllegalArgumentException if {@code keyBits} is invalid + * @throws IllegalStateException if this {@code HybridDerived} instance is + * missing required base configuration + * @since 1.0 + */ + public HmacDataContentBuilder applyToHmac(HmacDataContentBuilder hmac, int keyBits) { + Objects.requireNonNull(hmac, "hmac"); + validateBase(); + + int keyLenBytes = bitsToBytesStrict(keyBits); + byte[] keyRaw = exportBytes(label + "/key", keyLenBytes); + + // Prefer raw import to avoid duplicating MAC algorithm naming and to keep the + // builder as the source of truth. + return hmac.importKeyRaw(keyRaw); + } + + private void validateBase() { + if (label == null || label.isEmpty()) { + throw new IllegalStateException("label must be set"); + } + } + + private byte[] resolveAad() { + if (aadExplicit != null) { + return aadExplicit.clone(); + } + if (aadDerive) { + return exportBytes(label + "/aad", aadDeriveLen); + } + return null; // NOPMD + } + + private byte[] exportBytes(String subLabel, int len) { + byte[] info = transcript; + return exporter.export(subLabel, info, len); + } + + private static int bitsToBytesStrict(int bits) { + if (bits < 8 || (bits % 8) != 0) { + throw new IllegalArgumentException("bits must be a positive multiple of 8"); + } + return bits / 8; + } +} diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/derived/package-info.java b/lib/src/main/java/zeroecho/sdk/hybrid/derived/package-info.java new file mode 100644 index 0000000..00bd95b --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/derived/package-info.java @@ -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. + * + *

    + * This package provides a thin, SDK-level integration layer between hybrid key + * exchange ({@link zeroecho.sdk.hybrid.kex.HybridKexContext} / + * {@link zeroecho.sdk.hybrid.kex.HybridKexExporter}) and streaming data-content + * builders (for example AES/ChaCha/HMAC builders in + * {@link zeroecho.sdk.builders.alg}). + *

    + * + *

    + * The central concept is derived material: purpose-separated keying + * bytes (key, optional IV/nonce, optional AAD) derived via HKDF labels. The + * material is then applied to an existing builder via {@code applyTo(...)} + * which returns the same builder instance to preserve fluent pipeline + * construction. + *

    + * + *

    Design goals

    + *
      + *
    • Keep cryptographic primitives unchanged; only inject derived + * parameters.
    • + *
    • Provide safe-by-construction key separation using labels and transcript + * binding.
    • + *
    • Preserve fluent builder usage by returning the original builder from + * {@code applyTo(...)}.
    • + *
    + * + * @since 1.0 + */ +package zeroecho.sdk.hybrid.derived; diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexContext.java b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexContext.java new file mode 100644 index 0000000..af3ce89 --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexContext.java @@ -0,0 +1,463 @@ +/******************************************************************************* + * Copyright (C) 2025, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.sdk.hybrid.kex; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.Key; +import java.security.PublicKey; +import java.util.Objects; +import java.util.logging.Level; +import java.util.logging.Logger; + +import zeroecho.core.CryptoAlgorithm; +import zeroecho.core.context.AgreementContext; +import zeroecho.core.context.MessageAgreementContext; +import zeroecho.sdk.util.Kdf; + +/** + * Hybrid key exchange (KEX) context that combines a classic agreement and a + * post-quantum KEM-style agreement. + * + *

    + * This context composes two independent shared secrets: + *

    + *
      + *
    • Classic leg: a {@link AgreementContext} (e.g. X25519, ECDH, DH) + * that derives a raw shared secret after the peer public key is + * configured.
    • + *
    • PQC leg: a {@link MessageAgreementContext} (typically ML-KEM + * exposed via core capabilities) whose "peer message" represents the + * encapsulation/decapsulation payload (e.g. KEM ciphertext).
    • + *
    + * + *

    Wire format

    + *

    + * This class implements {@link MessageAgreementContext} to provide a single + * hybrid message that can be transmitted between parties. The encoding is: + *

    + *
    {@code
    + * [int classicLen][classicBytes...][int pqcLen][pqcBytes...]
    + * }
    + * + *

    + * For typical pairings such as {@code X25519 + ML-KEM}, the classic part is + * empty because the classic leg is configured using the peer public key + * out-of-band. If the classic leg itself is message-capable, the classic part + * may be populated. + *

    + * + *

    Key derivation

    + *

    + * The final keying material is derived using HKDF-SHA256 (RFC 5869): + *

    + *
    {@code
    + * classicSS = classic.deriveSecret()
    + * pqcSS     = pqc.deriveSecret()
    + * IKM       = classicSS || pqcSS
    + * OKM       = HKDF-SHA256(IKM, salt, info, outLen)
    + * }
    + * + *

    + * Intermediate raw secrets are treated as sensitive and are zeroized + * (best-effort) once HKDF completes. + *

    + * + *

    CryptoContext identity

    + *

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

    + * + *

    Error handling

    + *
      + *
    • Malformed hybrid messages cause {@link IllegalArgumentException} in + * {@link #setPeerMessage(byte[])}.
    • + *
    • HKDF failures are surfaced as {@link IllegalStateException} in + * {@link #deriveSecret()}.
    • + *
    • {@link #close()} closes both component contexts and aggregates + * {@link IOException}s via suppressed exceptions.
    • + *
    + * + *

    Thread safety

    + *

    + * Instances are mutable and not thread-safe; use one instance per + * handshake/session and thread. + *

    + * + * @since 1.0 + */ +public final class HybridKexContext implements MessageAgreementContext { + + private static final Logger LOG = Logger.getLogger(HybridKexContext.class.getName()); + + private final HybridKexProfile profile; + private final AgreementContext classic; + private final MessageAgreementContext pqc; + + private byte[] peerMessage; + + /** + * Creates a hybrid KEX context by composing two underlying contexts. + * + * @param profile hybrid profile defining HKDF binding and output length + * @param classic classic agreement context (must not be {@code null}) + * @param pqc PQC message agreement context (must not be {@code null}) + * @throws NullPointerException if any argument is {@code null} + * @since 1.0 + */ + public HybridKexContext(HybridKexProfile profile, AgreementContext classic, MessageAgreementContext pqc) { + this.profile = Objects.requireNonNull(profile, "profile"); + this.classic = Objects.requireNonNull(classic, "classic"); + this.pqc = Objects.requireNonNull(pqc, "pqc"); + } + + /** + * Returns the representative algorithm of this context. + * + *

    + * This is currently delegated to the classic leg. Hybrid constructions bind + * multiple algorithms; callers that need full visibility should track both legs + * explicitly. + *

    + * + * @return representative algorithm (classic leg) + * @since 1.0 + */ + @Override + public CryptoAlgorithm algorithm() { + return classic.algorithm(); + } + + /** + * Returns the representative key of this context. + * + *

    + * This is delegated to the classic leg to satisfy the {@code CryptoContext} + * contract. The hybrid KEX also depends on the PQC leg keys; callers should + * store those keys separately if needed by the application. + *

    + * + * @return representative key (classic leg) + * @since 1.0 + */ + @Override + public Key key() { + return classic.key(); + } + + /** + * Sets the peer public key for the classic leg. + * + *

    + * This is the usual configuration step for classic DH-style agreements. The PQC + * leg typically does not use peer public keys through this method; however, the + * call is forwarded on a best-effort basis for implementations that accept it. + *

    + * + * @param peer peer public key for the classic agreement + * @since 1.0 + */ + @Override + public void setPeerPublic(PublicKey peer) { + classic.setPeerPublic(peer); + try { + pqc.setPeerPublic(peer); + } catch (RuntimeException ignore) { // NOPMD + // KEM-style agreements usually do not accept peer public here. + } + } + + /** + * Supplies the hybrid peer message received from the remote party. + * + *

    + * The message is decoded into its classic and PQC parts. The PQC part is always + * forwarded to the PQC leg via + * {@link MessageAgreementContext#setPeerMessage(byte[])}. The classic part is + * forwarded only if the classic leg also implements + * {@link MessageAgreementContext}; otherwise it is ignored. + *

    + * + *

    + * Passing {@code null} resets the peer-message related state of both legs + * (best-effort). + *

    + * + * @param message hybrid message, or {@code null} to reset + * @throws IllegalArgumentException if the provided message does not conform to + * the hybrid encoding + * @since 1.0 + */ + @Override + public void setPeerMessage(byte[] message) { + if (message == null) { + this.peerMessage = null; + try { + pqc.setPeerMessage(null); + } catch (RuntimeException ignore) { // NOPMD + } + try { + setClassicMessageIfSupported(null); + } catch (RuntimeException ignore) { // NOPMD + } + return; + } + + Parts parts; + try { + parts = decode(message); + } catch (IOException e) { + throw new IllegalArgumentException("Invalid hybrid peer message encoding", e); + } + + this.peerMessage = message.clone(); + + try { + setClassicMessageIfSupported(parts.classicPart()); + } catch (RuntimeException ignore) { // NOPMD + // classic leg may be non-message agreement; ignore + } + + byte[] pqcPart = parts.pqcPart(); + if (pqcPart != null && pqcPart.length > 0) { + pqc.setPeerMessage(pqcPart); + } + } + + /** + * Returns the hybrid message to be sent to the peer. + * + *

    + * The PQC leg typically produces a non-empty message (encapsulation ciphertext) + * for initiator roles. The classic leg contributes an empty message unless it + * supports message mode. + *

    + * + * @return encoded hybrid peer message + * @throws IllegalStateException if encoding fails or the PQC leg cannot produce + * a message in the current role + * @since 1.0 + */ + @Override + public byte[] getPeerMessage() { + byte[] classicMsg = getClassicMessageIfSupported(); + byte[] pqcMsg; + try { + pqcMsg = pqc.getPeerMessage(); + } catch (RuntimeException e) { // NOPMD + // Responder-side KEM leg typically does not produce an outbound message. + pqcMsg = new byte[0]; + } + + try { + byte[] msg = encode(classicMsg, pqcMsg); + this.peerMessage = msg.clone(); + return msg; + } catch (IOException e) { + throw new IllegalStateException("Unable to encode hybrid peer message", e); + } + } + + /** + * Derives the final hybrid shared secret (OKM) using HKDF-SHA256. + * + *

    + * This method derives both component secrets and then performs HKDF over their + * concatenation. Higher-level protocols should additionally bind + * transcript/context data through the HKDF {@code info} parameter + * ({@link HybridKexProfile#hkdfInfo()}). + *

    + * + * @return derived keying material (OKM) of length + * {@link HybridKexProfile#outLenBytes()} + * @throws IllegalStateException if HKDF fails or underlying contexts are not + * configured + * @since 1.0 + */ + @Override + public byte[] deriveSecret() { + byte[] classicSs = classic.deriveSecret(); + byte[] pqcSs = pqc.deriveSecret(); + + byte[] ikm = new byte[classicSs.length + pqcSs.length]; + System.arraycopy(classicSs, 0, ikm, 0, classicSs.length); + System.arraycopy(pqcSs, 0, ikm, classicSs.length, pqcSs.length); + + try { + byte[] out = Kdf.hkdfSha256(ikm, profile.hkdfSalt(), profile.hkdfInfo(), profile.outLenBytes()); + if (LOG.isLoggable(Level.FINE)) { + LOG.fine("HybridKexContext.deriveSecret(): derived OKM length=" + out.length); + } + return out; + } catch (GeneralSecurityException e) { + throw new IllegalStateException("HKDF-SHA256 failed", e); + } finally { + zeroize(classicSs); + zeroize(pqcSs); + zeroize(ikm); + } + } + + /** + * Closes both underlying contexts. + * + *

    + * If closing the second context fails after the first one already failed, the + * second failure is added as a suppressed exception on the first one. + *

    + * + * @throws IOException if closing either leg fails + * @since 1.0 + */ + @Override + public void close() throws IOException { + IOException first = null; + + try { + classic.close(); + } catch (IOException e) { + first = e; + } + + try { + pqc.close(); + } catch (IOException e) { + if (first == null) { + first = e; + } else { + first.addSuppressed(e); + } + } + + if (first != null) { + throw first; + } + } + + /** + * Returns the last hybrid peer message that was produced or set on this + * instance. + * + *

    + * This is intended for diagnostics and testing. The returned array is a + * defensive copy. + *

    + * + * @return last hybrid message, or {@code null} if none has been produced or set + * @since 1.0 + */ + public byte[] lastPeerMessageOrNull() { + return (peerMessage == null) ? null : peerMessage.clone(); + } + + // ------------------------------------------------------------------------- + // Encoding helpers + // ------------------------------------------------------------------------- + + private static byte[] encode(byte[] classicMsg, byte[] pqcMsg) throws IOException { + byte[] c = (classicMsg == null) ? new byte[0] : classicMsg; + byte[] p = (pqcMsg == null) ? new byte[0] : pqcMsg; + + ByteArrayOutputStream bout = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bout); + + out.writeInt(c.length); + out.write(c); + + out.writeInt(p.length); + out.write(p); + + out.flush(); + return bout.toByteArray(); + } + + private static Parts decode(byte[] msg) throws IOException { + DataInputStream in = new DataInputStream(new ByteArrayInputStream(msg)); + + int cLen = in.readInt(); + if (cLen < 0) { + throw new IOException("negative classic length"); + } + byte[] c = new byte[cLen]; + in.readFully(c); + + int pLen = in.readInt(); + if (pLen < 0) { + throw new IOException("negative pqc length"); + } + byte[] p = new byte[pLen]; + in.readFully(p); + + return new Parts(c, p); + } + + private static void zeroize(byte[] b) { + if (b == null) { + return; + } + for (int i = 0; i < b.length; i++) { + b[i] = 0; + } + } + + private byte[] getClassicMessageIfSupported() { + if (classic instanceof MessageAgreementContext) { + try { + return ((MessageAgreementContext) classic).getPeerMessage(); + } catch (RuntimeException ignore) { // NOPMD + return new byte[0]; + } + } + return new byte[0]; + } + + private void setClassicMessageIfSupported(byte[] msg) { + if (classic instanceof MessageAgreementContext) { + ((MessageAgreementContext) classic).setPeerMessage(msg); + } + } + + private record Parts(byte[] classicPart, byte[] pqcPart) { + } +} diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexContexts.java b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexContexts.java new file mode 100644 index 0000000..e40668f --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexContexts.java @@ -0,0 +1,314 @@ +/******************************************************************************* + * Copyright (C) 2025, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.sdk.hybrid.kex; + +import java.io.IOException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.util.Objects; + +import zeroecho.core.CryptoAlgorithms; +import zeroecho.core.KeyUsage; +import zeroecho.core.context.AgreementContext; +import zeroecho.core.context.MessageAgreementContext; +import zeroecho.core.spec.ContextSpec; + +/** + * Factory utilities for constructing hybrid key exchange (KEX) contexts. + * + *

    + * This class implements the SDK-level composition layer for hybrid key + * exchange. It does not introduce new core contracts; instead, it composes + * existing core contexts: + *

    + *
      + *
    • {@link AgreementContext} for the classic (pre-quantum) leg (e.g. X25519, + * ECDH, DH), and
    • + *
    • {@link MessageAgreementContext} for the post-quantum leg (typically a + * KEM-style agreement, e.g. ML-KEM exposed as {@code KeyUsage.AGREEMENT}).
    • + *
    + * + *

    Handshake model

    + *

    + * The two legs have different wire semantics: + *

    + *
      + *
    • Classic leg 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.
    • + *
    • PQC leg 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[])}.
    • + *
    + * + *

    + * {@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). + *

    + * + *

    Error handling

    + *

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

    + * + *

    Thread safety

    + *

    + * This factory is stateless and thread-safe. Produced contexts are mutable and + * not thread-safe. + *

    + * + * @since 1.0 + */ +public final class HybridKexContexts { + + private HybridKexContexts() { + // utility + } + + /** + * Creates an initiator-side hybrid KEX context. + * + *

    + * This method constructs: + *

    + *
      + *
    • a classic {@link AgreementContext} from the initiator's classic + * {@link PrivateKey} and configures it with the peer's classic + * {@link PublicKey}, and
    • + *
    • a PQC {@link MessageAgreementContext} from the peer's PQC + * {@link PublicKey} (encapsulation side).
    • + *
    + * + *

    + * The returned {@link HybridKexContext} will typically produce a peer message + * whose PQC part is non-empty (e.g. KEM ciphertext), while the classic part is + * empty unless the chosen classic implementation itself supports message mode. + *

    + * + * @param profile hybrid profile defining HKDF binding and + * output length + * @param classicAlgId classic agreement algorithm identifier (for + * example {@code "X25519"}, {@code "ECDH"}, + * {@code "DH"}) + * @param classicInitiatorPrivate initiator private key for the classic leg + * @param classicPeerPublic peer public key for the classic leg + * @param classicSpec optional classic context spec (may be + * {@code null} if the algorithm supports a + * default) + * @param pqcAlgId post-quantum agreement algorithm identifier + * (for example {@code "ML-KEM"} exposed via + * {@code KeyUsage.AGREEMENT}) + * @param pqcPeerPublic peer public key for the PQC leg (encapsulation + * side) + * @param pqcSpec optional PQC context spec (may be {@code null} + * if the algorithm supports a default) + * @return initiator-side {@link HybridKexContext} + * @throws NullPointerException if any required argument is {@code null} + * @throws IOException if underlying context creation fails + * @since 1.0 + */ + public static HybridKexContext initiator(HybridKexProfile profile, String classicAlgId, + PrivateKey classicInitiatorPrivate, PublicKey classicPeerPublic, ContextSpec classicSpec, String pqcAlgId, + PublicKey pqcPeerPublic, ContextSpec pqcSpec) throws IOException { + + Objects.requireNonNull(profile, "profile"); + Objects.requireNonNull(classicAlgId, "classicAlgId"); + Objects.requireNonNull(classicInitiatorPrivate, "classicInitiatorPrivate"); + Objects.requireNonNull(classicPeerPublic, "classicPeerPublic"); + Objects.requireNonNull(pqcAlgId, "pqcAlgId"); + Objects.requireNonNull(pqcPeerPublic, "pqcPeerPublic"); + + AgreementContext classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT, classicInitiatorPrivate, + classicSpec); + classic.setPeerPublic(classicPeerPublic); + + MessageAgreementContext pqc = CryptoAlgorithms.create(pqcAlgId, KeyUsage.AGREEMENT, pqcPeerPublic, pqcSpec); + + return new HybridKexContext(profile, classic, pqc); + } + + /** + * Creates a responder-side hybrid KEX context. + * + *

    + * This method constructs: + *

    + *
      + *
    • a classic {@link AgreementContext} from the responder's classic + * {@link PrivateKey} and configures it with the peer's classic + * {@link PublicKey}, and
    • + *
    • a PQC {@link MessageAgreementContext} from the responder's PQC + * {@link PrivateKey} (decapsulation side).
    • + *
    + * + *

    + * The returned {@link HybridKexContext} is typically used after receiving the + * initiator's hybrid peer message and passing it to + * {@link HybridKexContext#setPeerMessage(byte[])}. + *

    + * + * @param profile hybrid profile defining HKDF binding and + * output length + * @param classicAlgId classic agreement algorithm identifier + * @param classicResponderPrivate responder private key for the classic leg + * @param classicPeerPublic peer public key for the classic leg + * @param classicSpec optional classic context spec (may be + * {@code null}) + * @param pqcAlgId post-quantum agreement algorithm identifier + * @param pqcResponderPrivate responder private key for the PQC leg + * (decapsulation side) + * @param pqcSpec optional PQC context spec (may be + * {@code null}) + * @return responder-side {@link HybridKexContext} + * @throws NullPointerException if any required argument is {@code null} + * @throws IOException if underlying context creation fails + * @since 1.0 + */ + public static HybridKexContext responder(HybridKexProfile profile, String classicAlgId, + PrivateKey classicResponderPrivate, PublicKey classicPeerPublic, ContextSpec classicSpec, String pqcAlgId, + PrivateKey pqcResponderPrivate, ContextSpec pqcSpec) throws IOException { + + Objects.requireNonNull(profile, "profile"); + Objects.requireNonNull(classicAlgId, "classicAlgId"); + Objects.requireNonNull(classicResponderPrivate, "classicResponderPrivate"); + Objects.requireNonNull(classicPeerPublic, "classicPeerPublic"); + Objects.requireNonNull(pqcAlgId, "pqcAlgId"); + Objects.requireNonNull(pqcResponderPrivate, "pqcResponderPrivate"); + + AgreementContext classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT, classicResponderPrivate, + classicSpec); + classic.setPeerPublic(classicPeerPublic); + + MessageAgreementContext pqc = CryptoAlgorithms.create(pqcAlgId, KeyUsage.AGREEMENT, pqcResponderPrivate, + pqcSpec); + + return new HybridKexContext(profile, classic, pqc); + } + + /** + * Creates an initiator-side hybrid KEX where the classic leg is message-based + * (PAIR_MESSAGE). + * + *

    + * The classic message is the SPKI encoding of the initiator's classic public + * key as produced by the underlying {@link MessageAgreementContext}. The PQC + * message is the PQC encapsulation payload (typically KEM ciphertext). + *

    + * + * @param profile hybrid profile defining HKDF binding and + * output length + * @param classicAlgId classic agreement algorithm identifier (e.g. + * "X25519", "ECDH", "DH") + * @param classicInitiatorKeyPair classic initiator key pair wrapped as + * {@code KeyPairKey} + * @param classicSpec classic context spec (may be {@code null} if + * supported) + * @param pqcAlgId post-quantum agreement algorithm identifier + * (e.g. "ML-KEM") + * @param pqcPeerPublic PQC peer public key (encapsulation side) + * @param pqcSpec optional PQC context spec (may be + * {@code null}) + * @return initiator-side {@link HybridKexContext} + * @throws NullPointerException if any required argument is {@code null} + * @throws IOException if underlying context creation fails + */ + public static HybridKexContext initiatorPairMessage(HybridKexProfile profile, String classicAlgId, + zeroecho.core.alg.common.agreement.KeyPairKey classicInitiatorKeyPair, ContextSpec classicSpec, + String pqcAlgId, PublicKey pqcPeerPublic, ContextSpec pqcSpec) throws IOException { + + Objects.requireNonNull(profile, "profile"); + Objects.requireNonNull(classicAlgId, "classicAlgId"); + Objects.requireNonNull(classicInitiatorKeyPair, "classicInitiatorKeyPair"); + Objects.requireNonNull(pqcAlgId, "pqcAlgId"); + Objects.requireNonNull(pqcPeerPublic, "pqcPeerPublic"); + + MessageAgreementContext classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT, + classicInitiatorKeyPair, classicSpec); + + MessageAgreementContext pqc = CryptoAlgorithms.create(pqcAlgId, KeyUsage.AGREEMENT, pqcPeerPublic, pqcSpec); + + return new HybridKexContext(profile, classic, pqc); + } + + /** + * Creates a responder-side hybrid KEX where the classic leg is message-based + * (PAIR_MESSAGE). + * + *

    + * The responder must call {@link HybridKexContext#setPeerMessage(byte[])} with + * the initiator's hybrid message before calling + * {@link HybridKexContext#deriveSecret()}. + *

    + * + * @param profile hybrid profile defining HKDF binding and + * output length + * @param classicAlgId classic agreement algorithm identifier + * @param classicResponderKeyPair classic responder key pair wrapped as + * {@code KeyPairKey} + * @param classicSpec classic context spec (may be {@code null}) + * @param pqcAlgId post-quantum agreement algorithm identifier + * @param pqcResponderPrivate PQC responder private key (decapsulation side) + * @param pqcSpec optional PQC context spec (may be + * {@code null}) + * @return responder-side {@link HybridKexContext} + * @throws NullPointerException if any required argument is {@code null} + * @throws IOException if underlying context creation fails + */ + public static HybridKexContext responderPairMessage(HybridKexProfile profile, String classicAlgId, + zeroecho.core.alg.common.agreement.KeyPairKey classicResponderKeyPair, ContextSpec classicSpec, + String pqcAlgId, PrivateKey pqcResponderPrivate, ContextSpec pqcSpec) throws IOException { + + Objects.requireNonNull(profile, "profile"); + Objects.requireNonNull(classicAlgId, "classicAlgId"); + Objects.requireNonNull(classicResponderKeyPair, "classicResponderKeyPair"); + Objects.requireNonNull(pqcAlgId, "pqcAlgId"); + Objects.requireNonNull(pqcResponderPrivate, "pqcResponderPrivate"); + + MessageAgreementContext classic = CryptoAlgorithms.create(classicAlgId, KeyUsage.AGREEMENT, + classicResponderKeyPair, classicSpec); + + MessageAgreementContext pqc = CryptoAlgorithms.create(pqcAlgId, KeyUsage.AGREEMENT, pqcResponderPrivate, + pqcSpec); + + return new HybridKexContext(profile, classic, pqc); + } +} diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexExporter.java b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexExporter.java new file mode 100644 index 0000000..a6da588 --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexExporter.java @@ -0,0 +1,134 @@ +/******************************************************************************* + * Copyright (C) 2025, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.sdk.hybrid.kex; + +import java.security.GeneralSecurityException; +import java.util.Objects; + +import zeroecho.sdk.util.Kdf; + +/** + * Deterministic exporter for deriving multiple independent keys from a single + * hybrid KEX. + * + *

    + * Many protocols require more than one key from a single handshake (for + * example, separate traffic keys for each direction, confirmation keys, or + * application exporters). This class provides a minimal key schedule API over + * HKDF-SHA256. + *

    + * + *

    Construction

    + *

    + * 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}. + *

    + * + *

    Security notes

    + *
      + *
    • Use distinct labels per purpose (for example {@code "app/tx"} vs + * {@code "app/rx"}).
    • + *
    • Bind transcript/context by including it in the {@code info} bytes.
    • + *
    + * + * @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. + * + *

    + * This is intended for diagnostics/testing only. Applications should prefer + * {@link #export(String, byte[], int)}. + *

    + * + * @return copy of root secret + */ + public byte[] rootSecretCopy() { + return rootSecret.clone(); + } +} diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexPolicy.java b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexPolicy.java new file mode 100644 index 0000000..794901d --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexPolicy.java @@ -0,0 +1,124 @@ +/******************************************************************************* + * Copyright (C) 2025, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.sdk.hybrid.kex; + +import java.security.Key; +import java.util.Objects; + +import zeroecho.core.context.AgreementContext; +import zeroecho.core.context.MessageAgreementContext; +import zeroecho.core.policy.SecurityStrengthAdvisor; + +/** + * Hybrid KEX policy helper for minimum security strength enforcement. + * + *

    + * This policy applies additional hybrid-specific checks beyond per-algorithm + * policy validation. It is intended to prevent accidental downgrade + * combinations (for example a strong PQC leg combined with a too-weak classic + * leg, or an undersized OKM output). + *

    + * + *

    What is checked

    + *
      + *
    • Classic leg estimated strength in bits (algorithm id + key)
    • + *
    • PQC leg estimated strength in bits (algorithm id + key)
    • + *
    • Minimum OKM length (in bytes) for the intended usage
    • + *
    + * + *

    + * Strength estimates are provided by + * {@link SecurityStrengthAdvisor#estimateBits(String, Key)} and are + * conservative heuristics suitable for gating and coarse comparisons. + *

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

    + * A hybrid KEX combines two independently derived secrets: + *

    + *
      + *
    • a classic (pre-quantum) agreement secret produced by an + * {@code AgreementContext} (e.g. X25519, ECDH, DH), and
    • + *
    • a post-quantum agreement secret produced by a + * {@code MessageAgreementContext} (typically a KEM-style agreement such as + * ML-KEM).
    • + *
    + * + *

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

    + * + *

    Notes

    + *
      + *
    • 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.
    • + *
    • {@code salt} is optional; if null/empty, HKDF uses a zero-filled salt as + * per RFC 5869 and {@link zeroecho.sdk.util.Kdf}.
    • + *
    + * + * @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(); + } +} diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexTranscript.java b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexTranscript.java new file mode 100644 index 0000000..935b16f --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/kex/HybridKexTranscript.java @@ -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. + * + *

    + * 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}. + *

    + * + *

    Encoding

    + *

    + * The transcript is encoded as a sequence of TLV-like entries: + *

    + *
    {@code
    + * [u16 tagLen][tagBytes...][u32 valueLen][valueBytes...] ...
    + * }
    + * + *

    + * Tags are ASCII identifiers (for example {@code "suite"}, {@code "role"}, + * {@code "peerA"}, {@code "peerB"}, {@code "classicMsg"}, {@code "pqcMsg"}). + * Values are arbitrary bytes. The encoding is stable across JVMs and + * independent of locale. + *

    + * + *

    Thread safety

    + *

    + * Instances are mutable and not thread-safe. + *

    + * + * @since 1.0 + */ +public final class HybridKexTranscript { + + private final ByteArrayOutputStream buffer; + private final DataOutputStream out; + + /** + * Creates a new empty transcript. + */ + public HybridKexTranscript() { + this.buffer = new ByteArrayOutputStream(); + this.out = new DataOutputStream(buffer); + } + + /** + * Adds a UTF-8 string value under a tag. + * + * @param tag ASCII tag identifier + * @param value UTF-8 string value (must not be null) + * @return this transcript + * @throws NullPointerException if tag or value is null + * @throws IllegalArgumentException if tag is empty + */ + public HybridKexTranscript addUtf8(String tag, String value) { + Objects.requireNonNull(value, "value"); + return addBytes(tag, value.getBytes(StandardCharsets.UTF_8)); + } + + /** + * Adds a raw byte value under a tag. + * + *

    + * The value is defensively copied by the caller if needed; this method does not + * retain a reference to the provided array. + *

    + * + * @param tag ASCII tag identifier + * @param value byte value (must not be null) + * @return this transcript + * @throws NullPointerException if tag or value is null + * @throws IllegalArgumentException if tag is empty + */ + public HybridKexTranscript addBytes(String tag, byte[] value) { + Objects.requireNonNull(tag, "tag"); + Objects.requireNonNull(value, "value"); + if (tag.isEmpty()) { + throw new IllegalArgumentException("tag must not be empty"); + } + + byte[] tagBytes = tag.getBytes(StandardCharsets.US_ASCII); + + try { + if (tagBytes.length > 65_535) { // NOPMD + throw new IllegalArgumentException("tag too long"); + } + out.writeShort(tagBytes.length); + out.write(tagBytes); + out.writeInt(value.length); + out.write(value); + out.flush(); + return this; + } catch (IOException e) { + // ByteArrayOutputStream should not throw, but keep failure explicit. + throw new IllegalStateException("Unable to encode transcript entry", e); + } + } + + /** + * Returns the canonical transcript bytes (defensive copy). + * + * @return transcript bytes + */ + public byte[] toByteArray() { + return buffer.toByteArray(); + } +} diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/kex/package-info.java b/lib/src/main/java/zeroecho/sdk/hybrid/kex/package-info.java new file mode 100644 index 0000000..42f8e9a --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/kex/package-info.java @@ -0,0 +1,81 @@ +/******************************************************************************* + * Copyright (C) 2025, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +/** + * Hybrid key exchange (KEX) utilities combining a classic agreement and a + * post-quantum KEM-style agreement into one derived shared secret. + * + *

    + * This package provides an SDK-level composition layer over existing core + * contracts: + *

    + *
      + *
    • {@link zeroecho.core.context.AgreementContext} for classic DH-style + * agreements (e.g. X25519, ECDH, DH), and
    • + *
    • {@link zeroecho.core.context.MessageAgreementContext} for message-based + * agreements (typically PQC KEM-style flows such as ML-KEM exposed via + * {@code KeyUsage.AGREEMENT}).
    • + *
    + * + *

    Wire model

    + *

    + * 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: + *

    + *
      + *
    1. classic message (often empty for classic agreements that only require a + * peer public key),
    2. + *
    3. PQC message (typically KEM ciphertext produced by + * {@link zeroecho.core.context.MessageAgreementContext}).
    4. + *
    + * + *

    Key derivation

    + *

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

    + * + *

    Intended usage

    + *

    + * Use {@link zeroecho.sdk.hybrid.kex.HybridKexContexts} to build initiator and + * responder contexts and exchange + * {@link zeroecho.sdk.hybrid.kex.HybridKexContext#getPeerMessage()} between + * parties. + *

    + * + * @since 1.0 + */ +package zeroecho.sdk.hybrid.kex; diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/package-info.java b/lib/src/main/java/zeroecho/sdk/hybrid/package-info.java new file mode 100644 index 0000000..edaac66 --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/package-info.java @@ -0,0 +1,94 @@ +/******************************************************************************* + * Copyright (C) 2025, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +/** + * SDK-level hybrid cryptography utilities. + * + *

    + * This package groups hybrid composition helpers that combine classical and + * post-quantum primitives at the SDK layer while keeping the underlying core + * contracts unchanged. Hybrid constructions are exposed as regular streaming + * contexts and builder integrations, so they can be used with existing pipeline + * APIs (for example {@link zeroecho.sdk.builders.core.DataContentChainBuilder} + * and trailer-oriented stages). + *

    + * + *

    Subpackages

    + *
      + *
    • {@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.
    • + *
    • {@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.
    • + *
    • {@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.
    • + *
    + * + *

    Design principles

    + *
      + *
    • Composition over modification: hybrids are implemented as + * SDK-level compositions over existing core contexts rather than by expanding + * core API contracts.
    • + *
    • Explicit messages where needed: 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.
    • + *
    • Key separation via KDF: hybrid secrets are combined and expanded + * using HKDF label separation and transcript binding; concatenation is avoided + * as a primary combination method.
    • + *
    + * + *

    Security notes

    + *
      + *
    • Hybrid constructions increase protocol and implementation complexity. + * Prefer clear profiles, stable transcript inputs, and explicit policy to avoid + * ambiguous security expectations.
    • + *
    • Do not log or otherwise expose sensitive material (private keys, seeds, + * derived keying bytes, plaintexts, intermediate secrets).
    • + *
    + * + *

    Thread safety

    + *

    + * Hybrid contexts and builders are not thread-safe. Create a new instance per + * independent operation and do not share instances across concurrent pipeline + * executions. + *

    + * + * @since 1.0 + */ +package zeroecho.sdk.hybrid; diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/signature/CapturePredicate.java b/lib/src/main/java/zeroecho/sdk/hybrid/signature/CapturePredicate.java new file mode 100644 index 0000000..b693d7c --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/signature/CapturePredicate.java @@ -0,0 +1,212 @@ +/******************************************************************************* + * Copyright (C) 2025, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.sdk.hybrid.signature; + +import java.security.Signature; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; + +import zeroecho.core.err.VerificationException; +import zeroecho.core.tag.ThrowingBiPredicate.VerificationBiPredicate; + +/** + * Verification predicate wrapper that captures the boolean verification result + * and never propagates {@link VerificationException} to its caller. + * + *

    + * This wrapper delegates verification to a supplied + * {@link VerificationBiPredicate} and records the boolean outcome into a shared + * {@link AtomicBoolean}. If the delegate throws {@link VerificationException}, + * the exception is suppressed and the verification is treated as a failure + * (returns {@code false}). + *

    + * + *

    + * This behavior is required for robust hybrid verification semantics, most + * notably for logical OR composition: individual component verifiers may throw + * on malformed or structurally invalid signatures (for example, certain Ed25519 + * invalid point encodings). Translating such exceptions into a boolean failure + * allows the hybrid engine to continue evaluating alternative verification + * paths and to aggregate the final decision deterministically. + *

    + * + *

    Logging

    + *

    + * For diagnostic purposes, a {@link Level#FINE} log entry is emitted on each + * invocation containing: + *

    + *
      + *
    • a short hexadecimal prefix of {@code expectedTag} (bounded and + * truncated), and
    • + *
    • the resulting verification decision ({@code true}/{@code false}).
    • + *
    + * + *

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

    + * + *

    Threading and side effects

    + *
      + *
    • This class is immutable with respect to its own fields.
    • + *
    • The {@code ok} flag is updated exactly once per invocation with the + * result returned by this method (including failures caused by suppressed + * exceptions).
    • + *
    • Correctness depends on coordinated usage of the shared + * {@link AtomicBoolean} when used concurrently.
    • + *
    + * + *

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

    + * + * @since 1.0 + */ +final class CapturePredicate extends VerificationBiPredicate { + + 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 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 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}. + * + *

    + * If the delegate completes normally, its return value is recorded into + * {@link #ok} and returned. If the delegate throws + * {@link VerificationException}, the exception is suppressed, {@code false} is + * recorded into {@link #ok}, and {@code false} is returned. + *

    + * + *

    + * A {@link Level#FINE} log entry is emitted with a truncated hexadecimal prefix + * of {@code expectedTag} and the resulting decision. + *

    + * + * @param signature the signature object to verify + * @param expectedTag the expected signature tag (may be {@code null}, in which + * case {@code ""} 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. + * + *

    + * At most {@link #TAG_LOG_PREFIX_BYTES} bytes are included. If the tag is + * longer, the output is suffixed with {@code "..."}. + *

    + * + *

    + * The output format uses lowercase hexadecimal digits without separators. + *

    + * + * @param tag tag bytes to format (may be {@code null}) + * @return a bounded, log-safe hexadecimal prefix, or {@code ""} if + * {@code tag} is {@code null} + */ + private static String formatTagPrefix(byte[] tag) { + if (tag == null) { + return ""; + } + + 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(); + } +} diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/signature/HybridCorePredicate.java b/lib/src/main/java/zeroecho/sdk/hybrid/signature/HybridCorePredicate.java new file mode 100644 index 0000000..20e2a5f --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/signature/HybridCorePredicate.java @@ -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. + * + *

    + * This predicate does not perform any cryptographic verification on its own. + * Instead, it acts as a bridge between the generic verification pipeline and + * the hybrid streaming engine, where the actual verification of classic and PQC + * signatures is performed once the complete payload has been consumed. + *

    + * + *

    + * The boolean result returned by {@link #verify(Signature, byte[])} reflects + * the outcome computed during EOF processing and stored in the shared + * {@link AtomicBoolean} instance. This allows the verification decision to be + * made exactly once, based on the fully buffered message body and the hybrid + * verification rule. + *

    + * + *

    Design notes

    + *
      + *
    • 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.
    • + *
    • This class is immutable and thread-safe with respect to its own state; + * however, correctness depends on coordinated usage with the associated hybrid + * stream.
    • + *
    • The predicate must be used only in conjunction with a hybrid signature + * stream that updates the shared {@code lastOk} flag.
    • + *
    + * + *

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

    + * + * @since 1.0 + */ +final class HybridCorePredicate extends VerificationBiPredicate { + 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. + * + *

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

    + * + * @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; + } +} diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/signature/HybridSignatureContext.java b/lib/src/main/java/zeroecho/sdk/hybrid/signature/HybridSignatureContext.java new file mode 100644 index 0000000..2f089b6 --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/signature/HybridSignatureContext.java @@ -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. + * + *

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

    + * + *

    Streaming semantics

    + *

    + * This context is compatible with the existing ZeroEcho signing/verification + * pipelines: callers wrap an {@link InputStream} and read until EOF. At EOF, + * the underlying engines produce/verify the trailer. + *

    + * + *

    + * Implementation note: the wrapper buffers the message body and runs the + * component engines at EOF. This avoids any need for changes in core contracts + * while keeping the same external streaming behavior. + *

    + * + *

    Verification aggregation

    + *

    + * 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.). + *

    + * + * @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 hybridCore; + + private VerificationBiPredicate 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 approach) { + this.verificationApproach = Objects.requireNonNull(approach, "approach"); + } + + @Override + public VerificationBiPredicate 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. + * + *

    + * 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: + *

    + * + *
      + *
    • Produce mode ({@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.
    • + *
    • Verify mode ({@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.
    • + *
    + * + *

    Lifecycle and invariants

    + *
      + *
    • EOF finalization ({@link #finishAtEof()}) is executed at most once, + * guarded by {@link #eofSeen}.
    • + *
    • In produce mode, the trailer is emitted strictly after all upstream + * bytes.
    • + *
    • In verify mode, the stream ends immediately after the upstream ends.
    • + *
    • This class is not thread-safe and assumes sequential consumption.
    • + *
    + * + *

    + * 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). + *

    + */ + 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. + * + *

    + * Delegates to {@link #read(byte[], int, int)} and follows the standard + * {@link InputStream} contract. + *

    + * + * @return the next byte of data as an unsigned value in the range + * {@code 0–255}, 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}. + * + *

    + * 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). + *

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

    + * Note: closing this stream closes the wrapped {@code upstream} stream. + *

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

    + * The buffer is bounded by {@code maxBufferedBytes}. If appending would exceed + * the configured limit, the method fails fast with an {@link IOException}. + *

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

    + * This method is invoked exactly once upon the first observation of upstream + * EOF. It uses the buffered body to either: + *

    + * + *
      + *
    • Produce mode: 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.
    • + *
    • Verify mode: 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.
    • + *
    + * + * @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. + * + *

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

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

    + * Instances are created only after the expected tag length is validated and + * then used to feed the per-algorithm verification routines. + *

    + * + *

    + * Security note: this structure stores signature bytes; it must not be logged + * or exposed outside the narrow verification flow. + *

    + */ + 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; + } +} diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/signature/HybridSignatureContexts.java b/lib/src/main/java/zeroecho/sdk/hybrid/signature/HybridSignatureContexts.java new file mode 100644 index 0000000..99c879e --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/signature/HybridSignatureContexts.java @@ -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. + * + *

    + * The returned contexts implement the standard ZeroEcho streaming contract: + * wrapping a stream produces/verifies a signature trailer at EOF. + *

    + * + * @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); + } +} diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/signature/HybridSignatureProfile.java b/lib/src/main/java/zeroecho/sdk/hybrid/signature/HybridSignatureProfile.java new file mode 100644 index 0000000..5ff9247 --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/signature/HybridSignatureProfile.java @@ -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: + *
      + *
    • which two signature algorithms participate in the hybrid + * composition,
    • + *
    • their fixed ordering within the produced signature trailer,
    • + *
    • the verification aggregation rule applied to their results.
    • + *
    + * + *

    + * The profile is the single source of truth for hybrid signature + * processing. No algorithm identifiers or metadata are embedded in the + * signature trailer itself; both signing and verification sides must use the + * same profile. + *

    + * + *

    + * Instances of this record are immutable and thread-safe. + *

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

    + * Uses {@link Objects#requireNonNull(Object, String)} to enforce mandatory + * components while keeping validation idiomatic and consistent with the rest of + * the ZeroEcho codebase. + *

    + * + * @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"); + } +} diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/signature/package-info.java b/lib/src/main/java/zeroecho/sdk/hybrid/signature/package-info.java new file mode 100644 index 0000000..d5a71a9 --- /dev/null +++ b/lib/src/main/java/zeroecho/sdk/hybrid/signature/package-info.java @@ -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. + * + *

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

    + * + *

    Concept

    + *

    + * A hybrid signature computes two component signatures over the same message + * stream and then aggregates verification according to a configured rule: + *

    + *
      + *
    • AND - verification succeeds only if both component signatures + * verify.
    • + *
    • OR - verification succeeds if at least one component signature + * verifies.
    • + *
    + * + *

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

    + * + *

    Main types

    + *
      + *
    • {@link zeroecho.sdk.hybrid.signature.HybridSignatureProfile} - immutable + * configuration describing the two component algorithms, optional per-algorithm + * specs, and the verification rule.
    • + *
    • {@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}.
    • + *
    • {@link zeroecho.sdk.hybrid.signature.HybridSignatureContext} - the + * streaming hybrid {@link zeroecho.core.context.SignatureContext} + * implementation that computes/verifies two signatures in a single pass.
    • + *
    + * + *

    Integration with pipeline builders

    + *

    + * The hybrid signature contexts created by this package are intended to be used + * as engines in trailer-oriented pipeline stages. In particular: + *

    + *
      + *
    • {@link zeroecho.sdk.builders.SignatureTrailerDataContentBuilder} can + * construct hybrid contexts directly and is the preferred signature-specialized + * builder API.
    • + *
    • {@link zeroecho.sdk.builders.TagTrailerDataContentBuilder} can also be + * used with a hybrid {@link zeroecho.core.context.SignatureContext} when + * generic tag handling is desired.
    • + *
    + * + *

    Streaming and resource management

    + *

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

    + * + *

    Security notes

    + *
      + *
    • 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.
    • + *
    • Implementations must not log or otherwise expose sensitive material + * (private keys, seeds, message contents, intermediate state).
    • + *
    + * + *

    Thread safety

    + *

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

    + * + * @since 1.0 + */ +package zeroecho.sdk.hybrid.signature; \ No newline at end of file diff --git a/lib/src/main/resources/jul.properties b/lib/src/main/resources/jul.properties index 79d78cb..225ef76 100644 --- a/lib/src/main/resources/jul.properties +++ b/lib/src/main/resources/jul.properties @@ -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 diff --git a/lib/src/test/java/zeroecho/core/alg/common/agreement/AgreementAlgorithmsRoundTripTest.java b/lib/src/test/java/zeroecho/core/alg/common/agreement/AgreementAlgorithmsRoundTripTest.java index 12f1a7a..32d3b34 100644 --- a/lib/src/test/java/zeroecho/core/alg/common/agreement/AgreementAlgorithmsRoundTripTest.java +++ b/lib/src/test/java/zeroecho/core/alg/common/agreement/AgreementAlgorithmsRoundTripTest.java @@ -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 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 specType = (Class) bi.specType; - AsymmetricKeyBuilder b = alg.asymmetricKeyBuilder(specType); + AsymmetricKeyBuilder 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); } diff --git a/lib/src/test/java/zeroecho/sdk/hybrid/derived/HybridDerivedTest.java b/lib/src/test/java/zeroecho/sdk/hybrid/derived/HybridDerivedTest.java new file mode 100644 index 0000000..b7b3e75 --- /dev/null +++ b/lib/src/test/java/zeroecho/sdk/hybrid/derived/HybridDerivedTest.java @@ -0,0 +1,341 @@ +/******************************************************************************* + * Copyright (C) 2025, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.sdk.hybrid.derived; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Arrays; + +import org.junit.jupiter.api.Test; + +import zeroecho.sdk.builders.alg.AesDataContentBuilder; +import zeroecho.sdk.builders.alg.ChaChaDataContentBuilder; +import zeroecho.sdk.builders.alg.HmacDataContentBuilder; +import zeroecho.sdk.builders.core.DataContentBuilder; +import zeroecho.sdk.builders.core.DataContentChainBuilder; +import zeroecho.sdk.builders.core.PlainBytesBuilder; +import zeroecho.sdk.content.api.DataContent; +import zeroecho.sdk.hybrid.kex.HybridKexExporter; + +/** + * Coverage tests for {@link HybridDerived} derived-material application + * helpers. + * + *

    + * The tests use deterministic exporter inputs (fixed OKM and salt) to ensure + * stable results. + *

    + */ +public class HybridDerivedTest { + + @Test + void aes_gcm_applyTo_roundtrip() throws Exception { + System.out.println("aes_gcm_applyTo_roundtrip()"); + HybridKexExporter exporter = testExporter(); + + byte[] transcript = "demo-transcript".getBytes(StandardCharsets.UTF_8); + byte[] aad = "aad".getBytes(StandardCharsets.UTF_8); + byte[] msg = fixedBytes(1024, (byte) 0x5A); + + AesDataContentBuilder encAes = AesDataContentBuilder.builder().withHeader().modeGcm(128); + + AesDataContentBuilder returnedEnc = HybridDerived.from(exporter).label("app/enc/aes").transcript(transcript) + .aad(aad).applyToAesGcm(encAes, 256, 12); + + System.out.println("...returnedEncSame=" + (returnedEnc == encAes)); + assertSame(encAes, returnedEnc); + + byte[] ciphertext = runEncrypt(encAes, msg); + System.out.println("...ciphertextLen=" + ciphertext.length); + System.out.println("...ciphertextPrefix=" + shortHex(ciphertext, 32)); + + AesDataContentBuilder decAes = AesDataContentBuilder.builder().withHeader().modeGcm(128); + + HybridDerived.from(exporter).label("app/enc/aes").transcript(transcript).aad(aad).applyToAesGcm(decAes, 256, + 12); + + byte[] out = runDecrypt(decAes, ciphertext); + System.out.println("...outPrefix=" + shortHex(out, 32)); + + assertArrayEquals(msg, out); + System.out.println("aes_gcm_applyTo_roundtrip...ok"); + } + + @Test + void aes_gcm_applyTo_negative_label_mismatch() throws Exception { + System.out.println("aes_gcm_applyTo_negative_label_mismatch()"); + HybridKexExporter exporter = testExporter(); + + byte[] transcript = "demo-transcript".getBytes(StandardCharsets.UTF_8); + byte[] aad = "aad".getBytes(StandardCharsets.UTF_8); + byte[] msg = fixedBytes(256, (byte) 0x1C); + + AesDataContentBuilder encAes = AesDataContentBuilder.builder().withHeader().modeGcm(128); + + HybridDerived.from(exporter).label("app/enc/aes").transcript(transcript).aad(aad).applyToAesGcm(encAes, 256, + 12); + + byte[] ciphertext = runEncrypt(encAes, msg); + System.out.println("...ciphertextLen=" + ciphertext.length); + + AesDataContentBuilder decAesWrong = AesDataContentBuilder.builder().withHeader().modeGcm(128); + + // ...label mismatch -> wrong key/iv/aad -> decryption must fail + HybridDerived.from(exporter).label("app/enc/aes_WRONG").transcript(transcript).aad(aad) + .applyToAesGcm(decAesWrong, 256, 12); + + assertThrows(Exception.class, () -> runDecrypt(decAesWrong, ciphertext)); + + System.out.println("aes_gcm_applyTo_negative_label_mismatch...ok"); + } + + @Test + void chacha_aead_applyTo_roundtrip() throws Exception { + System.out.println("chacha_aead_applyTo_roundtrip()"); + HybridKexExporter exporter = testExporter(); + + byte[] transcript = "demo-transcript".getBytes(StandardCharsets.UTF_8); + byte[] aad = "aad".getBytes(StandardCharsets.UTF_8); + byte[] msg = fixedBytes(777, (byte) 0x33); + + ChaChaDataContentBuilder encChaCha = ChaChaDataContentBuilder.builder().withHeader(); + + ChaChaDataContentBuilder returnedEnc = HybridDerived.from(exporter).label("app/enc/chacha") + .transcript(transcript).aad(aad).applyToChaChaAead(encChaCha, 256, 12); + + System.out.println("...returnedEncSame=" + (returnedEnc == encChaCha)); + assertSame(encChaCha, returnedEnc); + + byte[] ciphertext = runEncrypt(encChaCha, msg); + System.out.println("...ciphertextLen=" + ciphertext.length); + System.out.println("...ciphertextPrefix=" + shortHex(ciphertext, 32)); + + ChaChaDataContentBuilder decChaCha = ChaChaDataContentBuilder.builder().withHeader(); + + HybridDerived.from(exporter).label("app/enc/chacha").transcript(transcript).aad(aad) + .applyToChaChaAead(decChaCha, 256, 12); + + byte[] out = runDecrypt(decChaCha, ciphertext); + System.out.println("...outPrefix=" + shortHex(out, 32)); + + assertArrayEquals(msg, out); + System.out.println("chacha_aead_applyTo_roundtrip...ok"); + } + + @Test + void hmac_applyTo_default_and_override() throws Exception { + System.out.println("hmac_applyTo_default_and_override()"); + HybridKexExporter exporter = testExporter(); + + byte[] transcript = "demo-transcript".getBytes(StandardCharsets.UTF_8); + byte[] msg = fixedBytes(2048, (byte) 0x7E); + + // -------------------- + // Default key size path: applyToHmac(hmac) derives key using builder's + // recommended bits + // -------------------- + + HmacDataContentBuilder macBuilder = HmacDataContentBuilder.builder().sha256().emitHexTag(); + + int recommendedBits = macBuilder.recommendedKeyBits(); + System.out.println("...recommendedBits=" + recommendedBits); + + HybridDerived.from(exporter).label("app/mac/hmac-default").transcript(transcript).applyToHmac(macBuilder); + + String tagHex = runHmacHex(macBuilder, msg); + System.out.println("...tagHexPrefix=" + shortText(tagHex, 64)); + + HmacDataContentBuilder verifyBuilder = HmacDataContentBuilder.builder().sha256().expectedTagHex(tagHex) + .emitVerificationBoolean(); + + HybridDerived.from(exporter).label("app/mac/hmac-default").transcript(transcript).applyToHmac(verifyBuilder); + + String ok = runHmacVerifyBool(verifyBuilder, msg); + System.out.println("...verifyBool=" + ok); + assertEquals("true", ok); + + // -------------------- + // Override key size path: applyToHmac(hmac, keyBits) + // -------------------- + + HmacDataContentBuilder macBuilderOv = HmacDataContentBuilder.builder().sha256().emitHexTag(); + + // ...override to 512-bit keying material (still valid for HMAC; explicit expert + // choice) + HybridDerived.from(exporter).label("app/mac/hmac-override").transcript(transcript).applyToHmac(macBuilderOv, + 512); + + String tagHexOv = runHmacHex(macBuilderOv, msg); + System.out.println("...tagHexOvPrefix=" + shortText(tagHexOv, 64)); + + HmacDataContentBuilder verifyBuilderOv = HmacDataContentBuilder.builder().sha256().expectedTagHex(tagHexOv) + .emitVerificationBoolean(); + + HybridDerived.from(exporter).label("app/mac/hmac-override").transcript(transcript).applyToHmac(verifyBuilderOv, + 512); + + String okOv = runHmacVerifyBool(verifyBuilderOv, msg); + System.out.println("...verifyBoolOv=" + okOv); + assertEquals("true", okOv); + + // -------------------- + // Negative: wrong expected tag -> must emit "false" + // -------------------- + + HmacDataContentBuilder verifyBad = HmacDataContentBuilder.builder().sha256() + .expectedTagHex(tagHex.substring(0, Math.max(0, tagHex.length() - 2)) + "00").emitVerificationBoolean(); + + HybridDerived.from(exporter).label("app/mac/hmac-default").transcript(transcript).applyToHmac(verifyBad); + + String bad = runHmacVerifyBool(verifyBad, msg); + System.out.println("...verifyBoolBad=" + bad); + assertEquals("false", bad); + + // sanity: ensure the two tags differ (default vs override label/key schedule) + assertTrue(!tagHex.equals(tagHexOv)); + + System.out.println("hmac_applyTo_default_and_override...ok"); + } + + // -------------------- + // helpers + // -------------------- + + private static HybridKexExporter testExporter() { + byte[] okm = fixedBytes(32, (byte) 0x11); + byte[] salt = fixedBytes(32, (byte) 0x22); + return new HybridKexExporter(okm, salt); + } + + private static byte[] runEncrypt(DataContentBuilder 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 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; + } +} diff --git a/lib/src/test/java/zeroecho/sdk/hybrid/kex/HybridKexTest.java b/lib/src/test/java/zeroecho/sdk/hybrid/kex/HybridKexTest.java new file mode 100644 index 0000000..fbee9ab --- /dev/null +++ b/lib/src/test/java/zeroecho/sdk/hybrid/kex/HybridKexTest.java @@ -0,0 +1,251 @@ +/******************************************************************************* + * Copyright (C) 2025, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.sdk.hybrid.kex; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.security.KeyPair; +import java.util.Arrays; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import zeroecho.core.CryptoAlgorithms; +import zeroecho.core.alg.common.agreement.KeyPairKey; +import zeroecho.core.alg.kyber.KyberKeyGenSpec; +import zeroecho.core.alg.xdh.XdhSpec; +import zeroecho.sdk.util.BouncyCastleActivator; + +/** + * Hybrid KEX tests. + */ +public class HybridKexTest { + + @BeforeAll + static void setup() { + // Optional: enable BC if you use BC-only modes in KEM payloads (EAX/OCB/CCM, + // etc.) + try { + BouncyCastleActivator.init(); + } catch (Throwable ignore) { + // keep tests runnable without BC if not present + } + } + + private static void logBegin(Object... params) { + String thisClass = HybridKexTest.class.getName(); + String method = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE) + .walk(frames -> frames + .dropWhile(f -> !f.getClassName().equals(thisClass) || f.getMethodName().equals("logBegin")) + .findFirst().map(StackWalker.StackFrame::getMethodName).orElse("")); + System.out.println(method + "(" + Arrays.deepToString(params) + ")"); + } + + private static void logEnd() { + String thisClass = HybridKexTest.class.getName(); + String method = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE) + .walk(frames -> frames + .dropWhile(f -> !f.getClassName().equals(thisClass) || f.getMethodName().equals("logEnd")) + .findFirst().map(StackWalker.StackFrame::getMethodName).orElse("")); + System.out.println(method + "...ok"); + } + + private static String hex(byte[] b) { + if (b == null) { + return "null"; + } + StringBuilder sb = new StringBuilder(b.length * 2); + for (byte v : b) { + if (sb.length() == 80) { + sb.append("..."); + break; + } + if ((v & 0xFF) < 16) { + sb.append('0'); + } + sb.append(Integer.toHexString(v & 0xFF)); + } + return sb.toString(); + } + + private static String lens(byte[] msg) { + if (msg == null || msg.length < 8) { + return "classicLen=?, pqcLen=?"; + } + try { + DataInputStream in = new DataInputStream(new ByteArrayInputStream(msg)); + int classicLen = in.readInt(); + int pqcLen = 0; + if (msg.length >= 8 + Math.max(0, classicLen)) { + if (classicLen > 0) { + in.skipBytes(classicLen); + } + pqcLen = in.readInt(); + } + return "classicLen=" + classicLen + ", pqcLen=" + pqcLen; + } catch (Exception e) { + return "classicLen=?, pqcLen=?"; + } + } + + @Test + void hybrid_x25519_mlkem_roundtrip() throws Exception { + logBegin("CLASSIC_AGREEMENT + KEM_ADAPTER", "Xdh/X25519 + ML-KEM(768)", "HKDF-SHA256", "32 bytes"); + + HybridKexProfile profile = HybridKexProfile.defaultProfile(32); + + // Classic: X25519 key pairs (Xdh + XdhSpec.X25519) + KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + + // PQC: ML-KEM key pair (Kyber variant) + KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768()); + + HybridKexContext alice = null; + HybridKexContext bob = null; + + try { + // Initiator: classic uses Alice private + Bob classic public; PQC uses Bob PQC + // public + alice = HybridKexContexts.initiator(profile, "Xdh", aliceClassic.getPrivate(), bobClassic.getPublic(), + XdhSpec.X25519, "ML-KEM", bobPqc.getPublic(), null); + + // Responder: classic uses Bob private + Alice classic public; PQC uses Bob PQC + // private + bob = HybridKexContexts.responder(profile, "Xdh", bobClassic.getPrivate(), aliceClassic.getPublic(), + XdhSpec.X25519, "ML-KEM", bobPqc.getPrivate(), null); + + // Alice produces message (contains PQC ciphertext; classic part is empty here) + byte[] aliceMsg = alice.getPeerMessage(); + System.out.println("...aliceMsg(" + lens(aliceMsg) + ")=" + hex(aliceMsg)); + + // Bob consumes message + bob.setPeerMessage(aliceMsg); + + byte[] kA = alice.deriveSecret(); + byte[] kB = bob.deriveSecret(); + + System.out.println("...kA=" + hex(kA)); + System.out.println("...kB=" + hex(kB)); + + assertNotNull(kA); + assertNotNull(kB); + assertArrayEquals(kA, kB); + } finally { + if (alice != null) { + try { + alice.close(); + } catch (Exception ignore) { + } + } + if (bob != null) { + try { + bob.close(); + } catch (Exception ignore) { + } + } + } + + logEnd(); + } + + @Test + void hybrid_x25519_pairmessage_mlkem_roundtrip() throws Exception { + logBegin("PAIR_MESSAGE + KEM_ADAPTER", "Xdh/X25519 + ML-KEM(768)", "HKDF-SHA256", "32 bytes"); + + HybridKexProfile profile = HybridKexProfile.defaultProfile(32); + + // Classic: X25519 key pairs (Xdh + XdhSpec.X25519) + KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + + // PQC: ML-KEM key pair (recipient/responder) + KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768()); + + HybridKexContext alice = null; + HybridKexContext bob = null; + + try { + // Classic leg is message-based on both sides (PAIR_MESSAGE capability: + // KeyPairKey + ContextSpec). + // PQC leg is KEM-style: initiator uses recipient public key; responder uses + // recipient private key. + alice = HybridKexContexts.initiatorPairMessage(profile, "Xdh", new KeyPairKey(aliceClassic), XdhSpec.X25519, + "ML-KEM", bobPqc.getPublic(), null); + + bob = HybridKexContexts.responderPairMessage(profile, "Xdh", new KeyPairKey(bobClassic), XdhSpec.X25519, + "ML-KEM", bobPqc.getPrivate(), null); + + // Step 1: Alice -> Bob (classic SPKI + PQC ciphertext) + byte[] msgA = alice.getPeerMessage(); + System.out.println("...msgA(" + lens(msgA) + ")=" + hex(msgA)); + bob.setPeerMessage(msgA); + + // Step 2: Bob -> Alice (classic SPKI only; PQC part is empty) + byte[] msgB = bob.getPeerMessage(); + System.out.println("...msgB(" + lens(msgB) + ")=" + hex(msgB)); + alice.setPeerMessage(msgB); + + // Both sides derive the final hybrid OKM. + byte[] kA = alice.deriveSecret(); + byte[] kB = bob.deriveSecret(); + + System.out.println("...kA=" + hex(kA)); + System.out.println("...kB=" + hex(kB)); + + assertNotNull(kA); + assertNotNull(kB); + assertArrayEquals(kA, kB); + } finally { + if (alice != null) { + try { + alice.close(); + } catch (Exception ignore) { + } + } + if (bob != null) { + try { + bob.close(); + } catch (Exception ignore) { + } + } + } + + logEnd(); + } +} diff --git a/lib/src/test/java/zeroecho/sdk/hybrid/signature/HybridSignatureTest.java b/lib/src/test/java/zeroecho/sdk/hybrid/signature/HybridSignatureTest.java new file mode 100644 index 0000000..c5efdc7 --- /dev/null +++ b/lib/src/test/java/zeroecho/sdk/hybrid/signature/HybridSignatureTest.java @@ -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: + *
      + *
    • {@link HybridSignatureProfile.VerifyRule#AND} and + * {@link HybridSignatureProfile.VerifyRule#OR}
    • + *
    • direct streaming use via {@link SignatureContext#wrap(InputStream)}
    • + *
    • integration via {@link TagTrailerDataContentBuilder} and + * {@link DataContentChainBuilder}
    • + *
    + * + *

    + * Tests focus on practical combinations: Ed25519 + SPHINCS+, and + * RSA-PSS(SHA-256) + SPHINCS+ (if registered). + *

    + */ +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 { + 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(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(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(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(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(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(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(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(tagDec).bufferSize(8192).throwOnMismatch()) + .build(); + + byte[] pt = readAll(dec.getStream()); + assertArrayEquals(msg, pt, "hybrid TagTrailer AND (RSA+SPHINCS+) roundtrip mismatch"); + } + + logEnd(); + } +} diff --git a/samples/src/test/java/demo/AgreementVariantsTest.java b/samples/src/test/java/demo/AgreementVariantsTest.java new file mode 100644 index 0000000..096d38a --- /dev/null +++ b/samples/src/test/java/demo/AgreementVariantsTest.java @@ -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. + * + *

    + * This sample illustrates three complementary models used in practice: + *

    + *
      + *
    • KEM_ADAPTER (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.
    • + *
    • CLASSIC_AGREEMENT (example: XDH / X25519): both parties hold their + * own {@link PrivateKey}, set the peer {@link PublicKey} explicitly, and derive + * the same raw shared secret.
    • + *
    • PAIR_MESSAGE (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.
    • + *
    + * + *

    Important note

    + *

    + * All examples below produce a raw agreement secret (the direct output + * of KEM decapsulation or Diffie–Hellman agreement). Real protocols should feed + * the raw secret into a suitable KDF (typically HKDF) together with + * transcript/context info before using it as key material. + *

    + * + *

    Note on resource management

    + *

    + * The examples in this class intentionally do not use the + * {@code try-with-resources} construct when working with + * {@link zeroecho.core.context.AgreementContext} and + * {@link zeroecho.core.context.MessageAgreementContext}. + *

    + * + *

    + * Agreement contexts represent protocol-level state rather than traditional I/O + * resources. In real-world applications their lifecycle often spans multiple + * protocol steps (message send, receive, validation, key derivation) and may + * cross method or thread boundaries. Using explicit {@code try/finally} blocks + * in the examples makes this lifecycle visible and closer to how agreement + * contexts are typically managed in production code. + *

    + * + *

    + * In short-lived, fully synchronous scenarios (such as unit tests), + * {@code try-with-resources} is perfectly acceptable. It is omitted here purely + * for didactic reasons. + *

    + */ +class AgreementVariantsTest { + + private static final Logger LOG = Logger.getLogger(AgreementVariantsTest.class.getName()); + + @BeforeAll + static void setup() { + // Optional: activate BC/BCPQC if present. + // Keeps tests runnable even when providers are missing. + try { + BouncyCastleActivator.init(); + } catch (Throwable ignore) { + // ignore + } + } + + /** + * KEM_ADAPTER example for ML-KEM (Kyber): + * + *

    + * This models a common "send one message, derive shared secret" pattern: + *

    + *
      + *
    • Initiator uses recipient {@link PublicKey} and produces an encapsulation + * message.
    • + *
    • Responder uses recipient {@link PrivateKey}, consumes the message, and + * derives the same secret.
    • + *
    + */ + @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: + * + *

    + * This is the traditional Diffie–Hellman model: both parties generate their own + * key pair, each side keeps a private key, and the peer public key is provided + * out-of-band (protocol message or session state). + *

    + */ + @Test + void classicAgreement_x25519_roundTrip() throws Exception { + LOG.info("classicAgreement_x25519_roundTrip - CLASSIC_AGREEMENT (X25519)"); + + CryptoAlgorithm xdh = CryptoAlgorithms.require("Xdh"); + KeyPair alice = xdh.generateKeyPair(); + KeyPair bob = xdh.generateKeyPair(); + + AgreementContext aCtx = null; + AgreementContext bCtx = null; + + try { + // Both contexts are built from local private keys. + aCtx = CryptoAlgorithms.create("Xdh", KeyUsage.AGREEMENT, alice.getPrivate(), XdhSpec.X25519); + bCtx = CryptoAlgorithms.create("Xdh", KeyUsage.AGREEMENT, bob.getPrivate(), XdhSpec.X25519); + + // The protocol layer provides peer public keys (here we use in-memory + // exchange). + aCtx.setPeerPublic(bob.getPublic()); + bCtx.setPeerPublic(alice.getPublic()); + + byte[] s1 = aCtx.deriveSecret(); + byte[] s2 = bCtx.deriveSecret(); + + LOG.log(Level.INFO, "CLASSIC_AGREEMENT: aliceSecret={0}", Strings.toShortHexString(s1)); + LOG.log(Level.INFO, "CLASSIC_AGREEMENT: bobSecret={0}", Strings.toShortHexString(s2)); + LOG.log(Level.INFO, "CLASSIC_AGREEMENT: secretsEqual={0}", Boolean.valueOf(Arrays.equals(s1, s2))); + } finally { + if (aCtx != null) { + try { + aCtx.close(); + } catch (Exception ignore) { + // ignore + } + } + if (bCtx != null) { + try { + bCtx.close(); + } catch (Exception ignore) { + // ignore + } + } + } + } + + /** + * PAIR_MESSAGE example for XDH/X25519: + * + *

    + * This demonstrates the "message-oriented" handshake for DH-style agreements. + * Each party holds a key pair and the outbound message is simply the local + * public key encoding (SPKI). The receiver imports the encoding and binds it as + * the peer key. + *

    + * + *

    + * This model is particularly practical for protocol implementations because it + * makes the "to-be-sent" artifact explicit (a byte array message), similarly to + * KEM ciphertexts. + *

    + */ + @Test + void pairMessage_x25519_roundTrip() throws Exception { + LOG.info("pairMessage_x25519_roundTrip - PAIR_MESSAGE (X25519)"); + + CryptoAlgorithm xdh = CryptoAlgorithms.require("Xdh"); + KeyPair alice = xdh.generateKeyPair(); + KeyPair bob = xdh.generateKeyPair(); + + // Wrapper is required because ZeroEcho capability dispatch uses Key (KeyPair is + // not a Key). + KeyPairKey aliceKey = new KeyPairKey(alice); + KeyPairKey bobKey = new KeyPairKey(bob); + + MessageAgreementContext aCtx = null; + MessageAgreementContext bCtx = null; + + try { + aCtx = CryptoAlgorithms.create("Xdh", KeyUsage.AGREEMENT, aliceKey, XdhSpec.X25519); + bCtx = CryptoAlgorithms.create("Xdh", KeyUsage.AGREEMENT, bobKey, XdhSpec.X25519); + + // Outbound messages: SPKI encodings of local public keys. + byte[] aMsg = aCtx.getPeerMessage(); + byte[] bMsg = bCtx.getPeerMessage(); + + LOG.log(Level.INFO, "PAIR_MESSAGE: aliceMsg={0}", Strings.toShortHexString(aMsg)); + LOG.log(Level.INFO, "PAIR_MESSAGE: bobMsg={0}", Strings.toShortHexString(bMsg)); + + // Each side imports peer public key from message. + aCtx.setPeerMessage(bMsg); + bCtx.setPeerMessage(aMsg); + + byte[] s1 = aCtx.deriveSecret(); + byte[] s2 = bCtx.deriveSecret(); + + LOG.log(Level.INFO, "PAIR_MESSAGE: aliceSecret={0}", Strings.toShortHexString(s1)); + LOG.log(Level.INFO, "PAIR_MESSAGE: bobSecret={0}", Strings.toShortHexString(s2)); + LOG.log(Level.INFO, "PAIR_MESSAGE: secretsEqual={0}", Boolean.valueOf(Arrays.equals(s1, s2))); + } finally { + if (aCtx != null) { + try { + aCtx.close(); + } catch (Exception ignore) { + // ignore + } + } + if (bCtx != null) { + try { + bCtx.close(); + } catch (Exception ignore) { + // ignore + } + } + } + } +} diff --git a/samples/src/test/java/demo/HybridDerivedAesDemoTest.java b/samples/src/test/java/demo/HybridDerivedAesDemoTest.java new file mode 100644 index 0000000..68f622e --- /dev/null +++ b/samples/src/test/java/demo/HybridDerivedAesDemoTest.java @@ -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. + * + *

    + * This sample is intentionally structured in two variants: + *

    + *
      + *
    • Condensed - compact fluent chains suitable for everyday use.
    • + *
    • Expanded - the same operations, step-by-step, for explanatory + * documentation.
    • + *
    + * + *

    + * The hybrid combination (classic + PQC) happens in {@link HybridKexContext}. + * The derived layer ({@link HybridDerived}) consumes the exporter output (OKM + + * HKDF salt) and injects key/IV/AAD into existing streaming builders. + *

    + */ +class HybridDerivedAesDemoTest { + + private static final Logger LOG = Logger.getLogger(HybridDerivedAesDemoTest.class.getName()); + + @BeforeAll + static void setup() { + // Optional: enable BC if you use BC-only algorithms in the broader test suite. + try { + BouncyCastleActivator.init(); + } catch (Throwable ignore) { + // Keep samples runnable without BC if not present. + } + } + + @Test + void hybridDerived_aes_gcm_condensed() throws Exception { + System.out.println("hybridDerived_aes_gcm_condensed()"); + LOG.info("Hybrid-derived AES-GCM demo (condensed form)"); + + // ...Select a standard hybrid KEX profile (HKDF info/salt + OKM length). + HybridKexProfile profile = HybridKexProfile.defaultProfile(32); + + // ...Prepare plaintext. + byte[] msg = randomBytes(1024); + + // ...Prepare transcript (public context bound into HKDF info and derived + // labels). + HybridKexTranscript transcript = new HybridKexTranscript().addUtf8("suite", "X25519+MLKEM768").addUtf8("demo", + "hybrid-derived-aes-gcm-condensed"); + + // ...Generate classic key pairs for X25519 (Xdh + XdhSpec.X25519). + KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + + // ...Generate PQC key pair for ML-KEM-768 (recipient; used by Bob side to + // decapsulate). + KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768()); + + // ...Build Alice initiator: classic agreement (out-of-band peer pub) + PQC + // encapsulation. + HybridKexContext alice = HybridKexBuilder.builder() + // ...Set mandatory profile. + .profile(profile) + // ...Bind builder HKDF info to transcript. + .transcript(transcript) + // ...Select classic mode: peer public key is out-of-band. + .classicAgreement() + // ...Select classic algorithm id (Xdh). + .algorithm("Xdh") + // ...Select classic spec (X25519). + .spec(XdhSpec.X25519) + // ...Set Alice classic private key. + .privateKey(aliceClassic.getPrivate()) + // ...Set Bob classic public key. + .peerPublic(bobClassic.getPublic()) + // ...Switch to PQC KEM configuration. + .pqcKem() + // ...Select PQC algorithm id (ML-KEM). + .algorithm("ML-KEM") + // ...Set recipient PQC public key for encapsulation. + .peerPublic(bobPqc.getPublic()) + // ...Build initiator context. + .buildInitiator(); + + // ...Build Bob responder: classic agreement + PQC decapsulation. + HybridKexContext bob = HybridKexBuilder.builder() + // ...Set mandatory profile. + .profile(profile) + // ...Bind builder HKDF info to transcript. + .transcript(transcript) + // ...Select classic mode: peer public key is out-of-band. + .classicAgreement() + // ...Select classic algorithm id (Xdh). + .algorithm("Xdh") + // ...Select classic spec (X25519). + .spec(XdhSpec.X25519) + // ...Set Bob classic private key. + .privateKey(bobClassic.getPrivate()) + // ...Set Alice classic public key. + .peerPublic(aliceClassic.getPublic()) + // ...Switch to PQC KEM configuration. + .pqcKem() + // ...Select PQC algorithm id (ML-KEM). + .algorithm("ML-KEM") + // ...Set recipient PQC private key for decapsulation. + .privateKey(bobPqc.getPrivate()) + // ...Build responder context. + .buildResponder(); + + try { + // ...Alice produces peer message (PQC ciphertext; classic is out-of-band in + // this mode). + byte[] peerMsg = alice.getPeerMessage(); + System.out.println("...peerMsg " + lens(peerMsg) + " " + shortHex(peerMsg, 48)); + + // ...Bob consumes the peer message to complete the PQC leg. + bob.setPeerMessage(peerMsg); + + // ...Derive OKM on both sides (must match for a valid hybrid exchange). + byte[] okmA = alice.deriveSecret(); + byte[] okmB = bob.deriveSecret(); + System.out.println("...okmEqual " + Arrays.equals(okmA, okmB)); + if (!Arrays.equals(okmA, okmB)) { + throw new IllegalStateException("Hybrid KEX mismatch"); + } + + // ...Create exporter directly from OKM and profile salt (avoid exporterFromOkm + // validation requirements). + HybridKexExporter exporter = new HybridKexExporter(okmA, profile.hkdfSalt()); + + // ...Choose explicit AAD (public) for AEAD; must match on decrypt. + byte[] aad = "aad:demo".getBytes(StandardCharsets.UTF_8); + + // ...Encrypt: build pipeline in compact form with inline derived injection. + DataContent enc = DataContentChainBuilder.encrypt() + // ...Input: plaintext bytes. + .add(PlainBytesBuilder.builder().bytes(msg)) + // ...AEAD: derive key/IV/AAD and inject into AES-GCM builder. + .add(HybridDerived.from(exporter) + // ...Purpose separation label for AEAD encryption. + .label("app/enc/aes-gcm") + // ...Bind derivation to transcript bytes (public). + .transcript(transcript.toByteArray()) + // ...Inject explicit AAD. + .aad(aad) + // ...Apply derived key(256b) and IV(12B) to AES-GCM with header. + .applyToAesGcm(AesDataContentBuilder.builder() + // ...Store IV in header for decrypt side. + .withHeader() + // ...Use AES-GCM with 128-bit authentication tag. + .modeGcm(128), 256, 12)) + // ...Finalize pipeline. + .build(); + + byte[] ciphertext; + try (InputStream in = enc.getStream()) { + ciphertext = readAll(in); + } + System.out.println("...ciphertext " + lens(ciphertext) + " " + shortHex(ciphertext, 48)); + + // ...Decrypt: rebuild the same derived inputs and run decrypt pipeline. + DataContent dec = DataContentChainBuilder.decrypt() + // ...Input: ciphertext bytes. + .add(PlainBytesBuilder.builder().bytes(ciphertext)) + // ...AEAD: apply the same label/transcript/AAD to get identical key/IV. + .add(HybridDerived.from(exporter) + // ...Same purpose label as encryption. + .label("app/enc/aes-gcm") + // ...Same transcript binding as encryption. + .transcript(transcript.toByteArray()) + // ...Same explicit AAD as encryption. + .aad(aad) + // ...Apply derived key and IV to AES-GCM with header. + .applyToAesGcm(AesDataContentBuilder.builder() + // ...Parse IV from header. + .withHeader() + // ...Use AES-GCM with 128-bit authentication tag. + .modeGcm(128), 256, 12)) + // ...Finalize pipeline. + .build(); + + byte[] out; + try (InputStream in = dec.getStream()) { + out = readAll(in); + } + System.out.println("...plaintextEqual " + Arrays.equals(msg, out)); + if (!Arrays.equals(msg, out)) { + throw new IllegalStateException("Roundtrip mismatch"); + } + + System.out.println("hybridDerived_aes_gcm_condensed...ok"); + } finally { + closeQuiet(alice); + closeQuiet(bob); + } + } + + @Test + void hybridDerived_aes_gcm_expanded() throws Exception { + System.out.println("hybridDerived_aes_gcm_expanded()"); + LOG.info("Hybrid-derived AES-GCM demo (expanded form)"); + + // ...Select a standard hybrid KEX profile (HKDF info/salt + OKM length). + HybridKexProfile profile = HybridKexProfile.defaultProfile(32); + + // ...Prepare plaintext. + byte[] msg = randomBytes(1024); + + // ...Prepare transcript (public context bound into HKDF info and derived + // labels). + HybridKexTranscript transcript = new HybridKexTranscript().addUtf8("suite", "X25519+MLKEM768").addUtf8("demo", + "hybrid-derived-aes-gcm-expanded"); + + // ...Generate classic key pairs for X25519. + KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + + // ...Generate PQC key pair for ML-KEM-768. + KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768()); + + // ...Build Alice initiator in a step-by-step manner. + HybridKexBuilder aliceBuilder = HybridKexBuilder.builder(); + // ...Set mandatory profile. + aliceBuilder.profile(profile); + // ...Bind builder HKDF info to transcript. + aliceBuilder.transcript(transcript); + + // ...Select classic mode: peer public key is out-of-band. + HybridKexBuilder.ClassicAgreement aliceClassicCfg = aliceBuilder.classicAgreement(); + // ...Select classic algorithm id (Xdh). + aliceClassicCfg.algorithm("Xdh"); + // ...Select classic spec (X25519). + aliceClassicCfg.spec(XdhSpec.X25519); + // ...Set Alice classic private key. + aliceClassicCfg.privateKey(aliceClassic.getPrivate()); + // ...Set Bob classic public key. + aliceClassicCfg.peerPublic(bobClassic.getPublic()); + + // ...Switch to PQC KEM configuration. + HybridKexBuilder.PqcKem alicePqcCfg = aliceClassicCfg.pqcKem(); + // ...Select PQC algorithm id (ML-KEM). + alicePqcCfg.algorithm("ML-KEM"); + // ...Set recipient PQC public key for encapsulation. + alicePqcCfg.peerPublic(bobPqc.getPublic()); + + // ...Build initiator context. + HybridKexContext alice = alicePqcCfg.buildInitiator(); + + // ...Build Bob responder in a step-by-step manner. + HybridKexBuilder bobBuilder = HybridKexBuilder.builder(); + // ...Set mandatory profile. + bobBuilder.profile(profile); + // ...Bind builder HKDF info to transcript. + bobBuilder.transcript(transcript); + + // ...Select classic mode: peer public key is out-of-band. + HybridKexBuilder.ClassicAgreement bobClassicCfg = bobBuilder.classicAgreement(); + // ...Select classic algorithm id (Xdh). + bobClassicCfg.algorithm("Xdh"); + // ...Select classic spec (X25519). + bobClassicCfg.spec(XdhSpec.X25519); + // ...Set Bob classic private key. + bobClassicCfg.privateKey(bobClassic.getPrivate()); + // ...Set Alice classic public key. + bobClassicCfg.peerPublic(aliceClassic.getPublic()); + + // ...Switch to PQC KEM configuration. + HybridKexBuilder.PqcKem bobPqcCfg = bobClassicCfg.pqcKem(); + // ...Select PQC algorithm id (ML-KEM). + bobPqcCfg.algorithm("ML-KEM"); + // ...Set recipient PQC private key for decapsulation. + bobPqcCfg.privateKey(bobPqc.getPrivate()); + + // ...Build responder context. + HybridKexContext bob = bobPqcCfg.buildResponder(); + + try { + // ...Alice produces peer message (PQC ciphertext in this classic mode). + byte[] peerMsg = alice.getPeerMessage(); + System.out.println("...peerMsg " + lens(peerMsg) + " " + shortHex(peerMsg, 48)); + + // ...Bob consumes peer message to complete the PQC leg. + bob.setPeerMessage(peerMsg); + + // ...Derive OKM and ensure both sides match. + byte[] okmA = alice.deriveSecret(); + byte[] okmB = bob.deriveSecret(); + System.out.println("...okmEqual " + Arrays.equals(okmA, okmB)); + if (!Arrays.equals(okmA, okmB)) { + throw new IllegalStateException("Hybrid KEX mismatch"); + } + + // ...Create exporter directly from OKM and profile salt. + HybridKexExporter exporter = new HybridKexExporter(okmA, profile.hkdfSalt()); + + // ...Choose explicit AAD (public) for AEAD. + byte[] aad = "aad:demo:expanded".getBytes(StandardCharsets.UTF_8); + + // ...Prepare AES builder for encryption. + AesDataContentBuilder aesEnc = AesDataContentBuilder.builder(); + // ...Store IV in header. + aesEnc.withHeader(); + // ...Use AES-GCM with 128-bit authentication tag. + aesEnc.modeGcm(128); + + // ...Inject derived key/IV/AAD into AES builder. + HybridDerived.from(exporter) + // ...Purpose separation label for AEAD. + .label("app/enc/aes-gcm") + // ...Bind derivation to transcript bytes. + .transcript(transcript.toByteArray()) + // ...Inject explicit AAD. + .aad(aad) + // ...Apply derived key(256b) and IV(12B). + .applyToAesGcm(aesEnc, 256, 12); + + // ...Build encryption pipeline. + DataContent enc = DataContentChainBuilder.encrypt() + // ...Input: plaintext bytes. + .add(PlainBytesBuilder.builder().bytes(msg)) + // ...AES encryption stage. + .add(aesEnc) + // ...Finalize. + .build(); + + byte[] ciphertext; + try (InputStream in = enc.getStream()) { + ciphertext = readAll(in); + } + System.out.println("...ciphertext " + lens(ciphertext) + " " + shortHex(ciphertext, 48)); + + // ...Prepare AES builder for decryption. + AesDataContentBuilder aesDec = AesDataContentBuilder.builder(); + // ...Parse IV from header. + aesDec.withHeader(); + // ...Use AES-GCM with 128-bit authentication tag. + aesDec.modeGcm(128); + + // ...Inject the same derived key/IV/AAD into decryption builder. + HybridDerived.from(exporter) + // ...Same purpose label. + .label("app/enc/aes-gcm") + // ...Same transcript binding. + .transcript(transcript.toByteArray()) + // ...Same explicit AAD. + .aad(aad) + // ...Apply the same derived key and IV. + .applyToAesGcm(aesDec, 256, 12); + + // ...Build decryption pipeline. + DataContent dec = DataContentChainBuilder.decrypt() + // ...Input: ciphertext bytes. + .add(PlainBytesBuilder.builder().bytes(ciphertext)) + // ...AES decryption stage. + .add(aesDec) + // ...Finalize. + .build(); + + byte[] out; + try (InputStream in = dec.getStream()) { + out = readAll(in); + } + System.out.println("...plaintextEqual " + Arrays.equals(msg, out)); + if (!Arrays.equals(msg, out)) { + throw new IllegalStateException("Roundtrip mismatch"); + } + + System.out.println("hybridDerived_aes_gcm_expanded...ok"); + } finally { + closeQuiet(alice); + closeQuiet(bob); + } + } + + @Test + void hybridDerived_aes_gcm_local_self_recipient() throws Exception { + System.out.println("hybridDerived_aes_gcm_local_self_recipient()"); + LOG.info("Hybrid-derived AES-GCM demo (local self-recipient)"); + + // ...Select a standard hybrid KEX profile (HKDF info/salt + OKM length). + HybridKexProfile profile = HybridKexProfile.defaultProfile(32); + + // ...Prepare plaintext. + byte[] msg = randomBytes(1024); + + // ...Prepare transcript (public context bound into KDF and derived labels). + HybridKexTranscript transcript = new HybridKexTranscript() + // ...Identify the suite used by this envelope. + .addUtf8("suite", "X25519+MLKEM768") + // ...Identify that this is a local/self-recipient envelope. + .addUtf8("mode", "local-self"); + + // ...Choose explicit AAD (public) for AEAD; must match on decrypt. + byte[] aad = "aad:local-self".getBytes(StandardCharsets.UTF_8); + + // ...Generate classic identity keys (X25519). + KeyPair selfClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + + // ...Generate PQC identity keys (ML-KEM-768). + KeyPair selfPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768()); + + // ...Build local initiator (encapsulation) against our own public keys. + HybridKexContext encKex = HybridKexBuilder.builder() + // ...Set mandatory profile. + .profile(profile) + // ...Bind derivation to transcript. + .transcript(transcript) + // ...Select classic mode: peer public key is known out-of-band (here: our own + // public key). + .classicAgreement() + // ...Classic algorithm id (X25519). + .algorithm("Xdh") + // ...Classic spec (X25519). + .spec(XdhSpec.X25519) + // ...Use our private key. + .privateKey(selfClassic.getPrivate()) + // ...Use our public key as the peer public key (self-recipient). + .peerPublic(selfClassic.getPublic()) + // ...Switch to PQC KEM. + .pqcKem() + // ...PQC algorithm id (ML-KEM). + .algorithm("ML-KEM") + // ...Use our PQC public key as the recipient key for encapsulation. + .peerPublic(selfPqc.getPublic()) + // ...Build initiator. + .buildInitiator(); + + // ...Produce the envelope header (peer message); must be stored next to + // ciphertext. + byte[] peerMsg = encKex.getPeerMessage(); + System.out.println("...peerMsg " + lens(peerMsg) + " " + shortHex(peerMsg, 48)); + + // ...Derive OKM for this local envelope. + byte[] okm = encKex.deriveSecret(); + System.out.println("...okm " + shortHex(okm, 48)); + + // ...Create exporter directly from OKM and profile salt. + HybridKexExporter exporter = new HybridKexExporter(okm, profile.hkdfSalt()); + + // ...Encrypt: build pipeline; derived key/IV/AAD are injected into AES-GCM. + DataContent enc = DataContentChainBuilder.encrypt() + // ...Input: plaintext bytes. + .add(PlainBytesBuilder.builder().bytes(msg)) + // ...AEAD: inject derived material into AES-GCM builder. + .add(HybridDerived.from(exporter) + // ...Purpose separation label for AEAD encryption. + .label("app/local/aes-gcm") + // ...Bind derivation to transcript bytes. + .transcript(transcript.toByteArray()) + // ...Inject explicit AAD. + .aad(aad) + // ...Apply derived key(256b) and IV(12B) to AES-GCM with header. + .applyToAesGcm(AesDataContentBuilder.builder() + // ...Store IV in header for decrypt side. + .withHeader() + // ...Use AES-GCM with 128-bit authentication tag. + .modeGcm(128), 256, 12)) + // ...Finalize pipeline. + .build(); + + byte[] ciphertext; + try (InputStream in = enc.getStream()) { + ciphertext = readAll(in); + } + System.out.println("...ciphertext " + lens(ciphertext) + " " + shortHex(ciphertext, 48)); + + // ...Build local responder (decapsulation) using our own private keys and + // stored peer message. + HybridKexContext decKex = HybridKexBuilder.builder() + // ...Set mandatory profile. + .profile(profile) + // ...Bind derivation to transcript. + .transcript(transcript) + // ...Select classic mode: peer public key is known out-of-band (here: our own + // public key). + .classicAgreement() + // ...Classic algorithm id (X25519). + .algorithm("Xdh") + // ...Classic spec (X25519). + .spec(XdhSpec.X25519) + // ...Use our private key. + .privateKey(selfClassic.getPrivate()) + // ...Use our public key as the peer public key (self-recipient). + .peerPublic(selfClassic.getPublic()) + // ...Switch to PQC KEM. + .pqcKem() + // ...PQC algorithm id (ML-KEM). + .algorithm("ML-KEM") + // ...Use our PQC private key for decapsulation. + .privateKey(selfPqc.getPrivate()) + // ...Build responder. + .buildResponder(); + + try { + // ...Provide the stored peer message (envelope header) to complete + // decapsulation. + decKex.setPeerMessage(peerMsg); + + // ...Derive the same OKM and create the exporter. + byte[] okmDec = decKex.deriveSecret(); + System.out.println("...okmEqual " + Arrays.equals(okm, okmDec)); + if (!Arrays.equals(okm, okmDec)) { + throw new IllegalStateException("Local hybrid envelope mismatch"); + } + + HybridKexExporter exporterDec = new HybridKexExporter(okmDec, profile.hkdfSalt()); + + // ...Decrypt: rebuild the same derived inputs and run decrypt pipeline. + DataContent dec = DataContentChainBuilder.decrypt() + // ...Input: ciphertext bytes. + .add(PlainBytesBuilder.builder().bytes(ciphertext)) + // ...AEAD: apply the same label/transcript/AAD to get identical key/IV. + .add(HybridDerived.from(exporterDec) + // ...Same purpose label as encryption. + .label("app/local/aes-gcm") + // ...Same transcript binding. + .transcript(transcript.toByteArray()) + // ...Same explicit AAD. + .aad(aad) + // ...Apply derived key and IV to AES-GCM with header. + .applyToAesGcm(AesDataContentBuilder.builder() + // ...Parse IV from header. + .withHeader() + // ...Use AES-GCM with 128-bit authentication tag. + .modeGcm(128), 256, 12)) + // ...Finalize pipeline. + .build(); + + byte[] out; + try (InputStream in = dec.getStream()) { + out = readAll(in); + } + + System.out.println("...plaintextEqual " + Arrays.equals(msg, out)); + if (!Arrays.equals(msg, out)) { + throw new IllegalStateException("Roundtrip mismatch"); + } + + System.out.println("hybridDerived_aes_gcm_local_self_recipient...ok"); + } finally { + closeQuiet(encKex); + closeQuiet(decKex); + } + } + + // helpers + + private static byte[] randomBytes(int len) { + byte[] data = new byte[len]; + new SecureRandom().nextBytes(data); + return data; + } + + private static byte[] readAll(InputStream in) throws Exception { + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + in.transferTo(out); + out.flush(); + return out.toByteArray(); + } + } + + private static String lens(byte[] b) { + if (b == null) { + return "len=null"; + } + return "len=" + b.length; + } + + private static String shortHex(byte[] data, int maxBytes) { + if (data == null) { + return "null"; + } + int n = Math.min(data.length, Math.max(0, maxBytes)); + StringBuilder sb = new StringBuilder(n * 2 + 3); + for (int i = 0; i < n; i++) { + int v = data[i] & 0xFF; + sb.append(Character.forDigit((v >>> 4) & 0x0F, 16)); + sb.append(Character.forDigit(v & 0x0F, 16)); + } + if (data.length > n) { + sb.append("..."); + } + return sb.toString(); + } + + private static void closeQuiet(HybridKexContext ctx) { + if (ctx == null) { + return; + } + try { + ctx.close(); + } catch (Exception ignore) { + // ignore + } + } +} diff --git a/samples/src/test/java/demo/HybridKexDemoTest.java b/samples/src/test/java/demo/HybridKexDemoTest.java new file mode 100644 index 0000000..19f1269 --- /dev/null +++ b/samples/src/test/java/demo/HybridKexDemoTest.java @@ -0,0 +1,536 @@ +/******************************************************************************* + * Copyright (C) 2025, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package demo; + +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.security.KeyPair; +import java.util.Arrays; +import java.util.logging.Logger; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import zeroecho.core.CryptoAlgorithms; +import zeroecho.core.alg.common.agreement.KeyPairKey; +import zeroecho.core.alg.kyber.KyberKeyGenSpec; +import zeroecho.core.alg.xdh.XdhSpec; +import zeroecho.sdk.hybrid.kex.HybridKexContext; +import zeroecho.sdk.hybrid.kex.HybridKexContexts; +import zeroecho.sdk.hybrid.kex.HybridKexProfile; +import zeroecho.sdk.util.BouncyCastleActivator; + +/** + * Demonstration of hybrid key exchange (KEX) usage in ZeroEcho. + * + *

    + * Hybrid KEX in this project means: + *

    + *
      + *
    • A classic agreement leg (DH/ECDH/XDH), and
    • + *
    • A post-quantum leg implemented as a message-based agreement (KEM + * adapter, e.g. ML-KEM).
    • + *
    + * + *

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

    + * + *

    Available hybrid variants

    + *

    + * The classic leg can be wired in two ways: + *

    + *
      + *
    • CLASSIC_AGREEMENT + KEM_ADAPTER (most common in practice): + *
        + *
      • Classic peer public key is supplied out-of-band + * (certificate/directory/session state).
      • + *
      • The hybrid message carries only the PQC payload (KEM ciphertext).
      • + *
      + *
    • + *
    • PAIR_MESSAGE + KEM_ADAPTER (fully message-oriented hybrid): + *
        + *
      • Classic public keys are carried explicitly as messages (SPKI + * encodings).
      • + *
      • The hybrid message carries both: classic public-key message and PQC + * ciphertext.
      • + *
      • Responder may reply with a classic message only (PQC part empty), + * depending on PQC role.
      • + *
      + *
    • + *
    + * + *

    Note on resource management

    + *

    + * These examples intentionally avoid {@code try-with-resources}. Agreement + * contexts represent protocol-level state rather than traditional I/O + * resources; in real protocols their lifecycle often spans multiple + * send/receive steps and does not naturally fit a single lexical scope. Using + * explicit close blocks keeps the handshake lifecycle visible to the reader. + *

    + */ +class HybridKexDemoTest { + + private static final Logger LOG = Logger.getLogger(HybridKexDemoTest.class.getName()); + + @BeforeAll + static void setup() { + // Optional: enable BC/BCPQC if present. + try { + BouncyCastleActivator.init(); + } catch (Throwable ignore) { + // keep runnable without BC if not present + } + } + + @Test + void x25519_classicAgreement_plus_mlKem768_kemAdapter() throws Exception { + logBegin("CLASSIC_AGREEMENT + KEM_ADAPTER", "Xdh/X25519 + ML-KEM-768", "HKDF-SHA256", "OKM=32B"); + + // Classic leg keys (Xdh + XdhSpec.X25519). + KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + + // PQC leg: Bob is the KEM recipient (has ML-KEM keypair). + KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768()); + + // Hybrid profile: default HKDF label, 32-byte output suitable for symmetric + // keys. + HybridKexProfile profile = HybridKexProfile.defaultProfile(32); + + HybridKexContext alice = null; + HybridKexContext bob = null; + + try { + // Alice (initiator): classic uses Alice private + Bob classic public + // (out-of-band). + // ...PQC uses Bob PQC public and will produce a KEM ciphertext. + alice = HybridKexContexts.initiator(profile, "Xdh", aliceClassic.getPrivate(), bobClassic.getPublic(), + XdhSpec.X25519, "ML-KEM", bobPqc.getPublic(), null); + + // Bob (responder): classic uses Bob private + Alice classic public + // (out-of-band). + // ...PQC uses Bob PQC private and will consume Alice's ciphertext. + bob = HybridKexContexts.responder(profile, "Xdh", bobClassic.getPrivate(), aliceClassic.getPublic(), + XdhSpec.X25519, "ML-KEM", bobPqc.getPrivate(), null); + + // Alice -> Bob: hybrid message carries PQC ciphertext; classic part is empty. + byte[] msgA = alice.getPeerMessage(); + System.out.println("...msgA " + lens(msgA) + " " + shortHex(msgA)); + + bob.setPeerMessage(msgA); + + // Both derive the final hybrid OKM (HKDF output). + byte[] okmA = alice.deriveSecret(); + byte[] okmB = bob.deriveSecret(); + + System.out.println("...okmA " + shortHex(okmA)); + System.out.println("...okmB " + shortHex(okmB)); + System.out.println("...equal " + Arrays.equals(okmA, okmB)); + + // Application would now feed OKM into symmetric key schedule / AEAD keys / etc. + } finally { + closeQuiet(alice); + closeQuiet(bob); + } + + logEnd(); + } + + @Test + void x25519_pairMessage_plus_mlKem768_kemAdapter() throws Exception { + logBegin("PAIR_MESSAGE + KEM_ADAPTER", "Xdh/X25519 + ML-KEM-768", "HKDF-SHA256", "OKM=32B"); + + // Classic leg keys (Xdh + XdhSpec.X25519). + KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + + // PQC leg: Bob is the KEM recipient (has ML-KEM keypair). + KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768()); + + HybridKexProfile profile = HybridKexProfile.defaultProfile(32); + + HybridKexContext alice = null; + HybridKexContext bob = null; + + try { + // Alice classic leg is message-based (PAIR_MESSAGE): it will emit her public + // key as SPKI bytes. + // ...PQC leg (KEM initiator) will emit ciphertext. + alice = HybridKexContexts.initiatorPairMessage(profile, "Xdh", new KeyPairKey(aliceClassic), XdhSpec.X25519, + "ML-KEM", bobPqc.getPublic(), null); + + // Bob classic leg is message-based (PAIR_MESSAGE): it will emit his public key + // as SPKI bytes. + // ...PQC leg (KEM responder) consumes ciphertext and typically does not emit a + // PQC message. + bob = HybridKexContexts.responderPairMessage(profile, "Xdh", new KeyPairKey(bobClassic), XdhSpec.X25519, + "ML-KEM", bobPqc.getPrivate(), null); + + // Alice -> Bob: hybrid message carries classic SPKI + PQC ciphertext. + byte[] msgA = alice.getPeerMessage(); + System.out.println("...msgA " + lens(msgA) + " " + shortHex(msgA)); + bob.setPeerMessage(msgA); + + // Bob -> Alice: hybrid message carries classic SPKI; PQC part may be empty + // (depends on PQC role). + byte[] msgB = bob.getPeerMessage(); + System.out.println("...msgB " + lens(msgB) + " " + shortHex(msgB)); + alice.setPeerMessage(msgB); + + // Both derive the final hybrid OKM (HKDF output). + byte[] okmA = alice.deriveSecret(); + byte[] okmB = bob.deriveSecret(); + + System.out.println("...okmA " + shortHex(okmA)); + System.out.println("...okmB " + shortHex(okmB)); + System.out.println("...equal " + Arrays.equals(okmA, okmB)); + } finally { + closeQuiet(alice); + closeQuiet(bob); + } + + logEnd(); + } + + @Test + void builder_x25519_classicAgreement_plus_mlKem768() throws Exception { + logBegin("Builder", "CLASSIC_AGREEMENT + KEM_ADAPTER", "Xdh/X25519 + ML-KEM-768", "HKDF-SHA256", "OKM=32B"); + + // ...Generate classic leg keys (Xdh + XdhSpec.X25519). + java.security.KeyPair aliceClassic = zeroecho.core.CryptoAlgorithms.generateKeyPair("Xdh", + zeroecho.core.alg.xdh.XdhSpec.X25519); + java.security.KeyPair bobClassic = zeroecho.core.CryptoAlgorithms.generateKeyPair("Xdh", + zeroecho.core.alg.xdh.XdhSpec.X25519); + + // ...Generate PQC (recipient) keys (ML-KEM-768). + java.security.KeyPair bobPqc = zeroecho.core.CryptoAlgorithms.generateKeyPair("ML-KEM", + zeroecho.core.alg.kyber.KyberKeyGenSpec.kyber768()); + + // ...Create a profile for HKDF (output length 32 bytes). + zeroecho.sdk.hybrid.kex.HybridKexProfile profile = zeroecho.sdk.hybrid.kex.HybridKexProfile.defaultProfile(32); + + // ...Build a transcript to bind HKDF info to public handshake context. + zeroecho.sdk.hybrid.kex.HybridKexTranscript transcript = new zeroecho.sdk.hybrid.kex.HybridKexTranscript() + .addUtf8("suite", "X25519+MLKEM768").addUtf8("role", "demo"); + + // ...Define a minimum-strength policy (example: classic >= 128, PQC >= 192, OKM + // >= 32 bytes). + zeroecho.sdk.hybrid.kex.HybridKexPolicy policy = new zeroecho.sdk.hybrid.kex.HybridKexPolicy(128, 192, 32); + + // ...Start the builder. + zeroecho.sdk.builders.HybridKexBuilder b = zeroecho.sdk.builders.HybridKexBuilder.builder() + // ...Set HKDF profile (salt/info/outLen). + .profile(profile) + // ...Bind HKDF info to transcript (protocol context). + .transcript(transcript) + // ...Enable hybrid policy gating for this build. + .policy(policy); + + // ...Select classic mode where peer public key is known out-of-band. + zeroecho.sdk.builders.HybridKexBuilder.ClassicAgreement classicCfg = b.classicAgreement() + // ...Set classic algorithm id (X25519 is represented as "Xdh" with + // XdhSpec.X25519). + .algorithm("Xdh") + // ...Set classic parameters (X25519 curve spec). + .spec(zeroecho.core.alg.xdh.XdhSpec.X25519) + // ...Set local classic private key (Alice). + .privateKey(aliceClassic.getPrivate()) + // ...Set peer classic public key (Bob). + .peerPublic(bobClassic.getPublic()); + + // ...Continue with PQC KEM adapter configuration for initiator role. + zeroecho.sdk.hybrid.kex.HybridKexContext alice = classicCfg.pqcKem() + // ...Set PQC algorithm id (ML-KEM). + .algorithm("ML-KEM") + // ...Set PQC recipient public key (Bob). + .peerPublic(bobPqc.getPublic()) + // ...Build initiator-side hybrid context. + .buildInitiator(); + + // ...Build responder-side context (Bob) with symmetric configuration (note: PQC + // uses private key). + zeroecho.sdk.hybrid.kex.HybridKexContext bob = zeroecho.sdk.builders.HybridKexBuilder.builder() + // ...Set the same HKDF profile to derive the same OKM. + .profile(profile) + // ...Bind the same transcript to ensure both sides derive the same OKM. + .transcript(transcript) + // ...Enable the same policy gate. + .policy(policy) + // ...Select classic agreement mode (peer public is out-of-band). + .classicAgreement() + // ...Set classic algorithm id. + .algorithm("Xdh") + // ...Set classic parameters. + .spec(zeroecho.core.alg.xdh.XdhSpec.X25519) + // ...Set local classic private key (Bob). + .privateKey(bobClassic.getPrivate()) + // ...Set peer classic public key (Alice). + .peerPublic(aliceClassic.getPublic()) + // ...Continue to PQC configuration. + .pqcKem() + // ...Set PQC algorithm id. + .algorithm("ML-KEM") + // ...Set PQC recipient private key (Bob). + .privateKey(bobPqc.getPrivate()) + // ...Build responder-side hybrid context. + .buildResponder(); + + try { + // ...Alice produces the hybrid message (PQC ciphertext; classic part is empty + // in this mode). + byte[] msgA = alice.getPeerMessage(); + System.out.println("...msgA " + lens(msgA) + " " + shortHex(msgA)); + + // ...Bob consumes Alice message. + bob.setPeerMessage(msgA); + + // ...Both sides derive identical OKM. + byte[] okmA = alice.deriveSecret(); + byte[] okmB = bob.deriveSecret(); + + System.out.println("...okmA " + shortHex(okmA)); + System.out.println("...okmB " + shortHex(okmB)); + System.out.println("...equal " + java.util.Arrays.equals(okmA, okmB)); + + // ...Use exporter to derive purpose-specific keys bound to transcript. + zeroecho.sdk.hybrid.kex.HybridKexExporter exporterA = b.exporterFromOkm(okmA); + byte[] txA = exporterA.export("app/tx", transcript.toByteArray(), 32); + byte[] rxA = exporterA.export("app/rx", transcript.toByteArray(), 32); + + System.out.println("...txA " + shortHex(txA)); + System.out.println("...rxA " + shortHex(rxA)); + } finally { + closeQuiet(alice); + closeQuiet(bob); + } + + logEnd(); + } + + @Test + void builder_x25519_pairMessage_plus_mlKem768() throws Exception { + logBegin("Builder", "PAIR_MESSAGE + KEM_ADAPTER", "Xdh/X25519 + ML-KEM-768", "HKDF-SHA256", "OKM=32B"); + + // ...Generate classic leg keys (Xdh + XdhSpec.X25519). + KeyPair aliceClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + KeyPair bobClassic = CryptoAlgorithms.generateKeyPair("Xdh", XdhSpec.X25519); + + // ...Generate PQC (recipient) keys (ML-KEM-768). + KeyPair bobPqc = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber768()); + + // ...Create a profile for HKDF (output length 32 bytes). + HybridKexProfile profile = HybridKexProfile.defaultProfile(32); + + // ...Build a transcript to bind HKDF info to public handshake context. + // ...(Use identical transcript on both sides to derive the same OKM.) + zeroecho.sdk.hybrid.kex.HybridKexTranscript transcript = new zeroecho.sdk.hybrid.kex.HybridKexTranscript() + .addUtf8("suite", "X25519+MLKEM768").addUtf8("mode", "PAIR_MESSAGE").addUtf8("role", "demo"); + + // ...Define a minimum-strength policy (example: classic >= 128, PQC >= 192, OKM + // >= 32 bytes). + zeroecho.sdk.hybrid.kex.HybridKexPolicy policy = new zeroecho.sdk.hybrid.kex.HybridKexPolicy(128, 192, 32); + + // ...Start the initiator builder. + zeroecho.sdk.builders.HybridKexBuilder initBuilder = zeroecho.sdk.builders.HybridKexBuilder.builder() + // ...Set HKDF profile (salt/info/outLen). + .profile(profile) + // ...Bind HKDF info to transcript (protocol context). + .transcript(transcript) + // ...Enable hybrid policy gating for this build. + .policy(policy); + + // ...Select classic mode where the public key is carried in-band as a classic + // message (PAIR_MESSAGE). + zeroecho.sdk.hybrid.kex.HybridKexContext alice = initBuilder + // ...Switch classic leg to PAIR_MESSAGE mode. + .classicPairMessage() + // ...Set classic algorithm id (X25519 is represented as "Xdh" with + // XdhSpec.X25519). + .algorithm("Xdh") + // ...Set classic parameters (X25519 curve spec). + .spec(XdhSpec.X25519) + // ...Set local classic key pair wrapper (Alice). + .keyPair(new KeyPairKey(aliceClassic)) + // ...Continue with PQC KEM adapter configuration. + .pqcKem() + // ...Set PQC algorithm id (ML-KEM). + .algorithm("ML-KEM") + // ...Set PQC recipient public key (Bob). + .peerPublic(bobPqc.getPublic()) + // ...Build initiator-side hybrid context. + .buildInitiator(); + + // ...Start the responder builder. + zeroecho.sdk.builders.HybridKexBuilder respBuilder = zeroecho.sdk.builders.HybridKexBuilder.builder() + // ...Set the same HKDF profile to derive the same OKM. + .profile(profile) + // ...Bind the same transcript to ensure both sides derive the same OKM. + .transcript(transcript) + // ...Enable the same policy gate. + .policy(policy); + + // ...Build responder-side context (Bob). + zeroecho.sdk.hybrid.kex.HybridKexContext bob = respBuilder + // ...Switch classic leg to PAIR_MESSAGE mode. + .classicPairMessage() + // ...Set classic algorithm id. + .algorithm("Xdh") + // ...Set classic parameters. + .spec(XdhSpec.X25519) + // ...Set local classic key pair wrapper (Bob). + .keyPair(new KeyPairKey(bobClassic)) + // ...Continue with PQC KEM adapter configuration. + .pqcKem() + // ...Set PQC algorithm id. + .algorithm("ML-KEM") + // ...Set PQC recipient private key (Bob). + .privateKey(bobPqc.getPrivate()) + // ...Build responder-side hybrid context. + .buildResponder(); + + try { + // ...Alice produces the hybrid message: classic SPKI + PQC ciphertext. + byte[] msgA = alice.getPeerMessage(); + System.out.println("...msgA " + lens(msgA) + " " + shortHex(msgA)); + + // ...Bob consumes Alice message (learns Alice classic public + decapsulates PQC + // ciphertext). + bob.setPeerMessage(msgA); + + // ...Bob produces response message: classic SPKI; PQC part may be empty + // (role-dependent). + byte[] msgB = bob.getPeerMessage(); + System.out.println("...msgB " + lens(msgB) + " " + shortHex(msgB)); + + // ...Alice consumes Bob message (learns Bob classic public). + alice.setPeerMessage(msgB); + + // ...Both sides derive identical OKM. + byte[] okmA = alice.deriveSecret(); + byte[] okmB = bob.deriveSecret(); + + System.out.println("...okmA " + shortHex(okmA)); + System.out.println("...okmB " + shortHex(okmB)); + System.out.println("...equal " + Arrays.equals(okmA, okmB)); + + // ...Use exporter to derive purpose-specific keys bound to transcript. + zeroecho.sdk.hybrid.kex.HybridKexExporter exporterA = initBuilder.exporterFromOkm(okmA); + byte[] txA = exporterA.export("app/tx", transcript.toByteArray(), 32); + byte[] rxA = exporterA.export("app/rx", transcript.toByteArray(), 32); + + System.out.println("...txA " + shortHex(txA)); + System.out.println("...rxA " + shortHex(rxA)); + } finally { + closeQuiet(alice); + closeQuiet(bob); + } + + logEnd(); + } + + // ------------------------------------------------------------------------- + // helpers (JUnit output conventions) + // ------------------------------------------------------------------------- + + private static void logBegin(Object... params) { + String thisClass = HybridKexDemoTest.class.getName(); + String method = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE) + .walk(frames -> frames + .dropWhile(f -> !f.getClassName().equals(thisClass) || f.getMethodName().equals("logBegin")) + .findFirst().map(StackWalker.StackFrame::getMethodName).orElse("")); + System.out.println(method + "(" + Arrays.deepToString(params) + ")"); + } + + private static void logEnd() { + String thisClass = HybridKexDemoTest.class.getName(); + String method = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE) + .walk(frames -> frames + .dropWhile(f -> !f.getClassName().equals(thisClass) || f.getMethodName().equals("logEnd")) + .findFirst().map(StackWalker.StackFrame::getMethodName).orElse("")); + System.out.println(method + "...ok"); + } + + private static void closeQuiet(HybridKexContext ctx) { + if (ctx == null) { + return; + } + try { + ctx.close(); + } catch (Exception e) { + LOG.fine("close failed: " + e.getClass().getName()); + } + } + + private static String shortHex(byte[] b) { + if (b == null) { + return "null"; + } + StringBuilder sb = new StringBuilder(b.length * 2); + for (byte v : b) { + if (sb.length() == 80) { + sb.append("..."); + break; + } + if ((v & 0xFF) < 16) { + sb.append('0'); + } + sb.append(Integer.toHexString(v & 0xFF)); + } + return sb.toString(); + } + + private static String lens(byte[] msg) { + if (msg == null || msg.length < 8) { + return "[classicLen=?, pqcLen=?]"; + } + try { + DataInputStream in = new DataInputStream(new ByteArrayInputStream(msg)); + int classicLen = in.readInt(); + if (classicLen < 0) { + return "[classicLen=?, pqcLen=?]"; + } + if (classicLen > 0) { + in.skipBytes(classicLen); + } + int pqcLen = in.readInt(); + return "[classicLen=" + classicLen + ", pqcLen=" + pqcLen + "]"; + } catch (Exception e) { + return "[classicLen=?, pqcLen=?]"; + } + } +} diff --git a/samples/src/test/java/demo/HybridSigningAesTest.java b/samples/src/test/java/demo/HybridSigningAesTest.java new file mode 100644 index 0000000..f7d0a50 --- /dev/null +++ b/samples/src/test/java/demo/HybridSigningAesTest.java @@ -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. + * + *

    + * This sample shows both canonical compositions: + *

      + *
    • StE: Sign-then-Encrypt
    • + *
    • EtS: Encrypt-then-Sign
    • + *
    + *

    + * + *

    + * Hybrid signature used here (popular practical choice): Ed25519 + SPHINCS+ + * with AND verification. + *

    + */ +class HybridSigningAesTest { + + private static final Logger LOG = Logger.getLogger(HybridSigningAesTest.class.getName()); + + @BeforeAll + static void setup() { + // Optional: enable BC if you use BC-only modes in KEM payloads (EAX/OCB/CCM, + // etc.) + try { + BouncyCastleActivator.init(); + } catch (Throwable ignore) { + // keep tests runnable without BC if not present + } + } + + @Test + void aesRoundStE_withHybridSignature() throws GeneralSecurityException, IOException { + LOG.info("aesRoundStE_withHybridSignature - Sign then Encrypt (Hybrid signature)"); + + // Prepare plaintext + byte[] msg = randomBytes(100); + + // AES-GCM with header, runtime params are stored in header + AesDataContentBuilder aesBuilder = AesDataContentBuilder.builder().generateKey(256).modeGcm(128).withHeader(); + + // Hybrid signature: Ed25519 + SPHINCS+ (AND) + HybridSignatureProfile profile = new HybridSignatureProfile("Ed25519", "SPHINCS+", null, null, + HybridSignatureProfile.VerifyRule.AND); + + KeyPair ed = generateKeyPair("Ed25519"); + KeyPair spx = generateKeyPair("SPHINCS+"); + + SignatureContext tagEnc = HybridSignatureContexts.sign(profile, ed.getPrivate(), spx.getPrivate(), + 2 * 1024 * 1024); + SignatureContext tagDec = HybridSignatureContexts.verify(profile, ed.getPublic(), spx.getPublic(), + 2 * 1024 * 1024); + + // For verification, make mismatch behavior explicit (builder also supports + // throwOnMismatch()). + tagDec.setVerificationApproach(tagDec.getVerificationCore().getThrowOnMismatch()); + + // Build StE pipeline: PLAIN -> SIGN(trailer) -> ENCRYPT + DataContent dccb = DataContentChainBuilder.encrypt() + // plaintext source + .add(PlainBytesBuilder.builder().bytes(msg)) + // hybrid signature trailer + .add(new TagTrailerDataContentBuilder(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(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(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(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(); + } + } +} diff --git a/samples/src/test/java/demo/SigningAesTest.java b/samples/src/test/java/demo/SigningAesTest.java index a289829..bdf880e 100644 --- a/samples/src/test/java/demo/SigningAesTest.java +++ b/samples/src/test/java/demo/SigningAesTest.java @@ -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());