14 Commits

Author SHA1 Message Date
300f40c283 feat: add hybrid-derived key injection
Extend HMAC metadata and builders to expose recommended key sizes and
enable safe derived-key injection without duplicating algorithm
configuration.

Key changes:
- Add HybridDerived utility for expanding hybrid KEX output and
  injecting purpose-separated keys, IVs/nonces and optional AAD into
  existing DataContent builders (AES-GCM, ChaCha, HMAC)
- Improve HmacSpec and HmacDataContentBuilder to expose recommended key
  material characteristics for derived use
- Refine HybridKexContexts to better support exporter-based derived
  workflows
- Add comprehensive unit tests for hybrid-derived functionality
- Add documented demo showing hybrid-derived AES-GCM encryption,
  including local (self-recipient) hybrid usage
- Introduce top-level sdk.hybrid package documentation and derived
  subpackage Javadoc

All changes are additive at the SDK layer; core cryptographic contracts
remain unchanged.

Signed-off-by: Leo Galambos <lg@hq.egothor.org>
2025-12-26 21:00:01 +01:00
55da24735f feat: add hybrid key exchange framework
Introduce a complete SDK-level hybrid KEX framework combining classic
(DH/ECDH/XDH) and post-quantum (KEM adapter) agreement contexts.

Key additions:
- HybridKexContext and HybridKexContexts for hybrid handshake
  orchestration over existing AgreementContext and
  MessageAgreementContext APIs
- HybridKexProfile, HybridKexTranscript and HybridKexExporter providing
  HKDF-based key derivation, transcript binding and key schedule support
- HybridKexPolicy for optional security strength and output-length
  gating
- HybridKexBuilder offering a fluent, professional API for constructing
  CLASSIC_AGREEMENT + KEM_ADAPTER and PAIR_MESSAGE + KEM_ADAPTER
  variants
- Comprehensive JUnit tests and documented demo illustrating both hybrid
  modes

No changes to core cryptographic APIs; all hybrid logic is implemented
as additive functionality in the SDK layer.

Signed-off-by: Leo Galambos <lg@hq.egothor.org>
2025-12-26 17:48:39 +01:00
34eca245f0 feat: add message-oriented agreement contexts for DH, ECDH and XDH
Introduce GenericJcaMessageAgreementContext and KeyPairKey to support
message-based key agreement without breaking existing AgreementContext
capabilities.

Key changes:
- Add KeyPairKey wrapper to carry KeyPair through capability dispatch.
- Introduce GenericJcaMessageAgreementContext implementing
  MessageAgreementContext, mapping the protocol message to an
  SPKI-encoded public key.
- Extend DH, ECDH and XDH algorithms with an additional
  MessageAgreementContext capability while preserving existing
  PrivateKey-based agreement usage.
- Improve core agreement tests to cover CLASSIC_AGREEMENT, PAIR_MESSAGE
  and KEM_ADAPTER variants with explicit branch identification.
- Add demo samples illustrating practical usage patterns for ML-KEM and
  XDH agreement variants, including lifecycle and resource management
  guidance.

This change adds capabilities by extension rather than replacement and
keeps existing APIs and behaviors fully backward compatible.

Signed-off-by: Leo Galambos <lg@hq.egothor.org>
2025-12-26 14:56:47 +01:00
7f79082adc feat: introduce hybrid signature framework and signature trailer builder
Add a complete hybrid signature implementation combining two independent
signature algorithms with AND/OR verification semantics, designed for
streaming pipelines.

Key changes:
- Add zeroecho.sdk.hybrid.signature package with core hybrid signature
  abstractions (HybridSignatureContext, HybridSignatureProfile,
  factories, predicates, and package documentation).
- Introduce SignatureTrailerDataContentBuilder as a
  signature-specialized replacement for
  TagTrailerDataContentBuilder<Signature>, supporting
  core, single-algorithm, and hybrid signature construction.
- Extend sdk.builders package documentation to reference the new
  signature trailer builder and newly added PQC signature builders.
- Adjust TagEngineBuilder where required to support hybrid verification
  integration.
- Update JUL configuration to accommodate hybrid signature diagnostics
  without leaking sensitive material.

Tests and samples:
- Add comprehensive JUnit 5 tests covering hybrid signatures in all
  supported modes, including positive and negative cases.
- Add a dedicated sample demonstrating hybrid signing combined with AES
  encryption (StE and EtS).
- Update existing signing samples to reflect the new signature trailer
  builder.

The changes introduce a unified, extensible hybrid signature model
without breaking existing core APIs or pipeline composition patterns.

Signed-off-by: Leo Galambos <lg@hq.egothor.org>
2025-12-26 02:01:29 +01:00
174d63dff4 feat: add ML-DSA and SLH-DSA streaming builders
chore: update alg package docs

Introduce DataContentBuilder implementations for ML-DSA and SLH-DSA
aligned with the existing SphincsPlus builder, and update the
builders.alg package Javadoc to document the newly supported
post-quantum signature schemes.

Signed-off-by: Leo Galambos <lg@hq.egothor.org>
2025-12-25 19:24:39 +01:00
84b97b4e0a feat: add ML-DSA (FIPS 204) support with policy enforcement
Introduce ML-DSA (FIPS 204) as a first-class signature algorithm:
- algorithm binding and streaming signature context
- key generation specs/builders and key import specs
- correct handling of pure vs pre-hash (SHA-512) ML-DSA JCA variants
- policy security strength mapping (44/65/87 → 128/192/256)
- comprehensive JUnit streaming sign/verify tests

Signed-off-by: Leo Galambos <lg@hq.egothor.org>
2025-12-25 18:36:35 +01:00
2b4559884f fix: add SLH-DSA security strength estimation for policy enforcement
Extend SecurityStrengthAdvisor to recognize SLH-DSA keys and map their
parameter sets (128/192/256) to NIST security strengths.

This enables CryptoPolicy.minStrength(...) to enforce SLH-DSA profiles
consistently with other PQC algorithms.

Signed-off-by: Leo Galambos <lg@hq.egothor.org>
2025-12-25 17:43:00 +01:00
8f228c7ada feat: SLH-DSA (FIPS 205) signature algorithm added
Signed-off-by: Leo Galambos <lg@hq.egothor.org>
2025-12-25 01:54:24 +01:00
4da4547a46 fix: defensively copy secret and encapsulation before destroy()
SecretWithEncapsulation may zeroize internal buffers on destroy().
Create defensive copies of the shared secret and ciphertext using
Arrays.copyOf() before destroying the result object to ensure stable
output.

No cryptographic behavior changes; fixes a potential lifecycle bug.

Signed-off-by: Leo Galambos <lg@hq.egothor.org>
2025-12-24 23:39:32 +01:00
cb363ba2f4 chore: deps upgrade
chore: PMD 8.0.0 obsolete rules replaced

Signed-off-by: Leo Galambos <lg@hq.egothor.org>
2025-12-24 22:33:00 +01:00
0b4b4de603 chore: PMD warnings clean-up
Signed-off-by: Leo Galambos <lg@hq.egothor.org>
2025-12-24 21:41:44 +01:00
eba163dd21 chore: deprecated applied
Signed-off-by: Leo Galambos <lg@hq.egothor.org>
2025-12-07 22:20:08 +01:00
31018235dc chore: javadoc fixes (format)
Signed-off-by: Leo Galambos <lg@hq.egothor.org>
2025-12-07 21:57:50 +01:00
e328a6a103 chore: softprops/action-gh-release wants newline-delimited globs of paths 2025-11-02 14:10:51 +01:00
85 changed files with 12053 additions and 199 deletions

View File

@@ -89,5 +89,7 @@ jobs:
- name: Create Gitea Release - name: Create Gitea Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
files: app/build/distributions/*.tar app/build/distributions/*.zip files: |
app/build/distributions/*.tar
app/build/distributions/*.zip
body_path: /tmp/release_notes.md body_path: /tmp/release_notes.md

View File

@@ -136,7 +136,7 @@
<rule ref="category/java/codestyle.xml/UnnecessaryConstructor"/> <rule ref="category/java/codestyle.xml/UnnecessaryConstructor"/>
<rule ref="category/java/codestyle.xml/UnnecessaryFullyQualifiedName"/> <rule ref="category/java/codestyle.xml/UnnecessaryFullyQualifiedName"/>
<rule ref="category/java/codestyle.xml/UnnecessaryImport"/> <rule ref="category/java/codestyle.xml/UnnecessaryImport"/>
<rule ref="category/java/codestyle.xml/UnnecessaryLocalBeforeReturn"/> <!-- PMD 8.0.0: obsolete rule ref="category/java/codestyle.xml/UnnecessaryLocalBeforeReturn"/ -->
<rule ref="category/java/codestyle.xml/UnnecessaryModifier"/> <rule ref="category/java/codestyle.xml/UnnecessaryModifier"/>
<rule ref="category/java/codestyle.xml/UnnecessaryReturn"/> <rule ref="category/java/codestyle.xml/UnnecessaryReturn"/>
<rule ref="category/java/codestyle.xml/UnnecessarySemicolon"/> <rule ref="category/java/codestyle.xml/UnnecessarySemicolon"/>
@@ -147,7 +147,6 @@
<rule ref="category/java/codestyle.xml/UseShortArrayInitializer"/> <rule ref="category/java/codestyle.xml/UseShortArrayInitializer"/>
<rule ref="category/java/codestyle.xml/UseUnderscoresInNumericLiterals"/> <rule ref="category/java/codestyle.xml/UseUnderscoresInNumericLiterals"/>
<rule ref="category/java/design.xml/AbstractClassWithoutAnyMethod"/> <rule ref="category/java/design.xml/AbstractClassWithoutAnyMethod"/>
<rule ref="category/java/design.xml/AvoidCatchingGenericException"/>
<rule ref="category/java/design.xml/AvoidDeeplyNestedIfStmts"/> <rule ref="category/java/design.xml/AvoidDeeplyNestedIfStmts"/>
<rule ref="category/java/design.xml/AvoidRethrowingException"/> <rule ref="category/java/design.xml/AvoidRethrowingException"/>
<rule ref="category/java/design.xml/AvoidThrowingNewInstanceOfSameException"/> <rule ref="category/java/design.xml/AvoidThrowingNewInstanceOfSameException"/>
@@ -231,8 +230,11 @@
<rule ref="category/java/errorprone.xml/AvoidAssertAsIdentifier"/> <rule ref="category/java/errorprone.xml/AvoidAssertAsIdentifier"/>
<rule ref="category/java/errorprone.xml/AvoidBranchingStatementAsLastInLoop"/> <rule ref="category/java/errorprone.xml/AvoidBranchingStatementAsLastInLoop"/>
<rule ref="category/java/errorprone.xml/AvoidCallingFinalize"/> <rule ref="category/java/errorprone.xml/AvoidCallingFinalize"/>
<rule ref="category/java/errorprone.xml/AvoidCatchingNPE"/> <rule ref="category/java/errorprone.xml/AvoidCatchingGenericException">
<rule ref="category/java/errorprone.xml/AvoidCatchingThrowable"/> <properties>
<property name="typesThatShouldNotBeCaught" value="java.lang.RuntimeException,java.lang.Throwable,java.lang.Error" />
</properties>
</rule>
<rule ref="category/java/errorprone.xml/AvoidDecimalLiteralsInBigDecimalConstructor"/> <rule ref="category/java/errorprone.xml/AvoidDecimalLiteralsInBigDecimalConstructor"/>
<rule ref="category/java/errorprone.xml/AvoidDuplicateLiterals"> <rule ref="category/java/errorprone.xml/AvoidDuplicateLiterals">
<properties> <properties>
@@ -245,7 +247,6 @@
<rule ref="category/java/errorprone.xml/AvoidFieldNameMatchingTypeName"/> <rule ref="category/java/errorprone.xml/AvoidFieldNameMatchingTypeName"/>
<rule ref="category/java/errorprone.xml/AvoidInstanceofChecksInCatchClause"/> <rule ref="category/java/errorprone.xml/AvoidInstanceofChecksInCatchClause"/>
<rule ref="category/java/errorprone.xml/AvoidLiteralsInIfCondition"/> <rule ref="category/java/errorprone.xml/AvoidLiteralsInIfCondition"/>
<rule ref="category/java/errorprone.xml/AvoidLosingExceptionInformation"/>
<rule ref="category/java/errorprone.xml/AvoidMultipleUnaryOperators"/> <rule ref="category/java/errorprone.xml/AvoidMultipleUnaryOperators"/>
<rule ref="category/java/errorprone.xml/AvoidUsingOctalValues"/> <rule ref="category/java/errorprone.xml/AvoidUsingOctalValues"/>
<rule ref="category/java/errorprone.xml/BrokenNullCheck"/> <rule ref="category/java/errorprone.xml/BrokenNullCheck"/>
@@ -313,7 +314,7 @@
<rule ref="category/java/errorprone.xml/UnusedNullCheckInEquals"/> <rule ref="category/java/errorprone.xml/UnusedNullCheckInEquals"/>
<rule ref="category/java/errorprone.xml/UseCorrectExceptionLogging"/> <rule ref="category/java/errorprone.xml/UseCorrectExceptionLogging"/>
<rule ref="category/java/errorprone.xml/UseEqualsToCompareStrings"/> <rule ref="category/java/errorprone.xml/UseEqualsToCompareStrings"/>
<rule ref="category/java/errorprone.xml/UselessOperationOnImmutable"/> <rule ref="category/java/errorprone.xml/UselessPureMethodCall" />
<rule ref="category/java/errorprone.xml/UseLocaleWithCaseConversions"/> <rule ref="category/java/errorprone.xml/UseLocaleWithCaseConversions"/>
<rule ref="category/java/errorprone.xml/UseProperClassLoader"/> <rule ref="category/java/errorprone.xml/UseProperClassLoader"/>
<rule ref="category/java/multithreading.xml/AvoidSynchronizedAtMethodLevel"/> <rule ref="category/java/multithreading.xml/AvoidSynchronizedAtMethodLevel"/>

View File

@@ -30,9 +30,9 @@ repositories {
dependencies { dependencies {
constraints { constraints {
// Define dependency versions as constraints // Define dependency versions as constraints
implementation 'org.apache.commons:commons-text:1.11.0' implementation 'org.apache.commons:commons-text:1.15.0'
implementation 'commons-cli:commons-cli:1.9.0' implementation 'commons-cli:commons-cli:1.11.0'
implementation 'org.bouncycastle:bcpkix-jdk18on:1.81' implementation 'org.bouncycastle:bcpkix-jdk18on:1.83'
implementation 'org.egothor:conflux:[1.0,2.0)' implementation 'org.egothor:conflux:[1.0,2.0)'
implementation 'org.apache.commons:commons-imaging:1.0.0-alpha6' implementation 'org.apache.commons:commons-imaging:1.0.0-alpha6'
} }
@@ -45,7 +45,7 @@ dependencies {
pmd { pmd {
consoleOutput = true consoleOutput = true
toolVersion = '7.16.0' toolVersion = '7.19.0'
sourceSets = [sourceSets.main] sourceSets = [sourceSets.main]
ruleSetFiles = files(rootProject.file(".ruleset")) ruleSetFiles = files(rootProject.file(".ruleset"))
} }

View File

@@ -518,7 +518,7 @@ public final class CryptoAlgorithms {
destroyed = true; destroyed = true;
} }
} }
} catch (Exception ignored) { // NOPMD } catch (Exception ignored) {
// swallow and report via audit only if destroyed // swallow and report via audit only if destroyed
} }
if (destroyed) { if (destroyed) {

View File

@@ -39,6 +39,7 @@ import java.security.Key;
import java.security.PrivateKey; import java.security.PrivateKey;
import java.security.PublicKey; import java.security.PublicKey;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Objects; import java.util.Objects;
import javax.security.auth.DestroyFailedException; import javax.security.auth.DestroyFailedException;
@@ -158,8 +159,8 @@ public final class BikeKemContext implements KemContext {
.createKey(key.getEncoded()); .createKey(key.getEncoded());
BIKEKEMGenerator gen = new BIKEKEMGenerator(new SecureRandom()); BIKEKEMGenerator gen = new BIKEKEMGenerator(new SecureRandom());
SecretWithEncapsulation res = gen.generateEncapsulated(keyParam); SecretWithEncapsulation res = gen.generateEncapsulated(keyParam);
byte[] secret = res.getSecret(); byte[] secret = Arrays.copyOf(res.getSecret(), res.getSecret().length);
byte[] ct = res.getEncapsulation(); byte[] ct = Arrays.copyOf(res.getEncapsulation(), res.getEncapsulation().length);
res.destroy(); res.destroy();
return new KemResult(ct, secret); return new KemResult(ct, secret);
} catch (DestroyFailedException e) { } catch (DestroyFailedException e) {
@@ -186,7 +187,7 @@ public final class BikeKemContext implements KemContext {
.createKey(key.getEncoded()); .createKey(key.getEncoded());
BIKEKEMExtractor ex = new BIKEKEMExtractor(keyParam); BIKEKEMExtractor ex = new BIKEKEMExtractor(keyParam);
return ex.extractSecret(ciphertext); return ex.extractSecret(ciphertext);
} catch (Exception e) { // NOPMD } catch (Exception e) {
throw new IOException("BIKE decapsulate failed", e); throw new IOException("BIKE decapsulate failed", e);
} }
} }

View File

@@ -247,7 +247,7 @@ abstract class AbstractChaChaCipherContext<S extends ChaChaBaseSpec> implements
if (nonce == null) { if (nonce == null) {
nonce = new byte[NONCE_LEN]; nonce = new byte[NONCE_LEN];
rnd.nextBytes(nonce); rnd.nextBytes(nonce);
if (ctx != null) { if (ctx != null) { // NOPMD
ctx.put(ConfluxKeys.iv(id), nonce); ctx.put(ConfluxKeys.iv(id), nonce);
} }
} else if (nonce.length != NONCE_LEN) { } else if (nonce.length != NONCE_LEN) {

View File

@@ -39,6 +39,7 @@ import java.security.Key;
import java.security.PrivateKey; import java.security.PrivateKey;
import java.security.PublicKey; import java.security.PublicKey;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Objects; import java.util.Objects;
import javax.security.auth.DestroyFailedException; import javax.security.auth.DestroyFailedException;
@@ -190,8 +191,8 @@ public final class CmceKemContext implements KemContext {
.createKey(key.getEncoded()); .createKey(key.getEncoded());
CMCEKEMGenerator gen = new CMCEKEMGenerator(new SecureRandom()); CMCEKEMGenerator gen = new CMCEKEMGenerator(new SecureRandom());
SecretWithEncapsulation res = gen.generateEncapsulated(keyParam); SecretWithEncapsulation res = gen.generateEncapsulated(keyParam);
byte[] secret = res.getSecret(); byte[] secret = Arrays.copyOf(res.getSecret(), res.getSecret().length);
byte[] ct = res.getEncapsulation(); byte[] ct = Arrays.copyOf(res.getEncapsulation(), res.getEncapsulation().length);
res.destroy(); res.destroy();
return new KemResult(ct, secret); return new KemResult(ct, secret);
} catch (DestroyFailedException e) { } catch (DestroyFailedException e) {
@@ -219,7 +220,7 @@ public final class CmceKemContext implements KemContext {
.createKey(key.getEncoded()); .createKey(key.getEncoded());
CMCEKEMExtractor ex = new CMCEKEMExtractor(keyParam); CMCEKEMExtractor ex = new CMCEKEMExtractor(keyParam);
return ex.extractSecret(ciphertext); return ex.extractSecret(ciphertext);
} catch (Exception e) { // NOPMD } catch (Exception e) {
throw new IOException("CMCE decapsulate failed", e); throw new IOException("CMCE decapsulate failed", e);
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,6 +39,7 @@ import java.security.Key;
import java.security.PrivateKey; import java.security.PrivateKey;
import java.security.PublicKey; import java.security.PublicKey;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Objects; import java.util.Objects;
import javax.security.auth.DestroyFailedException; import javax.security.auth.DestroyFailedException;
@@ -179,8 +180,8 @@ public final class FrodoKemContext implements KemContext {
.createKey(key.getEncoded()); .createKey(key.getEncoded());
FrodoKEMGenerator gen = new FrodoKEMGenerator(new SecureRandom()); FrodoKEMGenerator gen = new FrodoKEMGenerator(new SecureRandom());
SecretWithEncapsulation res = gen.generateEncapsulated(keyParam); SecretWithEncapsulation res = gen.generateEncapsulated(keyParam);
byte[] secret = res.getSecret(); byte[] secret = Arrays.copyOf(res.getSecret(), res.getSecret().length);
byte[] ct = res.getEncapsulation(); byte[] ct = Arrays.copyOf(res.getEncapsulation(), res.getEncapsulation().length);
res.destroy(); res.destroy();
return new KemResult(ct, secret); return new KemResult(ct, secret);
} catch (DestroyFailedException e) { } catch (DestroyFailedException e) {
@@ -212,7 +213,7 @@ public final class FrodoKemContext implements KemContext {
.createKey(key.getEncoded()); .createKey(key.getEncoded());
FrodoKEMExtractor ex = new FrodoKEMExtractor(keyParam); FrodoKEMExtractor ex = new FrodoKEMExtractor(keyParam);
return ex.extractSecret(ciphertext); return ex.extractSecret(ciphertext);
} catch (Exception e) { // NOPMD } catch (Exception e) {
throw new IOException("Frodo decapsulate failed", e); throw new IOException("Frodo decapsulate failed", e);
} }
} }

View File

@@ -137,4 +137,50 @@ public final class HmacSpec implements ContextSpec, Describable {
public String description() { public String description() {
return macName; return macName;
} }
/**
* Returns a recommended key size (in bits) for this HMAC variant.
*
* <p>
* HMAC is defined for keys of arbitrary length; this method therefore does not
* express a strict requirement. It provides a conservative,
* interoperability-friendly recommendation intended for default key derivation
* and key generation paths, especially where the caller does not want to
* manually select a key size.
* </p>
*
* <p>
* The recommendation follows common practice: use a key size at least equal to
* the underlying hash output length. For the built-in variants this yields:
* </p>
* <ul>
* <li>HmacSHA256 - 256 bits</li>
* <li>HmacSHA384 - 384 bits</li>
* <li>HmacSHA512 - 512 bits</li>
* </ul>
*
* <p>
* If this spec uses an unrecognized {@link #macName()} value, the method
* returns {@code 256} bits as a safe default and to avoid failing existing
* applications that rely on custom provider names. Applications with strict
* requirements should enforce their own policy and/or explicitly specify a key
* size.
* </p>
*
* @return recommended key size in bits (positive, multiple of 8)
* @since 1.0
*/
public int recommendedKeyBits() {
return recommendedKeyBitsForMacName(macName);
}
private static int recommendedKeyBitsForMacName(String macName) {
return switch (macName) {
case "HmacSHA256" -> 256;
case "HmacSHA384" -> 384;
case "HmacSHA512" -> 512;
default -> 256;
};
}
} }

View File

@@ -39,6 +39,7 @@ import java.security.Key;
import java.security.PrivateKey; import java.security.PrivateKey;
import java.security.PublicKey; import java.security.PublicKey;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Objects; import java.util.Objects;
import javax.security.auth.DestroyFailedException; import javax.security.auth.DestroyFailedException;
@@ -179,8 +180,8 @@ public final class HqcKemContext implements KemContext {
.createKey(key.getEncoded()); .createKey(key.getEncoded());
HQCKEMGenerator gen = new HQCKEMGenerator(new SecureRandom()); HQCKEMGenerator gen = new HQCKEMGenerator(new SecureRandom());
SecretWithEncapsulation res = gen.generateEncapsulated(keyParam); SecretWithEncapsulation res = gen.generateEncapsulated(keyParam);
byte[] secret = res.getSecret(); byte[] secret = Arrays.copyOf(res.getSecret(), res.getSecret().length);
byte[] ct = res.getEncapsulation(); byte[] ct = Arrays.copyOf(res.getEncapsulation(), res.getEncapsulation().length);
res.destroy(); res.destroy();
return new KemResult(ct, secret); return new KemResult(ct, secret);
} catch (DestroyFailedException e) { } catch (DestroyFailedException e) {
@@ -213,7 +214,7 @@ public final class HqcKemContext implements KemContext {
.createKey(key.getEncoded()); .createKey(key.getEncoded());
HQCKEMExtractor ex = new HQCKEMExtractor(keyParam); HQCKEMExtractor ex = new HQCKEMExtractor(keyParam);
return ex.extractSecret(ciphertext); return ex.extractSecret(ciphertext);
} catch (Exception e) { // NOPMD } catch (Exception e) {
throw new IOException("HQC decapsulate failed", e); throw new IOException("HQC decapsulate failed", e);
} }
} }

View File

@@ -39,6 +39,7 @@ import java.security.Key;
import java.security.PrivateKey; import java.security.PrivateKey;
import java.security.PublicKey; import java.security.PublicKey;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Objects; import java.util.Objects;
import javax.security.auth.DestroyFailedException; import javax.security.auth.DestroyFailedException;
@@ -182,8 +183,8 @@ public final class KyberKemContext implements KemContext {
.createKey(key.getEncoded()); .createKey(key.getEncoded());
MLKEMGenerator gen = new MLKEMGenerator(new SecureRandom()); MLKEMGenerator gen = new MLKEMGenerator(new SecureRandom());
SecretWithEncapsulation res = gen.generateEncapsulated(keyParam); SecretWithEncapsulation res = gen.generateEncapsulated(keyParam);
byte[] secret = res.getSecret(); byte[] secret = Arrays.copyOf(res.getSecret(), res.getSecret().length);
byte[] ct = res.getEncapsulation(); byte[] ct = Arrays.copyOf(res.getEncapsulation(), res.getEncapsulation().length);
res.destroy(); res.destroy();
return new KemResult(ct, secret); return new KemResult(ct, secret);
} catch (DestroyFailedException e) { } catch (DestroyFailedException e) {
@@ -220,7 +221,7 @@ public final class KyberKemContext implements KemContext {
MLKEMExtractor gen = new MLKEMExtractor(keyParam); MLKEMExtractor gen = new MLKEMExtractor(keyParam);
return gen.extractSecret(ciphertext); return gen.extractSecret(ciphertext);
} catch (Exception e) { // NOPMD } catch (Exception e) {
throw new IOException("Kyber decapsulate failed", e); throw new IOException("Kyber decapsulate failed", e);
} }
} }

View File

@@ -0,0 +1,105 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.alg.mldsa;
import java.security.GeneralSecurityException;
import java.security.PrivateKey;
import java.security.PublicKey;
import zeroecho.core.AlgorithmFamily;
import zeroecho.core.KeyUsage;
import zeroecho.core.alg.AbstractCryptoAlgorithm;
import zeroecho.core.context.SignatureContext;
import zeroecho.core.spec.VoidSpec;
/**
* ML-DSA (FIPS 204) signature algorithm binding for the ZeroEcho framework.
*
* <p>
* This binding exposes ML-DSA as a first-class algorithm identity while relying
* on the provider's ML-DSA JCA implementations.
* </p>
*
* <p>
* This algorithm registers two roles:
* </p>
* <ul>
* <li>{@link KeyUsage#SIGN}: produces ML-DSA signatures using a
* {@link PrivateKey}.</li>
* <li>{@link KeyUsage#VERIFY}: verifies ML-DSA signatures using a
* {@link PublicKey}.</li>
* </ul>
*
* <p>
* Both roles are configured with {@link VoidSpec}, as ML-DSA requires no
* runtime context parameters beyond the key material.
* </p>
*
* @since 1.0
*/
public final class MldsaAlgorithm extends AbstractCryptoAlgorithm {
/**
* Creates a new ML-DSA algorithm instance and registers its capabilities.
*
* @throws IllegalArgumentException if a signature context cannot be initialized
* due to provider errors
*/
public MldsaAlgorithm() {
super("ML-DSA", "MLDSA");
capability(AlgorithmFamily.ASYMMETRIC, KeyUsage.SIGN, SignatureContext.class, PrivateKey.class, VoidSpec.class,
(PrivateKey k, VoidSpec s) -> {
try {
return new MldsaSignatureContext(this, k);
} catch (GeneralSecurityException e) {
throw new IllegalArgumentException("Cannot init ML-DSA signer", e);
}
}, () -> VoidSpec.INSTANCE);
capability(AlgorithmFamily.ASYMMETRIC, KeyUsage.VERIFY, SignatureContext.class, PublicKey.class, VoidSpec.class,
(PublicKey k, VoidSpec s) -> {
try {
return new MldsaSignatureContext(this, k);
} catch (GeneralSecurityException e) {
throw new IllegalArgumentException("Cannot init ML-DSA verifier", e);
}
}, () -> VoidSpec.INSTANCE);
registerAsymmetricKeyBuilder(MldsaKeyGenSpec.class, new MldsaKeyGenBuilder(), MldsaKeyGenSpec::defaultSpec);
registerAsymmetricKeyBuilder(MldsaPublicKeySpec.class, new MldsaPublicKeyBuilder(), null);
registerAsymmetricKeyBuilder(MldsaPrivateKeySpec.class, new MldsaPrivateKeyBuilder(), null);
}
}

View File

@@ -0,0 +1,167 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.alg.mldsa;
import java.lang.reflect.Field;
import java.security.GeneralSecurityException;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.util.Locale;
import java.util.Objects;
import zeroecho.core.spi.AsymmetricKeyBuilder;
/**
* Key pair builder for ML-DSA (FIPS 204) using the Bouncy Castle provider.
*
* <p>
* This builder maps {@link MldsaKeyGenSpec} to the appropriate
* {@code org.bouncycastle.jcajce.spec.MLDSAParameterSpec} constant. Reflection
* is used to avoid a hard dependency on any particular set of parameter
* constants across provider versions.
* </p>
*
* @since 1.0
*/
public final class MldsaKeyGenBuilder implements AsymmetricKeyBuilder<MldsaKeyGenSpec> {
private static final String ALG_PURE = "MLDSA";
private static final String ALG_SHA512 = "SHA512withMLDSA";
/**
* Generates a key pair according to the given specification.
*
* @param spec key generation specification
* @return generated key pair
* @throws GeneralSecurityException if the JCA engine cannot be initialized or
* the parameter set is unknown
*/
@Override
public KeyPair generateKeyPair(MldsaKeyGenSpec spec) throws GeneralSecurityException {
Objects.requireNonNull(spec, "spec");
Object bcParamSpec = resolveBcParameterSpec(spec);
String kpgAlg = (spec.preHash() == MldsaKeyGenSpec.PreHash.SHA512) ? ALG_SHA512 : ALG_PURE;
KeyPairGenerator kpg = (spec.providerName() == null) ? KeyPairGenerator.getInstance(kpgAlg)
: KeyPairGenerator.getInstance(kpgAlg, spec.providerName());
if (bcParamSpec != null) {
kpg.initialize((java.security.spec.AlgorithmParameterSpec) bcParamSpec);
}
return kpg.generateKeyPair();
}
/**
* Key generation specs cannot import public keys.
*
* @param spec key generation specification
* @return never returns normally
* @throws UnsupportedOperationException always
*/
@Override
public java.security.PublicKey importPublic(MldsaKeyGenSpec spec) {
throw new UnsupportedOperationException("Use MldsaPublicKeySpec to import a public key.");
}
/**
* Key generation specs cannot import private keys.
*
* @param spec key generation specification
* @return never returns normally
* @throws UnsupportedOperationException always
*/
@Override
public java.security.PrivateKey importPrivate(MldsaKeyGenSpec spec) {
throw new UnsupportedOperationException("Use MldsaPrivateKeySpec to import a private key.");
}
private static Object resolveBcParameterSpec(MldsaKeyGenSpec spec) throws GeneralSecurityException {
if (spec.explicitParamConstant() != null) {
Object c = fetchStaticField("org.bouncycastle.jcajce.spec.MLDSAParameterSpec",
spec.explicitParamConstant());
if (c != null) {
return c;
}
throw new GeneralSecurityException("Unknown MLDSAParameterSpec constant: " + spec.explicitParamConstant());
}
String base = "ml_dsa_" + Integer.toString(spec.parameterSet().number);
String suffix = "";
if (spec.preHash() != MldsaKeyGenSpec.PreHash.NONE) {
suffix = "_with_" + spec.preHash().name().toLowerCase(Locale.ROOT);
}
String name = base + suffix;
// Fail fast if we detect clearly unsupported pre-hash requests.
validateCombination(spec);
Object c = fetchStaticField("org.bouncycastle.jcajce.spec.MLDSAParameterSpec", name);
if (c != null) {
return c;
}
// Fallback: attempt base (no pre-hash), then fail.
if (!suffix.isEmpty()) {
Object baseC = fetchStaticField("org.bouncycastle.jcajce.spec.MLDSAParameterSpec", base);
if (baseC != null) {
return baseC;
}
}
throw new GeneralSecurityException("Cannot resolve MLDSAParameterSpec constant: " + name);
}
private static void validateCombination(MldsaKeyGenSpec spec) throws GeneralSecurityException {
if (spec.preHash() == MldsaKeyGenSpec.PreHash.NONE) {
return;
}
if (spec.preHash() != MldsaKeyGenSpec.PreHash.SHA512) {
throw new GeneralSecurityException("ML-DSA supports only PreHash.NONE or PreHash.SHA512.");
}
}
private static Object fetchStaticField(String className, String field) {
try {
Class<?> cls = Class.forName(className);
Field f = cls.getField(field);
return f.get(null);
} catch (ReflectiveOperationException e) {
return null;
}
}
}

View File

@@ -0,0 +1,193 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.alg.mldsa;
import java.util.Objects;
import zeroecho.core.spec.AlgorithmKeySpec;
/**
* Specification for generating ML-DSA key pairs (FIPS 204).
*
* <p>
* ML-DSA is parameterized by a parameter set number (44/65/87). Bouncy Castle
* may also expose pre-hash variants (e.g., "with SHA-512") as separate
* parameter specs.
* </p>
*
* <p>
* This spec intentionally restricts choices to the standardized ML-DSA
* parameter sets only.
* </p>
*
* @since 1.0
*/
public final class MldsaKeyGenSpec implements AlgorithmKeySpec {
/**
* ML-DSA parameter sets as defined by FIPS 204.
*
* @since 1.0
*/
public enum ParameterSet {
/** ML-DSA-44 (~128-bit security). */
ML_DSA_44(44, 128),
/** ML-DSA-65 (~192-bit security). */
ML_DSA_65(65, 192),
/** ML-DSA-87 (~256-bit security). */
ML_DSA_87(87, 256);
/**
* Parameter set number (44/65/87).
*/
public final int number;
/**
* Claimed security strength in bits (128/192/256) used by policy.
*/
public final int strengthBits;
ParameterSet(int number, int strengthBits) {
this.number = number;
this.strengthBits = strengthBits;
}
}
/**
* Optional pre-hash variant selection.
*
* <p>
* If the provider exposes "with hash" variants as distinct parameter specs,
* they can be selected here.
* </p>
*
* @since 1.0
*/
public enum PreHash {
/** No pre-hash parameter set (pure ML-DSA). */
NONE,
/** Pre-hash with SHA-512 (provider-specific variant). */
SHA512
}
private static final MldsaKeyGenSpec DEFAULT = new MldsaKeyGenSpec("BC", ParameterSet.ML_DSA_65, PreHash.NONE,
null);
private final String providerName;
private final ParameterSet parameterSet;
private final PreHash preHash;
private final String explicitParamConstant; // nullable
private MldsaKeyGenSpec(String providerName, ParameterSet parameterSet, PreHash preHash,
String explicitParamConstant) {
this.providerName = Objects.requireNonNull(providerName, "providerName");
this.parameterSet = Objects.requireNonNull(parameterSet, "parameterSet");
this.preHash = Objects.requireNonNull(preHash, "preHash");
this.explicitParamConstant = explicitParamConstant;
}
/**
* Returns the default ML-DSA key generation spec.
*
* @return a singleton default specification (ML-DSA-65, no pre-hash)
*/
public static MldsaKeyGenSpec defaultSpec() {
return DEFAULT;
}
/**
* Creates a new specification with explicit algorithm parameters.
*
* @param providerName JCA provider name (typically {@code "BC"})
* @param parameterSet parameter set (44/65/87)
* @param preHash optional pre-hash selection
* @return a new {@code MldsaKeyGenSpec}
*/
public static MldsaKeyGenSpec of(String providerName, ParameterSet parameterSet, PreHash preHash) {
return new MldsaKeyGenSpec(providerName, parameterSet, preHash, null);
}
/**
* Returns a copy of this specification with an explicit Bouncy Castle parameter
* constant override.
*
* <p>
* This bypasses automatic mapping. The name must match a static field in
* {@code org.bouncycastle.jcajce.spec.MLDSAParameterSpec}.
* </p>
*
* @param name field name in {@code MLDSAParameterSpec}
* @return a new {@code MldsaKeyGenSpec} with the override
*/
public MldsaKeyGenSpec withExplicitParamConstant(String name) {
return new MldsaKeyGenSpec(providerName, parameterSet, preHash, name);
}
/**
* Returns the provider name used for key generation.
*
* @return provider name (never {@code null})
*/
public String providerName() {
return providerName;
}
/**
* Returns the ML-DSA parameter set.
*
* @return parameter set
*/
public ParameterSet parameterSet() {
return parameterSet;
}
/**
* Returns the pre-hash selection.
*
* @return pre-hash selection
*/
public PreHash preHash() {
return preHash;
}
/**
* Returns the explicit parameter constant override, if set.
*
* @return constant name, or {@code null} if automatic mapping is used
*/
public String explicitParamConstant() {
return explicitParamConstant;
}
}

View File

@@ -0,0 +1,93 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.alg.mldsa;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import zeroecho.core.spi.AsymmetricKeyBuilder;
/**
* Builder for importing ML-DSA private keys from encoded specifications.
*
* @since 1.0
*/
public final class MldsaPrivateKeyBuilder implements AsymmetricKeyBuilder<MldsaPrivateKeySpec> {
private static final String ALG = "ML-DSA";
/**
* Generation is not supported by this spec.
*
* @param spec encoded private key spec
* @return never returns normally
* @throws UnsupportedOperationException always
*/
@Override
public KeyPair generateKeyPair(MldsaPrivateKeySpec spec) {
throw new UnsupportedOperationException("Generation not supported by this spec.");
}
/**
* Public key import is not supported by this spec.
*
* @param spec encoded private key spec
* @return never returns normally
* @throws UnsupportedOperationException always
*/
@Override
public PublicKey importPublic(MldsaPrivateKeySpec spec) {
throw new UnsupportedOperationException("Use MldsaPublicKeySpec for public keys.");
}
/**
* Imports a private key from PKCS#8 encoding.
*
* @param spec encoded private key spec
* @return imported private key
* @throws GeneralSecurityException if the key cannot be parsed or the provider
* does not support ML-DSA
*/
@Override
public PrivateKey importPrivate(MldsaPrivateKeySpec spec) throws GeneralSecurityException {
KeyFactory kf = (spec.providerName() == null) ? KeyFactory.getInstance(ALG)
: KeyFactory.getInstance(ALG, spec.providerName());
return kf.generatePrivate(new PKCS8EncodedKeySpec(spec.encoded()));
}
}

View File

@@ -0,0 +1,153 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.alg.mldsa;
import java.util.Base64;
import zeroecho.core.marshal.PairSeq;
import zeroecho.core.spec.AlgorithmKeySpec;
/**
* Encoded representation of an ML-DSA private key.
*
* <p>
* {@code MldsaPrivateKeySpec} is an immutable value object that wraps a
* PKCS#8-encoded ML-DSA private key together with the JCA provider name that
* should be used when importing the key.
* </p>
*
* <h2>Encoding</h2>
* <ul>
* <li>The private key material is stored as a defensive copy of the provided
* PKCS#8 byte array.</li>
* <li>Marshalling/unmarshalling uses {@link PairSeq} with Base64 encoding under
* {@code "pkcs8.b64"}.</li>
* </ul>
*
* <h2>Provider selection</h2>
* <p>
* The default provider is {@code "BC"}.
* </p>
*
* @since 1.0
*/
public final class MldsaPrivateKeySpec implements AlgorithmKeySpec {
private final byte[] encodedPkcs8;
private final String providerName;
/**
* Creates a new specification using the default provider {@code "BC"}.
*
* @param encodedPkcs8 PKCS#8-encoded ML-DSA private key bytes
* @throws IllegalArgumentException if {@code encodedPkcs8} is {@code null}
*/
public MldsaPrivateKeySpec(byte[] encodedPkcs8) {
this(encodedPkcs8, "BC");
}
/**
* Creates a new specification using the supplied provider.
*
* @param encodedPkcs8 PKCS#8-encoded ML-DSA private key bytes
* @param providerName JCA provider name to use for import; may be {@code null}
* @throws IllegalArgumentException if {@code encodedPkcs8} is {@code null}
*/
public MldsaPrivateKeySpec(byte[] encodedPkcs8, String providerName) {
if (encodedPkcs8 == null) {
throw new IllegalArgumentException("encodedPkcs8 must not be null");
}
this.encodedPkcs8 = encodedPkcs8.clone();
this.providerName = (providerName == null ? "BC" : providerName);
}
/**
* Returns a defensive copy of the PKCS#8-encoded private key bytes.
*
* @return a copy of the encoded private key
*/
public byte[] encoded() {
return encodedPkcs8.clone();
}
/**
* Returns the provider name associated with this specification.
*
* @return provider name, never {@code null}
*/
public String providerName() {
return providerName;
}
/**
* Serializes the given spec into a {@link PairSeq}.
*
* @param spec the private key specification to serialize
* @return serialized representation
* @throws NullPointerException if {@code spec} is {@code null}
*/
public static PairSeq marshal(MldsaPrivateKeySpec spec) {
String b64 = Base64.getEncoder().withoutPadding().encodeToString(spec.encodedPkcs8);
return PairSeq.of("type", "MLDSA-PRIV", "pkcs8.b64", b64, "provider", spec.providerName);
}
/**
* Deserializes a {@link MldsaPrivateKeySpec} from a {@link PairSeq}.
*
* @param p serialized input
* @return reconstructed private key specification
* @throws IllegalArgumentException if {@code "pkcs8.b64"} is missing
* @throws NullPointerException if {@code p} is {@code null}
*/
public static MldsaPrivateKeySpec unmarshal(PairSeq p) {
byte[] out = null;
String prov = "BC";
PairSeq.Cursor c = p.cursor();
while (c.next()) {
String k = c.key();
String v = c.value();
switch (k) {
case "pkcs8.b64" -> out = Base64.getDecoder().decode(v);
case "provider" -> prov = v;
default -> {
}
}
}
if (out == null) {
throw new IllegalArgumentException("pkcs8.b64 missing for ML-DSA private key");
}
return new MldsaPrivateKeySpec(out, prov);
}
}

View File

@@ -0,0 +1,93 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.alg.mldsa;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec;
import zeroecho.core.spi.AsymmetricKeyBuilder;
/**
* Builder for importing ML-DSA public keys from encoded specifications.
*
* @since 1.0
*/
public final class MldsaPublicKeyBuilder implements AsymmetricKeyBuilder<MldsaPublicKeySpec> {
private static final String ALG = "ML-DSA";
/**
* Generation is not supported by this spec.
*
* @param spec encoded public key spec
* @return never returns normally
* @throws UnsupportedOperationException always
*/
@Override
public KeyPair generateKeyPair(MldsaPublicKeySpec spec) {
throw new UnsupportedOperationException("Generation not supported by this spec.");
}
/**
* Imports a public key from X.509 encoding.
*
* @param spec encoded public key spec
* @return imported public key
* @throws GeneralSecurityException if the key cannot be parsed or the provider
* does not support ML-DSA
*/
@Override
public PublicKey importPublic(MldsaPublicKeySpec spec) throws GeneralSecurityException {
KeyFactory kf = (spec.providerName() == null) ? KeyFactory.getInstance(ALG)
: KeyFactory.getInstance(ALG, spec.providerName());
return kf.generatePublic(new X509EncodedKeySpec(spec.encoded()));
}
/**
* Private key import is not supported by this spec.
*
* @param spec encoded public key spec
* @return never returns normally
* @throws UnsupportedOperationException always
*/
@Override
public PrivateKey importPrivate(MldsaPublicKeySpec spec) {
throw new UnsupportedOperationException("Use MldsaPrivateKeySpec for private keys.");
}
}

View File

@@ -0,0 +1,153 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.alg.mldsa;
import java.util.Base64;
import zeroecho.core.marshal.PairSeq;
import zeroecho.core.spec.AlgorithmKeySpec;
/**
* Encoded representation of an ML-DSA public key.
*
* <p>
* {@code MldsaPublicKeySpec} is an immutable value object that wraps an X.509
* (SubjectPublicKeyInfo) encoded ML-DSA public key together with the JCA
* provider name that should be used when importing the key.
* </p>
*
* <h2>Encoding</h2>
* <ul>
* <li>The public key bytes are stored as a defensive copy of the provided X.509
* byte array.</li>
* <li>Marshalling/unmarshalling uses {@link PairSeq} with Base64 encoding under
* {@code "x509.b64"}.</li>
* </ul>
*
* <h2>Provider selection</h2>
* <p>
* The default provider is {@code "BC"}.
* </p>
*
* @since 1.0
*/
public final class MldsaPublicKeySpec implements AlgorithmKeySpec {
private final byte[] encodedX509;
private final String providerName;
/**
* Creates a new specification using the default provider {@code "BC"}.
*
* @param encodedX509 X.509-encoded ML-DSA public key bytes
* @throws IllegalArgumentException if {@code encodedX509} is {@code null}
*/
public MldsaPublicKeySpec(byte[] encodedX509) {
this(encodedX509, "BC");
}
/**
* Creates a new specification using the supplied provider.
*
* @param encodedX509 X.509-encoded ML-DSA public key bytes
* @param providerName JCA provider name to use for import; may be {@code null}
* @throws IllegalArgumentException if {@code encodedX509} is {@code null}
*/
public MldsaPublicKeySpec(byte[] encodedX509, String providerName) {
if (encodedX509 == null) {
throw new IllegalArgumentException("encodedX509 must not be null");
}
this.encodedX509 = encodedX509.clone();
this.providerName = (providerName == null ? "BC" : providerName);
}
/**
* Returns a defensive copy of the X.509-encoded public key bytes.
*
* @return a copy of the encoded public key
*/
public byte[] encoded() {
return encodedX509.clone();
}
/**
* Returns the provider name associated with this specification.
*
* @return provider name, never {@code null}
*/
public String providerName() {
return providerName;
}
/**
* Serializes the given spec into a {@link PairSeq}.
*
* @param spec the public key specification to serialize
* @return serialized representation
* @throws NullPointerException if {@code spec} is {@code null}
*/
public static PairSeq marshal(MldsaPublicKeySpec spec) {
String b64 = Base64.getEncoder().withoutPadding().encodeToString(spec.encodedX509);
return PairSeq.of("type", "MLDSA-PUB", "x509.b64", b64, "provider", spec.providerName);
}
/**
* Deserializes a {@link MldsaPublicKeySpec} from a {@link PairSeq}.
*
* @param p serialized input
* @return reconstructed public key specification
* @throws IllegalArgumentException if {@code "x509.b64"} is missing
* @throws NullPointerException if {@code p} is {@code null}
*/
public static MldsaPublicKeySpec unmarshal(PairSeq p) {
byte[] out = null;
String prov = "BC";
PairSeq.Cursor c = p.cursor();
while (c.next()) {
String k = c.key();
String v = c.value();
switch (k) {
case "x509.b64" -> out = Base64.getDecoder().decode(v);
case "provider" -> prov = v;
default -> {
}
}
}
if (out == null) {
throw new IllegalArgumentException("x509.b64 missing for ML-DSA public key");
}
return new MldsaPublicKeySpec(out, prov);
}
}

View File

@@ -0,0 +1,300 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.alg.mldsa;
import java.io.IOException;
import java.io.InputStream;
import java.security.GeneralSecurityException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.Signature;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.bouncycastle.jcajce.interfaces.MLDSAPublicKey;
import org.bouncycastle.jcajce.spec.MLDSAParameterSpec;
import zeroecho.core.CryptoAlgorithm;
import zeroecho.core.alg.common.sig.GenericJcaSignatureContext;
import zeroecho.core.context.SignatureContext;
import zeroecho.core.tag.ThrowingBiPredicate.VerificationBiPredicate;
/**
* Streaming signature context for ML-DSA (FIPS 204).
*
* <p>
* {@code MldsaSignatureContext} adapts a JCA {@link Signature} engine for use
* within the ZeroEcho streaming signature infrastructure. It supports both
* signing and verification and delegates the low-level mechanics to
* {@link GenericJcaSignatureContext}.
* </p>
*
* <h2>Provider and algorithm</h2>
* <ul>
* <li>JCA algorithm: {@code "ML-DSA"}.</li>
* <li>Provider: {@code "BC"} (Bouncy Castle core provider).</li>
* </ul>
*
* <h2>Streaming contract</h2>
* <ul>
* <li><b>SIGN</b>: the wrapped stream emits the message body and appends a
* detached signature trailer at EOF.</li>
* <li><b>VERIFY</b>: the wrapped stream emits the body only; verification is
* performed at EOF against a caller-supplied expected tag.</li>
* </ul>
*
* @since 1.0
*/
public final class MldsaSignatureContext implements SignatureContext {
private static final String JCA_PURE = "MLDSA";
private static final String JCA_SHA512 = "SHA512withMLDSA";
private static final String PROVIDER = "BC";
private static final Pattern PARAM_PATTERN = Pattern.compile("^ml-dsa-(44|65|87)(?:-with-sha512)?$");
private final GenericJcaSignatureContext delegate;
/**
* Creates a signing context bound to a private key.
*
* <p>
* The produced signature length is resolved by probing the JCA engine via
* {@link GenericJcaSignatureContext.SignLengthResolver#probeWith(String, String)}.
* </p>
*
* @param algorithm the parent algorithm instance; must not be {@code null}
* @param privateKey ML-DSA private key; must not be {@code null}
* @throws GeneralSecurityException if the JCA signature engine cannot be
* initialized
*/
public MldsaSignatureContext(final CryptoAlgorithm algorithm, final PrivateKey privateKey)
throws GeneralSecurityException {
String jcaAlg = jcaSignatureAlgFromKey(privateKey);
this.delegate = new GenericJcaSignatureContext(algorithm, privateKey,
GenericJcaSignatureContext.jcaFactory(jcaAlg, PROVIDER),
GenericJcaSignatureContext.SignLengthResolver.probeWith(jcaAlg, PROVIDER));
}
/**
* Creates a verification context bound to a public key.
*
* <p>
* The expected signature length is derived from the public key parameter set
* via {@link MLDSAParameterSpec#getName()} and canonical ML-DSA signature
* sizes.
* </p>
*
* @param algorithm the parent algorithm instance; must not be {@code null}
* @param publicKey ML-DSA public key; must not be {@code null}
* @throws GeneralSecurityException if the key is invalid or the parameter set
* is unsupported
*/
public MldsaSignatureContext(final CryptoAlgorithm algorithm, final PublicKey publicKey)
throws GeneralSecurityException {
String jcaAlg = jcaSignatureAlgFromKey(publicKey);
this.delegate = new GenericJcaSignatureContext(algorithm, publicKey,
GenericJcaSignatureContext.jcaFactory(jcaAlg, PROVIDER), MldsaSignatureContext::sigLenFromPublicKey);
}
/**
* Resolves the canonical ML-DSA signature length (bytes) from a public key.
*
* <p>
* Bouncy Castle public keys expose an {@link MLDSAParameterSpec} whose
* {@linkplain MLDSAParameterSpec#getName() name} encodes the parameter set. The
* resolver normalizes the returned name to lowercase and replaces underscores
* with hyphens to tolerate provider naming differences.
* </p>
*
* <p>
* Canonical signature sizes:
* </p>
* <ul>
* <li>ML-DSA-44: 2420 bytes</li>
* <li>ML-DSA-65: 3309 bytes</li>
* <li>ML-DSA-87: 4627 bytes</li>
* </ul>
*
* @param pk ML-DSA public key
* @return signature length in bytes for the key's parameter set
* @throws GeneralSecurityException if the key type or parameter specification
* is missing or unrecognized
*/
private static int sigLenFromPublicKey(PublicKey pk) throws GeneralSecurityException {
if (!(pk instanceof MLDSAPublicKey mldsaPk)) {
throw new GeneralSecurityException("Expected a BouncyCastle ML-DSA public key (BC)");
}
MLDSAParameterSpec ps = mldsaPk.getParameterSpec();
if (ps == null) {
throw new GeneralSecurityException("Missing ML-DSA parameter spec on public key");
}
String name = ps.getName();
if (name == null || name.isEmpty()) {
throw new GeneralSecurityException("Unknown ML-DSA parameter (no name)");
}
String normalized = name.toLowerCase(Locale.ROOT).replace('_', '-');
// Some providers may return "ML-DSA-65" (normalized => "ml-dsa-65").
// Others may include "with" variants. We accept only the standard sets
// 44/65/87.
Matcher m = PARAM_PATTERN.matcher(normalized);
if (!m.matches()) {
throw new GeneralSecurityException("Cannot parse ML-DSA parameter from: " + name);
}
int set = Integer.parseInt(m.group(1));
return switch (set) {
case 44 -> 2_420;
case 65 -> 3_309;
case 87 -> 4_627;
default -> throw new GeneralSecurityException("Unsupported ML-DSA parameter set: " + set);
};
}
/**
* Returns the parent {@link CryptoAlgorithm} associated with this context.
*
* @return the algorithm instance
*/
@Override
public CryptoAlgorithm algorithm() {
return delegate.algorithm();
}
/**
* Returns the key bound to this context.
*
* @return signing {@link PrivateKey} or verification {@link PublicKey}
*/
@Override
public java.security.Key key() {
return delegate.key();
}
/**
* Closes this context and releases any underlying resources.
*
* <p>
* Once closed, the context must not be reused.
* </p>
*/
@Override
public void close() {
delegate.close();
}
/**
* Wraps an input stream such that bytes read from the returned stream update
* the underlying signature engine.
*
* @param upstream input stream providing message bytes; must not be
* {@code null}
* @return wrapped stream that performs signing or verification as bytes are
* read
* @throws IOException if wrapping fails
*/
@Override
public InputStream wrap(InputStream upstream) throws IOException {
return delegate.wrap(upstream);
}
/**
* Returns the signature (tag) length in bytes for the parameter set in use.
*
* @return signature length in bytes
*/
@Override
public int tagLength() {
return delegate.tagLength();
}
/**
* Supplies the expected signature (tag) for VERIFY mode.
*
* @param expected expected signature bytes; must not be {@code null}
*/
@Override
public void setExpectedTag(byte[] expected) {
delegate.setExpectedTag(expected);
}
/**
* Sets the verification approach used at EOF to compare the computed and
* expected signatures.
*
* @param strategy verification predicate; must not be {@code null}
*/
@Override
public void setVerificationApproach(VerificationBiPredicate<Signature> strategy) {
delegate.setVerificationApproach(strategy);
}
/**
* Returns the verification predicate core that can be used to select a mismatch
* handling strategy.
*
* @return the verification predicate core
*/
@Override
public VerificationBiPredicate<Signature> getVerificationCore() {
return delegate.getVerificationCore();
}
private static String jcaSignatureAlgFromKey(java.security.Key key) throws GeneralSecurityException {
if (key instanceof org.bouncycastle.jcajce.interfaces.MLDSAKey mldsaKey) {
MLDSAParameterSpec ps = mldsaKey.getParameterSpec();
if (ps == null || ps.getName() == null || ps.getName().isEmpty()) {
throw new GeneralSecurityException("Missing ML-DSA parameter spec on key");
}
String n = ps.getName().toLowerCase(Locale.ROOT).replace('_', '-');
if (n.contains("with-sha512")) {
return JCA_SHA512;
}
return JCA_PURE;
}
// Fallback: rely on algorithm string if not a BC-native key.
String a = key.getAlgorithm();
if (a != null && a.toLowerCase(Locale.ROOT).contains("sha512")) {
return JCA_SHA512;
}
return JCA_PURE;
}
}

View File

@@ -0,0 +1,78 @@
/*******************************************************************************
* 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.
******************************************************************************/
/**
* ML-DSA (FIPS&nbsp;204) signature algorithm binding.
*
* <p>
* This package provides the ZeroEcho binding for <em>ML-DSA</em>
* (Module-Lattice-Based Digital Signature Algorithm), standardized by NIST as
* FIPS&nbsp;204. ML-DSA is derived from CRYSTALS-Dilithium and is exposed here
* as a standards-compliant algorithm identity with a restricted parameter
* space.
* </p>
*
* <h2>Algorithm identity</h2>
* <ul>
* <li>JCA algorithm name: {@code "ML-DSA"}</li>
* <li>ZeroEcho algorithm id: {@code "MLDSA"}</li>
* <li>Provider: Bouncy Castle core provider ({@code "BC"})</li>
* </ul>
*
* <h2>Supported parameter sets</h2>
* <p>
* The supported parameter sets are those defined by FIPS&nbsp;204: ML-DSA-44,
* ML-DSA-65, and ML-DSA-87. Bouncy Castle exposes these via
* {@code org.bouncycastle.jcajce.spec.MLDSAParameterSpec}.
* </p>
*
* <h2>Streaming signature model</h2>
* <ul>
* <li>In {@link zeroecho.core.KeyUsage#SIGN} mode, the signature is appended as
* a fixed-length trailer to the output stream.</li>
* <li>In {@link zeroecho.core.KeyUsage#VERIFY} mode, the wrapped stream emits
* only the message body and performs verification at end-of-stream against a
* caller-supplied expected signature.</li>
* </ul>
*
* <h2>Security considerations</h2>
* <ul>
* <li>No sensitive material (private keys, signatures, message contents) is
* logged by this package.</li>
* <li>All externally returned byte arrays are defensive copies.</li>
* </ul>
*
* @since 1.0
*/
package zeroecho.core.alg.mldsa;

View File

@@ -39,6 +39,7 @@ import java.security.Key;
import java.security.PrivateKey; import java.security.PrivateKey;
import java.security.PublicKey; import java.security.PublicKey;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Objects; import java.util.Objects;
import javax.security.auth.DestroyFailedException; import javax.security.auth.DestroyFailedException;
@@ -213,8 +214,8 @@ public final class NtruKemContext implements KemContext {
.createKey(key.getEncoded()); .createKey(key.getEncoded());
NTRUKEMGenerator gen = new NTRUKEMGenerator(new SecureRandom()); NTRUKEMGenerator gen = new NTRUKEMGenerator(new SecureRandom());
SecretWithEncapsulation res = gen.generateEncapsulated(keyParam); SecretWithEncapsulation res = gen.generateEncapsulated(keyParam);
byte[] secret = res.getSecret(); byte[] secret = Arrays.copyOf(res.getSecret(), res.getSecret().length);
byte[] ct = res.getEncapsulation(); byte[] ct = Arrays.copyOf(res.getEncapsulation(), res.getEncapsulation().length);
res.destroy(); res.destroy();
return new KemResult(ct, secret); return new KemResult(ct, secret);
} catch (DestroyFailedException e) { } catch (DestroyFailedException e) {
@@ -250,7 +251,7 @@ public final class NtruKemContext implements KemContext {
.createKey(key.getEncoded()); .createKey(key.getEncoded());
NTRUKEMExtractor ex = new NTRUKEMExtractor(keyParam); NTRUKEMExtractor ex = new NTRUKEMExtractor(keyParam);
return ex.extractSecret(ciphertext); return ex.extractSecret(ciphertext);
} catch (Exception e) { // NOPMD } catch (Exception e) {
throw new IOException("NTRU decapsulate failed", e); throw new IOException("NTRU decapsulate failed", e);
} }
} }

View File

@@ -39,6 +39,7 @@ import java.security.Key;
import java.security.PrivateKey; import java.security.PrivateKey;
import java.security.PublicKey; import java.security.PublicKey;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Objects; import java.util.Objects;
import javax.security.auth.DestroyFailedException; import javax.security.auth.DestroyFailedException;
@@ -175,8 +176,8 @@ public final class NtrulPrimeKemContext implements KemContext {
.createKey(key.getEncoded()); .createKey(key.getEncoded());
NTRULPRimeKEMGenerator gen = new NTRULPRimeKEMGenerator(new SecureRandom()); NTRULPRimeKEMGenerator gen = new NTRULPRimeKEMGenerator(new SecureRandom());
SecretWithEncapsulation res = gen.generateEncapsulated(keyParam); SecretWithEncapsulation res = gen.generateEncapsulated(keyParam);
byte[] secret = res.getSecret(); byte[] secret = Arrays.copyOf(res.getSecret(), res.getSecret().length);
byte[] ct = res.getEncapsulation(); byte[] ct = Arrays.copyOf(res.getEncapsulation(), res.getEncapsulation().length);
res.destroy(); res.destroy();
return new KemResult(ct, secret); return new KemResult(ct, secret);
} catch (DestroyFailedException e) { } catch (DestroyFailedException e) {
@@ -212,7 +213,7 @@ public final class NtrulPrimeKemContext implements KemContext {
.createKey(key.getEncoded()); .createKey(key.getEncoded());
NTRULPRimeKEMExtractor ex = new NTRULPRimeKEMExtractor(keyParam); NTRULPRimeKEMExtractor ex = new NTRULPRimeKEMExtractor(keyParam);
return ex.extractSecret(ciphertext); return ex.extractSecret(ciphertext);
} catch (Exception e) { // NOPMD } catch (Exception e) {
throw new IOException("NTRULPRime decapsulate failed", e); throw new IOException("NTRULPRime decapsulate failed", e);
} }
} }

View File

@@ -39,6 +39,7 @@ import java.security.Key;
import java.security.PrivateKey; import java.security.PrivateKey;
import java.security.PublicKey; import java.security.PublicKey;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Objects; import java.util.Objects;
import javax.security.auth.DestroyFailedException; import javax.security.auth.DestroyFailedException;
@@ -181,8 +182,8 @@ public final class SntruPrimeKemContext implements KemContext {
.createKey(key.getEncoded()); .createKey(key.getEncoded());
SNTRUPrimeKEMGenerator gen = new SNTRUPrimeKEMGenerator(new SecureRandom()); SNTRUPrimeKEMGenerator gen = new SNTRUPrimeKEMGenerator(new SecureRandom());
SecretWithEncapsulation res = gen.generateEncapsulated(keyParam); SecretWithEncapsulation res = gen.generateEncapsulated(keyParam);
byte[] secret = res.getSecret(); byte[] secret = Arrays.copyOf(res.getSecret(), res.getSecret().length);
byte[] ct = res.getEncapsulation(); byte[] ct = Arrays.copyOf(res.getEncapsulation(), res.getEncapsulation().length);
res.destroy(); res.destroy();
return new KemResult(ct, secret); return new KemResult(ct, secret);
} catch (DestroyFailedException e) { } catch (DestroyFailedException e) {
@@ -218,7 +219,7 @@ public final class SntruPrimeKemContext implements KemContext {
.createKey(key.getEncoded()); .createKey(key.getEncoded());
SNTRUPrimeKEMExtractor ex = new SNTRUPrimeKEMExtractor(keyParam); SNTRUPrimeKEMExtractor ex = new SNTRUPrimeKEMExtractor(keyParam);
return ex.extractSecret(ciphertext); return ex.extractSecret(ciphertext);
} catch (Exception e) { // NOPMD } catch (Exception e) {
throw new IOException("SNTRUPrime decapsulate failed", e); throw new IOException("SNTRUPrime decapsulate failed", e);
} }
} }

View File

@@ -39,6 +39,7 @@ import java.security.Key;
import java.security.PrivateKey; import java.security.PrivateKey;
import java.security.PublicKey; import java.security.PublicKey;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Objects; import java.util.Objects;
import javax.security.auth.DestroyFailedException; import javax.security.auth.DestroyFailedException;
@@ -182,8 +183,8 @@ public final class SaberKemContext implements KemContext {
.createKey(key.getEncoded()); .createKey(key.getEncoded());
SABERKEMGenerator gen = new SABERKEMGenerator(new SecureRandom()); SABERKEMGenerator gen = new SABERKEMGenerator(new SecureRandom());
SecretWithEncapsulation res = gen.generateEncapsulated(keyParam); SecretWithEncapsulation res = gen.generateEncapsulated(keyParam);
byte[] secret = res.getSecret(); byte[] secret = Arrays.copyOf(res.getSecret(), res.getSecret().length);
byte[] ct = res.getEncapsulation(); byte[] ct = Arrays.copyOf(res.getEncapsulation(), res.getEncapsulation().length);
res.destroy(); res.destroy();
return new KemResult(ct, secret); return new KemResult(ct, secret);
} catch (DestroyFailedException e) { } catch (DestroyFailedException e) {
@@ -217,7 +218,7 @@ public final class SaberKemContext implements KemContext {
.createKey(key.getEncoded()); .createKey(key.getEncoded());
SABERKEMExtractor ex = new SABERKEMExtractor(keyParam); SABERKEMExtractor ex = new SABERKEMExtractor(keyParam);
return ex.extractSecret(ciphertext); return ex.extractSecret(ciphertext);
} catch (Exception e) { // NOPMD } catch (Exception e) {
throw new IOException("SABER decapsulate failed", e); throw new IOException("SABER decapsulate failed", e);
} }
} }

View File

@@ -0,0 +1,106 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.alg.slhdsa;
import java.security.GeneralSecurityException;
import java.security.PrivateKey;
import java.security.PublicKey;
import zeroecho.core.AlgorithmFamily;
import zeroecho.core.KeyUsage;
import zeroecho.core.alg.AbstractCryptoAlgorithm;
import zeroecho.core.context.SignatureContext;
import zeroecho.core.spec.VoidSpec;
/**
* SLH-DSA (FIPS 205) signature algorithm binding for the ZeroEcho framework.
*
* <p>
* SLH-DSA is the NIST-standardized profile of the SPHINCS+ stateless hash-based
* signature scheme. This binding exposes SLH-DSA as a first-class algorithm
* identity while relying on the provider's SLH-DSA JCA implementations.
* </p>
*
* <p>
* This algorithm registers two roles:
* </p>
* <ul>
* <li>{@link KeyUsage#SIGN}: produces SLH-DSA signatures using a
* {@link PrivateKey}.</li>
* <li>{@link KeyUsage#VERIFY}: verifies SLH-DSA signatures using a
* {@link PublicKey}.</li>
* </ul>
*
* <p>
* Both roles are configured with {@link VoidSpec}, as SLH-DSA requires no
* runtime context parameters beyond the key material.
* </p>
*
* @since 1.0
*/
public final class SlhDsaAlgorithm extends AbstractCryptoAlgorithm {
/**
* Creates a new SLH-DSA algorithm instance and registers its capabilities.
*
* @throws IllegalArgumentException if a signature context cannot be initialized
* due to provider errors
*/
public SlhDsaAlgorithm() {
super("SLH-DSA", "SLHDSA");
capability(AlgorithmFamily.ASYMMETRIC, KeyUsage.SIGN, SignatureContext.class, PrivateKey.class, VoidSpec.class,
(PrivateKey k, VoidSpec s) -> {
try {
return new SlhDsaSignatureContext(this, k);
} catch (GeneralSecurityException e) {
throw new IllegalArgumentException("Cannot init SLH-DSA signer", e);
}
}, () -> VoidSpec.INSTANCE);
capability(AlgorithmFamily.ASYMMETRIC, KeyUsage.VERIFY, SignatureContext.class, PublicKey.class, VoidSpec.class,
(PublicKey k, VoidSpec s) -> {
try {
return new SlhDsaSignatureContext(this, k);
} catch (GeneralSecurityException e) {
throw new IllegalArgumentException("Cannot init SLH-DSA verifier", e);
}
}, () -> VoidSpec.INSTANCE);
registerAsymmetricKeyBuilder(SlhDsaKeyGenSpec.class, new SlhDsaKeyGenBuilder(), SlhDsaKeyGenSpec::defaultSpec);
registerAsymmetricKeyBuilder(SlhDsaPublicKeySpec.class, new SlhDsaPublicKeyBuilder(), null);
registerAsymmetricKeyBuilder(SlhDsaPrivateKeySpec.class, new SlhDsaPrivateKeyBuilder(), null);
}
}

View File

@@ -0,0 +1,168 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.alg.slhdsa;
import java.lang.reflect.Field;
import java.security.GeneralSecurityException;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.util.Locale;
import java.util.Objects;
import zeroecho.core.spi.AsymmetricKeyBuilder;
/**
* Key pair builder for SLH-DSA (FIPS 205) using the Bouncy Castle PQC provider.
*
* <p>
* This builder maps {@link SlhDsaKeyGenSpec} to the appropriate
* {@code org.bouncycastle.jcajce.spec.SLHDSAParameterSpec} constant.
* :contentReference[oaicite:3]{index=3} Reflection is used to avoid a hard
* dependency on any particular set of parameter constants across provider
* versions.
* </p>
*
* @since 1.0
*/
public final class SlhDsaKeyGenBuilder implements AsymmetricKeyBuilder<SlhDsaKeyGenSpec> {
private static final String ALG = "SLH-DSA";
@Override
public KeyPair generateKeyPair(SlhDsaKeyGenSpec spec) throws GeneralSecurityException {
Objects.requireNonNull(spec, "spec");
Object bcParamSpec = resolveBcParameterSpec(spec);
KeyPairGenerator kpg = (spec.providerName() == null) ? KeyPairGenerator.getInstance(ALG)
: KeyPairGenerator.getInstance(ALG, spec.providerName());
if (bcParamSpec != null) {
kpg.initialize((java.security.spec.AlgorithmParameterSpec) bcParamSpec);
}
return kpg.generateKeyPair();
}
@Override
public java.security.PublicKey importPublic(SlhDsaKeyGenSpec spec) {
throw new UnsupportedOperationException("Use SlhDsaPublicKeySpec to import a public key.");
}
@Override
public java.security.PrivateKey importPrivate(SlhDsaKeyGenSpec spec) {
throw new UnsupportedOperationException("Use SlhDsaPrivateKeySpec to import a private key.");
}
private static Object resolveBcParameterSpec(SlhDsaKeyGenSpec spec) throws GeneralSecurityException {
if (spec.explicitParamConstant() != null) {
Object c = fetchStaticField("org.bouncycastle.jcajce.spec.SLHDSAParameterSpec",
spec.explicitParamConstant());
if (c != null) {
return c;
}
throw new GeneralSecurityException("Unknown SLHDSAParameterSpec constant: " + spec.explicitParamConstant());
}
String fam = (spec.hash() == SlhDsaKeyGenSpec.Hash.SHA2) ? "sha2" : "shake";
String bits = Integer.toString(spec.security().bits);
String v = (spec.variant() == SlhDsaKeyGenSpec.Variant.FAST) ? "f" : "s";
// Base constant name: slh_dsa_{sha2|shake}_{128|192|256}{f|s}
String base = "slh_dsa_" + fam + "_" + bits + v;
// Optional pre-hash suffix used by BC:
// _with_sha256/_with_sha512/_with_shake128/_with_shake256
// :contentReference[oaicite:4]{index=4}
String suffix = "";
if (spec.preHash() != SlhDsaKeyGenSpec.PreHash.NONE) {
suffix = "_with_" + spec.preHash().name().toLowerCase(Locale.ROOT);
}
String name = base + suffix;
// Validate supported combinations (fail fast with a clear message).
validateCombination(spec);
Object c = fetchStaticField("org.bouncycastle.jcajce.spec.SLHDSAParameterSpec", name);
if (c != null) {
return c;
}
// As a fallback, attempt base (no pre-hash), then fail.
if (!suffix.isEmpty()) {
Object baseC = fetchStaticField("org.bouncycastle.jcajce.spec.SLHDSAParameterSpec", base);
if (baseC != null) {
return baseC;
}
}
throw new GeneralSecurityException("Cannot resolve SLHDSAParameterSpec constant: " + name);
}
private static void validateCombination(SlhDsaKeyGenSpec spec) throws GeneralSecurityException {
SlhDsaKeyGenSpec.Hash h = spec.hash();
int bits = spec.security().bits;
SlhDsaKeyGenSpec.PreHash p = spec.preHash();
if (p == SlhDsaKeyGenSpec.PreHash.NONE) {
return;
}
if (h == SlhDsaKeyGenSpec.Hash.SHA2) {
if (bits == 128 && p != SlhDsaKeyGenSpec.PreHash.SHA256) {
throw new GeneralSecurityException("SLH-DSA SHA2-128 supports only PreHash.SHA256.");
}
if ((bits == 192 || bits == 256) && p != SlhDsaKeyGenSpec.PreHash.SHA512) {
throw new GeneralSecurityException("SLH-DSA SHA2-192/256 support only PreHash.SHA512.");
}
} else {
if (bits == 128 && p != SlhDsaKeyGenSpec.PreHash.SHAKE128) {
throw new GeneralSecurityException("SLH-DSA SHAKE-128 supports only PreHash.SHAKE128.");
}
if ((bits == 192 || bits == 256) && p != SlhDsaKeyGenSpec.PreHash.SHAKE256) {
throw new GeneralSecurityException("SLH-DSA SHAKE-192/256 support only PreHash.SHAKE256.");
}
}
}
private static Object fetchStaticField(String className, String field) {
try {
Class<?> cls = Class.forName(className);
Field f = cls.getField(field);
return f.get(null);
} catch (ReflectiveOperationException e) {
return null;
}
}
}

View File

@@ -0,0 +1,249 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.alg.slhdsa;
import java.util.Objects;
import zeroecho.core.spec.AlgorithmKeySpec;
/**
* Specification for generating SLH-DSA key pairs (FIPS 205).
*
* <p>
* SLH-DSA is parameterized by hash family (SHA2 or SHAKE), a security strength
* (128/192/256), and a variant (fast vs small). Additionally, Bouncy Castle
* exposes pre-hash variants (e.g., "with SHA-256") as separate parameter specs.
* </p>
*
* <p>
* This spec intentionally restricts the available choices to SLH-DSA parameters
* (i.e., no Haraka and no SPHINCS+ "simple/robust" split).
* </p>
*
* @since 1.0
*/
public final class SlhDsaKeyGenSpec implements AlgorithmKeySpec {
/**
* SLH-DSA hash families (FIPS 205).
*/
public enum Hash {
/** SHA-2 based SLH-DSA parameter sets. */
SHA2,
/** SHAKE based SLH-DSA parameter sets. */
SHAKE
}
/**
* Security levels as defined by NIST PQC (L1, L3, L5).
*/
public enum Security {
/** NIST Level 1 (~128-bit security). */
L1_128(128),
/** NIST Level 3 (~192-bit security). */
L3_192(192),
/** NIST Level 5 (~256-bit security). */
L5_256(256);
/** Claimed security level in bits. */
public final int bits;
Security(int b) {
bits = b;
}
}
/**
* Variant trading performance against signature size.
*
* <p>
* {@code FAST} variants are optimized for speed (larger signatures).
* {@code SMALL} variants reduce signature size at some performance cost.
* </p>
*/
public enum Variant {
/** Larger, faster signatures. */
FAST,
/** Smaller, slower signatures. */
SMALL
}
/**
* Optional pre-hash variant selection.
*
* <p>
* Bouncy Castle exposes "with hash" variants as distinct parameter specs, for
* example {@code slh_dsa_sha2_128s_with_sha256}.
* :contentReference[oaicite:1]{index=1}
* </p>
*
* <p>
* Valid combinations depend on the hash family and security strength:
* </p>
* <ul>
* <li>SHA2-128 uses SHA-256</li>
* <li>SHA2-192/256 use SHA-512</li>
* <li>SHAKE-128 uses SHAKE128</li>
* <li>SHAKE-192/256 use SHAKE256</li>
* </ul>
*/
public enum PreHash {
/** No pre-hash parameter set (pure SLH-DSA). */
NONE,
/** Pre-hash with SHA-256 (only for SHA2-128). */
SHA256,
/** Pre-hash with SHA-512 (only for SHA2-192/256). */
SHA512,
/** Pre-hash with SHAKE128 (only for SHAKE-128). */
SHAKE128,
/** Pre-hash with SHAKE256 (only for SHAKE-192/256). */
SHAKE256
}
private static final SlhDsaKeyGenSpec DEFAULT = new SlhDsaKeyGenSpec("BC", Hash.SHAKE, Security.L5_256,
Variant.SMALL, PreHash.NONE, null);
private final String providerName;
private final Hash hash;
private final Security security;
private final Variant variant;
private final PreHash preHash;
private final String explicitParamConstant; // nullable
private SlhDsaKeyGenSpec(String providerName, Hash hash, Security security, Variant variant, PreHash preHash,
String explicitParamConstant) {
this.providerName = Objects.requireNonNull(providerName, "providerName");
this.hash = Objects.requireNonNull(hash, "hash");
this.security = Objects.requireNonNull(security, "security");
this.variant = Objects.requireNonNull(variant, "variant");
this.preHash = Objects.requireNonNull(preHash, "preHash");
this.explicitParamConstant = explicitParamConstant;
}
/**
* Returns the default SLH-DSA key generation spec.
*
* @return a singleton default specification (SHAKE, L5, SMALL, no pre-hash)
*/
public static SlhDsaKeyGenSpec defaultSpec() {
return DEFAULT;
}
/**
* Creates a new specification with explicit algorithm parameters.
*
* @param providerName JCA provider name (e.g., "BC", "BCPQC")
* @param hash hash family (SHA2 or SHAKE)
* @param security security level
* @param variant variant (FAST vs SMALL)
* @param preHash optional pre-hash selection
* @return a new {@code SlhDsaKeyGenSpec}
*/
public static SlhDsaKeyGenSpec of(String providerName, Hash hash, Security security, Variant variant,
PreHash preHash) {
return new SlhDsaKeyGenSpec(providerName, hash, security, variant, preHash, null);
}
/**
* Returns a copy of this specification with an explicit Bouncy Castle parameter
* constant override.
*
* <p>
* This bypasses automatic mapping. The name must match a static field in
* {@code org.bouncycastle.jcajce.spec.SLHDSAParameterSpec}.
* :contentReference[oaicite:2]{index=2}
* </p>
*
* @param name field name in {@code SLHDSAParameterSpec}
* @return a new {@code SlhDsaKeyGenSpec} with the override
*/
public SlhDsaKeyGenSpec withExplicitParamConstant(String name) {
return new SlhDsaKeyGenSpec(providerName, hash, security, variant, preHash, name);
}
/**
* Returns the provider name used for key generation.
*
* @return provider name (never {@code null})
*/
public String providerName() {
return providerName;
}
/**
* Returns the SLH-DSA hash family.
*
* @return hash family
*/
public Hash hash() {
return hash;
}
/**
* Returns the security level.
*
* @return security level
*/
public Security security() {
return security;
}
/**
* Returns the variant.
*
* @return variant
*/
public Variant variant() {
return variant;
}
/**
* Returns the pre-hash selection.
*
* @return pre-hash selection
*/
public PreHash preHash() {
return preHash;
}
/**
* Returns the explicit parameter constant override, if set.
*
* @return constant name, or {@code null} if automatic mapping is used
*/
public String explicitParamConstant() {
return explicitParamConstant;
}
}

View File

@@ -0,0 +1,71 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.alg.slhdsa;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import zeroecho.core.spi.AsymmetricKeyBuilder;
/**
* Builder for importing SLH-DSA private keys from encoded specifications.
*
* @since 1.0
*/
public final class SlhDsaPrivateKeyBuilder implements AsymmetricKeyBuilder<SlhDsaPrivateKeySpec> {
private static final String ALG = "SLH-DSA";
@Override
public KeyPair generateKeyPair(SlhDsaPrivateKeySpec spec) {
throw new UnsupportedOperationException("Generation not supported by this spec.");
}
@Override
public PublicKey importPublic(SlhDsaPrivateKeySpec spec) {
throw new UnsupportedOperationException("Use SlhDsaPublicKeySpec for public keys.");
}
@Override
public PrivateKey importPrivate(SlhDsaPrivateKeySpec spec) throws GeneralSecurityException {
KeyFactory kf = (spec.providerName() == null) ? KeyFactory.getInstance(ALG)
: KeyFactory.getInstance(ALG, spec.providerName());
return kf.generatePrivate(new PKCS8EncodedKeySpec(spec.encoded()));
}
}

View File

@@ -0,0 +1,182 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.alg.slhdsa;
import java.util.Base64;
import zeroecho.core.marshal.PairSeq;
import zeroecho.core.spec.AlgorithmKeySpec;
/**
* Encoded representation of an SLH-DSA private key.
*
* <p>
* {@code SlhDsaPrivateKeySpec} is an immutable value object that wraps a
* PKCS#8-encoded SLH-DSA private key together with the JCA provider name that
* should be used when importing the key.
* </p>
*
* <h2>Encoding</h2>
* <ul>
* <li>The private key material is stored as a defensive copy of the provided
* PKCS#8 byte array.</li>
* <li>Marshalling/unmarshalling uses {@link PairSeq} with Base64 encoding under
* {@code "pkcs8.b64"}.</li>
* </ul>
*
* <h2>Provider selection</h2>
* <p>
* The default provider is {@code "BC"} because SLH-DSA is registered by the
* Bouncy Castle core provider (as opposed to the PQC-only provider).
* </p>
*
* <h2>Security considerations</h2>
* <ul>
* <li>All byte arrays returned by this class are defensive copies.</li>
* <li>This class performs no cryptographic operations.</li>
* <li>Callers must protect serialized private key material at rest and in
* transit.</li>
* </ul>
*
* <h2>Thread-safety</h2>
* <p>
* Instances are immutable and therefore thread-safe.
* </p>
*
* @since 1.0
*/
public final class SlhDsaPrivateKeySpec implements AlgorithmKeySpec {
private final byte[] encodedPkcs8;
private final String providerName;
/**
* Creates a new specification using the default provider {@code "BC"}.
*
* @param encodedPkcs8 PKCS#8-encoded SLH-DSA private key bytes
* @throws IllegalArgumentException if {@code encodedPkcs8} is {@code null}
*/
public SlhDsaPrivateKeySpec(byte[] encodedPkcs8) {
this(encodedPkcs8, "BC");
}
/**
* Creates a new specification using the supplied provider.
*
* <p>
* If {@code providerName} is {@code null}, the default provider {@code "BC"} is
* used.
* </p>
*
* @param encodedPkcs8 PKCS#8-encoded SLH-DSA private key bytes
* @param providerName JCA provider name to use for import; may be {@code null}
* @throws IllegalArgumentException if {@code encodedPkcs8} is {@code null}
*/
public SlhDsaPrivateKeySpec(byte[] encodedPkcs8, String providerName) {
if (encodedPkcs8 == null) {
throw new IllegalArgumentException("encodedPkcs8 must not be null");
}
this.encodedPkcs8 = encodedPkcs8.clone();
this.providerName = (providerName == null ? "BC" : providerName);
}
/**
* Returns a defensive copy of the PKCS#8-encoded private key bytes.
*
* @return a copy of the encoded private key
*/
public byte[] encoded() {
return encodedPkcs8.clone();
}
/**
* Returns the provider name associated with this specification.
*
* @return provider name, never {@code null}
*/
public String providerName() {
return providerName;
}
/**
* Serializes the given spec into a {@link PairSeq}.
*
* <p>
* The encoded key is stored as Base64 without padding under
* {@code "pkcs8.b64"}. The provider is stored under {@code "provider"}.
* </p>
*
* @param spec the private key specification to serialize
* @return serialized representation
* @throws NullPointerException if {@code spec} is {@code null}
*/
public static PairSeq marshal(SlhDsaPrivateKeySpec spec) {
String b64 = Base64.getEncoder().withoutPadding().encodeToString(spec.encodedPkcs8);
return PairSeq.of("type", "SLHDSA-PRIV", "pkcs8.b64", b64, "provider", spec.providerName);
}
/**
* Deserializes a {@link SlhDsaPrivateKeySpec} from a {@link PairSeq}.
*
* <p>
* Expects key {@code "pkcs8.b64"} (required) and {@code "provider"} (optional;
* defaults to {@code "BC"}).
* </p>
*
* @param p serialized input
* @return reconstructed private key specification
* @throws IllegalArgumentException if {@code "pkcs8.b64"} is missing
* @throws NullPointerException if {@code p} is {@code null}
*/
public static SlhDsaPrivateKeySpec unmarshal(PairSeq p) {
byte[] out = null;
String prov = "BC";
PairSeq.Cursor c = p.cursor();
while (c.next()) {
String k = c.key();
String v = c.value();
switch (k) {
case "pkcs8.b64" -> out = Base64.getDecoder().decode(v);
case "provider" -> prov = v;
default -> {
}
}
}
if (out == null) {
throw new IllegalArgumentException("pkcs8.b64 missing for SLH-DSA private key");
}
return new SlhDsaPrivateKeySpec(out, prov);
}
}

View File

@@ -0,0 +1,71 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.alg.slhdsa;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec;
import zeroecho.core.spi.AsymmetricKeyBuilder;
/**
* Builder for importing SLH-DSA public keys from encoded specifications.
*
* @since 1.0
*/
public final class SlhDsaPublicKeyBuilder implements AsymmetricKeyBuilder<SlhDsaPublicKeySpec> {
private static final String ALG = "SLH-DSA";
@Override
public KeyPair generateKeyPair(SlhDsaPublicKeySpec spec) {
throw new UnsupportedOperationException("Generation not supported by this spec.");
}
@Override
public PublicKey importPublic(SlhDsaPublicKeySpec spec) throws GeneralSecurityException {
KeyFactory kf = (spec.providerName() == null) ? KeyFactory.getInstance(ALG)
: KeyFactory.getInstance(ALG, spec.providerName());
return kf.generatePublic(new X509EncodedKeySpec(spec.encoded()));
}
@Override
public PrivateKey importPrivate(SlhDsaPublicKeySpec spec) {
throw new UnsupportedOperationException("Use SlhDsaPrivateKeySpec for private keys.");
}
}

View File

@@ -0,0 +1,174 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.alg.slhdsa;
import java.util.Base64;
import zeroecho.core.marshal.PairSeq;
import zeroecho.core.spec.AlgorithmKeySpec;
/**
* Encoded representation of an SLH-DSA public key.
*
* <p>
* {@code SlhDsaPublicKeySpec} is an immutable value object that wraps an X.509
* (SubjectPublicKeyInfo) encoded SLH-DSA public key together with the JCA
* provider name that should be used when importing the key.
* </p>
*
* <h2>Encoding</h2>
* <ul>
* <li>The public key bytes are stored as a defensive copy of the provided X.509
* byte array.</li>
* <li>Marshalling/unmarshalling uses {@link PairSeq} with Base64 encoding under
* {@code "x509.b64"}.</li>
* </ul>
*
* <h2>Provider selection</h2>
* <p>
* The default provider is {@code "BC"} because SLH-DSA is registered by the
* Bouncy Castle core provider.
* </p>
*
* <h2>Thread-safety</h2>
* <p>
* Instances are immutable and can be safely shared across threads.
* </p>
*
* @since 1.0
*/
public final class SlhDsaPublicKeySpec implements AlgorithmKeySpec {
private final byte[] encodedX509;
private final String providerName;
/**
* Creates a new specification using the default provider {@code "BC"}.
*
* @param encodedX509 X.509-encoded SLH-DSA public key bytes
* @throws IllegalArgumentException if {@code encodedX509} is {@code null}
*/
public SlhDsaPublicKeySpec(byte[] encodedX509) {
this(encodedX509, "BC");
}
/**
* Creates a new specification using the supplied provider.
*
* <p>
* If {@code providerName} is {@code null}, the default provider {@code "BC"} is
* used.
* </p>
*
* @param encodedX509 X.509-encoded SLH-DSA public key bytes
* @param providerName JCA provider name to use for import; may be {@code null}
* @throws IllegalArgumentException if {@code encodedX509} is {@code null}
*/
public SlhDsaPublicKeySpec(byte[] encodedX509, String providerName) {
if (encodedX509 == null) {
throw new IllegalArgumentException("encodedX509 must not be null");
}
this.encodedX509 = encodedX509.clone();
this.providerName = (providerName == null ? "BC" : providerName);
}
/**
* Returns a defensive copy of the X.509-encoded public key bytes.
*
* @return a copy of the encoded public key
*/
public byte[] encoded() {
return encodedX509.clone();
}
/**
* Returns the provider name associated with this specification.
*
* @return provider name, never {@code null}
*/
public String providerName() {
return providerName;
}
/**
* Serializes the given spec into a {@link PairSeq}.
*
* <p>
* The encoded key is stored as Base64 without padding under {@code "x509.b64"}.
* The provider is stored under {@code "provider"}.
* </p>
*
* @param spec the public key specification to serialize
* @return serialized representation
* @throws NullPointerException if {@code spec} is {@code null}
*/
public static PairSeq marshal(SlhDsaPublicKeySpec spec) {
String b64 = Base64.getEncoder().withoutPadding().encodeToString(spec.encodedX509);
return PairSeq.of("type", "SLHDSA-PUB", "x509.b64", b64, "provider", spec.providerName);
}
/**
* Deserializes a {@link SlhDsaPublicKeySpec} from a {@link PairSeq}.
*
* <p>
* Expects key {@code "x509.b64"} (required) and {@code "provider"} (optional;
* defaults to {@code "BC"}).
* </p>
*
* @param p serialized input
* @return reconstructed public key specification
* @throws IllegalArgumentException if {@code "x509.b64"} is missing
* @throws NullPointerException if {@code p} is {@code null}
*/
public static SlhDsaPublicKeySpec unmarshal(PairSeq p) {
byte[] out = null;
String prov = "BC";
PairSeq.Cursor c = p.cursor();
while (c.next()) {
String k = c.key();
String v = c.value();
switch (k) {
case "x509.b64" -> out = Base64.getDecoder().decode(v);
case "provider" -> prov = v;
default -> {
}
}
}
if (out == null) {
throw new IllegalArgumentException("x509.b64 missing for SLH-DSA public key");
}
return new SlhDsaPublicKeySpec(out, prov);
}
}

View File

@@ -0,0 +1,283 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.alg.slhdsa;
import java.io.IOException;
import java.io.InputStream;
import java.security.GeneralSecurityException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.Signature;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.bouncycastle.jcajce.interfaces.SLHDSAPublicKey;
import org.bouncycastle.jcajce.spec.SLHDSAParameterSpec;
import zeroecho.core.CryptoAlgorithm;
import zeroecho.core.alg.common.sig.GenericJcaSignatureContext;
import zeroecho.core.context.SignatureContext;
import zeroecho.core.tag.ThrowingBiPredicate.VerificationBiPredicate;
/**
* Streaming signature context for SLH-DSA (FIPS 205).
*
* <p>
* {@code SlhDsaSignatureContext} adapts a JCA {@link Signature} engine for use
* within the ZeroEcho streaming signature infrastructure. It supports both
* signing and verification and delegates the low-level mechanics to
* {@link GenericJcaSignatureContext}.
* </p>
*
* <h2>Provider and algorithm</h2>
* <ul>
* <li>JCA algorithm: {@code "SLH-DSA"}.</li>
* <li>Provider: {@code "BC"} (Bouncy Castle core provider).</li>
* </ul>
*
* <h2>Streaming contract</h2>
* <ul>
* <li><b>SIGN</b>: the wrapped stream emits the message body and appends a
* detached signature trailer at EOF.</li>
* <li><b>VERIFY</b>: the wrapped stream emits the body only; verification is
* performed at EOF against a caller-supplied expected tag.</li>
* </ul>
*
* <h2>Security</h2>
* <p>
* This class never logs secrets, key material, plaintext, or signature bytes.
* </p>
*
* @since 1.0
*/
public final class SlhDsaSignatureContext implements SignatureContext {
private static final String ALG = "SLH-DSA";
private static final String PROVIDER = "BC";
private static final Pattern PARAM_PATTERN = Pattern
.compile("^slh-dsa-(sha2|shake)-(128|192|256)([fs])(?:-with-[a-z0-9]+)?$");
private final GenericJcaSignatureContext delegate;
/**
* Creates a signing context bound to a private key.
*
* <p>
* The produced signature length is resolved by probing the JCA engine via
* {@link GenericJcaSignatureContext.SignLengthResolver#probeWith(String, String)}.
* </p>
*
* @param algorithm the parent algorithm instance; must not be {@code null}
* @param privateKey SLH-DSA private key; must not be {@code null}
* @throws GeneralSecurityException if the JCA signature engine cannot be
* initialized
*/
public SlhDsaSignatureContext(final CryptoAlgorithm algorithm, final PrivateKey privateKey)
throws GeneralSecurityException {
this.delegate = new GenericJcaSignatureContext(algorithm, privateKey,
GenericJcaSignatureContext.jcaFactory(ALG, PROVIDER),
GenericJcaSignatureContext.SignLengthResolver.probeWith(ALG, PROVIDER));
}
/**
* Creates a verification context bound to a public key.
*
* <p>
* The expected signature length is derived from the public key parameter set
* via {@link SLHDSAParameterSpec#getName()} and the canonical SLH-DSA sizes.
* </p>
*
* @param algorithm the parent algorithm instance; must not be {@code null}
* @param publicKey SLH-DSA public key; must not be {@code null}
* @throws GeneralSecurityException if the key is invalid or the parameter set
* is unsupported
*/
public SlhDsaSignatureContext(final CryptoAlgorithm algorithm, final PublicKey publicKey)
throws GeneralSecurityException {
this.delegate = new GenericJcaSignatureContext(algorithm, publicKey,
GenericJcaSignatureContext.jcaFactory(ALG, PROVIDER), SlhDsaSignatureContext::sigLenFromPublicKey);
}
/**
* Resolves the canonical SLH-DSA signature length (bytes) from a public key.
*
* <p>
* The Bouncy Castle public key exposes an {@link SLHDSAParameterSpec} whose
* {@linkplain SLHDSAParameterSpec#getName() name} encodes the family
* (SHA2/SHAKE), security level (128/192/256) and variant (s/f). The resolver
* normalizes the returned name to lowercase and replaces underscores with
* hyphens to tolerate provider naming differences.
* </p>
*
* @param pk SLH-DSA public key
* @return signature length in bytes for the key's parameter set
* @throws GeneralSecurityException if the key type or parameter specification
* is missing or unrecognized
*/
private static int sigLenFromPublicKey(PublicKey pk) throws GeneralSecurityException {
if (!(pk instanceof SLHDSAPublicKey slhPk)) {
throw new GeneralSecurityException("Expected a BouncyCastle SLH-DSA public key (BC)");
}
SLHDSAParameterSpec ps = slhPk.getParameterSpec();
if (ps == null) {
throw new GeneralSecurityException("Missing SLH-DSA parameter spec on public key");
}
String name = ps.getName();
if (name == null || name.isEmpty()) {
throw new GeneralSecurityException("Unknown SLH-DSA parameter (no name)");
}
String normalized = name.toLowerCase(Locale.ROOT).replace('_', '-');
Matcher m = PARAM_PATTERN.matcher(normalized);
if (!m.matches()) {
throw new GeneralSecurityException("Cannot parse SLH-DSA parameter from: " + name);
}
int level = Integer.parseInt(m.group(2));
char var = m.group(3).charAt(0);
boolean isSmall = var == 's';
return switch (level) {
case 128 -> isSmall ? 7_856 : 17_088;
case 192 -> isSmall ? 16_224 : 35_664;
case 256 -> isSmall ? 29_792 : 49_856;
default -> throw new GeneralSecurityException("Unsupported SLH-DSA level: " + level);
};
}
/**
* Returns the parent {@link CryptoAlgorithm} associated with this context.
*
* @return the algorithm instance
*/
@Override
public CryptoAlgorithm algorithm() {
return delegate.algorithm();
}
/**
* Returns the key bound to this context.
*
* @return signing {@link PrivateKey} or verification {@link PublicKey}
*/
@Override
public java.security.Key key() {
return delegate.key();
}
/**
* Closes this context and releases any underlying resources.
*
* <p>
* Once closed, the context must not be reused.
* </p>
*/
@Override
public void close() {
delegate.close();
}
/**
* Wraps an input stream such that bytes read from the returned stream update
* the underlying signature engine.
*
* <p>
* In SIGN mode, the wrapper appends the signature trailer at EOF. In VERIFY
* mode, the wrapper compares the computed signature at EOF against the expected
* tag configured via {@link #setExpectedTag(byte[])}.
* </p>
*
* @param upstream input stream providing message bytes; must not be
* {@code null}
* @return wrapped stream that performs signing or verification as bytes are
* read
* @throws IOException if wrapping fails
*/
@Override
public InputStream wrap(InputStream upstream) throws IOException {
return delegate.wrap(upstream);
}
/**
* Returns the signature (tag) length in bytes for the parameter set in use.
*
* @return signature length in bytes
*/
@Override
public int tagLength() {
return delegate.tagLength();
}
/**
* Supplies the expected signature (tag) for VERIFY mode.
*
* <p>
* Implementations may defensively copy the provided array. Callers should treat
* this value as sensitive and avoid logging or persisting it unless explicitly
* required by the application.
* </p>
*
* @param expected expected signature bytes; must not be {@code null}
*/
@Override
public void setExpectedTag(byte[] expected) {
delegate.setExpectedTag(expected);
}
/**
* Sets the verification approach used at EOF to compare the computed and
* expected signatures.
*
* @param strategy verification predicate; must not be {@code null}
*/
@Override
public void setVerificationApproach(VerificationBiPredicate<Signature> strategy) {
delegate.setVerificationApproach(strategy);
}
/**
* Returns the verification predicate core that can be used to select a
* particular mismatch handling strategy (e.g., throw on mismatch).
*
* @return the verification predicate core
*/
@Override
public VerificationBiPredicate<Signature> getVerificationCore() {
return delegate.getVerificationCore();
}
}

View File

@@ -0,0 +1,121 @@
/*******************************************************************************
* 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.
******************************************************************************/
/**
* SLH-DSA (FIPS&nbsp;205) signature algorithm binding.
*
* <p>
* This package provides a ZeroEcho binding for <em>SLH-DSA</em>, the
* NIST-standardized profile of the stateless hash-based signature scheme
* SPHINCS+. While SLH-DSA is derived from SPHINCS+, it is exposed here as a
* distinct algorithm identity with a restricted, standards-compliant parameter
* space.
* </p>
*
* <h2>Algorithm identity</h2>
* <ul>
* <li>JCA algorithm name: {@code "SLH-DSA"}</li>
* <li>ZeroEcho algorithm id: {@code "SLHDSA"}</li>
* <li>Provider: Bouncy Castle core provider ({@code "BC"})</li>
* </ul>
*
* <h2>Supported parameter space</h2>
* <p>
* The supported parameters correspond exactly to the SLH-DSA profiles defined
* by FIPS&nbsp;205:
* </p>
* <ul>
* <li>Hash families: SHA2 and SHAKE</li>
* <li>Security levels: 128, 192, 256 bits (NIST levels L1, L3, L5)</li>
* <li>Variants: {@code FAST} (larger, faster signatures) and {@code SMALL}
* (smaller, slower signatures)</li>
* <li>Optional pre-hash variants as defined by the standard and exposed by the
* provider</li>
* </ul>
*
* <p>
* Non-standard SPHINCS+ variants (e.g. Haraka, simple/robust modes) are
* intentionally excluded from this package.
* </p>
*
* <h2>Key management</h2>
* <ul>
* <li>Key generation is configured via
* {@link zeroecho.core.alg.slhdsa.SlhDsaKeyGenSpec} and performed by
* {@link zeroecho.core.alg.slhdsa.SlhDsaKeyGenBuilder}.</li>
* <li>Encoded public and private keys are represented by
* {@link zeroecho.core.alg.slhdsa.SlhDsaPublicKeySpec} and
* {@link zeroecho.core.alg.slhdsa.SlhDsaPrivateKeySpec}.</li>
* <li>All key specifications are immutable and use defensive copies.</li>
* </ul>
*
* <h2>Streaming signature model</h2>
* <p>
* Signatures are processed through the ZeroEcho streaming signature
* infrastructure:
* </p>
* <ul>
* <li>In {@link zeroecho.core.KeyUsage#SIGN} mode, the signature is appended as
* a fixed-length trailer to the output stream.</li>
* <li>In {@link zeroecho.core.KeyUsage#VERIFY} mode, the wrapped stream emits
* only the message body; verification is performed at end-of-stream against a
* caller-supplied expected signature.</li>
* </ul>
*
* <p>
* The canonical signature length is derived from the public key parameters by
* {@link zeroecho.core.alg.slhdsa.SlhDsaSignatureContext} and matches the
* standard SLH-DSA signature sizes defined by FIPS&nbsp;205.
* </p>
*
* <h2>Security considerations</h2>
* <ul>
* <li>No sensitive material (private keys, signatures, message contents) is
* logged or exposed by this package.</li>
* <li>All byte arrays returned to callers are defensive copies.</li>
* <li>Verification failures are surfaced exclusively via the configured
* verification strategy.</li>
* </ul>
*
* <h2>Relationship to SPHINCS+</h2>
* <p>
* The {@code slhdsa} package represents the standardized SLH-DSA profile only.
* A separate {@code sphincsplus} package may expose the full SPHINCS+ design
* space and experimental variants. No API-level equivalence between the two
* packages is assumed.
* </p>
*
* @since 1.0
*/
package zeroecho.core.alg.slhdsa;

View File

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

View File

@@ -166,7 +166,7 @@ final class SmartContinuousBlockStream extends AbstractChunkTransformInputStream
out = outBuf = Arrays.copyOf(outBuf, outOff + finBlockSize); // NOPMD out = outBuf = Arrays.copyOf(outBuf, outOff + finBlockSize); // NOPMD
} }
int written = cipher.doFinal(in, inOff, len, out, outOff); // NOPMD int written = cipher.doFinal(in, inOff, len, out, outOff);
return written; return written;
// return cipher.doFinal(in, inOff, len, out, outOff); // return cipher.doFinal(in, inOff, len, out, outOff);

View File

@@ -229,9 +229,9 @@ public abstract class TailStrippingInputStream extends InputStream {
emitPos += chunk; emitPos += chunk;
if (emitPos == emitLen) { if (emitPos == emitLen) {
// Emission finished; now it's safe to compact tail to start. // Emission finished; now it's safe to compact tail to start.
if (needCompactAfterEmit) { if (needCompactAfterEmit) { // NOPMD
int remaining = accLen - emitLen; // this is the withheld tail (<= tailLen) int remaining = accLen - emitLen; // this is the withheld tail (<= tailLen)
if (remaining > 0) { // NOPMD if (remaining > 0) {
System.arraycopy(window, emitLen, window, 0, remaining); System.arraycopy(window, emitLen, window, 0, remaining);
} }
accLen = remaining; accLen = remaining;
@@ -251,7 +251,7 @@ public abstract class TailStrippingInputStream extends InputStream {
// Whatever remains in window are the final tail bytes (<= tailLen). // Whatever remains in window are the final tail bytes (<= tailLen).
byte[] tail = Arrays.copyOf(window, accLen); byte[] tail = Arrays.copyOf(window, accLen);
if (LOG.isLoggable(Level.FINE)) { if (LOG.isLoggable(Level.FINE)) { // NOPMD
LOG.log(Level.FINE, "tail found {0}", Strings.toShortString(tail)); LOG.log(Level.FINE, "tail found {0}", Strings.toShortString(tail));
} }
tailProcessed = true; tailProcessed = true;
@@ -286,7 +286,7 @@ public abstract class TailStrippingInputStream extends InputStream {
System.arraycopy(window, emitPos, b, off, chunk); System.arraycopy(window, emitPos, b, off, chunk);
emitPos += chunk; emitPos += chunk;
if (emitPos == emitLen) { if (emitPos == emitLen) {
if (needCompactAfterEmit) { if (needCompactAfterEmit) { // NOPMD
int remaining = accLen - emitLen; int remaining = accLen - emitLen;
if (remaining > 0) { if (remaining > 0) {
System.arraycopy(window, emitLen, window, 0, remaining); System.arraycopy(window, emitLen, window, 0, remaining);

View File

@@ -70,6 +70,10 @@ import javax.crypto.SecretKey;
* {@code key.getAlgorithm()} (for example, 512, 768, 1024 for ML-KEM; * {@code key.getAlgorithm()} (for example, 512, 768, 1024 for ML-KEM;
* 640/976/1344 for FrodoKEM) or from NIST security levels labeled as L1/L3/L5. * 640/976/1344 for FrodoKEM) or from NIST security levels labeled as L1/L3/L5.
* If neither is present, defaults to 128.</li> * If neither is present, defaults to 128.</li>
* <li><b>ML-DSA</b>: estimated from the parameter set markers (44/65/87 mapped
* to 128/192/256, or L1/L3/L5).</li>
* <li><b>SLH-DSA:</b> parse 128/192/256 from {@code key.getAlgorithm()}
* similarly to SPHINCS+; else default 128.</li>
* <li><b>SPHINCS+:</b> parses the parameter size 128/192/256 from the algorithm * <li><b>SPHINCS+:</b> parses the parameter size 128/192/256 from the algorithm
* string; otherwise defaults to 128.</li> * string; otherwise defaults to 128.</li>
* <li><b>EdDSA:</b> returns fixed strengths (Ed25519 -> 128, Ed448 -> * <li><b>EdDSA:</b> returns fixed strengths (Ed25519 -> 128, Ed448 ->
@@ -122,6 +126,8 @@ public final class SecurityStrengthAdvisor { // NOPMD
private static final Pattern SPHINCS_STRENGTH_PATTERN = Pattern.compile("(128|192|256)"); private static final Pattern SPHINCS_STRENGTH_PATTERN = Pattern.compile("(128|192|256)");
private static final Pattern HMAC_SHA_PATTERN = Pattern.compile("HMAC(?:-)?SHA(?:-)?(1|224|256|384|512)", private static final Pattern HMAC_SHA_PATTERN = Pattern.compile("HMAC(?:-)?SHA(?:-)?(1|224|256|384|512)",
Pattern.CASE_INSENSITIVE); Pattern.CASE_INSENSITIVE);
private static final Pattern SLHDSA_STRENGTH_PATTERN = Pattern.compile("(128|192|256)");
private static final Pattern MLDSA_SET_PATTERN = Pattern.compile("(44|65|87)");
private SecurityStrengthAdvisor() { private SecurityStrengthAdvisor() {
} }
@@ -153,6 +159,7 @@ public final class SecurityStrengthAdvisor { // NOPMD
case "ED25519" -> 128; case "ED25519" -> 128;
case "ED448" -> 224; case "ED448" -> 224;
case "ML-KEM" -> kyberStrength(key); case "ML-KEM" -> kyberStrength(key);
case "ML-DSA", "MLDSA" -> mldsaStrength(key);
case "BIKE" -> mapByNistLevel(key, 128, 192, 256); case "BIKE" -> mapByNistLevel(key, 128, 192, 256);
case "HQC" -> mapByNistLevel(key, 128, 192, 256); case "HQC" -> mapByNistLevel(key, 128, 192, 256);
case "FRODO" -> frodoStrength(key); case "FRODO" -> frodoStrength(key);
@@ -161,6 +168,7 @@ public final class SecurityStrengthAdvisor { // NOPMD
case "NTRU" -> ntruStrength(key); case "NTRU" -> ntruStrength(key);
case "SNTRUPRIME" -> sntruPrimeStrength(key); case "SNTRUPRIME" -> sntruPrimeStrength(key);
case "NTRULPRIME" -> ntruLPrimeStrength(key); case "NTRULPRIME" -> ntruLPrimeStrength(key);
case "SLH-DSA", "SLHDSA" -> slhDsaStrength(key);
case "SPHINCS+", "SPHINCSPLUS" -> sphincsPlusStrength(key); case "SPHINCS+", "SPHINCSPLUS" -> sphincsPlusStrength(key);
case "DIGEST" -> 128; case "DIGEST" -> 128;
default -> 128; default -> 128;
@@ -356,6 +364,31 @@ public final class SecurityStrengthAdvisor { // NOPMD
return 128; return 128;
} }
private static int slhDsaStrength(Key key) {
// Provider strings observed: "SLH-DSA-SHAKE-128S", "slh-dsa-sha2-192f",
// sometimes with separators or additional tokens. We normalize and then parse.
String a = safeAlgo(key);
String normalized = a.toLowerCase(Locale.ROOT).replace('_', '-');
// Prefer explicit numeric strength markers (128/192/256) in the algorithm name.
Matcher m = SLHDSA_STRENGTH_PATTERN.matcher(normalized);
if (m.find()) {
int v = parseIntSafe(m.group(1));
if (v == 128 || v == 192 || v == 256) {
return v;
}
}
// Fall back to L1/L3/L5 markers when present.
int byLevel = mapByNistLevel(key, 128, 192, 256);
if (byLevel != 0) {
return byLevel;
}
// Conservative default.
return 128;
}
private static int mapByNistLevel(Key key, int l1, int l3, int l5) { private static int mapByNistLevel(Key key, int l1, int l3, int l5) {
String a = safeAlgo(key); String a = safeAlgo(key);
@@ -416,4 +449,27 @@ public final class SecurityStrengthAdvisor { // NOPMD
return 0; return 0;
} }
} }
private static int mldsaStrength(Key key) {
String a = safeAlgo(key);
String normalized = a.toLowerCase(Locale.ROOT).replace('_', '-');
Matcher m = MLDSA_SET_PATTERN.matcher(normalized);
if (m.find()) {
int set = parseIntSafe(m.group(1));
return switch (set) {
case 44 -> 128;
case 65 -> 192;
case 87 -> 256;
default -> 128;
};
}
int byLevel = mapByNistLevel(key, 128, 192, 256);
if (byLevel != 0) {
return byLevel;
}
return 128;
}
} }

View File

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

View File

@@ -59,12 +59,7 @@ public final class Strings {
* truncated after 32 elements, followed by an ellipsis marker: {@code [...]}. * truncated after 32 elements, followed by an ellipsis marker: {@code [...]}.
* </p> * </p>
* *
* <p> * <h4>Examples</h4> <pre>{@code
* Examples:
* </p>
*
* <pre>
* {@code
* Strings.toShortString(null) -> "null" * Strings.toShortString(null) -> "null"
* Strings.toShortString(new byte[0]) -> "[]" * Strings.toShortString(new byte[0]) -> "[]"
* Strings.toShortString(new byte[]{1, 2, 3}) * Strings.toShortString(new byte[]{1, 2, 3})
@@ -118,7 +113,7 @@ public final class Strings {
* <li>If {@code a} is empty, the string {@code "[]"} is returned.</li> * <li>If {@code a} is empty, the string {@code "[]"} is returned.</li>
* </ul> * </ul>
* *
* <h2>Examples</h2> <pre>{@code * <h4>Examples</h4> <pre>{@code
* toShortHexString(null) -> "null" * toShortHexString(null) -> "null"
* toShortHexString(new byte[0]) -> "[]" * toShortHexString(new byte[0]) -> "[]"
* toShortHexString(new byte[]{0x01}) -> "[0x01]" * toShortHexString(new byte[]{0x01}) -> "[0x01]"

View File

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

View File

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

View File

@@ -451,7 +451,7 @@ public final class ChaChaDataContentBuilder implements DataContentBuilder<DataCo
ctx.put(ConfluxKeys.iv(algId), nonce); ctx.put(ConfluxKeys.iv(algId), nonce);
} }
if (v == Variant.AEAD) { if (v == Variant.AEAD) {
if (aad != null) { if (aad != null) { // NOPMD
ctx.put(ConfluxKeys.aad(algId), aad); ctx.put(ConfluxKeys.aad(algId), aad);
} }
} else { } else {

View File

@@ -253,6 +253,40 @@ public final class HmacDataContentBuilder implements DataContentBuilder<PlainCon
return new HmacDataContentBuilder(); return new HmacDataContentBuilder();
} }
/**
* Returns the currently configured HMAC specification.
*
* <p>
* This accessor is intentionally read-only and exists to support safe
* integrations (for example hybrid-derived key injection) without duplicating
* the HMAC variant configuration outside of this builder.
* </p>
*
* @return current HMAC spec (never null)
* @since 1.0
*/
public HmacSpec spec() {
return this.spec;
}
/**
* Returns a recommended HMAC key size (in bits) for the currently configured
* {@link #spec()}.
*
* <p>
* This is a convenience forwarding method to
* {@link HmacSpec#recommendedKeyBits()} and is intended as the default choice
* for derived-key integrations. Callers that intentionally need a non-default
* size may override it explicitly.
* </p>
*
* @return recommended key size in bits (positive, multiple of 8)
* @since 1.0
*/
public int recommendedKeyBits() {
return this.spec.recommendedKeyBits();
}
/** /**
* Switches the builder to MAC mode. * Switches the builder to MAC mode.
* *

View File

@@ -0,0 +1,258 @@
/*******************************************************************************
* 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.alg;
import java.security.GeneralSecurityException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.Objects;
import java.util.function.Supplier;
import zeroecho.core.CryptoAlgorithm;
import zeroecho.core.alg.mldsa.MldsaKeyGenSpec;
import zeroecho.core.alg.mldsa.MldsaPrivateKeySpec;
import zeroecho.core.alg.mldsa.MldsaPublicKeySpec;
import zeroecho.core.alg.mldsa.MldsaSignatureContext;
import zeroecho.core.context.SignatureContext;
/**
* Streaming signature builder for ML-DSA (FIPS 204).
*
* <p>
* This builder integrates ML-DSA with the reusable streaming pipeline provided
* by {@link AbstractStreamingSignatureDataBuilder}. It supports signing or
* verifying data while it flows through an {@link java.io.InputStream}, as well
* as emitting detached signature artifacts in raw, hex or Base64 encodings.
* </p>
*
* <p>
* Key material may be provided directly, imported (X.509 / PKCS#8), or
* generated on demand using an algorithm-specific {@link MldsaKeyGenSpec}.
* </p>
*
* @since 1.0
*/
public final class MldsaDataContentBuilder
extends AbstractStreamingSignatureDataBuilder<MldsaKeyGenSpec, MldsaPublicKeySpec, MldsaPrivateKeySpec> {
private MldsaKeyGenSpec keyGenSpec;
/**
* Creates a new ML-DSA streaming builder instance.
*
* @return new builder
*/
public static MldsaDataContentBuilder builder() {
return new MldsaDataContentBuilder();
}
/**
* Sets a non-default key generation specification used when
* {@link #generateKeyPair()} is requested.
*
* @param spec key generation spec; must not be {@code null}
* @return {@code this} for chaining
* @throws NullPointerException if {@code spec} is {@code null}
*/
public MldsaDataContentBuilder withKeyGenSpec(MldsaKeyGenSpec spec) {
this.keyGenSpec = Objects.requireNonNull(spec);
return this;
}
@Override
protected String algorithmName() {
return "ML-DSA";
}
@Override
protected SignatureContext newSignContext(CryptoAlgorithm alg, PrivateKey key) throws GeneralSecurityException {
return new MldsaSignatureContext(alg, key);
}
@Override
protected SignatureContext newVerifyContext(CryptoAlgorithm alg, PublicKey key) throws GeneralSecurityException {
return new MldsaSignatureContext(alg, key);
}
@Override
protected Class<MldsaKeyGenSpec> keyGenSpecClass() {
return MldsaKeyGenSpec.class;
}
@Override
protected Class<MldsaPublicKeySpec> publicKeySpecClass() {
return MldsaPublicKeySpec.class;
}
@Override
protected Class<MldsaPrivateKeySpec> privateKeySpecClass() {
return MldsaPrivateKeySpec.class;
}
@Override
protected Supplier<MldsaKeyGenSpec> defaultKeyGenSpecSupplier() {
return MldsaKeyGenSpec::defaultSpec;
}
@Override
protected MldsaKeyGenSpec currentKeyGenSpecOrNull() {
return keyGenSpec;
}
@Override
protected MldsaPublicKeySpec makePublicKeySpec(byte[] x509, String providerHint) {
return (providerHint == null) ? new MldsaPublicKeySpec(x509) : new MldsaPublicKeySpec(x509, providerHint);
}
@Override
protected MldsaPrivateKeySpec makePrivateKeySpec(byte[] pkcs8, String providerHint) {
return (providerHint == null) ? new MldsaPrivateKeySpec(pkcs8) : new MldsaPrivateKeySpec(pkcs8, providerHint);
}
@Override
protected String defaultProviderHint() {
return "BC";
}
// Optional: covariant fluent overrides for better chaining ergonomics.
@Override
public MldsaDataContentBuilder sign() {
super.sign();
return this;
}
@Override
public MldsaDataContentBuilder verify() {
super.verify();
return this;
}
@Override
public MldsaDataContentBuilder passThrough() {
super.passThrough();
return this;
}
@Override
public MldsaDataContentBuilder emitRawSignature() {
super.emitRawSignature();
return this;
}
@Override
public MldsaDataContentBuilder emitHexSignature() {
super.emitHexSignature();
return this;
}
@Override
public MldsaDataContentBuilder emitBase64Signature() {
super.emitBase64Signature();
return this;
}
@Override
public MldsaDataContentBuilder emitVerificationBoolean() {
super.emitVerificationBoolean();
return this;
}
@Override
public MldsaDataContentBuilder bufferSize(int bytes) {
super.bufferSize(bytes);
return this;
}
@Override
public MldsaDataContentBuilder withPrivateKey(PrivateKey k) {
super.withPrivateKey(k);
return this;
}
@Override
public MldsaDataContentBuilder withPublicKey(PublicKey k) {
super.withPublicKey(k);
return this;
}
@Override
public MldsaDataContentBuilder generateKeyPair() {
super.generateKeyPair();
return this;
}
@Override
public MldsaDataContentBuilder importPrivatePkcs8(byte[] pkcs8) {
super.importPrivatePkcs8(pkcs8);
return this;
}
@Override
public MldsaDataContentBuilder importPrivatePkcs8(byte[] pkcs8, String providerName) {
super.importPrivatePkcs8(pkcs8, providerName);
return this;
}
@Override
public MldsaDataContentBuilder importPublicX509(byte[] x509) {
super.importPublicX509(x509);
return this;
}
@Override
public MldsaDataContentBuilder importPublicX509(byte[] x509, String providerName) {
super.importPublicX509(x509, providerName);
return this;
}
@Override
public MldsaDataContentBuilder expectedSignature(byte[] raw) {
super.expectedSignature(raw);
return this;
}
@Override
public MldsaDataContentBuilder expectedSignatureHex(String hex) {
super.expectedSignatureHex(hex);
return this;
}
@Override
public MldsaDataContentBuilder expectedSignatureBase64(String b64) {
super.expectedSignatureBase64(b64);
return this;
}
}

View File

@@ -0,0 +1,258 @@
/*******************************************************************************
* 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.alg;
import java.security.GeneralSecurityException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.Objects;
import java.util.function.Supplier;
import zeroecho.core.CryptoAlgorithm;
import zeroecho.core.alg.slhdsa.SlhDsaKeyGenSpec;
import zeroecho.core.alg.slhdsa.SlhDsaPrivateKeySpec;
import zeroecho.core.alg.slhdsa.SlhDsaPublicKeySpec;
import zeroecho.core.alg.slhdsa.SlhDsaSignatureContext;
import zeroecho.core.context.SignatureContext;
/**
* Streaming signature builder for SLH-DSA (FIPS 205).
*
* <p>
* This builder integrates SLH-DSA with the reusable streaming pipeline provided
* by {@link AbstractStreamingSignatureDataBuilder}. It supports signing or
* verifying data while it flows through an {@link java.io.InputStream}, as well
* as emitting detached signature artifacts in raw, hex or Base64 encodings.
* </p>
*
* <p>
* Key material may be provided directly, imported (X.509 / PKCS#8), or
* generated on demand using an algorithm-specific {@link SlhDsaKeyGenSpec}.
* </p>
*
* @since 1.0
*/
public final class SlhDsaDataContentBuilder
extends AbstractStreamingSignatureDataBuilder<SlhDsaKeyGenSpec, SlhDsaPublicKeySpec, SlhDsaPrivateKeySpec> {
private SlhDsaKeyGenSpec keyGenSpec;
/**
* Creates a new SLH-DSA streaming builder instance.
*
* @return new builder
*/
public static SlhDsaDataContentBuilder builder() {
return new SlhDsaDataContentBuilder();
}
/**
* Sets a non-default key generation specification used when
* {@link #generateKeyPair()} is requested.
*
* @param spec key generation spec; must not be {@code null}
* @return {@code this} for chaining
* @throws NullPointerException if {@code spec} is {@code null}
*/
public SlhDsaDataContentBuilder withKeyGenSpec(SlhDsaKeyGenSpec spec) {
this.keyGenSpec = Objects.requireNonNull(spec);
return this;
}
@Override
protected String algorithmName() {
return "SLH-DSA";
}
@Override
protected SignatureContext newSignContext(CryptoAlgorithm alg, PrivateKey key) throws GeneralSecurityException {
return new SlhDsaSignatureContext(alg, key);
}
@Override
protected SignatureContext newVerifyContext(CryptoAlgorithm alg, PublicKey key) throws GeneralSecurityException {
return new SlhDsaSignatureContext(alg, key);
}
@Override
protected Class<SlhDsaKeyGenSpec> keyGenSpecClass() {
return SlhDsaKeyGenSpec.class;
}
@Override
protected Class<SlhDsaPublicKeySpec> publicKeySpecClass() {
return SlhDsaPublicKeySpec.class;
}
@Override
protected Class<SlhDsaPrivateKeySpec> privateKeySpecClass() {
return SlhDsaPrivateKeySpec.class;
}
@Override
protected Supplier<SlhDsaKeyGenSpec> defaultKeyGenSpecSupplier() {
return SlhDsaKeyGenSpec::defaultSpec;
}
@Override
protected SlhDsaKeyGenSpec currentKeyGenSpecOrNull() {
return keyGenSpec;
}
@Override
protected SlhDsaPublicKeySpec makePublicKeySpec(byte[] x509, String providerHint) {
return (providerHint == null) ? new SlhDsaPublicKeySpec(x509) : new SlhDsaPublicKeySpec(x509, providerHint);
}
@Override
protected SlhDsaPrivateKeySpec makePrivateKeySpec(byte[] pkcs8, String providerHint) {
return (providerHint == null) ? new SlhDsaPrivateKeySpec(pkcs8) : new SlhDsaPrivateKeySpec(pkcs8, providerHint);
}
@Override
protected String defaultProviderHint() {
return "BC";
}
// Optional: covariant fluent overrides for better chaining ergonomics.
@Override
public SlhDsaDataContentBuilder sign() {
super.sign();
return this;
}
@Override
public SlhDsaDataContentBuilder verify() {
super.verify();
return this;
}
@Override
public SlhDsaDataContentBuilder passThrough() {
super.passThrough();
return this;
}
@Override
public SlhDsaDataContentBuilder emitRawSignature() {
super.emitRawSignature();
return this;
}
@Override
public SlhDsaDataContentBuilder emitHexSignature() {
super.emitHexSignature();
return this;
}
@Override
public SlhDsaDataContentBuilder emitBase64Signature() {
super.emitBase64Signature();
return this;
}
@Override
public SlhDsaDataContentBuilder emitVerificationBoolean() {
super.emitVerificationBoolean();
return this;
}
@Override
public SlhDsaDataContentBuilder bufferSize(int bytes) {
super.bufferSize(bytes);
return this;
}
@Override
public SlhDsaDataContentBuilder withPrivateKey(PrivateKey k) {
super.withPrivateKey(k);
return this;
}
@Override
public SlhDsaDataContentBuilder withPublicKey(PublicKey k) {
super.withPublicKey(k);
return this;
}
@Override
public SlhDsaDataContentBuilder generateKeyPair() {
super.generateKeyPair();
return this;
}
@Override
public SlhDsaDataContentBuilder importPrivatePkcs8(byte[] pkcs8) {
super.importPrivatePkcs8(pkcs8);
return this;
}
@Override
public SlhDsaDataContentBuilder importPrivatePkcs8(byte[] pkcs8, String providerName) {
super.importPrivatePkcs8(pkcs8, providerName);
return this;
}
@Override
public SlhDsaDataContentBuilder importPublicX509(byte[] x509) {
super.importPublicX509(x509);
return this;
}
@Override
public SlhDsaDataContentBuilder importPublicX509(byte[] x509, String providerName) {
super.importPublicX509(x509, providerName);
return this;
}
@Override
public SlhDsaDataContentBuilder expectedSignature(byte[] raw) {
super.expectedSignature(raw);
return this;
}
@Override
public SlhDsaDataContentBuilder expectedSignatureHex(String hex) {
super.expectedSignatureHex(hex);
return this;
}
@Override
public SlhDsaDataContentBuilder expectedSignatureBase64(String b64) {
super.expectedSignatureBase64(b64);
return this;
}
}

View File

@@ -67,8 +67,9 @@
* <li>{@link RsaEncDataContentBuilder} and {@link ElgamalEncDataContentBuilder} * <li>{@link RsaEncDataContentBuilder} and {@link ElgamalEncDataContentBuilder}
* - wrap asymmetric encryption.</li> * - wrap asymmetric encryption.</li>
* <li>{@link RsaSigDataContentBuilder}, {@link EcdsaDataContentBuilder}, * <li>{@link RsaSigDataContentBuilder}, {@link EcdsaDataContentBuilder},
* {@link Ed25519DataContentBuilder}, {@link Ed448DataContentBuilder}, and * {@link Ed25519DataContentBuilder}, {@link Ed448DataContentBuilder},
* {@link SphincsPlusDataContentBuilder} - perform streaming signatures and * {@link SphincsPlusDataContentBuilder}, {@link SlhDsaDataContentBuilder}, and
* {@link MldsaDataContentBuilder} - perform streaming signatures and
* verification.</li> * verification.</li>
* <li>{@link KemDataContentBuilder} - implement KEM-first envelopes and inject * <li>{@link KemDataContentBuilder} - implement KEM-first envelopes and inject
* the derived key into a chosen symmetric payload builder.</li> * the derived key into a chosen symmetric payload builder.</li>

View File

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

View File

@@ -129,7 +129,7 @@ final class Decryptor implements PlainContent {
byte[] maybe = op.tryOpen(id, blob, material); byte[] maybe = op.tryOpen(id, blob, material);
if (maybe != null) { if (maybe != null) {
if (maybe.length > keyBytes) { if (maybe.length > keyBytes) {
if (LOG.isLoggable(Level.WARNING)) { if (LOG.isLoggable(Level.WARNING)) { // NOPMD
LOG.log(Level.WARNING, LOG.log(Level.WARNING,
"Suspicious material in field {0}: {1}/{2} finds the secret of length {3}, while {4} is a limit. Ignoring.", "Suspicious material in field {0}: {1}/{2} finds the secret of length {3}, while {4} is a limit. Ignoring.",
new Object[] { i, id, op.toString(), maybe.length, keyBytes }); // NOPMD new Object[] { i, id, op.toString(), maybe.length, keyBytes }); // NOPMD

View File

@@ -78,7 +78,7 @@ public final class PasswordRecipient implements Recipient {
* marked as a decoy to obscure the actual number of usable recipients. * marked as a decoy to obscure the actual number of usable recipients.
* </p> * </p>
* *
* <h2>Security considerations</h2> * <h4>Security considerations</h4>
* <ul> * <ul>
* <li>The caller should clear the {@code password} array after constructing the * <li>The caller should clear the {@code password} array after constructing the
* recipient to minimize exposure in memory.</li> * recipient to minimize exposure in memory.</li>

View File

@@ -48,10 +48,7 @@ import java.security.GeneralSecurityException;
* envelope writes the identifier and a length-prefixed copy of the blob into * envelope writes the identifier and a length-prefixed copy of the blob into
* its header. * its header.
* *
* <p> * <h2>Typical usage</h2> <pre>{@code
* Typical usage:
* </p>
* <pre>{@code
* // RSA-OAEP recipient; the header will contain the id and the RSA-wrapped CEK * // RSA-OAEP recipient; the header will contain the id and the RSA-wrapped CEK
* Recipient r = new MultiRecipientDataSourceBuilder.RsaOaepRecipient(rsaPublicKey); * Recipient r = new MultiRecipientDataSourceBuilder.RsaOaepRecipient(rsaPublicKey);
* byte[] entryBlob = r.buildRecipientEntry(cek); * byte[] entryBlob = r.buildRecipientEntry(cek);
@@ -67,7 +64,7 @@ public interface Recipient {
* intentionally unusable for recovering the content-encryption key (CEK). * intentionally unusable for recovering the content-encryption key (CEK).
* </p> * </p>
* *
* <h2>Security considerations</h2> * <h4>Security considerations</h4>
* <ul> * <ul>
* <li>Decoys prevent traffic analysis from revealing how many legitimate * <li>Decoys prevent traffic analysis from revealing how many legitimate
* recipients are present.</li> * recipients are present.</li>

View File

@@ -0,0 +1,378 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.sdk.hybrid.derived;
import java.util.Objects;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import zeroecho.core.alg.hmac.HmacSpec;
import zeroecho.sdk.builders.alg.AesDataContentBuilder;
import zeroecho.sdk.builders.alg.ChaChaDataContentBuilder;
import zeroecho.sdk.builders.alg.HmacDataContentBuilder;
import zeroecho.sdk.hybrid.kex.HybridKexExporter;
/**
* Builder-style utility for deriving purpose-separated key material from a
* hybrid KEX exporter and applying it to streaming algorithm builders.
*
* <p>
* This class does not implement new cryptographic primitives. It derives keying
* bytes via HKDF labels (using {@link HybridKexExporter}) and injects them into
* existing builder instances.
* </p>
*
* <h2>Labeling</h2>
* <p>
* Derivation uses a base label, plus fixed suffixes for individual fields:
* </p>
* <ul>
* <li>{@code label + "/key"} for the secret key</li>
* <li>{@code label + "/iv"} for AES IV</li>
* <li>{@code label + "/nonce"} for ChaCha nonce</li>
* <li>{@code label + "/aad"} for AEAD AAD (optional, if derived)</li>
* </ul>
*
* <p>
* A caller-supplied transcript binding (public handshake context) may be
* included and will be passed to the exporter as {@code info}. This improves
* cross-protocol separation and reduces configuration mistakes.
* </p>
*
* <h2>Thread safety</h2>
* <p>
* Instances are mutable and not thread-safe.
* </p>
*
* @since 1.0
*/
public final class HybridDerived {
private final HybridKexExporter exporter;
private String label;
private byte[] transcript;
private byte[] aadExplicit;
private boolean aadDerive;
private int aadDeriveLen;
/**
* Creates a new derived-material builder backed by an exporter.
*
* @param exporter exporter seeded from a hybrid KEX result (must not be null)
* @return new derived-material builder
* @throws NullPointerException if exporter is null
* @since 1.0
*/
public static HybridDerived from(HybridKexExporter exporter) {
Objects.requireNonNull(exporter, "exporter");
return new HybridDerived(exporter);
}
private HybridDerived(HybridKexExporter exporter) {
this.exporter = exporter;
}
/**
* Sets the base label used for purpose separation.
*
* <p>
* The label should identify the protocol purpose of the derived material, for
* example {@code "app/enc"} or {@code "handshake/confirm"}.
* </p>
*
* @param label base label (must not be null or empty)
* @return this builder
* @throws NullPointerException if label is null
* @throws IllegalArgumentException if label is empty
* @since 1.0
*/
public HybridDerived label(String label) {
Objects.requireNonNull(label, "label");
if (label.isEmpty()) {
throw new IllegalArgumentException("label must not be empty");
}
this.label = label;
return this;
}
/**
* Sets transcript binding bytes used as exporter {@code info}.
*
* <p>
* The transcript should contain only public context (negotiated suite, public
* keys/messages, channel binding, etc.). It must not contain secrets.
* </p>
*
* @param transcript transcript bytes (may be null to clear)
* @return this builder
* @since 1.0
*/
public HybridDerived transcript(byte[] transcript) {
this.transcript = (transcript == null) ? null : transcript.clone();
return this;
}
/**
* Supplies explicit AAD bytes to be injected into AEAD builders.
*
* <p>
* If set, no AAD derivation is performed.
* </p>
*
* @param aad AAD bytes (may be null to clear)
* @return this builder
* @since 1.0
*/
public HybridDerived aad(byte[] aad) {
this.aadExplicit = (aad == null) ? null : aad.clone();
this.aadDerive = false;
this.aadDeriveLen = 0;
return this;
}
/**
* Requests deterministic derivation of AAD bytes from the exporter.
*
* <p>
* This is optional. Many applications prefer to keep AAD as an
* application-defined, already-available public context. If derived, the AAD is
* separated using {@code label + "/aad"}.
* </p>
*
* @param aadLen number of bytes to derive (must be &gt;= 1)
* @return this builder
* @throws IllegalArgumentException if aadLen &lt; 1
* @since 1.0
*/
public HybridDerived deriveAad(int aadLen) {
if (aadLen < 1) { // NOPMD
throw new IllegalArgumentException("aadLen must be >= 1");
}
this.aadDerive = true;
this.aadDeriveLen = aadLen;
this.aadExplicit = null;
return this;
}
/**
* Derives an AES key and applies it (and optional IV/AAD) to the provided AES
* builder.
*
* <p>
* The returned value is the same builder instance to preserve fluent pipeline
* construction.
* </p>
*
* @param aes AES builder to configure (must not be null)
* @param keyBits AES key size in bits (128/192/256)
* @param ivLenBytes if &gt; 0, derive IV of this length and inject it via
* {@code withIv(...)}; if 0, do not set IV (header/ctx may
* generate it)
* @return the provided builder instance
* @throws NullPointerException if aes is null
* @throws IllegalArgumentException if keyBits is invalid
* @since 1.0
*/
public AesDataContentBuilder applyToAesGcm(AesDataContentBuilder aes, int keyBits, int ivLenBytes) {
Objects.requireNonNull(aes, "aes");
validateBase();
int keyLenBytes = bitsToBytesStrict(keyBits);
byte[] keyRaw = exportBytes(label + "/key", keyLenBytes); // NOPMD
SecretKey key = new SecretKeySpec(keyRaw, "AES");
aes.withKey(key);
if (ivLenBytes > 0) {
byte[] iv = exportBytes(label + "/iv", ivLenBytes);
aes.withIv(iv);
}
byte[] aad = resolveAad();
if (aad != null) {
aes.withAad(aad);
}
return aes;
}
/**
* Derives a ChaCha key and applies it (and optional nonce/AAD) to the provided
* ChaCha builder.
*
* <p>
* The returned value is the same builder instance to preserve fluent pipeline
* construction.
* </p>
*
* @param chacha ChaCha builder to configure (must not be null)
* @param keyBits key size in bits (typically 256)
* @param nonceLenBytes if &gt; 0, derive nonce of this length and inject it via
* {@code withNonce(...)}; if 0, do not set nonce
* (header/ctx may generate it)
* @return the provided builder instance
* @throws NullPointerException if chacha is null
* @throws IllegalArgumentException if keyBits is invalid
* @since 1.0
*/
public ChaChaDataContentBuilder applyToChaChaAead(ChaChaDataContentBuilder chacha, int keyBits, int nonceLenBytes) {
Objects.requireNonNull(chacha, "chacha");
validateBase();
int keyLenBytes = bitsToBytesStrict(keyBits);
byte[] keyRaw = exportBytes(label + "/key", keyLenBytes); // NOPMD
SecretKey key = new SecretKeySpec(keyRaw, "ChaCha20");
chacha.withKey(key);
if (nonceLenBytes > 0) {
byte[] nonce = exportBytes(label + "/nonce", nonceLenBytes);
chacha.withNonce(nonce);
}
byte[] aad = resolveAad();
if (aad != null) {
chacha.withAad(aad);
}
return chacha;
}
/**
* Derives a MAC key using the builder's recommended size and applies it to the
* provided HMAC builder.
*
* <p>
* This is the preferred integration method because it avoids duplicated
* configuration: the HMAC variant is chosen by the builder
* ({@link HmacDataContentBuilder#spec()}), and the key size recommendation is
* provided by {@link HmacDataContentBuilder#recommendedKeyBits()}.
* </p>
*
* <p>
* The returned value is the same builder instance to preserve fluent pipeline
* construction.
* </p>
*
* @param hmac HMAC builder to configure (must not be null)
* @return the provided builder instance
* @throws NullPointerException if {@code hmac} is null
* @throws IllegalStateException if this {@code HybridDerived} instance is
* missing required base configuration
* @since 1.0
*/
public HmacDataContentBuilder applyToHmac(HmacDataContentBuilder hmac) {
Objects.requireNonNull(hmac, "hmac");
validateBase();
int keyBits = hmac.recommendedKeyBits();
return applyToHmac(hmac, keyBits);
}
/**
* Derives a MAC key of an explicit size (override) and applies it to the
* provided HMAC builder.
*
* <p>
* This overload exists for advanced use-cases where the application
* intentionally chooses a key size different from
* {@link HmacSpec#recommendedKeyBits()}, for example to align a policy across
* different MAC functions or to satisfy interoperability constraints.
* </p>
*
* <p>
* Because HMAC accepts arbitrary key lengths, this method does not attempt to
* validate semantic suitability of {@code keyBits}. Applications that require
* stricter controls should enforce them via policy (for example minimum bit
* strength) and use transcript-bound labels to guarantee key separation.
* </p>
*
* <p>
* The returned value is the same builder instance to preserve fluent pipeline
* construction.
* </p>
*
* @param hmac HMAC builder to configure (must not be null)
* @param keyBits key size in bits (must be a positive multiple of 8)
* @return the provided builder instance
* @throws NullPointerException if {@code hmac} is null
* @throws IllegalArgumentException if {@code keyBits} is invalid
* @throws IllegalStateException if this {@code HybridDerived} instance is
* missing required base configuration
* @since 1.0
*/
public HmacDataContentBuilder applyToHmac(HmacDataContentBuilder hmac, int keyBits) {
Objects.requireNonNull(hmac, "hmac");
validateBase();
int keyLenBytes = bitsToBytesStrict(keyBits);
byte[] keyRaw = exportBytes(label + "/key", keyLenBytes);
// Prefer raw import to avoid duplicating MAC algorithm naming and to keep the
// builder as the source of truth.
return hmac.importKeyRaw(keyRaw);
}
private void validateBase() {
if (label == null || label.isEmpty()) {
throw new IllegalStateException("label must be set");
}
}
private byte[] resolveAad() {
if (aadExplicit != null) {
return aadExplicit.clone();
}
if (aadDerive) {
return exportBytes(label + "/aad", aadDeriveLen);
}
return null; // NOPMD
}
private byte[] exportBytes(String subLabel, int len) {
byte[] info = transcript;
return exporter.export(subLabel, info, len);
}
private static int bitsToBytesStrict(int bits) {
if (bits < 8 || (bits % 8) != 0) {
throw new IllegalArgumentException("bits must be a positive multiple of 8");
}
return bits / 8;
}
}

View File

@@ -0,0 +1,67 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
/**
* Derived-key utilities for integrating hybrid KEX output with streaming
* builders.
*
* <p>
* This package provides a thin, SDK-level integration layer between hybrid key
* exchange ({@link zeroecho.sdk.hybrid.kex.HybridKexContext} /
* {@link zeroecho.sdk.hybrid.kex.HybridKexExporter}) and streaming data-content
* builders (for example AES/ChaCha/HMAC builders in
* {@link zeroecho.sdk.builders.alg}).
* </p>
*
* <p>
* The central concept is <b>derived material</b>: purpose-separated keying
* bytes (key, optional IV/nonce, optional AAD) derived via HKDF labels. The
* material is then applied to an existing builder via {@code applyTo(...)}
* which returns the same builder instance to preserve fluent pipeline
* construction.
* </p>
*
* <h2>Design goals</h2>
* <ul>
* <li>Keep cryptographic primitives unchanged; only inject derived
* parameters.</li>
* <li>Provide safe-by-construction key separation using labels and transcript
* binding.</li>
* <li>Preserve fluent builder usage by returning the original builder from
* {@code applyTo(...)}.</li>
* </ul>
*
* @since 1.0
*/
package zeroecho.sdk.hybrid.derived;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,94 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
/**
* SDK-level hybrid cryptography utilities.
*
* <p>
* This package groups hybrid composition helpers that combine classical and
* post-quantum primitives at the SDK layer while keeping the underlying core
* contracts unchanged. Hybrid constructions are exposed as regular streaming
* contexts and builder integrations, so they can be used with existing pipeline
* APIs (for example {@link zeroecho.sdk.builders.core.DataContentChainBuilder}
* and trailer-oriented stages).
* </p>
*
* <h2>Subpackages</h2>
* <ul>
* <li>{@link zeroecho.sdk.hybrid.kex} - hybrid key exchange (KEX) that composes
* a classic agreement and a message-based (KEM-style) agreement into a single
* derived shared secret, and emits an explicit peer message suitable for
* transport.</li>
* <li>{@link zeroecho.sdk.hybrid.derived} - derived-key utilities that consume
* hybrid KEX output and inject purpose-separated keying material (key, optional
* IV/nonce, optional AAD) into streaming builders while preserving fluent
* builder usage.</li>
* <li>{@link zeroecho.sdk.hybrid.signature} - hybrid signature composition that
* combines two independent signature schemes and exposes them as a single
* streaming {@link zeroecho.core.context.SignatureContext} suitable for
* trailer-style pipeline stages.</li>
* </ul>
*
* <h2>Design principles</h2>
* <ul>
* <li><b>Composition over modification</b>: hybrids are implemented as
* SDK-level compositions over existing core contexts rather than by expanding
* core API contracts.</li>
* <li><b>Explicit messages where needed</b>: whenever a hybrid operation has
* "to-be-sent" bytes (for example KEX peer messages), they are modeled as
* explicit byte sequences rather than hidden side effects.</li>
* <li><b>Key separation via KDF</b>: hybrid secrets are combined and expanded
* using HKDF label separation and transcript binding; concatenation is avoided
* as a primary combination method.</li>
* </ul>
*
* <h2>Security notes</h2>
* <ul>
* <li>Hybrid constructions increase protocol and implementation complexity.
* Prefer clear profiles, stable transcript inputs, and explicit policy to avoid
* ambiguous security expectations.</li>
* <li>Do not log or otherwise expose sensitive material (private keys, seeds,
* derived keying bytes, plaintexts, intermediate secrets).</li>
* </ul>
*
* <h2>Thread safety</h2>
* <p>
* Hybrid contexts and builders are not thread-safe. Create a new instance per
* independent operation and do not share instances across concurrent pipeline
* executions.
* </p>
*
* @since 1.0
*/
package zeroecho.sdk.hybrid;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,10 +14,12 @@ zeroecho.core.alg.frodo.FrodoAlgorithm
zeroecho.core.alg.hmac.HmacAlgorithm zeroecho.core.alg.hmac.HmacAlgorithm
zeroecho.core.alg.hqc.HqcAlgorithm zeroecho.core.alg.hqc.HqcAlgorithm
zeroecho.core.alg.kyber.KyberAlgorithm zeroecho.core.alg.kyber.KyberAlgorithm
zeroecho.core.alg.mldsa.MldsaAlgorithm
zeroecho.core.alg.ntru.NtruAlgorithm zeroecho.core.alg.ntru.NtruAlgorithm
zeroecho.core.alg.ntruprime.NtrulPrimeAlgorithm zeroecho.core.alg.ntruprime.NtrulPrimeAlgorithm
zeroecho.core.alg.ntruprime.SntruPrimeAlgorithm zeroecho.core.alg.ntruprime.SntruPrimeAlgorithm
zeroecho.core.alg.rsa.RsaAlgorithm zeroecho.core.alg.rsa.RsaAlgorithm
zeroecho.core.alg.saber.SaberAlgorithm zeroecho.core.alg.saber.SaberAlgorithm
zeroecho.core.alg.slhdsa.SlhDsaAlgorithm
zeroecho.core.alg.sphincsplus.SphincsPlusAlgorithm zeroecho.core.alg.sphincsplus.SphincsPlusAlgorithm
zeroecho.core.alg.xdh.XdhAlgorithm zeroecho.core.alg.xdh.XdhAlgorithm

View File

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

View File

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

View File

@@ -0,0 +1,265 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.alg.mldsa;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.KeyPair;
import java.security.SecureRandom;
import java.util.Arrays;
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.sdk.util.BouncyCastleActivator;
/**
* Large-data streaming test for ML-DSA integration.
*
* <p>
* Follows project rule "10) JUnit testy": prints test name, prints intermediate
* results with the {@code "..."} prefix, and prints {@code "...ok"} on success.
* </p>
*
* @since 1.0
*/
public final class MldsaLargeDataTest {
private static final String INDENT = "...";
private static final int MAX_HEX_BYTES = 32;
@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
}
}
/**
* Executes the complete ML-DSA parameter set suite in streaming mode.
*
* @throws Exception on test failure
*/
@Test
void mldsa_complete_suite_streaming_sign_verify_large_data() throws Exception {
System.out.println("mldsa_complete_suite_streaming_sign_verify_large_data");
if (!CryptoAlgorithms.available().contains("ML-DSA")) {
System.out.println(INDENT + " *** SKIP *** ML-DSA not registered");
System.out.println(INDENT + "ok");
return;
}
byte[] msg = randomBytes(48 * 1024 + 123);
System.out.println(INDENT + " msg.len=" + msg.length);
System.out.println(INDENT + " msg.hex=" + hexTruncated(msg, MAX_HEX_BYTES));
runCase(msg, MldsaKeyGenSpec.ParameterSet.ML_DSA_44, MldsaKeyGenSpec.PreHash.NONE);
runCase(msg, MldsaKeyGenSpec.ParameterSet.ML_DSA_65, MldsaKeyGenSpec.PreHash.NONE);
runCase(msg, MldsaKeyGenSpec.ParameterSet.ML_DSA_87, MldsaKeyGenSpec.PreHash.NONE);
runCase(msg, MldsaKeyGenSpec.ParameterSet.ML_DSA_44, MldsaKeyGenSpec.PreHash.SHA512);
runCase(msg, MldsaKeyGenSpec.ParameterSet.ML_DSA_65, MldsaKeyGenSpec.PreHash.SHA512);
runCase(msg, MldsaKeyGenSpec.ParameterSet.ML_DSA_87, MldsaKeyGenSpec.PreHash.SHA512);
System.out.println(INDENT + "ok");
}
private static void runCase(byte[] msg, MldsaKeyGenSpec.ParameterSet ps, MldsaKeyGenSpec.PreHash preHash)
throws Exception {
MldsaKeyGenSpec spec = MldsaKeyGenSpec.of("BC", ps, preHash);
String caseId = "ML-DSA " + ps.name() + " preHash=" + preHash.name();
System.out.println(INDENT + " case=" + safeText(caseId));
KeyPair kp = CryptoAlgorithms.keyPair("ML-DSA", spec);
SignatureContext verifierCtx = CryptoAlgorithms.create("ML-DSA", KeyUsage.VERIFY, kp.getPublic());
if (!(verifierCtx instanceof MldsaSignatureContext mldsaVerifier)) {
try {
verifierCtx.close();
} catch (Exception ignore) {
}
throw new AssertionError(
"VERIFY context must be MldsaSignatureContext, got: " + verifierCtx.getClass().getName());
}
int expectedSigLen = mldsaVerifier.tagLength();
System.out.println(INDENT + " expectedSigLen=" + expectedSigLen);
SignatureContext signer = CryptoAlgorithms.create("ML-DSA", KeyUsage.SIGN, kp.getPrivate());
final byte[][] sigHolder = new byte[1][];
byte[] passthrough;
try (InputStream in = new TailStrippingInputStream(signer.wrap(new ByteArrayInputStream(msg)), expectedSigLen,
8192) {
@Override
protected void processTail(byte[] tail) throws IOException {
sigHolder[0] = (tail == null) ? null : Arrays.copyOf(tail, tail.length);
}
}) {
passthrough = readAll(in);
} finally {
try {
signer.close();
} catch (Exception ignore) {
}
}
assertArrayEquals(msg, passthrough, "SIGN passthrough mismatch");
byte[] signature = sigHolder[0];
assertNotNull(signature, "signature trailer missing");
System.out.println(INDENT + " signature.len=" + signature.length);
System.out.println(INDENT + " signature.hex=" + hexTruncated(signature, MAX_HEX_BYTES));
if (signature.length != expectedSigLen) {
try {
mldsaVerifier.close();
} catch (Exception ignore) {
}
throw new AssertionError(
"Signature length mismatch: got=" + signature.length + " expected=" + expectedSigLen);
}
mldsaVerifier.setExpectedTag(Arrays.copyOf(signature, signature.length));
byte[] verifyOut;
try (InputStream verIn = mldsaVerifier.wrap(new ByteArrayInputStream(msg))) {
verifyOut = readAll(verIn);
} finally {
try {
mldsaVerifier.close();
} catch (Exception ignore) {
}
}
assertArrayEquals(msg, verifyOut, "VERIFY passthrough mismatch");
System.out.println(INDENT + " verify=accepted");
// Negative case: bit flip.
byte[] badSig = Arrays.copyOf(signature, signature.length);
badSig[0] = (byte) (badSig[0] ^ 0x01);
SignatureContext badVerifierCtx = CryptoAlgorithms.create("ML-DSA", KeyUsage.VERIFY, kp.getPublic());
if (!(badVerifierCtx instanceof MldsaSignatureContext badVerifier)) {
try {
badVerifierCtx.close();
} catch (Exception ignore) {
}
throw new AssertionError("VERIFY context must be MldsaSignatureContext (negative), got: "
+ badVerifierCtx.getClass().getName());
}
try {
badVerifier.setExpectedTag(badSig);
badVerifier.setVerificationApproach(badVerifier.getVerificationCore().getThrowOnMismatch());
try (InputStream verBad = badVerifier.wrap(new ByteArrayInputStream(msg))) {
readAll(verBad);
}
throw new AssertionError("Expected verification failure for mismatched signature");
} catch (Exception expected) {
System.out.println(INDENT + " verify=reject (mismatch)");
} finally {
try {
badVerifier.close();
} catch (Exception ignore) {
}
}
}
private static byte[] randomBytes(int len) {
byte[] data = new byte[len];
SecureRandom rnd = new SecureRandom();
rnd.nextBytes(data);
return data;
}
private static byte[] readAll(InputStream in) throws IOException {
try (InputStream src = in; ByteArrayOutputStream out = new ByteArrayOutputStream()) {
byte[] buf = new byte[4096];
int n;
while ((n = src.read(buf)) != -1) {
out.write(buf, 0, n);
}
return out.toByteArray();
}
}
private static String safeText(String s) {
if (s == null) {
return "null";
}
if (s.length() <= 30) {
return s;
}
return s.substring(0, 30) + "...";
}
private static String hexTruncated(byte[] data, int maxBytes) {
if (data == null) {
return "null";
}
int n = Math.min(data.length, maxBytes);
StringBuilder sb = new StringBuilder(n * 2 + 3);
for (int i = 0; i < n; i++) {
int v = data[i] & 0xFF;
sb.append(HEX[(v >>> 4) & 0x0F]);
sb.append(HEX[v & 0x0F]);
}
if (data.length > maxBytes) {
sb.append("...");
}
return sb.toString();
}
private static final char[] HEX = new char[] { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd',
'e', 'f' };
}

View File

@@ -0,0 +1,258 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.alg.slhdsa;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.KeyPair;
import java.security.SecureRandom;
import java.util.Arrays;
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.sdk.util.BouncyCastleActivator;
/**
* Large-data streaming test for SLH-DSA integration.
*
* <p>
* Signature length is determined via {@link SlhDsaSignatureContext} created for
* verification (public key). If the verification context is not an instance of
* {@link SlhDsaSignatureContext}, the test fails.
* </p>
*/
public final class SlhDsaLargeDataTest {
private static final String INDENT = "...";
private static final int MAX_HEX_BYTES = 32;
@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 slhdsa_complete_suite_streaming_sign_verify_large_data() throws Exception {
String testName = "slhdsa_complete_suite_streaming_sign_verify_large_data";
System.out.println(testName);
if (!CryptoAlgorithms.available().contains("SLH-DSA")) {
System.out.println(INDENT + " *** SKIP *** SLH-DSA not registered");
System.out.println(INDENT + "ok");
return;
}
int payloadLen = 48 * 1024 + 123;
byte[] msg = randomBytes(payloadLen);
System.out.println(INDENT + " msg.len=" + msg.length);
System.out.println(INDENT + " msg.hex=" + hexTruncated(msg, MAX_HEX_BYTES));
// Complete suite: 128/192/256 x FAST/SMALL, for both SHA2 and SHAKE, no
// pre-hash
runSuiteForHash(msg, SlhDsaKeyGenSpec.Hash.SHA2);
runSuiteForHash(msg, SlhDsaKeyGenSpec.Hash.SHAKE);
System.out.println(INDENT + "ok");
}
private static void runSuiteForHash(byte[] msg, SlhDsaKeyGenSpec.Hash hash) throws Exception {
runCase(msg, hash, SlhDsaKeyGenSpec.Security.L1_128, SlhDsaKeyGenSpec.Variant.FAST);
runCase(msg, hash, SlhDsaKeyGenSpec.Security.L1_128, SlhDsaKeyGenSpec.Variant.SMALL);
runCase(msg, hash, SlhDsaKeyGenSpec.Security.L3_192, SlhDsaKeyGenSpec.Variant.FAST);
runCase(msg, hash, SlhDsaKeyGenSpec.Security.L3_192, SlhDsaKeyGenSpec.Variant.SMALL);
runCase(msg, hash, SlhDsaKeyGenSpec.Security.L5_256, SlhDsaKeyGenSpec.Variant.FAST);
runCase(msg, hash, SlhDsaKeyGenSpec.Security.L5_256, SlhDsaKeyGenSpec.Variant.SMALL);
}
private static void runCase(byte[] msg, SlhDsaKeyGenSpec.Hash hash, SlhDsaKeyGenSpec.Security sec,
SlhDsaKeyGenSpec.Variant variant) throws Exception {
SlhDsaKeyGenSpec spec = SlhDsaKeyGenSpec.of("BC", hash, sec, variant, SlhDsaKeyGenSpec.PreHash.NONE);
String caseId = "SLH-DSA " + hash.name() + " " + sec.name() + " " + variant.name();
System.out.println(INDENT + " case=" + safeText(caseId));
KeyPair kp = CryptoAlgorithms.keyPair("SLH-DSA", spec);
// Create verifier FIRST to obtain tag length via
// SlhDsaSignatureContext.sigLenFromPublicKey.
SignatureContext verifierCtx = CryptoAlgorithms.create("SLH-DSA", KeyUsage.VERIFY, kp.getPublic());
int expectedSigLen = verifierCtx.tagLength();
System.out.println(INDENT + " expectedSigLen=" + expectedSigLen);
// Now sign and strip trailer using the expected length from verifier (not from
// signer).
SignatureContext signer = CryptoAlgorithms.create("SLH-DSA", KeyUsage.SIGN, kp.getPrivate());
final byte[][] sigHolder = new byte[1][];
byte[] passthrough;
try (InputStream in = new TailStrippingInputStream(signer.wrap(new ByteArrayInputStream(msg)), expectedSigLen,
8192) {
@Override
protected void processTail(byte[] tail) throws IOException {
sigHolder[0] = (tail == null ? null : Arrays.copyOf(tail, tail.length));
}
}) {
passthrough = readAll(in);
} finally {
try {
signer.close();
} catch (Exception ignore) {
}
}
assertArrayEquals(msg, passthrough, "SIGN passthrough mismatch");
byte[] signature = sigHolder[0];
assertNotNull(signature, "signature trailer missing");
System.out.println(INDENT + " signature.len=" + signature.length);
System.out.println(INDENT + " signature.hex=" + hexTruncated(signature, MAX_HEX_BYTES));
if (signature.length != expectedSigLen) {
try {
verifierCtx.close();
} catch (Exception ignore) {
}
throw new AssertionError(
"Signature length mismatch: got=" + signature.length + " expected=" + expectedSigLen);
}
// Verify OK with expected signature (use already-created SLH verifier).
verifierCtx.setExpectedTag(Arrays.copyOf(signature, signature.length));
byte[] verifyOut;
try (InputStream verIn = verifierCtx.wrap(new ByteArrayInputStream(msg))) {
verifyOut = readAll(verIn);
} finally {
try {
verifierCtx.close();
} catch (Exception ignore) {
}
}
assertArrayEquals(msg, verifyOut, "VERIFY passthrough mismatch");
System.out.println(INDENT + " verify=accepted");
// Negative: bit flip and expect rejection (new verifier instance, must again be
// our context).
byte[] badSig = Arrays.copyOf(signature, signature.length);
badSig[0] = (byte) (badSig[0] ^ 0x01);
SignatureContext badVerifier = CryptoAlgorithms.create("SLH-DSA", KeyUsage.VERIFY, kp.getPublic());
try {
badVerifier.setExpectedTag(badSig);
badVerifier.setVerificationApproach(badVerifier.getVerificationCore().getThrowOnMismatch());
try (InputStream verBad = badVerifier.wrap(new ByteArrayInputStream(msg))) {
readAll(verBad);
}
throw new AssertionError("Expected verification failure for mismatched signature");
} catch (Exception expected) {
System.out.println(INDENT + " verify=reject (mismatch)");
} finally {
try {
badVerifier.close();
} catch (Exception ignore) {
}
}
}
private static byte[] randomBytes(int len) {
byte[] data = new byte[len];
SecureRandom rnd = new SecureRandom();
rnd.nextBytes(data);
return data;
}
private static byte[] readAll(InputStream in) throws IOException {
try (InputStream src = in; ByteArrayOutputStream out = new ByteArrayOutputStream()) {
byte[] buf = new byte[4096];
int n;
while ((n = src.read(buf)) != -1) {
out.write(buf, 0, n);
}
return out.toByteArray();
}
}
private static String safeText(String s) {
if (s == null) {
return "null";
}
if (s.length() <= 30) {
return s;
}
return s.substring(0, 30) + "...";
}
private static String hexTruncated(byte[] data, int maxBytes) {
if (data == null) {
return "null";
}
int n = Math.min(data.length, maxBytes);
StringBuilder sb = new StringBuilder(n * 2 + 3);
for (int i = 0; i < n; i++) {
int v = data[i] & 0xFF;
sb.append(HEX[(v >>> 4) & 0x0F]);
sb.append(HEX[v & 0x0F]);
}
if (data.length > maxBytes) {
sb.append("...");
}
return sb.toString();
}
private static final char[] HEX = new char[] { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd',
'e', 'f' };
}

View File

@@ -0,0 +1,341 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.sdk.hybrid.derived;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
import zeroecho.sdk.builders.alg.AesDataContentBuilder;
import zeroecho.sdk.builders.alg.ChaChaDataContentBuilder;
import zeroecho.sdk.builders.alg.HmacDataContentBuilder;
import zeroecho.sdk.builders.core.DataContentBuilder;
import zeroecho.sdk.builders.core.DataContentChainBuilder;
import zeroecho.sdk.builders.core.PlainBytesBuilder;
import zeroecho.sdk.content.api.DataContent;
import zeroecho.sdk.hybrid.kex.HybridKexExporter;
/**
* Coverage tests for {@link HybridDerived} derived-material application
* helpers.
*
* <p>
* The tests use deterministic exporter inputs (fixed OKM and salt) to ensure
* stable results.
* </p>
*/
public class HybridDerivedTest {
@Test
void aes_gcm_applyTo_roundtrip() throws Exception {
System.out.println("aes_gcm_applyTo_roundtrip()");
HybridKexExporter exporter = testExporter();
byte[] transcript = "demo-transcript".getBytes(StandardCharsets.UTF_8);
byte[] aad = "aad".getBytes(StandardCharsets.UTF_8);
byte[] msg = fixedBytes(1024, (byte) 0x5A);
AesDataContentBuilder encAes = AesDataContentBuilder.builder().withHeader().modeGcm(128);
AesDataContentBuilder returnedEnc = HybridDerived.from(exporter).label("app/enc/aes").transcript(transcript)
.aad(aad).applyToAesGcm(encAes, 256, 12);
System.out.println("...returnedEncSame=" + (returnedEnc == encAes));
assertSame(encAes, returnedEnc);
byte[] ciphertext = runEncrypt(encAes, msg);
System.out.println("...ciphertextLen=" + ciphertext.length);
System.out.println("...ciphertextPrefix=" + shortHex(ciphertext, 32));
AesDataContentBuilder decAes = AesDataContentBuilder.builder().withHeader().modeGcm(128);
HybridDerived.from(exporter).label("app/enc/aes").transcript(transcript).aad(aad).applyToAesGcm(decAes, 256,
12);
byte[] out = runDecrypt(decAes, ciphertext);
System.out.println("...outPrefix=" + shortHex(out, 32));
assertArrayEquals(msg, out);
System.out.println("aes_gcm_applyTo_roundtrip...ok");
}
@Test
void aes_gcm_applyTo_negative_label_mismatch() throws Exception {
System.out.println("aes_gcm_applyTo_negative_label_mismatch()");
HybridKexExporter exporter = testExporter();
byte[] transcript = "demo-transcript".getBytes(StandardCharsets.UTF_8);
byte[] aad = "aad".getBytes(StandardCharsets.UTF_8);
byte[] msg = fixedBytes(256, (byte) 0x1C);
AesDataContentBuilder encAes = AesDataContentBuilder.builder().withHeader().modeGcm(128);
HybridDerived.from(exporter).label("app/enc/aes").transcript(transcript).aad(aad).applyToAesGcm(encAes, 256,
12);
byte[] ciphertext = runEncrypt(encAes, msg);
System.out.println("...ciphertextLen=" + ciphertext.length);
AesDataContentBuilder decAesWrong = AesDataContentBuilder.builder().withHeader().modeGcm(128);
// ...label mismatch -> wrong key/iv/aad -> decryption must fail
HybridDerived.from(exporter).label("app/enc/aes_WRONG").transcript(transcript).aad(aad)
.applyToAesGcm(decAesWrong, 256, 12);
assertThrows(Exception.class, () -> runDecrypt(decAesWrong, ciphertext));
System.out.println("aes_gcm_applyTo_negative_label_mismatch...ok");
}
@Test
void chacha_aead_applyTo_roundtrip() throws Exception {
System.out.println("chacha_aead_applyTo_roundtrip()");
HybridKexExporter exporter = testExporter();
byte[] transcript = "demo-transcript".getBytes(StandardCharsets.UTF_8);
byte[] aad = "aad".getBytes(StandardCharsets.UTF_8);
byte[] msg = fixedBytes(777, (byte) 0x33);
ChaChaDataContentBuilder encChaCha = ChaChaDataContentBuilder.builder().withHeader();
ChaChaDataContentBuilder returnedEnc = HybridDerived.from(exporter).label("app/enc/chacha")
.transcript(transcript).aad(aad).applyToChaChaAead(encChaCha, 256, 12);
System.out.println("...returnedEncSame=" + (returnedEnc == encChaCha));
assertSame(encChaCha, returnedEnc);
byte[] ciphertext = runEncrypt(encChaCha, msg);
System.out.println("...ciphertextLen=" + ciphertext.length);
System.out.println("...ciphertextPrefix=" + shortHex(ciphertext, 32));
ChaChaDataContentBuilder decChaCha = ChaChaDataContentBuilder.builder().withHeader();
HybridDerived.from(exporter).label("app/enc/chacha").transcript(transcript).aad(aad)
.applyToChaChaAead(decChaCha, 256, 12);
byte[] out = runDecrypt(decChaCha, ciphertext);
System.out.println("...outPrefix=" + shortHex(out, 32));
assertArrayEquals(msg, out);
System.out.println("chacha_aead_applyTo_roundtrip...ok");
}
@Test
void hmac_applyTo_default_and_override() throws Exception {
System.out.println("hmac_applyTo_default_and_override()");
HybridKexExporter exporter = testExporter();
byte[] transcript = "demo-transcript".getBytes(StandardCharsets.UTF_8);
byte[] msg = fixedBytes(2048, (byte) 0x7E);
// --------------------
// Default key size path: applyToHmac(hmac) derives key using builder's
// recommended bits
// --------------------
HmacDataContentBuilder macBuilder = HmacDataContentBuilder.builder().sha256().emitHexTag();
int recommendedBits = macBuilder.recommendedKeyBits();
System.out.println("...recommendedBits=" + recommendedBits);
HybridDerived.from(exporter).label("app/mac/hmac-default").transcript(transcript).applyToHmac(macBuilder);
String tagHex = runHmacHex(macBuilder, msg);
System.out.println("...tagHexPrefix=" + shortText(tagHex, 64));
HmacDataContentBuilder verifyBuilder = HmacDataContentBuilder.builder().sha256().expectedTagHex(tagHex)
.emitVerificationBoolean();
HybridDerived.from(exporter).label("app/mac/hmac-default").transcript(transcript).applyToHmac(verifyBuilder);
String ok = runHmacVerifyBool(verifyBuilder, msg);
System.out.println("...verifyBool=" + ok);
assertEquals("true", ok);
// --------------------
// Override key size path: applyToHmac(hmac, keyBits)
// --------------------
HmacDataContentBuilder macBuilderOv = HmacDataContentBuilder.builder().sha256().emitHexTag();
// ...override to 512-bit keying material (still valid for HMAC; explicit expert
// choice)
HybridDerived.from(exporter).label("app/mac/hmac-override").transcript(transcript).applyToHmac(macBuilderOv,
512);
String tagHexOv = runHmacHex(macBuilderOv, msg);
System.out.println("...tagHexOvPrefix=" + shortText(tagHexOv, 64));
HmacDataContentBuilder verifyBuilderOv = HmacDataContentBuilder.builder().sha256().expectedTagHex(tagHexOv)
.emitVerificationBoolean();
HybridDerived.from(exporter).label("app/mac/hmac-override").transcript(transcript).applyToHmac(verifyBuilderOv,
512);
String okOv = runHmacVerifyBool(verifyBuilderOv, msg);
System.out.println("...verifyBoolOv=" + okOv);
assertEquals("true", okOv);
// --------------------
// Negative: wrong expected tag -> must emit "false"
// --------------------
HmacDataContentBuilder verifyBad = HmacDataContentBuilder.builder().sha256()
.expectedTagHex(tagHex.substring(0, Math.max(0, tagHex.length() - 2)) + "00").emitVerificationBoolean();
HybridDerived.from(exporter).label("app/mac/hmac-default").transcript(transcript).applyToHmac(verifyBad);
String bad = runHmacVerifyBool(verifyBad, msg);
System.out.println("...verifyBoolBad=" + bad);
assertEquals("false", bad);
// sanity: ensure the two tags differ (default vs override label/key schedule)
assertTrue(!tagHex.equals(tagHexOv));
System.out.println("hmac_applyTo_default_and_override...ok");
}
// --------------------
// helpers
// --------------------
private static HybridKexExporter testExporter() {
byte[] okm = fixedBytes(32, (byte) 0x11);
byte[] salt = fixedBytes(32, (byte) 0x22);
return new HybridKexExporter(okm, salt);
}
private static byte[] runEncrypt(DataContentBuilder<DataContent> algorithmBuilder, byte[] plaintext)
throws Exception {
DataContent enc = DataContentChainBuilder.encrypt().add(PlainBytesBuilder.builder().bytes(plaintext))
.add(algorithmBuilder).build();
try (InputStream in = enc.getStream()) {
return readAll(in);
}
}
private static byte[] runDecrypt(DataContentBuilder<DataContent> algorithmBuilder, byte[] ciphertext)
throws Exception {
DataContent dec = DataContentChainBuilder.decrypt().add(PlainBytesBuilder.builder().bytes(ciphertext))
.add(algorithmBuilder).build();
try (InputStream in = dec.getStream()) {
return readAll(in);
}
}
private static String runHmacHex(HmacDataContentBuilder macBuilder, byte[] msg) throws Exception {
DataContent dc = DataContentChainBuilder.encrypt().add(PlainBytesBuilder.builder().bytes(msg)).add(macBuilder)
.build();
byte[] out;
try (InputStream in = dc.getStream()) {
out = readAll(in);
}
String tagHex = new String(out, StandardCharsets.UTF_8).trim();
return tagHex;
}
private static String runHmacVerifyBool(HmacDataContentBuilder verifyBuilder, byte[] msg) throws Exception {
DataContent dc = DataContentChainBuilder.decrypt().add(PlainBytesBuilder.builder().bytes(msg))
.add(verifyBuilder).build();
byte[] out;
try (InputStream in = dc.getStream()) {
out = readAll(in);
}
String s = new String(out, StandardCharsets.UTF_8).trim();
return s;
}
private static byte[] fixedBytes(int len, byte v) {
byte[] b = new byte[len];
Arrays.fill(b, v);
return b;
}
private static byte[] readAll(InputStream in) throws Exception {
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
in.transferTo(out);
out.flush();
return out.toByteArray();
}
}
private static String shortHex(byte[] data, int maxBytes) {
if (data == null) {
return "null";
}
int n = Math.min(data.length, Math.max(0, maxBytes));
StringBuilder sb = new StringBuilder(n * 2 + 3);
for (int i = 0; i < n; i++) {
int v = data[i] & 0xFF;
sb.append(Character.forDigit(v >>> 4, 16));
sb.append(Character.forDigit(v & 0x0F, 16));
}
if (data.length > n) {
sb.append("...");
}
return sb.toString();
}
private static String shortText(String s, int maxLen) {
if (s == null) {
return "null";
}
if (s.length() <= maxLen) {
return s;
}
return s.substring(0, maxLen) + "...";
}
@SuppressWarnings("unused")
private static byte[] randomBytes(int len) {
byte[] b = new byte[len];
new SecureRandom().nextBytes(b);
return b;
}
}

View File

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

View File

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

View File

@@ -86,6 +86,7 @@ public class OutputToInputStreamAdapterTest {
return new ByteArrayInputStream(baos.toByteArray()); return new ByteArrayInputStream(baos.toByteArray());
} }
@Deprecated
@Test @Test
public void testIncrementingAdapter() throws IOException { public void testIncrementingAdapter() throws IOException {
System.out.println("testIncrementingAdapter"); System.out.println("testIncrementingAdapter");

View File

@@ -0,0 +1,308 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package demo;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.Arrays;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import zeroecho.core.CryptoAlgorithm;
import zeroecho.core.CryptoAlgorithms;
import zeroecho.core.KeyUsage;
import zeroecho.core.alg.common.agreement.KeyPairKey;
import zeroecho.core.alg.kyber.KyberKeyGenSpec;
import zeroecho.core.alg.xdh.XdhSpec;
import zeroecho.core.context.AgreementContext;
import zeroecho.core.context.MessageAgreementContext;
import zeroecho.core.spec.VoidSpec;
import zeroecho.core.util.Strings;
import zeroecho.sdk.util.BouncyCastleActivator;
/**
* Demonstration of agreement usage variants in ZeroEcho.
*
* <p>
* This sample illustrates three complementary models used in practice:
* </p>
* <ul>
* <li><b>KEM_ADAPTER</b> (example: ML-KEM / Kyber): the initiator is
* constructed with the recipient's {@link PublicKey} and produces an outbound
* encapsulation message; the responder is constructed with the matching
* {@link PrivateKey} and consumes that message.</li>
* <li><b>CLASSIC_AGREEMENT</b> (example: XDH / X25519): both parties hold their
* own {@link PrivateKey}, set the peer {@link PublicKey} explicitly, and derive
* the same raw shared secret.</li>
* <li><b>PAIR_MESSAGE</b> (example: XDH / X25519): both parties hold a key pair
* and exchange messages that are simply the X.509 SPKI encodings of their
* public keys. This yields a message-oriented handshake without changing the
* underlying agreement primitive.</li>
* </ul>
*
* <h2>Important note</h2>
* <p>
* All examples below produce a <em>raw</em> agreement secret (the direct output
* of KEM decapsulation or DiffieHellman agreement). Real protocols should feed
* the raw secret into a suitable KDF (typically HKDF) together with
* transcript/context info before using it as key material.
* </p>
*
* <h2>Note on resource management</h2>
* <p>
* The examples in this class intentionally do <em>not</em> use the
* {@code try-with-resources} construct when working with
* {@link zeroecho.core.context.AgreementContext} and
* {@link zeroecho.core.context.MessageAgreementContext}.
* </p>
*
* <p>
* Agreement contexts represent protocol-level state rather than traditional I/O
* resources. In real-world applications their lifecycle often spans multiple
* protocol steps (message send, receive, validation, key derivation) and may
* cross method or thread boundaries. Using explicit {@code try/finally} blocks
* in the examples makes this lifecycle visible and closer to how agreement
* contexts are typically managed in production code.
* </p>
*
* <p>
* In short-lived, fully synchronous scenarios (such as unit tests),
* {@code try-with-resources} is perfectly acceptable. It is omitted here purely
* for didactic reasons.
* </p>
*/
class AgreementVariantsTest {
private static final Logger LOG = Logger.getLogger(AgreementVariantsTest.class.getName());
@BeforeAll
static void setup() {
// Optional: activate BC/BCPQC if present.
// Keeps tests runnable even when providers are missing.
try {
BouncyCastleActivator.init();
} catch (Throwable ignore) {
// ignore
}
}
/**
* KEM_ADAPTER example for ML-KEM (Kyber):
*
* <p>
* This models a common "send one message, derive shared secret" pattern:
* </p>
* <ul>
* <li>Initiator uses recipient {@link PublicKey} and produces an encapsulation
* message.</li>
* <li>Responder uses recipient {@link PrivateKey}, consumes the message, and
* derives the same secret.</li>
* </ul>
*/
@Test
void kemAdapter_mlKem_roundTrip() throws Exception {
LOG.info("kemAdapter_mlKem_roundTrip - KEM_ADAPTER (ML-KEM)");
KeyPair recipient = CryptoAlgorithms.generateKeyPair("ML-KEM", KyberKeyGenSpec.kyber1024());
MessageAgreementContext initiator = null;
MessageAgreementContext responder = null;
try {
// Initiator: constructed with recipient's public key (encapsulation side).
initiator = CryptoAlgorithms.create("ML-KEM", KeyUsage.AGREEMENT, recipient.getPublic(), VoidSpec.INSTANCE);
// Responder: constructed with recipient's private key (decapsulation side).
responder = CryptoAlgorithms.create("ML-KEM", KeyUsage.AGREEMENT, recipient.getPrivate(),
VoidSpec.INSTANCE);
// One-shot outbound message: KEM ciphertext / encapsulation payload.
byte[] enc = initiator.getPeerMessage();
// Responder consumes ciphertext to derive its secret.
responder.setPeerMessage(enc);
byte[] s1 = initiator.deriveSecret();
byte[] s2 = responder.deriveSecret();
LOG.log(Level.INFO, "KEM_ADAPTER: ciphertext={0}", Strings.toShortHexString(enc));
LOG.log(Level.INFO, "KEM_ADAPTER: initiatorSecret={0}", Strings.toShortHexString(s1));
LOG.log(Level.INFO, "KEM_ADAPTER: responderSecret={0}", Strings.toShortHexString(s2));
LOG.log(Level.INFO, "KEM_ADAPTER: secretsEqual={0}", Boolean.valueOf(Arrays.equals(s1, s2)));
} finally {
if (initiator != null) {
try {
initiator.close();
} catch (Exception ignore) {
// ignore
}
}
if (responder != null) {
try {
responder.close();
} catch (Exception ignore) {
// ignore
}
}
}
}
/**
* CLASSIC_AGREEMENT example for XDH/X25519:
*
* <p>
* This is the traditional DiffieHellman model: both parties generate their own
* key pair, each side keeps a private key, and the peer public key is provided
* out-of-band (protocol message or session state).
* </p>
*/
@Test
void classicAgreement_x25519_roundTrip() throws Exception {
LOG.info("classicAgreement_x25519_roundTrip - CLASSIC_AGREEMENT (X25519)");
CryptoAlgorithm xdh = CryptoAlgorithms.require("Xdh");
KeyPair alice = xdh.generateKeyPair();
KeyPair bob = xdh.generateKeyPair();
AgreementContext aCtx = null;
AgreementContext bCtx = null;
try {
// Both contexts are built from local private keys.
aCtx = CryptoAlgorithms.create("Xdh", KeyUsage.AGREEMENT, alice.getPrivate(), XdhSpec.X25519);
bCtx = CryptoAlgorithms.create("Xdh", KeyUsage.AGREEMENT, bob.getPrivate(), XdhSpec.X25519);
// The protocol layer provides peer public keys (here we use in-memory
// exchange).
aCtx.setPeerPublic(bob.getPublic());
bCtx.setPeerPublic(alice.getPublic());
byte[] s1 = aCtx.deriveSecret();
byte[] s2 = bCtx.deriveSecret();
LOG.log(Level.INFO, "CLASSIC_AGREEMENT: aliceSecret={0}", Strings.toShortHexString(s1));
LOG.log(Level.INFO, "CLASSIC_AGREEMENT: bobSecret={0}", Strings.toShortHexString(s2));
LOG.log(Level.INFO, "CLASSIC_AGREEMENT: secretsEqual={0}", Boolean.valueOf(Arrays.equals(s1, s2)));
} finally {
if (aCtx != null) {
try {
aCtx.close();
} catch (Exception ignore) {
// ignore
}
}
if (bCtx != null) {
try {
bCtx.close();
} catch (Exception ignore) {
// ignore
}
}
}
}
/**
* PAIR_MESSAGE example for XDH/X25519:
*
* <p>
* This demonstrates the "message-oriented" handshake for DH-style agreements.
* Each party holds a key pair and the outbound message is simply the local
* public key encoding (SPKI). The receiver imports the encoding and binds it as
* the peer key.
* </p>
*
* <p>
* This model is particularly practical for protocol implementations because it
* makes the "to-be-sent" artifact explicit (a byte array message), similarly to
* KEM ciphertexts.
* </p>
*/
@Test
void pairMessage_x25519_roundTrip() throws Exception {
LOG.info("pairMessage_x25519_roundTrip - PAIR_MESSAGE (X25519)");
CryptoAlgorithm xdh = CryptoAlgorithms.require("Xdh");
KeyPair alice = xdh.generateKeyPair();
KeyPair bob = xdh.generateKeyPair();
// Wrapper is required because ZeroEcho capability dispatch uses Key (KeyPair is
// not a Key).
KeyPairKey aliceKey = new KeyPairKey(alice);
KeyPairKey bobKey = new KeyPairKey(bob);
MessageAgreementContext aCtx = null;
MessageAgreementContext bCtx = null;
try {
aCtx = CryptoAlgorithms.create("Xdh", KeyUsage.AGREEMENT, aliceKey, XdhSpec.X25519);
bCtx = CryptoAlgorithms.create("Xdh", KeyUsage.AGREEMENT, bobKey, XdhSpec.X25519);
// Outbound messages: SPKI encodings of local public keys.
byte[] aMsg = aCtx.getPeerMessage();
byte[] bMsg = bCtx.getPeerMessage();
LOG.log(Level.INFO, "PAIR_MESSAGE: aliceMsg={0}", Strings.toShortHexString(aMsg));
LOG.log(Level.INFO, "PAIR_MESSAGE: bobMsg={0}", Strings.toShortHexString(bMsg));
// Each side imports peer public key from message.
aCtx.setPeerMessage(bMsg);
bCtx.setPeerMessage(aMsg);
byte[] s1 = aCtx.deriveSecret();
byte[] s2 = bCtx.deriveSecret();
LOG.log(Level.INFO, "PAIR_MESSAGE: aliceSecret={0}", Strings.toShortHexString(s1));
LOG.log(Level.INFO, "PAIR_MESSAGE: bobSecret={0}", Strings.toShortHexString(s2));
LOG.log(Level.INFO, "PAIR_MESSAGE: secretsEqual={0}", Boolean.valueOf(Arrays.equals(s1, s2)));
} finally {
if (aCtx != null) {
try {
aCtx.close();
} catch (Exception ignore) {
// ignore
}
}
if (bCtx != null) {
try {
bCtx.close();
} catch (Exception ignore) {
// ignore
}
}
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,240 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package demo;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.GeneralSecurityException;
import java.security.KeyPair;
import java.security.SecureRandom;
import java.security.Signature;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.crypto.SecretKey;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import zeroecho.core.CryptoAlgorithm;
import zeroecho.core.CryptoAlgorithms;
import zeroecho.core.context.SignatureContext;
import zeroecho.sdk.builders.TagTrailerDataContentBuilder;
import zeroecho.sdk.builders.alg.AesDataContentBuilder;
import zeroecho.sdk.builders.core.DataContentChainBuilder;
import zeroecho.sdk.builders.core.PlainBytesBuilder;
import zeroecho.sdk.content.api.DataContent;
import zeroecho.sdk.hybrid.signature.HybridSignatureContexts;
import zeroecho.sdk.hybrid.signature.HybridSignatureProfile;
import zeroecho.sdk.util.BouncyCastleActivator;
/**
* Demonstration of hybrid signing combined with AES-GCM encryption.
*
* <p>
* This sample shows both canonical compositions:
* <ul>
* <li>StE: Sign-then-Encrypt</li>
* <li>EtS: Encrypt-then-Sign</li>
* </ul>
* </p>
*
* <p>
* Hybrid signature used here (popular practical choice): Ed25519 + SPHINCS+
* with AND verification.
* </p>
*/
class HybridSigningAesTest {
private static final Logger LOG = Logger.getLogger(HybridSigningAesTest.class.getName());
@BeforeAll
static void setup() {
// Optional: enable BC if you use BC-only modes in KEM payloads (EAX/OCB/CCM,
// etc.)
try {
BouncyCastleActivator.init();
} catch (Throwable ignore) {
// keep tests runnable without BC if not present
}
}
@Test
void aesRoundStE_withHybridSignature() throws GeneralSecurityException, IOException {
LOG.info("aesRoundStE_withHybridSignature - Sign then Encrypt (Hybrid signature)");
// Prepare plaintext
byte[] msg = randomBytes(100);
// AES-GCM with header, runtime params are stored in header
AesDataContentBuilder aesBuilder = AesDataContentBuilder.builder().generateKey(256).modeGcm(128).withHeader();
// Hybrid signature: Ed25519 + SPHINCS+ (AND)
HybridSignatureProfile profile = new HybridSignatureProfile("Ed25519", "SPHINCS+", null, null,
HybridSignatureProfile.VerifyRule.AND);
KeyPair ed = generateKeyPair("Ed25519");
KeyPair spx = generateKeyPair("SPHINCS+");
SignatureContext tagEnc = HybridSignatureContexts.sign(profile, ed.getPrivate(), spx.getPrivate(),
2 * 1024 * 1024);
SignatureContext tagDec = HybridSignatureContexts.verify(profile, ed.getPublic(), spx.getPublic(),
2 * 1024 * 1024);
// For verification, make mismatch behavior explicit (builder also supports
// throwOnMismatch()).
tagDec.setVerificationApproach(tagDec.getVerificationCore().getThrowOnMismatch());
// Build StE pipeline: PLAIN -> SIGN(trailer) -> ENCRYPT
DataContent dccb = DataContentChainBuilder.encrypt()
// plaintext source
.add(PlainBytesBuilder.builder().bytes(msg))
// hybrid signature trailer
.add(new TagTrailerDataContentBuilder<Signature>(tagEnc).bufferSize(8192))
// AES-GCM encryption
.add(aesBuilder).build();
SecretKey aesKey = aesBuilder.generatedKey();
LOG.log(Level.INFO, "StE: produced ciphertext, aesKeySizeBits={0}",
Integer.valueOf(aesKey.getEncoded().length * 8));
byte[] ciphertext;
try (InputStream in = dccb.getStream()) {
ciphertext = readAll(in);
}
LOG.log(Level.INFO, "StE: ciphertextSize={0}", Integer.valueOf(ciphertext.length));
// Build decrypt pipeline: ENCRYPTED -> DECRYPT -> VERIFY(trailer)
dccb = DataContentChainBuilder.decrypt()
// encrypted input
.add(PlainBytesBuilder.builder().bytes(ciphertext))
// AES-GCM decryption
.add(AesDataContentBuilder.builder().importKeyRaw(aesKey.getEncoded()).modeGcm(128).withHeader())
// hybrid signature verification
.add(new TagTrailerDataContentBuilder<Signature>(tagDec).bufferSize(8192).throwOnMismatch()).build();
byte[] decrypted;
try (InputStream in = dccb.getStream()) {
decrypted = readAll(in);
}
LOG.log(Level.INFO, "StE: roundtrip ok={0}", Boolean.valueOf(java.util.Arrays.equals(msg, decrypted)));
}
@Test
void aesRoundEtS_withHybridSignature() throws GeneralSecurityException, IOException {
LOG.info("aesRoundEtS_withHybridSignature - Encrypt then Sign (Hybrid signature)");
// Prepare plaintext
byte[] msg = randomBytes(100);
// AES-GCM with header, runtime params are stored in header
AesDataContentBuilder aesBuilder = AesDataContentBuilder.builder().generateKey(256).modeGcm(128).withHeader();
// Hybrid signature: Ed25519 + SPHINCS+ (AND)
HybridSignatureProfile profile = new HybridSignatureProfile("Ed25519", "SPHINCS+", null, null,
HybridSignatureProfile.VerifyRule.AND);
KeyPair ed = generateKeyPair("Ed25519");
KeyPair spx = generateKeyPair("SPHINCS+");
SignatureContext tagEnc = HybridSignatureContexts.sign(profile, ed.getPrivate(), spx.getPrivate(),
2 * 1024 * 1024);
SignatureContext tagDec = HybridSignatureContexts.verify(profile, ed.getPublic(), spx.getPublic(),
2 * 1024 * 1024);
tagDec.setVerificationApproach(tagDec.getVerificationCore().getThrowOnMismatch());
// Build EtS pipeline: PLAIN -> ENCRYPT -> SIGN(trailer)
DataContent dccb = DataContentChainBuilder.encrypt()
// plaintext source
.add(PlainBytesBuilder.builder().bytes(msg))
// AES-GCM encryption
.add(aesBuilder)
// hybrid signature trailer
.add(new TagTrailerDataContentBuilder<Signature>(tagEnc).bufferSize(8192)).build();
SecretKey aesKey = aesBuilder.generatedKey();
LOG.log(Level.INFO, "EtS: produced ciphertext, aesKeySizeBits={0}",
Integer.valueOf(aesKey.getEncoded().length * 8));
byte[] ciphertext;
try (InputStream in = dccb.getStream()) {
ciphertext = readAll(in);
}
LOG.log(Level.INFO, "EtS: ciphertextSize={0}", Integer.valueOf(ciphertext.length));
// Build decrypt pipeline: ENCRYPTED -> VERIFY(trailer) -> DECRYPT
// Verification runs during streaming; consumer gets plaintext only if signature
// matches.
dccb = DataContentChainBuilder.decrypt()
// encrypted input
.add(PlainBytesBuilder.builder().bytes(ciphertext))
// hybrid signature verification
.add(new TagTrailerDataContentBuilder<Signature>(tagDec).bufferSize(8192).throwOnMismatch())
// AES-GCM decryption
.add(AesDataContentBuilder.builder().importKeyRaw(aesKey.getEncoded()).modeGcm(128).withHeader())
.build();
byte[] decrypted;
try (InputStream in = dccb.getStream()) {
decrypted = readAll(in);
}
LOG.log(Level.INFO, "EtS: roundtrip ok={0}", Boolean.valueOf(java.util.Arrays.equals(msg, decrypted)));
}
// helpers
private static KeyPair generateKeyPair(String algId) throws GeneralSecurityException {
CryptoAlgorithm alg = CryptoAlgorithms.require(algId);
return alg.generateKeyPair();
}
private static byte[] randomBytes(int len) {
byte[] data = new byte[len];
new SecureRandom().nextBytes(data);
return data;
}
private static byte[] readAll(InputStream in) throws IOException {
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
in.transferTo(out);
out.flush();
return out.toByteArray();
}
}
}

View File

@@ -48,9 +48,7 @@ import javax.crypto.SecretKey;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import zeroecho.core.CryptoAlgorithm;
import zeroecho.core.CryptoAlgorithms; import zeroecho.core.CryptoAlgorithms;
import zeroecho.core.alg.aes.AesKeyGenSpec;
import zeroecho.core.alg.rsa.RsaKeyGenSpec; import zeroecho.core.alg.rsa.RsaKeyGenSpec;
import zeroecho.core.alg.rsa.RsaSigSpec; import zeroecho.core.alg.rsa.RsaSigSpec;
import zeroecho.core.tag.TagEngine; import zeroecho.core.tag.TagEngine;
@@ -65,23 +63,6 @@ import zeroecho.sdk.content.api.DataContent;
class SigningAesTest { class SigningAesTest {
private static final Logger LOG = Logger.getLogger(SigningAesTest.class.getName()); 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 generateRsaKeys() throws GeneralSecurityException {
KeyPair kp = CryptoAlgorithms.generateKeyPair("RSA", RsaKeyGenSpec.rsa4096()); KeyPair kp = CryptoAlgorithms.generateKeyPair("RSA", RsaKeyGenSpec.rsa4096());