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/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/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/HybridException.java b/lib/src/main/java/zeroecho/sdk/hybrid/HybridException.java
    new file mode 100644
    index 0000000..5c4c346
    --- /dev/null
    +++ b/lib/src/main/java/zeroecho/sdk/hybrid/HybridException.java
    @@ -0,0 +1,63 @@
    +/*******************************************************************************
    + * 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;
    +
    +/**
    + * Base exception type for hybrid framework failures.
    + *
    + * @since 1.0
    + */
    +public class HybridException extends Exception {
    +    private static final long serialVersionUID = -3704484377176409054L;
    +
    +    /**
    +     * Creates a new exception with a message.
    +     *
    +     * @param message error description
    +     */
    +    public HybridException(String message) {
    +        super(message);
    +    }
    +
    +    /**
    +     * Creates a new exception with a message and a cause.
    +     *
    +     * @param message error description
    +     * @param cause   underlying cause
    +     */
    +    public HybridException(String message, Throwable cause) {
    +        super(message, cause);
    +    }
    +}
    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/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/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());