25 Commits

Author SHA1 Message Date
adfa0b4b51 feat: add universal AsyncBus infrastructure
Introduce a generic asynchronous bus used for internal PKI workflows,
with resilient sweep support and symmetric primitive/wrapper type
compatibility for dispatch handling.

Signed-off-by: Leo Galambos <lg@hq.egothor.org>
2026-01-02 22:28:53 +01:00
e01d95f48e chore: PMD 1.20.0 adaptation
Signed-off-by: Leo Galambos <lg@hq.egothor.org>
2026-01-01 11:44:13 +01:00
969a846d95 fix: proxy object might be returned
Signed-off-by: Leo Galambos <lg@hq.egothor.org>
2026-01-01 11:42:13 +01:00
d2ec77b8e3 feat: introduce SignatureWorkflow SPI and zeroecho-lib implementation
- add SignatureWorkflow SPI for asynchronous sign/verify operations
- define audit-friendly, exception-free failure model
- introduce stable OperationStatus, State and OperationResult semantics
- document trust boundaries, lifecycle, and audit constraints in SPI
  JavaDoc
- add ZeroEchoLibSignatureWorkflow backed by KeyringStore and ZeroEcho
  lib
- enforce opaque KeyRef handling and provider-local parsing
- add deterministic detail codes and UNKNOWN_OPERATION handling
- integrate workflow provider into ServiceLoader bootstrap
- align PkiBootstrap logging and defaults with crypto workflow SPI
- add comprehensive JUnit tests for validation and status semantics

Signed-off-by: Leo Galambos <lg@hq.egothor.org>
2025-12-29 22:41:11 +01:00
0346c5b30f feat: refactor SPI/bootstrap to generic configurable providers
- Introduce a universal ConfigurableProvider/ProviderConfig abstraction
  for ServiceLoader-based components and align PKI bootstrapping
  utilities with it.
- Document deterministic provider selection, property-based
  configuration conventions, and security requirements (never log config
  values), including package-level documentation for spi, spi.store and
  spi.bootstrap.

fix: harden audit runtime, fix gzip scanning, add bounds and docs

- Fix FileAuditSink concatenated gzip scan by shielding underlying
  stream
- Use JUnit @TempDir for filesystem-backed tests
- Bound InMemoryAuditSink with deterministic ring buffer
- Add ServiceLoader smoke test and expand DefaultAuditService coverage
- Improve JavaDoc and logging across audit implementation

feat: add deterministic tests for PkiBootstrap with real SPI providers

- add JUnit 5 test suite for PkiBootstrap
- cover SPI selection for filesystem PkiStore and audit sinks
- use @TempDir for filesystem-backed providers
- register test ServiceLoader providers under src/test/resources
- ensure deterministic bootstrap behavior via system properties

Signed-off-by: Leo Galambos <lg@hq.egothor.org>
2025-12-29 02:09:07 +01:00
cab1eeefe7 feat: add filesystem-based PkiStore reference implementation
Introduce a deterministic filesystem-backed PkiStore implementation
under zeroecho.pki.impl.fs.

Key characteristics:
- write-once semantics for immutable objects with explicit failure on
  overwrite
- history tracking for mutable records with full audit trail
- atomic writes using NIO (temp + move) with best-effort durability
- strict snapshot export supporting time-travel reconstruction
- configurable history retention (ON_WRITE policy)
- no secrets logged; JUL-only diagnostics for anomalies

Includes comprehensive JUnit 5 tests validating:
- write-once enforcement
- history creation and overwrite semantics
- strict snapshot export (failure and positive selection cases)
- deterministic on-disk layout and structure

This implementation is intentionally non-public and serves as a
reference and validation baseline for future persistence backends.

Signed-off-by: Leo Galambos <lg@hq.egothor.org>
2025-12-28 01:15:46 +01:00
7673e7d82f feat: PKI module core design
Signed-off-by: Leo Galambos <lg@hq.egothor.org>
2025-12-27 21:38:32 +01:00
276ac91eb4 chore: replace apache-cli deprecated methods
Signed-off-by: Leo Galambos <lg@hq.egothor.org>
2025-12-27 16:55:28 +01:00
e82e0e57fb Merge hybrid cryptography support (KEX, derived keys, signatures)
This merge introduces the sdk.hybrid package with support for:
- hybrid key exchange (classic + post-quantum),
- hybrid-derived key injection for encryption and MAC builders,
- hybrid signature composition.

The implementation is additive at the SDK layer and does not modify
core cryptographic contracts.
2025-12-26 21:08:31 +01:00
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
0114f46907 chore: javadoc upload disabled (workflow)
All checks were successful
Release / release (push) Successful in 1m5s
Signed-off-by: Leo Galambos <lg@hq.egothor.org>
2025-11-02 14:00:17 +01:00
56eb54bf9e fix: incorrect (package) javadoc for chacha and cmce
Signed-off-by: Leo Galambos <lg@hq.egothor.org>
2025-09-19 02:01:22 +02:00
307 changed files with 33445 additions and 389 deletions

View File

@@ -42,7 +42,8 @@ jobs:
apt install -y rsync
- name: Build and publish to Gitea Maven and JavaDoc to the website
run: ./gradlew clean publish uploadJavadoc --no-daemon -PgiteaToken=${{ secrets.CI_PUBLISH_TOKEN }} -PjavadocUser=${{ vars.JAVADOC_USER }} -PjavadocHost=${{ vars.JAVADOC_HOST }} -PjavadocPath=${{ vars.JAVADOC_PATH }} -PjavadocKeyPath=~/.ssh/id_rsa
run: ./gradlew clean publish --no-daemon -PgiteaToken=${{ secrets.CI_PUBLISH_TOKEN }}
# was run: ./gradlew clean publish uploadJavadoc --no-daemon -PgiteaToken=${{ secrets.CI_PUBLISH_TOKEN }} -PjavadocUser=${{ vars.JAVADOC_USER }} -PjavadocHost=${{ vars.JAVADOC_HOST }} -PjavadocPath=${{ vars.JAVADOC_PATH }} -PjavadocKeyPath=~/.ssh/id_rsa
- name: Generate release notes
id: notes
@@ -88,5 +89,7 @@ jobs:
- name: Create Gitea Release
uses: softprops/action-gh-release@v2
with:
files: app/build/libs/*.jar
files: |
app/build/distributions/*.tar
app/build/distributions/*.zip
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/UnnecessaryFullyQualifiedName"/>
<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/UnnecessaryReturn"/>
<rule ref="category/java/codestyle.xml/UnnecessarySemicolon"/>
@@ -147,7 +147,6 @@
<rule ref="category/java/codestyle.xml/UseShortArrayInitializer"/>
<rule ref="category/java/codestyle.xml/UseUnderscoresInNumericLiterals"/>
<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/AvoidRethrowingException"/>
<rule ref="category/java/design.xml/AvoidThrowingNewInstanceOfSameException"/>
@@ -231,8 +230,11 @@
<rule ref="category/java/errorprone.xml/AvoidAssertAsIdentifier"/>
<rule ref="category/java/errorprone.xml/AvoidBranchingStatementAsLastInLoop"/>
<rule ref="category/java/errorprone.xml/AvoidCallingFinalize"/>
<rule ref="category/java/errorprone.xml/AvoidCatchingNPE"/>
<rule ref="category/java/errorprone.xml/AvoidCatchingThrowable"/>
<rule ref="category/java/errorprone.xml/AvoidCatchingGenericException">
<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/AvoidDuplicateLiterals">
<properties>
@@ -245,7 +247,6 @@
<rule ref="category/java/errorprone.xml/AvoidFieldNameMatchingTypeName"/>
<rule ref="category/java/errorprone.xml/AvoidInstanceofChecksInCatchClause"/>
<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/AvoidUsingOctalValues"/>
<rule ref="category/java/errorprone.xml/BrokenNullCheck"/>
@@ -313,7 +314,7 @@
<rule ref="category/java/errorprone.xml/UnusedNullCheckInEquals"/>
<rule ref="category/java/errorprone.xml/UseCorrectExceptionLogging"/>
<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/UseProperClassLoader"/>
<rule ref="category/java/multithreading.xml/AvoidSynchronizedAtMethodLevel"/>

View File

@@ -132,17 +132,17 @@ public final class CovertCommand {
* @throws ParseException if the arguments are invalid or incomplete
*/
public static int main(String[] args, Options options) throws ParseException {
final Option EMBED_OPTION = Option.builder().longOpt("embed").desc("Embed a payload into a JPEG").build();
final Option EXTRACT_OPTION = Option.builder().longOpt("extract").desc("Extract a payload from a JPEG").build();
final Option EMBED_OPTION = Option.builder().longOpt("embed").desc("Embed a payload into a JPEG").get();
final Option EXTRACT_OPTION = Option.builder().longOpt("extract").desc("Extract a payload from a JPEG").get();
final Option JPEG_OPTION = Option.builder().longOpt("jpeg").hasArg().argName("input.jpg")
.desc("Input JPEG file").required().build();
.desc("Input JPEG file").required().get();
final Option PAYLOAD_OPTION = Option.builder().longOpt("payload").hasArg().argName("payload.dat")
.desc("Binary payload file to embed").build();
.desc("Binary payload file to embed").get();
final Option OUTPUT_OPTION = Option.builder().longOpt("output").hasArg().argName("outputFile")
.desc("Output JPEG or payload file").required().build();
.desc("Output JPEG or payload file").required().get();
final Option SLOTS_OPTION = Option.builder().longOpt("slots").hasArgs().valueSeparator(';')
.argName("slot1;slot2;...")
.desc("Custom EXIF slots (e.g. Exif.UserComment:4096;Exif.Custom/tag=700,ascii,64,exif:1024)").build();
.desc("Custom EXIF slots (e.g. Exif.UserComment:4096;Exif.Custom/tag=700,ascii,64,exif:1024)").get();
OptionGroup modeGroup = new OptionGroup();
modeGroup.addOption(EMBED_OPTION);

View File

@@ -128,71 +128,71 @@ public final class Guard {
// ---- operation selection
final Option OPT_ENCRYPT = Option.builder("e").longOpt("encrypt").hasArg().argName("in-file")
.desc("Encrypt the given file").build();
.desc("Encrypt the given file").get();
final Option OPT_DECRYPT = Option.builder("d").longOpt("decrypt").hasArg().argName("in-file")
.desc("Decrypt the given file").build();
.desc("Decrypt the given file").get();
final OptionGroup OP = new OptionGroup().addOption(OPT_ENCRYPT).addOption(OPT_DECRYPT);
OP.setRequired(true);
// ---- common I/O
final Option OPT_OUT = Option.builder("o").longOpt("output").hasArg().argName("out-file")
.desc("Output file (default: <in>.enc for encrypt, <in>.dec for decrypt)").build();
.desc("Output file (default: <in>.enc for encrypt, <in>.dec for decrypt)").get();
final Option OPT_KEYRING = Option.builder().longOpt("keyring").hasArg().argName("keyring.txt")
.desc("KeyringStore file for aliases (required when aliases are used)").build();
.desc("KeyringStore file for aliases (required when aliases are used)").get();
// ---- payload selection and parameters
final Option OPT_ALG = Option.builder().longOpt("alg").hasArg().argName("name")
.desc("Payload: aes-gcm | aes-ctr | aes-cbc-pkcs7 | aes-cbc-nopad | chacha-aead | chacha-stream "
+ "(default: aes-gcm)")
.build();
.get();
final Option OPT_AAD_HEX = Option.builder("a").longOpt("aad-hex").hasArg().argName("hex")
.desc("Additional authenticated data as hex (AEAD modes)").build();
.desc("Additional authenticated data as hex (AEAD modes)").get();
final Option OPT_TAG_BITS = Option.builder().longOpt("tag-bits").hasArg().argName("96..128")
.desc("AES-GCM tag length in bits (default 128)").build();
.desc("AES-GCM tag length in bits (default 128)").get();
final Option OPT_NONCE_HEX = Option.builder().longOpt("nonce-hex").hasArg().argName("hex")
.desc("ChaCha nonce (12-byte hex)").build();
.desc("ChaCha nonce (12-byte hex)").get();
final Option OPT_INIT_CTR = Option.builder().longOpt("init-ctr").hasArg().argName("int")
.desc("ChaCha stream initial counter (default 1)").build();
.desc("ChaCha stream initial counter (default 1)").get();
final Option OPT_CTR = Option.builder().longOpt("ctr").hasArg().argName("int")
.desc("ChaCha stream counter override (propagated via context)").build();
.desc("ChaCha stream counter override (propagated via context)").get();
final Option OPT_NO_HDR = Option.builder().longOpt("no-header")
.desc("Do not write or expect a symmetric header").build();
.desc("Do not write or expect a symmetric header").get();
// ---- envelope parameters
final Option OPT_CEK_BYTES = Option.builder().longOpt("cek-bytes").hasArg().argName("len")
.desc("Payload key (CEK) length in bytes (default 32)").build();
.desc("Payload key (CEK) length in bytes (default 32)").get();
final Option OPT_MAX_RECIPS = Option.builder().longOpt("max-recipients").hasArg().argName("n")
.desc("Max recipients in the envelope header (default 64)").build();
.desc("Max recipients in the envelope header (default 64)").get();
final Option OPT_MAX_ENTRY = Option.builder().longOpt("max-entry-len").hasArg().argName("bytes")
.desc("Max single recipient-entry length (default 1048576)").build();
.desc("Max single recipient-entry length (default 1048576)").get();
final Option OPT_NO_SHUFFLE = Option.builder().longOpt("no-shuffle")
.desc("Disable shuffling of recipients (enabled by default)").build();
.desc("Disable shuffling of recipients (enabled by default)").get();
// ---- recipients (real)
final Option OPT_TO_ALIAS = Option.builder().longOpt("to-alias").hasArg().argName("alias")
.desc("Add recipient by alias from keyring (repeatable)").build();
.desc("Add recipient by alias from keyring (repeatable)").get();
final Option OPT_TO_PSW = Option.builder().longOpt("to-psw").hasArg().argName("password")
.desc("Add password recipient (repeatable)").build();
.desc("Add password recipient (repeatable)").get();
final Option OPT_PSW_ITER = Option.builder().longOpt("to-iter").hasArg().argName("n")
.desc("PBKDF2 iterations for password recipients (default 200000)").build();
.desc("PBKDF2 iterations for password recipients (default 200000)").get();
final Option OPT_PSW_SALT = Option.builder().longOpt("to-salt-len").hasArg().argName("bytes")
.desc("PBKDF2 salt length for password recipients (default 16)").build();
.desc("PBKDF2 salt length for password recipients (default 16)").get();
final Option OPT_PSW_KEK = Option.builder().longOpt("to-kek-bytes").hasArg().argName("bytes")
.desc("Derived KEK length for password recipients (default 32)").build();
.desc("Derived KEK length for password recipients (default 32)").get();
// ---- decoys (all types)
final Option OPT_DECOY_ALIAS = Option.builder().longOpt("decoy-alias").hasArg().argName("alias")
.desc("Add a decoy recipient from keyring (repeatable)").build();
.desc("Add a decoy recipient from keyring (repeatable)").get();
final Option OPT_DECOY_PSW = Option.builder().longOpt("decoy-psw").hasArg().argName("password")
.desc("Add a decoy password recipient (repeatable)").build();
.desc("Add a decoy password recipient (repeatable)").get();
final Option OPT_DECOY_PSW_RAND = Option.builder().longOpt("decoy-psw-rand").hasArg().argName("n")
.desc("Add N random decoy password recipients").build();
.desc("Add N random decoy password recipients").get();
// ---- unlock (decrypt)
final Option OPT_PRIV_ALIAS = Option.builder().longOpt("priv-alias").hasArg().argName("alias")
.desc("Unlock with private key from keyring").build();
.desc("Unlock with private key from keyring").get();
final Option OPT_PASSWORD = Option.builder("p").longOpt("password").hasArg().argName("password")
.desc("Unlock with password").build();
.desc("Unlock with password").get();
options.addOptionGroup(OP);
options.addOption(OPT_OUT);

View File

@@ -113,91 +113,91 @@ public final class Kem { // NOPMD
/** Encrypt mode: -e|--encrypt &lt;input&gt; */
public static final Option OPT_ENCRYPT = Option.builder("e").longOpt("encrypt").hasArg().argName("input")
.desc("Encrypt the input file").build();
.desc("Encrypt the input file").get();
/** Decrypt mode: -d|--decrypt &lt;input&gt; */
public static final Option OPT_DECRYPT = Option.builder("d").longOpt("decrypt").hasArg().argName("input")
.desc("Decrypt the input file").build();
.desc("Decrypt the input file").get();
/** Output path: -o|--output &lt;file&gt; */
public static final Option OPT_OUTPUT = Option.builder("o").longOpt("output").hasArg().argName("file")
.desc("Output file path (default: &lt;input&gt;.enc for encrypt, &lt;input&gt;.dec for decrypt)").build();
.desc("Output file path (default: &lt;input&gt;.enc for encrypt, &lt;input&gt;.dec for decrypt)").get();
/** Keyring path: -K|--keyring &lt;file&gt; */
public static final Option OPT_KEYRING = Option.builder("K").longOpt("keyring").hasArg().argName("file")
.desc("Path to KeyringStore file").build();
.desc("Path to KeyringStore file").get();
/** Recipient public alias (encrypt): --pub &lt;alias&gt; */
public static final Option OPT_PUB = Option.builder().longOpt("pub").hasArg().argName("alias")
.desc("Recipient public key alias (encryption)").build();
.desc("Recipient public key alias (encryption)").get();
/** Recipient private alias (decrypt): --priv &lt;alias&gt; */
public static final Option OPT_PRIV = Option.builder().longOpt("priv").hasArg().argName("alias")
.desc("Recipient private key alias (decryption)").build();
.desc("Recipient private key alias (decryption)").get();
/** KEM id: --kem &lt;id&gt; */
public static final Option OPT_KEM = Option.builder().longOpt("kem").hasArg().argName("id")
.desc("KEM algorithm id (see --list-kems)").build();
.desc("KEM algorithm id (see --list-kems)").get();
/** Discovery: --list-kems */
public static final Option OPT_LIST_KEMS = Option.builder().longOpt("list-kems")
.desc("List KEM algorithms that support ENCAPSULATE and DECAPSULATE and exit").build();
.desc("List KEM algorithms that support ENCAPSULATE and DECAPSULATE and exit").get();
/** Payload switch: --aes */
public static final Option OPT_AES = Option.builder().longOpt("aes")
.desc("Use AES payload (select mode via --aes-cipher)").build();
.desc("Use AES payload (select mode via --aes-cipher)").get();
/** Payload switch: --chacha */
public static final Option OPT_CHACHA = Option.builder().longOpt("chacha")
.desc("Use ChaCha payload (AEAD if --aad is provided, otherwise stream)").build();
.desc("Use ChaCha payload (AEAD if --aad is provided, otherwise stream)").get();
/** AAD (hex): --aad &lt;hex&gt; */
public static final Option OPT_AAD = Option.builder().longOpt("aad").hasArg().argName("hex")
.desc("Additional Authenticated Data (hex)").build();
.desc("Additional Authenticated Data (hex)").get();
/** Header toggle: --header */
public static final Option OPT_HEADER = Option.builder().longOpt("header")
.desc("Write/read a compact symmetric header (IV/AAD/params) when supported").build();
.desc("Write/read a compact symmetric header (IV/AAD/params) when supported").get();
/** HKDF: --hkdf [infoHex] */
public static final Option OPT_HKDF = Option.builder().longOpt("hkdf").optionalArg(true).hasArg().argName("infoHex")
.desc("Use HKDF-SHA256 for KEM secret; optional info as hex (default internal info)").build();
.desc("Use HKDF-SHA256 for KEM secret; optional info as hex (default internal info)").get();
/** Direct secret: --direct */
public static final Option OPT_DIRECT = Option.builder().longOpt("direct")
.desc("Use the raw KEM shared secret directly (disable HKDF)").build();
.desc("Use the raw KEM shared secret directly (disable HKDF)").get();
/** Derived key bytes: --key-bytes &lt;int&gt; */
public static final Option OPT_KEY_BYTES = Option.builder().longOpt("key-bytes").hasArg().argName("int")
.type(Number.class).desc("Derived symmetric key length in bytes (default 32)").build();
.type(Number.class).desc("Derived symmetric key length in bytes (default 32)").get();
/** Max KEM ciphertext len: --max-kem-ct &lt;int&gt; */
public static final Option OPT_MAX_KEM_CT = Option.builder().longOpt("max-kem-ct").hasArg().argName("int")
.type(Number.class).desc("Maximum accepted KEM ciphertext length in bytes (default 65536)").build();
.type(Number.class).desc("Maximum accepted KEM ciphertext length in bytes (default 65536)").get();
/** AES mode: --aes-cipher gcm|ctr|cbc */
public static final Option OPT_AES_CIPHER = Option.builder().longOpt("aes-cipher").hasArg().argName("gcm|ctr|cbc")
.desc("AES cipher variant for payload (default gcm)").build();
.desc("AES cipher variant for payload (default gcm)").get();
/** AES IV: --aes-iv &lt;hex&gt; */
public static final Option OPT_AES_IV = Option.builder().longOpt("aes-iv").hasArg().argName("hex")
.desc("AES IV/nonce (hex)").build();
.desc("AES IV/nonce (hex)").get();
/** AES tag bits: --aes-tag-bits &lt;int&gt; */
public static final Option OPT_AES_TAG_BITS = Option.builder().longOpt("aes-tag-bits").hasArg().argName("int")
.type(Number.class).desc("AES-GCM authentication tag length in bits (default 128)").build();
.type(Number.class).desc("AES-GCM authentication tag length in bits (default 128)").get();
/** ChaCha nonce: --chacha-nonce &lt;hex&gt; */
public static final Option OPT_CHACHA_NONCE = Option.builder().longOpt("chacha-nonce").hasArg().argName("hex")
.desc("ChaCha nonce (hex, usually 12 bytes)").build();
.desc("ChaCha nonce (hex, usually 12 bytes)").get();
/** ChaCha counter value: --chacha-counter &lt;int&gt; */
public static final Option OPT_CHACHA_COUNTER = Option.builder().longOpt("chacha-counter").hasArg().argName("int")
.type(Number.class).desc("ChaCha counter value for stream mode (integer)").build();
.type(Number.class).desc("ChaCha counter value for stream mode (integer)").get();
/** ChaCha initial counter: --chacha-initial &lt;int&gt; */
public static final Option OPT_CHACHA_INITIAL = Option.builder().longOpt("chacha-initial").hasArg().argName("int")
.type(Number.class).desc("ChaCha initial counter (integer)").build();
.type(Number.class).desc("ChaCha initial counter (integer)").get();
private Kem() {
// no instances

View File

@@ -167,49 +167,49 @@ public final class KeyStoreManagement { // NOPMD
// ---------------------------------------------------------------------
private static final Option KEYSTORE_OPTION = Option.builder("k").longOpt("keystore").hasArg().argName("file")
.desc("Path to keyring store").build();
.desc("Path to keyring store").get();
private static final Option LIST_ALGORITHMS_OPTION = Option.builder().longOpt("list-algorithms")
.desc("List catalog algorithms with symmetric/asymmetric support").build();
.desc("List catalog algorithms with symmetric/asymmetric support").get();
private static final Option LIST_ALIASES_OPTION = Option.builder().longOpt("list-aliases")
.desc("List aliases present in the keyring").build();
.desc("List aliases present in the keyring").get();
private static final Option GENERATE_OPTION = Option.builder().longOpt("generate")
.desc("Generate a keypair or a secret").build();
.desc("Generate a keypair or a secret").get();
private static final Option ALG_OPTION = Option.builder().longOpt("alg").hasArg().argName("id")
.desc("Algorithm id (e.g., RSA, Ed25519, AES, Frodo)").build();
.desc("Algorithm id (e.g., RSA, Ed25519, AES, Frodo)").get();
private static final Option ALIAS_OPTION = Option.builder().longOpt("alias").hasArg().argName("name")
.desc("Alias base; for asymmetric, two entries will be written").build();
.desc("Alias base; for asymmetric, two entries will be written").get();
private static final Option KIND_OPTION = Option.builder().longOpt("kind").hasArg().argName("sym|asym")
.desc("Force symmetric or asymmetric when algorithm supports both").build();
.desc("Force symmetric or asymmetric when algorithm supports both").get();
private static final Option PUB_SUFFIX_OPTION = Option.builder().longOpt("pub-suffix").hasArg().argName("sfx")
.desc("Suffix for public alias (default .pub)").build();
.desc("Suffix for public alias (default .pub)").get();
private static final Option PRV_SUFFIX_OPTION = Option.builder().longOpt("prv-suffix").hasArg().argName("sfx")
.desc("Suffix for private alias (default .prv)").build();
.desc("Suffix for private alias (default .prv)").get();
private static final Option OVERWRITE_OPTION = Option.builder().longOpt("overwrite")
.desc("Overwrite existing aliases on conflict").build();
.desc("Overwrite existing aliases on conflict").get();
private static final Option EXPORT_OPTION = Option.builder().longOpt("export")
.desc("Export selected aliases as a versioned text snippet").build();
.desc("Export selected aliases as a versioned text snippet").get();
private static final Option IMPORT_OPTION = Option.builder().longOpt("import")
.desc("Import a versioned text snippet into the keyring").build();
.desc("Import a versioned text snippet into the keyring").get();
private static final Option ALIASES_OPTION = Option.builder().longOpt("aliases").hasArg().argName("a,b,c")
.desc("Comma-separated aliases to export; empty means all").build();
.desc("Comma-separated aliases to export; empty means all").get();
private static final Option OUTFILE_OPTION = Option.builder().longOpt("out").hasArg().argName("file|-")
.desc("Output file for export (default '-' for stdout)").build();
.desc("Output file for export (default '-' for stdout)").get();
private static final Option INFILE_OPTION = Option.builder().longOpt("in").hasArg().argName("file|-")
.desc("Input file for import (default '-' for stdin)").build();
.desc("Input file for import (default '-' for stdin)").get();
/** Prevents instantiation. */
private KeyStoreManagement() {

View File

@@ -48,10 +48,10 @@ import java.util.Objects;
import org.apache.commons.cli.CommandLine;
import org.apache.commons.cli.DefaultParser;
import org.apache.commons.cli.HelpFormatter;
import org.apache.commons.cli.Option;
import org.apache.commons.cli.Options;
import org.apache.commons.cli.ParseException;
import org.apache.commons.cli.help.HelpFormatter;
import zeroecho.core.alg.digest.DigestSpec;
import zeroecho.core.err.VerificationException;
@@ -103,28 +103,28 @@ public final class Tag { // NOPMD
// ---- All options as constants
private static final Option TYPE_OPT = Option.builder().longOpt("type").hasArg().argName("signature|digest")
.desc("tag primitive type").build();
.desc("tag primitive type").get();
private static final Option MODE_OPT = Option.builder().longOpt("mode").hasArg().argName("produce|verify")
.desc("operation mode").build();
.desc("operation mode").get();
private static final Option ALG_OPT = Option.builder().longOpt("alg").hasArg().argName("id")
.desc("algorithm id (signature: Ed25519/Ed448/ECDSA/RSA; digest: SHA-256/.../SHAKE256:N)").build();
.desc("algorithm id (signature: Ed25519/Ed448/ECDSA/RSA; digest: SHA-256/.../SHAKE256:N)").get();
private static final Option KS_OPT = Option.builder().longOpt("ks").hasArg().argName("file")
.desc("keyring file (required for signature)").build();
.desc("keyring file (required for signature)").get();
private static final Option PRIV_OPT = Option.builder().longOpt("priv").hasArg().argName("alias")
.desc("private key alias (signature + produce)").build();
.desc("private key alias (signature + produce)").get();
private static final Option PUB_OPT = Option.builder().longOpt("pub").hasArg().argName("alias")
.desc("public key alias (signature + verify)").build();
.desc("public key alias (signature + verify)").get();
private static final Option IN_OPT = Option.builder().longOpt("in").hasArg().argName("file|-")
.desc("input file or - for STDIN").build();
.desc("input file or - for STDIN").get();
private static final Option OUT_OPT = Option.builder().longOpt("out").hasArg().argName("file|-")
.desc("output file or - for STDOUT").build();
.desc("output file or - for STDOUT").get();
// ---- Allowed values and defaults (no enums)
private static final String TYPE_SIGNATURE = "signature";
@@ -172,7 +172,7 @@ public final class Tag { // NOPMD
if (!has(cli, TYPE_OPT) || !has(cli, MODE_OPT) || !has(cli, ALG_OPT) || !has(cli, IN_OPT)
|| !has(cli, OUT_OPT)) {
new HelpFormatter().printHelp("zeroecho -T [options]", opts);
HelpFormatter.builder().get().printHelp("zeroecho -T [options]", "", opts, "", false);
return 2;
}

View File

@@ -41,12 +41,12 @@ import java.util.logging.Logger;
import org.apache.commons.cli.CommandLineParser;
import org.apache.commons.cli.DefaultParser;
import org.apache.commons.cli.HelpFormatter;
import org.apache.commons.cli.MissingOptionException;
import org.apache.commons.cli.Option;
import org.apache.commons.cli.OptionGroup;
import org.apache.commons.cli.Options;
import org.apache.commons.cli.ParseException;
import org.apache.commons.cli.help.HelpFormatter;
import zeroecho.sdk.util.BouncyCastleActivator;
@@ -98,8 +98,9 @@ public final class ZeroEcho {
* prints the help message.
*
* @param args command-line arguments passed to the program
* @throws IOException If the output could not be written
*/
public static void main(final String[] args) {
public static void main(final String[] args) throws IOException {
final int errorCode = mainProcess(args);
if (errorCode == 0) {
@@ -116,15 +117,16 @@ public final class ZeroEcho {
*
* @param args command-line arguments passed to the program
* @return error-code
* @throws IOException If the output could not be written
*/
public static int mainProcess(final String... args) {
final Option KEM_OPTION = Option.builder("E").longOpt("kem").desc("KEM encryption/decryption").build();
public static int mainProcess(final String... args) throws IOException {
final Option KEM_OPTION = Option.builder("E").longOpt("kem").desc("KEM encryption/decryption").get();
final Option GUARD_OPTION = Option.builder("G").longOpt("guard")
.desc("multi-recipient encryption/decryption (keys+passwords), AES/ChaCha").build();
final Option KEYSTORE_OPTION = Option.builder("K").longOpt("ksm").desc("key store management").build();
final Option COVERT_OPTION = Option.builder("C").longOpt("covert").desc("covert channel processing").build();
.desc("multi-recipient encryption/decryption (keys+passwords), AES/ChaCha").get();
final Option KEYSTORE_OPTION = Option.builder("K").longOpt("ksm").desc("key store management").get();
final Option COVERT_OPTION = Option.builder("C").longOpt("covert").desc("covert channel processing").get();
final Option TAG_OPTION = Option.builder("T").longOpt("tag")
.desc("tag subcommand (signature/digest; produce/verify)").build();
.desc("tag subcommand (signature/digest; produce/verify)").get();
final OptionGroup OPERATION_GROUP = new OptionGroup();
OPERATION_GROUP.addOption(GUARD_OPTION);
@@ -186,12 +188,12 @@ public final class ZeroEcho {
* @param options The {@link Options} instance defining the available
* command-line options.
* @return always {@code 1}
* @throws IOException If the output could not be written
*/
private static int help(final Options options) {
private static int help(final Options options) throws IOException {
// automatically generate the help statement
final HelpFormatter formatter = new HelpFormatter();
formatter.setWidth(80);
formatter.printHelp(ZeroEcho.class.getName(), options);
final HelpFormatter formatter = HelpFormatter.builder().get();
formatter.printHelp(ZeroEcho.class.getName(), "", options, "", false);
return 1;
}

View File

@@ -36,12 +36,14 @@ package zeroecho;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.io.IOException;
import org.junit.jupiter.api.Test;
class ZeroEchoTest {
@Test
void testAsymetricOptionWithoutParamsReturnsOne() {
void testAsymetricOptionWithoutParamsReturnsOne() throws IOException {
System.out.println("testAsymetricOptionWithoutParamsReturnsOne");
int result = ZeroEcho.mainProcess(new String[] { "-A" });
assertEquals(1, result, "Asymetric option without parameters should return 1 (error/help)");
@@ -49,7 +51,7 @@ class ZeroEchoTest {
}
@Test
void testAesPswOptionWithoutParamsReturnsOne() {
void testAesPswOptionWithoutParamsReturnsOne() throws IOException {
System.out.println("testAesPswOptionWithoutParamsReturnsOne");
int result = ZeroEcho.mainProcess(new String[] { "-P" });
assertEquals(1, result, "AES-PSW option without parameters should return 1 (error/help)");
@@ -57,7 +59,7 @@ class ZeroEchoTest {
}
@Test
void testNoOptionReturnsOne() {
void testNoOptionReturnsOne() throws IOException {
System.out.println("testNoOptionReturnsOne");
int result = ZeroEcho.mainProcess(new String[] {});
assertEquals(1, result, "No options should return 1 (error/help)");
@@ -65,7 +67,7 @@ class ZeroEchoTest {
}
@Test
void testInvalidOptionReturnsOne() {
void testInvalidOptionReturnsOne() throws IOException {
System.out.println("testInvalidOptionReturnsOne");
int result = ZeroEcho.mainProcess(new String[] { "-X" });
assertEquals(1, result, "Invalid option should return 1 (error/help)");

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,56 +33,83 @@
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
/**
* Classic McEliece (CMCE) KEM integration and utilities.
* ChaCha algorithm implementation and runtime wiring.
*
* <p>
* This package adapts the Bouncy Castle PQC CMCE primitives to the core SPI. It
* provides the algorithm descriptor, a runtime KEM context, and key
* specifications for generation and import. The design keeps provider-specific
* details encapsulated behind factories while exposing clear roles and metadata
* to the higher layers.
* This package provides the ChaCha capability set for the core layer, including
* the stream cipher ChaCha20 and the AEAD construction ChaCha20-Poly1305. The
* module contains algorithm descriptors, streaming cipher contexts, immutable
* specifications, optional header codecs for runtime parameters, and symmetric
* key import/generation specifications. The design favors safe defaults
* (12-byte nonces, 128-bit AEAD tag), explicit role-to-context binding, and a
* clear separation between static configuration and per-operation parameters.
* </p>
*
* <h2>Scope and responsibilities</h2>
* <ul>
* <li>Expose a concrete algorithm descriptor that registers CMCE KEM roles and
* a KEM-backed message-agreement adapter.</li>
* <li>Provide a runtime context that performs encapsulation and
* decapsulation.</li>
* <li>Define key specifications for key-pair generation and for importing X.509
* and PKCS#8 encodings.</li>
* </ul>
*
* <h2>Components</h2>
* <ul>
* <li><b>Algorithm descriptor:</b> {@link zeroecho.core.alg.cmce.CmceAlgorithm}
* declares {@code ENCAPSULATE}/{@code DECAPSULATE} KEM roles and wires an
* {@code AGREEMENT} role through a KEM-based adapter. It also registers
* asymmetric key builders for generation and import. The provider requirement
* is the Bouncy Castle PQC provider under the standard name
* {@code "BCPQC"}.</li>
* <li><b>Runtime context:</b> {@link zeroecho.core.alg.cmce.CmceKemContext}
* holds state for encapsulation or decapsulation depending on which constructor
* is used.</li>
* <li><b>Key generation spec:</b> {@link zeroecho.core.alg.cmce.CmceKeyGenSpec}
* selects a CMCE parameter set (variant) used by the key-pair builder.</li>
* <li><b>Key import specs:</b> {@link zeroecho.core.alg.cmce.CmcePublicKeySpec}
* wraps X.509 public keys and {@link zeroecho.core.alg.cmce.CmcePrivateKeySpec}
* wraps PKCS#8 private keys; both are immutable and defensively copy their byte
* arrays.</li>
* <li><b>Algorithm descriptors:</b> {@link ChaChaAlgorithm} registers ENCRYPT
* and DECRYPT roles for the ChaCha20 stream cipher;
* {@link ChaCha20Poly1305Algorithm} registers the AEAD counterpart. Both share
* a common base that wires symmetric key builders for generation and import.
* </li>
*
* <li><b>Streaming contexts:</b> {@link ChaChaCipherContext} and
* {@link ChaCha20Poly1305CipherContext} implement {@code InputStream}-to-
* {@code InputStream} transforms. The abstract parent
* {@link AbstractChaChaCipherContext} handles nonce lifecycle, optional header
* injection/parsing, and construction of a pull-based cipher stream.</li>
*
* <li><b>Static configuration (specs):</b> {@link ChaChaSpec} captures the
* initial counter and optional header codec for ChaCha20;
* {@link ChaCha20Poly1305Spec} binds the optional header codec for AEAD. The
* sealed marker {@link ChaChaBaseSpec} unifies both.</li>
*
* <li><b>Header codecs (optional):</b> {@link ChaChaHeaderCodec} writes/reads a
* compact header carrying the 12-byte nonce and a packed stream counter;
* {@link ChaCha20Poly1305HeaderCodec} writes/reads the 12-byte nonce and an
* optional SHA-256 of AAD to assert out-of-band AAD integrity.</li>
*
* <li><b>Key specifications:</b> {@link ChaChaKeyGenSpec} defines parameters
* for generating 256-bit ChaCha keys; {@link ChaChaKeyImportSpec} wraps
* externally supplied raw keys. Generation and import are registered by the
* algorithm base.</li>
* </ul>
*
* <h2>Provider requirements</h2>
* <h2>Runtime parameters and context exchange</h2>
* <p>
* The algorithm expects the Bouncy Castle PQC provider to be installed before
* use; the descriptor verifies this when generating or importing keys.
* Streaming contexts exchange ephemeral parameters through a Conflux session
* context using namespaced keys. For ChaCha20 and ChaCha20-Poly1305, a 12-byte
* nonce is required for each operation. On encryption, if the session context
* does not provide a nonce, the context generates a fresh value and stores it
* back into the session; on decryption the nonce must already be present and
* have the correct length. ChaCha20 also uses an initial counter sourced from
* {@link ChaChaSpec} and optionally overridden by the session context. When a
* header codec is configured and a session context is set, encryption prepends
* a minimal header and decryption reads it first to hydrate the session before
* initializing the cipher.
* </p>
*
* <h2>Safety and validation</h2>
* <ul>
* <li><b>Nonce uniqueness:</b> Applications must ensure nonces are unique per
* key. The contexts will generate nonces for encryption, but cross-process
* uniqueness is the caller's responsibility. Decryption fails if a nonce is
* missing or has an unexpected size.</li>
*
* <li><b>Counter policy (ChaCha20):</b> The default initial counter is 1. A
* context may override the spec value through the session key dedicated to
* counter exchange; consistency is enforced at attach time.</li>
*
* <li><b>AEAD tag and AAD (ChaCha20-Poly1305):</b> The effective tag length is
* 128 bits. If AAD is supplied, it is included in authentication; when enabled,
* the header codec can store SHA-256(AAD) to verify that decrypt-time AAD
* matches encrypt-time AAD.</li>
* </ul>
*
* <h2>Thread-safety</h2>
* <ul>
* <li>Algorithm descriptors are immutable and safe to share across
* threads.</li>
* <li>Runtime contexts are stateful and not thread-safe.</li>
* <li>Algorithm descriptors are immutable and safe to share.</li>
* <li>Streaming contexts are stateful and not thread-safe.</li>
* </ul>
*
* @since 1.0

View File

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

View File

@@ -33,70 +33,58 @@
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
/**
* <h2>Classic McEliece (CMCE)</h2>
* Classic McEliece (CMCE) KEM integration and utilities.
*
* <p>
* This package integrates the Classic McEliece cryptosystem, one of the oldest
* and most studied code-based public-key cryptosystems. Originally proposed by
* Robert McEliece in 1978, it is based on the hardness of decoding random
* binary Goppa codes. Despite large public key sizes, the scheme has withstood
* decades of cryptanalysis and remains unbroken by both classical and quantum
* computers.
* This package adapts the Bouncy Castle PQC CMCE primitives to the core SPI. It
* provides the algorithm descriptor, a runtime KEM context, and key
* specifications for generation and import. The design keeps provider-specific
* details encapsulated behind factories while exposing clear roles and metadata
* to the higher layers.
* </p>
*
* <h2>Post-quantum KEM</h2>
* <h2>Scope and responsibilities</h2>
* <ul>
* <li>Expose a concrete algorithm descriptor that registers CMCE KEM roles and
* a KEM-backed message-agreement adapter.</li>
* <li>Provide a runtime context that performs encapsulation and
* decapsulation.</li>
* <li>Define key specifications for key-pair generation and for importing X.509
* and PKCS#8 encodings.</li>
* </ul>
*
* <h2>Components</h2>
* <ul>
* <li><b>Algorithm descriptor:</b> {@link zeroecho.core.alg.cmce.CmceAlgorithm}
* declares {@code ENCAPSULATE}/{@code DECAPSULATE} KEM roles and wires an
* {@code AGREEMENT} role through a KEM-based adapter. It also registers
* asymmetric key builders for generation and import. The provider requirement
* is the Bouncy Castle PQC provider under the standard name
* {@code "BCPQC"}.</li>
* <li><b>Runtime context:</b> {@link zeroecho.core.alg.cmce.CmceKemContext}
* holds state for encapsulation or decapsulation depending on which constructor
* is used.</li>
* <li><b>Key generation spec:</b> {@link zeroecho.core.alg.cmce.CmceKeyGenSpec}
* selects a CMCE parameter set (variant) used by the key-pair builder.</li>
* <li><b>Key import specs:</b> {@link zeroecho.core.alg.cmce.CmcePublicKeySpec}
* wraps X.509 public keys and {@link zeroecho.core.alg.cmce.CmcePrivateKeySpec}
* wraps PKCS#8 private keys; both are immutable and defensively copy their byte
* arrays.</li>
* </ul>
*
* <h2>Provider requirements</h2>
* <p>
* Classic McEliece has been selected by NIST in the post-quantum cryptography
* standardization process for key encapsulation. Its primary appeal is
* long-term confidence: no efficient attacks are known even in the quantum
* setting. It provides IND-CCA2 security through a well-studied transform and
* is especially suited for use cases where large public keys are acceptable but
* extremely strong security margins are desired.
* The algorithm expects the Bouncy Castle PQC provider to be installed before
* use; the descriptor verifies this when generating or importing keys.
* </p>
*
* <h2>Contents</h2>
* <h2>Thread-safety</h2>
* <ul>
* <li>{@link zeroecho.core.alg.cmce.CmceAlgorithm} algorithm adapter exposing
* CMCE as a KEM and agreement primitive.</li>
* <li>{@link zeroecho.core.alg.cmce.CmceKemContext} runtime context for
* encapsulation and decapsulation.</li>
* <li>{@link zeroecho.core.alg.cmce.CmceKeyGenSpec} enumeration of
* standardized CMCE parameter sets (variants).</li>
* <li>{@link zeroecho.core.alg.cmce.CmcePublicKeySpec} wrapper for
* X.509-encoded public keys.</li>
* <li>{@link zeroecho.core.alg.cmce.CmcePrivateKeySpec} wrapper for
* PKCS#8-encoded private keys.</li>
* <li>Algorithm descriptors are immutable and safe to share across
* threads.</li>
* <li>Runtime contexts are stateful and not thread-safe.</li>
* </ul>
*
* <h2>Security properties</h2>
* <ul>
* <li>Underlying assumption: hardness of decoding binary Goppa codes.</li>
* <li>Selected as a NIST post-quantum KEM standard (2022).</li>
* <li>Public keys are large (hundreds of kilobytes), but ciphertexts and
* secrets are compact.</li>
* <li>Considered quantum-resistant and secure against known attacks.</li>
* </ul>
*
* <h2>Usage</h2> <pre>{@code
* // Select a variant (e.g., 8192128F for 256-bit security)
* CmceKeyGenSpec spec = CmceKeyGenSpec.mceliece8192128f();
* CmceAlgorithm alg = new CmceAlgorithm();
* KeyPair kp = alg.asymmetricKeyBuilder(CmceKeyGenSpec.class).generateKeyPair(spec);
*
* // Encapsulation (sender)
* try (CmceKemContext ctx = new CmceKemContext(alg, kp.getPublic())) {
* KemResult kem = ctx.encapsulate();
* byte[] ct = kem.ciphertext();
* byte[] secret = kem.secret();
* }
*
* // Decapsulation (recipient)
* try (CmceKemContext ctx = new CmceKemContext(alg, kp.getPrivate())) {
* byte[] secret = ctx.decapsulate(ct);
* }
* }</pre>
*
* @since 1.0
*/
package zeroecho.core.alg.cmce;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,6 +39,7 @@ import java.security.Key;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Objects;
import javax.security.auth.DestroyFailedException;
@@ -179,8 +180,8 @@ public final class HqcKemContext implements KemContext {
.createKey(key.getEncoded());
HQCKEMGenerator gen = new HQCKEMGenerator(new SecureRandom());
SecretWithEncapsulation res = gen.generateEncapsulated(keyParam);
byte[] secret = res.getSecret();
byte[] ct = res.getEncapsulation();
byte[] secret = Arrays.copyOf(res.getSecret(), res.getSecret().length);
byte[] ct = Arrays.copyOf(res.getEncapsulation(), res.getEncapsulation().length);
res.destroy();
return new KemResult(ct, secret);
} catch (DestroyFailedException e) {
@@ -213,7 +214,7 @@ public final class HqcKemContext implements KemContext {
.createKey(key.getEncoded());
HQCKEMExtractor ex = new HQCKEMExtractor(keyParam);
return ex.extractSecret(ciphertext);
} catch (Exception e) { // NOPMD
} catch (Exception 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.PublicKey;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Objects;
import javax.security.auth.DestroyFailedException;
@@ -182,8 +183,8 @@ public final class KyberKemContext implements KemContext {
.createKey(key.getEncoded());
MLKEMGenerator gen = new MLKEMGenerator(new SecureRandom());
SecretWithEncapsulation res = gen.generateEncapsulated(keyParam);
byte[] secret = res.getSecret();
byte[] ct = res.getEncapsulation();
byte[] secret = Arrays.copyOf(res.getSecret(), res.getSecret().length);
byte[] ct = Arrays.copyOf(res.getEncapsulation(), res.getEncapsulation().length);
res.destroy();
return new KemResult(ct, secret);
} catch (DestroyFailedException e) {
@@ -220,7 +221,7 @@ public final class KyberKemContext implements KemContext {
MLKEMExtractor gen = new MLKEMExtractor(keyParam);
return gen.extractSecret(ciphertext);
} catch (Exception e) { // NOPMD
} catch (Exception 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.PublicKey;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Objects;
import javax.security.auth.DestroyFailedException;
@@ -213,8 +214,8 @@ public final class NtruKemContext implements KemContext {
.createKey(key.getEncoded());
NTRUKEMGenerator gen = new NTRUKEMGenerator(new SecureRandom());
SecretWithEncapsulation res = gen.generateEncapsulated(keyParam);
byte[] secret = res.getSecret();
byte[] ct = res.getEncapsulation();
byte[] secret = Arrays.copyOf(res.getSecret(), res.getSecret().length);
byte[] ct = Arrays.copyOf(res.getEncapsulation(), res.getEncapsulation().length);
res.destroy();
return new KemResult(ct, secret);
} catch (DestroyFailedException e) {
@@ -250,7 +251,7 @@ public final class NtruKemContext implements KemContext {
.createKey(key.getEncoded());
NTRUKEMExtractor ex = new NTRUKEMExtractor(keyParam);
return ex.extractSecret(ciphertext);
} catch (Exception e) { // NOPMD
} catch (Exception 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.PublicKey;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Objects;
import javax.security.auth.DestroyFailedException;
@@ -175,8 +176,8 @@ public final class NtrulPrimeKemContext implements KemContext {
.createKey(key.getEncoded());
NTRULPRimeKEMGenerator gen = new NTRULPRimeKEMGenerator(new SecureRandom());
SecretWithEncapsulation res = gen.generateEncapsulated(keyParam);
byte[] secret = res.getSecret();
byte[] ct = res.getEncapsulation();
byte[] secret = Arrays.copyOf(res.getSecret(), res.getSecret().length);
byte[] ct = Arrays.copyOf(res.getEncapsulation(), res.getEncapsulation().length);
res.destroy();
return new KemResult(ct, secret);
} catch (DestroyFailedException e) {
@@ -212,7 +213,7 @@ public final class NtrulPrimeKemContext implements KemContext {
.createKey(key.getEncoded());
NTRULPRimeKEMExtractor ex = new NTRULPRimeKEMExtractor(keyParam);
return ex.extractSecret(ciphertext);
} catch (Exception e) { // NOPMD
} catch (Exception 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.PublicKey;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Objects;
import javax.security.auth.DestroyFailedException;
@@ -181,8 +182,8 @@ public final class SntruPrimeKemContext implements KemContext {
.createKey(key.getEncoded());
SNTRUPrimeKEMGenerator gen = new SNTRUPrimeKEMGenerator(new SecureRandom());
SecretWithEncapsulation res = gen.generateEncapsulated(keyParam);
byte[] secret = res.getSecret();
byte[] ct = res.getEncapsulation();
byte[] secret = Arrays.copyOf(res.getSecret(), res.getSecret().length);
byte[] ct = Arrays.copyOf(res.getEncapsulation(), res.getEncapsulation().length);
res.destroy();
return new KemResult(ct, secret);
} catch (DestroyFailedException e) {
@@ -218,7 +219,7 @@ public final class SntruPrimeKemContext implements KemContext {
.createKey(key.getEncoded());
SNTRUPrimeKEMExtractor ex = new SNTRUPrimeKEMExtractor(keyParam);
return ex.extractSecret(ciphertext);
} catch (Exception e) { // NOPMD
} catch (Exception 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.PublicKey;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Objects;
import javax.security.auth.DestroyFailedException;
@@ -182,8 +183,8 @@ public final class SaberKemContext implements KemContext {
.createKey(key.getEncoded());
SABERKEMGenerator gen = new SABERKEMGenerator(new SecureRandom());
SecretWithEncapsulation res = gen.generateEncapsulated(keyParam);
byte[] secret = res.getSecret();
byte[] ct = res.getEncapsulation();
byte[] secret = Arrays.copyOf(res.getSecret(), res.getSecret().length);
byte[] ct = Arrays.copyOf(res.getEncapsulation(), res.getEncapsulation().length);
res.destroy();
return new KemResult(ct, secret);
} catch (DestroyFailedException e) {
@@ -217,7 +218,7 @@ public final class SaberKemContext implements KemContext {
.createKey(key.getEncoded());
SABERKEMExtractor ex = new SABERKEMExtractor(keyParam);
return ex.extractSecret(ciphertext);
} catch (Exception e) { // NOPMD
} catch (Exception 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,166 @@
/*******************************************************************************
* 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. 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
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,247 @@
/*******************************************************************************
* 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}.
* </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}.
* </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.alg.AbstractCryptoAlgorithm;
import zeroecho.core.alg.common.agreement.GenericJcaAgreementContext;
import zeroecho.core.alg.common.agreement.GenericJcaMessageAgreementContext;
import zeroecho.core.alg.common.agreement.KeyPairKey;
import zeroecho.core.context.AgreementContext;
import zeroecho.core.context.MessageAgreementContext;
import zeroecho.core.spi.AsymmetricKeyBuilder;
/**
@@ -145,6 +148,11 @@ public final class XdhAlgorithm extends AbstractCryptoAlgorithm {
XdhSpec.class,
(PrivateKey k, XdhSpec s) -> new GenericJcaAgreementContext(this, k, s.keyAgreementName(), null),
() -> XdhSpec.X25519);
// New capability: MessageAgreementContext over KeyPair
capability(AlgorithmFamily.AGREEMENT, KeyUsage.AGREEMENT, MessageAgreementContext.class, KeyPairKey.class,
XdhSpec.class, (KeyPairKey k, XdhSpec s) -> new GenericJcaMessageAgreementContext(this, k,
s.keyAgreementName(), null, "XDH", null),
() -> XdhSpec.X25519);
registerAsymmetricKeyBuilder(XdhSpec.class, new XdhKeyGenBuilder(), () -> XdhSpec.X25519);
registerAsymmetricKeyBuilder(XdhPublicKeySpec.class, new AsymmetricKeyBuilder<>() {

View File

@@ -166,7 +166,7 @@ final class SmartContinuousBlockStream extends AbstractChunkTransformInputStream
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 cipher.doFinal(in, inOff, len, out, outOff);

View File

@@ -229,9 +229,9 @@ public abstract class TailStrippingInputStream extends InputStream {
emitPos += chunk;
if (emitPos == emitLen) {
// 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)
if (remaining > 0) { // NOPMD
if (remaining > 0) {
System.arraycopy(window, emitLen, window, 0, remaining);
}
accLen = remaining;
@@ -251,7 +251,7 @@ public abstract class TailStrippingInputStream extends InputStream {
// Whatever remains in window are the final tail bytes (<= tailLen).
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));
}
tailProcessed = true;
@@ -286,7 +286,7 @@ public abstract class TailStrippingInputStream extends InputStream {
System.arraycopy(window, emitPos, b, off, chunk);
emitPos += chunk;
if (emitPos == emitLen) {
if (needCompactAfterEmit) {
if (needCompactAfterEmit) { // NOPMD
int remaining = accLen - emitLen;
if (remaining > 0) {
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;
* 640/976/1344 for FrodoKEM) or from NIST security levels labeled as L1/L3/L5.
* 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
* string; otherwise defaults to 128.</li>
* <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 HMAC_SHA_PATTERN = Pattern.compile("HMAC(?:-)?SHA(?:-)?(1|224|256|384|512)",
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() {
}
@@ -153,6 +159,7 @@ public final class SecurityStrengthAdvisor { // NOPMD
case "ED25519" -> 128;
case "ED448" -> 224;
case "ML-KEM" -> kyberStrength(key);
case "ML-DSA", "MLDSA" -> mldsaStrength(key);
case "BIKE" -> mapByNistLevel(key, 128, 192, 256);
case "HQC" -> mapByNistLevel(key, 128, 192, 256);
case "FRODO" -> frodoStrength(key);
@@ -161,6 +168,7 @@ public final class SecurityStrengthAdvisor { // NOPMD
case "NTRU" -> ntruStrength(key);
case "SNTRUPRIME" -> sntruPrimeStrength(key);
case "NTRULPRIME" -> ntruLPrimeStrength(key);
case "SLH-DSA", "SLHDSA" -> slhDsaStrength(key);
case "SPHINCS+", "SPHINCSPLUS" -> sphincsPlusStrength(key);
case "DIGEST" -> 128;
default -> 128;
@@ -356,6 +364,31 @@ public final class SecurityStrengthAdvisor { // NOPMD
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) {
String a = safeAlgo(key);
@@ -416,4 +449,27 @@ public final class SecurityStrengthAdvisor { // NOPMD
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

@@ -103,10 +103,10 @@ public class ByteVerificationStrategy extends VerificationBiPredicate<byte[]> {
if (LOG.isLoggable(Level.FINE)) {
if (r == 0) {
LOG.log(Level.FINE, "PASS {0} == {1}", // NOPMD
LOG.log(Level.FINE, "PASS {0} == {1}",
new Object[] { Strings.toShortString(a), Strings.toShortString(b) });
} else {
LOG.log(Level.FINE, "FAIL {0} != {1}", // NOPMD
LOG.log(Level.FINE, "FAIL {0} != {1}",
new Object[] { Strings.toShortString(a), Strings.toShortString(b) });
}
}

View File

@@ -103,9 +103,9 @@ public class SignatureVerificationStrategy extends VerificationBiPredicate<Signa
if (LOG.isLoggable(Level.FINE)) {
if (result) {
LOG.log(Level.FINE, "PASS {0}", Strings.toShortString(b)); // NOPMD
LOG.log(Level.FINE, "PASS {0}", Strings.toShortString(b));
} else {
LOG.log(Level.FINE, "FAIL {0}", Strings.toShortString(b)); // NOPMD
LOG.log(Level.FINE, "FAIL {0}", Strings.toShortString(b));
}
}

View File

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

View File

@@ -59,12 +59,7 @@ public final class Strings {
* truncated after 32 elements, followed by an ellipsis marker: {@code [...]}.
* </p>
*
* <p>
* Examples:
* </p>
*
* <pre>
* {@code
* <h4>Examples</h4> <pre>{@code
* Strings.toShortString(null) -> "null"
* Strings.toShortString(new byte[0]) -> "[]"
* 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>
* </ul>
*
* <h2>Examples</h2> <pre>{@code
* <h4>Examples</h4> <pre>{@code
* toShortHexString(null) -> "null"
* toShortHexString(new byte[0]) -> "[]"
* 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);
}
if (v == Variant.AEAD) {
if (aad != null) {
if (aad != null) { // NOPMD
ctx.put(ConfluxKeys.aad(algId), aad);
}
} else {

View File

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

View File

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

View File

@@ -129,7 +129,7 @@ final class Decryptor implements PlainContent {
byte[] maybe = op.tryOpen(id, blob, material);
if (maybe != null) {
if (maybe.length > keyBytes) {
if (LOG.isLoggable(Level.WARNING)) {
if (LOG.isLoggable(Level.WARNING)) { // NOPMD
LOG.log(Level.WARNING,
"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

View File

@@ -78,7 +78,7 @@ public final class PasswordRecipient implements Recipient {
* marked as a decoy to obscure the actual number of usable recipients.
* </p>
*
* <h2>Security considerations</h2>
* <h4>Security considerations</h4>
* <ul>
* <li>The caller should clear the {@code password} array after constructing the
* 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
* its header.
*
* <p>
* Typical usage:
* </p>
* <pre>{@code
* <h2>Typical usage</h2> <pre>{@code
* // RSA-OAEP recipient; the header will contain the id and the RSA-wrapped CEK
* Recipient r = new MultiRecipientDataSourceBuilder.RsaOaepRecipient(rsaPublicKey);
* byte[] entryBlob = r.buildRecipientEntry(cek);
@@ -67,7 +64,7 @@ public interface Recipient {
* intentionally unusable for recovering the content-encryption key (CEK).
* </p>
*
* <h2>Security considerations</h2>
* <h4>Security considerations</h4>
* <ul>
* <li>Decoys prevent traffic analysis from revealing how many legitimate
* 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.hqc.HqcAlgorithm
zeroecho.core.alg.kyber.KyberAlgorithm
zeroecho.core.alg.mldsa.MldsaAlgorithm
zeroecho.core.alg.ntru.NtruAlgorithm
zeroecho.core.alg.ntruprime.NtrulPrimeAlgorithm
zeroecho.core.alg.ntruprime.SntruPrimeAlgorithm
zeroecho.core.alg.rsa.RsaAlgorithm
zeroecho.core.alg.saber.SaberAlgorithm
zeroecho.core.alg.slhdsa.SlhDsaAlgorithm
zeroecho.core.alg.sphincsplus.SphincsPlusAlgorithm
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.SignatureVerificationStrategy.level = FINE
zeroecho.sdk.hybrid.signature.level = FINE
# Console handler uses our one-line formatter
java.util.logging.ConsoleHandler.level = ALL

View File

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

View File

@@ -0,0 +1,248 @@
/*******************************************************************************
* 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 mldsaVerifier = CryptoAlgorithms.create("ML-DSA", KeyUsage.VERIFY, kp.getPublic());
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 badVerifier = CryptoAlgorithms.create("ML-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,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());
}
@Deprecated
@Test
public void testIncrementingAdapter() throws IOException {
System.out.println("testIncrementingAdapter");

32
pki/.classpath Normal file
View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="src" output="bin/main" path="src/main/java">
<attributes>
<attribute name="gradle_scope" value="main"/>
<attribute name="gradle_used_by_scope" value="main,test"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="bin/main" path="src/main/resources">
<attributes>
<attribute name="gradle_scope" value="main"/>
<attribute name="gradle_used_by_scope" value="main,test"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="bin/test" path="src/test/java">
<attributes>
<attribute name="gradle_scope" value="test"/>
<attribute name="gradle_used_by_scope" value="test"/>
<attribute name="test" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="bin/test" path="src/test/resources">
<attributes>
<attribute name="gradle_scope" value="test"/>
<attribute name="gradle_used_by_scope" value="test"/>
<attribute name="test" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-21/"/>
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
<classpathentry kind="output" path="bin/default"/>
</classpath>

23
pki/.project Normal file
View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>pki</name>
<comment>Project pki created by Buildship.</comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.buildship.core.gradleprojectbuilder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.jdt.core.javanature</nature>
<nature>org.eclipse.buildship.core.gradleprojectnature</nature>
</natures>
</projectDescription>

31
pki/LICENSE Normal file
View File

@@ -0,0 +1,31 @@
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.

57
pki/build.gradle Normal file
View File

@@ -0,0 +1,57 @@
plugins {
id 'buildlogic.java-application-conventions'
id 'com.palantir.git-version'
}
group 'org.egothor'
dependencies {
implementation 'org.apache.commons:commons-text'
implementation 'commons-cli:commons-cli'
implementation project(':lib')
}
application {
// Define the main class for the application.
mainClass = 'zeroecho.pki.PkiApplication'
}
jar {
manifest {
attributes(
'Main-Class': application.mainClass,
'Implementation-Title': rootProject.name,
'Implementation-Version': "${version}"
)
}
from sourceSets.main.output
dependsOn configurations.runtimeClasspath
// Include each JAR dependency
configurations.runtimeClasspath.findAll { it.exists() && it.name.endsWith('.jar') }.each { jarFile ->
def jarName = jarFile.name.replaceAll(/\.jar$/, '')
from(zipTree(jarFile)) {
// Exclude signature-related files
exclude 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA'
// Rename license/notice files to avoid conflicts
eachFile { file ->
if (file.path ==~ /META-INF\/(LICENSE|NOTICE)(\..*)?/) {
file.path = "META-INF/licenses-from-${jarName}/${file.name}"
}
}
includeEmptyDirs = false
}
}
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
javadoc {
options.links("https://www.egothor.org/javadoc/zeroecho/lib")
}

View File

@@ -0,0 +1,309 @@
/*******************************************************************************
* 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.pki;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.spi.bootstrap.PkiBootstrap;
import zeroecho.pki.util.async.AsyncBus;
/**
* Minimal bootstrap entry point for the {@code pki} module.
*
* <p>
* This class is intentionally limited to process bootstrap and hosting
* concerns:
* </p>
* <ul>
* <li>initializes JUL logging conventions (without leaking secrets),</li>
* <li>installs an uncaught-exception handler,</li>
* <li>composes PKI runtime components using {@link PkiBootstrap},</li>
* <li>hosts the async-bus maintenance loop via periodic
* {@code sweep(...)},</li>
* <li>waits until process termination (Ctrl+C) and performs orderly
* shutdown.</li>
* </ul>
*
* <h2>Async bus sweep</h2>
* <p>
* The async bus requires periodic {@link AsyncBus#sweep(Instant)} calls to:
* expire operations past their deadline and re-synchronize open operations
* after restart (depending on the bus implementation).
* </p>
*
* <h2>Security</h2>
* <p>
* Command-line arguments and configuration values are never logged because they
* can contain sensitive material (paths, tokens, passphrases).
* </p>
*/
@SuppressWarnings("PMD.DoNotUseThreads")
public final class PkiApplication {
private static final Logger LOG = Logger.getLogger(PkiApplication.class.getName());
/**
* System property controlling the async sweep interval in milliseconds.
*
* <p>
* If missing or invalid, a safe default is used.
* </p>
*/
private static final String PROP_ASYNC_SWEEP_INTERVAL_MS = "zeroecho.pki.async.sweepIntervalMs";
/**
* Default async sweep interval used when not configured.
*/
private static final Duration DEFAULT_ASYNC_SWEEP_INTERVAL = Duration.ofSeconds(2L);
/**
* Shutdown grace time for the sweep executor.
*/
private static final Duration SWEEP_SHUTDOWN_GRACE = Duration.ofSeconds(10L);
private PkiApplication() {
throw new AssertionError("No instances.");
}
/**
* Starts the PKI process.
*
* <p>
* Security note: command-line arguments are not logged because they can contain
* sensitive material (paths, tokens, passphrases).
* </p>
*
* @param args command-line arguments (never logged)
*/
public static void main(String[] args) {
Objects.requireNonNull(args, "args");
PkiLogging.configureIfPresent();
PkiLogging.installUncaughtExceptionHandler();
LOG.info("ZeroEcho PKI starting.");
CountDownLatch shutdownLatch = new CountDownLatch(1);
// closed in the shutdown routine
ScheduledExecutorService sweepExecutor = Executors.newSingleThreadScheduledExecutor(new SweepThreadFactory()); // NOPMD
Runtime.getRuntime()
.addShutdownHook(new Thread(new ShutdownHook(sweepExecutor, shutdownLatch), "zeroecho-pki-shutdown"));
try {
AsyncBus<PkiId, Principal, String, Object> asyncBus = PkiBootstrap.openAsyncBus();
Duration sweepInterval = readSweepInterval(DEFAULT_ASYNC_SWEEP_INTERVAL);
if (LOG.isLoggable(Level.INFO)) {
LOG.log(Level.INFO, "Async bus sweep enabled; intervalMs={0}", sweepInterval.toMillis());
}
sweepExecutor.scheduleWithFixedDelay(new SweepTask(asyncBus), 0L, sweepInterval.toMillis(),
TimeUnit.MILLISECONDS);
LOG.info("ZeroEcho PKI started.");
// Keep process alive until Ctrl+C (or other shutdown signal).
awaitShutdown(shutdownLatch);
} catch (RuntimeException ex) { // NOPMD
// Do not include user-provided inputs in the message; log the exception object.
LOG.log(Level.SEVERE, "Fatal error during PKI bootstrap.", ex);
throw ex;
}
}
private static void awaitShutdown(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "Interrupted while awaiting shutdown.", ex);
}
}
}
private static Duration readSweepInterval(Duration defaultValue) {
String raw = System.getProperty(PROP_ASYNC_SWEEP_INTERVAL_MS);
if (raw == null || raw.isBlank()) {
return defaultValue;
}
try {
long ms = Long.parseLong(raw);
if (ms <= 0L) { // NOPMD
return defaultValue;
}
return Duration.ofMillis(ms);
} catch (NumberFormatException ex) {
if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "Invalid async sweep interval system property; using default.", ex);
}
return defaultValue;
}
}
/**
* Periodic maintenance task for asynchronous PKI infrastructure.
*
* <p>
* {@code SweepTask} represents a resilient, repeatable unit of work that
* invokes time-based maintenance logic on an {@link AsyncBus} instance. It is
* intended to be scheduled at a fixed rate by a background executor and must
* tolerate partial failures without disrupting the surrounding runtime
* environment.
* </p>
*
* <p>
* The task uses the current wall-clock time as a reference for sweep operations
* and deliberately suppresses runtime failures, logging them for diagnostic
* purposes while allowing future executions to proceed.
* </p>
*
* <p>
* This class is internal to the PKI bootstrap and lifecycle management logic
* and is not part of the public API surface.
* </p>
*/
private static final class SweepTask implements Runnable {
private final AsyncBus<PkiId, Principal, String, Object> asyncBus;
private SweepTask(AsyncBus<PkiId, Principal, String, Object> asyncBus) {
this.asyncBus = Objects.requireNonNull(asyncBus, "asyncBus");
}
@Override
public void run() {
Instant now = Instant.now();
try {
asyncBus.sweep(now);
} catch (RuntimeException ex) { // NOPMD
// Sweep must be resilient; log and continue.
if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "Async sweep failed.", ex);
}
}
}
}
/**
* JVM shutdown hook coordinating termination of asynchronous sweep services.
*
* <p>
* {@code ShutdownHook} is responsible for orchestrating an orderly shutdown of
* background sweep execution during JVM termination. It emits a structured
* shutdown message, initiates executor shutdown, and enforces a bounded grace
* period for task completion.
* </p>
*
* <p>
* The hook guarantees that shutdown coordination always completes by
* decrementing the associated {@link CountDownLatch}, regardless of whether
* shutdown is graceful, forced, or interrupted.
* </p>
*
* <p>
* This class must never throw exceptions or prevent JVM termination. All
* failure modes are handled internally.
* </p>
*/
private static final class ShutdownHook implements Runnable {
private final ScheduledExecutorService sweepExecutor;
private final CountDownLatch latch;
private ShutdownHook(ScheduledExecutorService sweepExecutor, CountDownLatch latch) {
this.sweepExecutor = Objects.requireNonNull(sweepExecutor, "sweepExecutor");
this.latch = Objects.requireNonNull(latch, "latch");
}
@Override
public void run() {
Logger shutdownLogger = Logger.getLogger(PkiApplication.class.getName());
PkiLogging.emitShutdownMessage(shutdownLogger, "ZeroEcho PKI stopping.");
sweepExecutor.shutdown();
try {
boolean ok = sweepExecutor.awaitTermination(SWEEP_SHUTDOWN_GRACE.toMillis(), TimeUnit.MILLISECONDS);
if (!ok) {
sweepExecutor.shutdownNow();
}
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
sweepExecutor.shutdownNow();
} finally {
latch.countDown();
}
}
}
/**
* Thread factory for asynchronous sweep execution.
*
* <p>
* {@code SweepThreadFactory} creates daemon threads with a stable and
* descriptive naming convention suitable for operational diagnostics and log
* correlation. Threads produced by this factory are intentionally marked as
* daemon threads so that they do not prolong JVM lifetime.
* </p>
*
* <p>
* The factory performs no additional customization such as priority changes or
* uncaught-exception handlers, relying instead on executor-level policies.
* </p>
*/
private static final class SweepThreadFactory implements ThreadFactory {
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "zeroecho-pki-async-sweep");
t.setDaemon(true);
return t;
}
}
}

View File

@@ -0,0 +1,178 @@
/*******************************************************************************
* 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.pki;
import java.io.IOException;
import java.io.InputStream;
import java.lang.Thread.UncaughtExceptionHandler;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level;
import java.util.logging.LogManager;
import java.util.logging.Logger;
/**
* Internal bootstrap utilities for JUL configuration in the {@code pki} module.
*
* <p>
* This helper intentionally avoids logging any potentially sensitive material.
* In particular, it never logs:
* </p>
* <ul>
* <li>command-line arguments,</li>
* <li>key material, seeds, shared secrets,</li>
* <li>private configuration values (tokens, passphrases).</li>
* </ul>
*
* <p>
* Configuration strategy:
* </p>
* <ul>
* <li>If a {@code /zeroecho-pki-logging.properties} resource is present on the
* classpath, it will be loaded via
* {@link LogManager#readConfiguration(InputStream)}.</li>
* <li>If not present, JUL defaults remain in place (minimal bootstrap
* behavior).</li>
* </ul>
*/
final class PkiLogging {
/**
* Optional classpath resource for JUL configuration.
*/
private static final String LOGGING_PROPERTIES_RESOURCE = "/zeroecho-pki-logging.properties";
private static final Logger LOG = Logger.getLogger(PkiLogging.class.getName());
/**
* One-shot guard ensuring JUL configuration is attempted at most once.
*/
private static final AtomicBoolean CONFIGURED = new AtomicBoolean(false);
private PkiLogging() {
throw new AssertionError("No instances.");
}
/**
* Configures JUL from an optional classpath resource, if present.
*
* <p>
* This method is idempotent and safe to call multiple times.
* </p>
*/
/* default */ static void configureIfPresent() {
// Fast-path: already configured
if (!CONFIGURED.compareAndSet(false, true)) {
return;
}
// getResourceAsStream() may return null; try-with-resources handles null safely
try (InputStream is = PkiLogging.class.getResourceAsStream(LOGGING_PROPERTIES_RESOURCE)) {
if (is == null) {
return;
}
LogManager.getLogManager().readConfiguration(is);
LOG.info("JUL configured from classpath resource.");
} catch (IOException ex) {
// Keep message generic; do not leak environment specifics.
LOG.log(Level.WARNING, "Failed to load JUL configuration; continuing with defaults.", ex);
}
}
/**
* Installs a process-wide uncaught exception handler that logs failures via
* JUL.
*
* <p>
* The handler emits a generic message and includes the throwable. It
* deliberately does not serialize additional contextual data that might contain
* secrets.
* </p>
*/
/* default */ static void installUncaughtExceptionHandler() {
UncaughtExceptionHandler handler = (Thread thread, Throwable throwable) -> { // NOPMD
Objects.requireNonNull(thread, "thread");
Objects.requireNonNull(throwable, "throwable");
Logger logger = Logger.getLogger(PkiApplication.class.getName());
if (logger.isLoggable(Level.SEVERE)) {
logger.log(Level.SEVERE, "Uncaught exception in thread: " + thread.getName(), throwable);
}
};
Thread.setDefaultUncaughtExceptionHandler(handler); // NOPMD
}
/**
* Emits a shutdown message in a way that remains visible even during late JVM
* teardown.
*
* <p>
* The primary path is JUL. As a fallback, a constant message is written to
* {@code System.err}. This avoids logging any secrets and improves reliability
* in environments where JUL output may be lost during shutdown.
* </p>
*
* @param logger logger to use for the primary JUL emission
* @param message message to emit; must not contain secrets
* @throws NullPointerException if {@code logger} or {@code message} is
* {@code null}
*/
/* default */ static void emitShutdownMessage(Logger logger, String message) {
Objects.requireNonNull(logger, "logger");
Objects.requireNonNull(message, "message");
// Primary path: JUL
logger.info(message);
// Flush root handlers (covers parent-handler delegation).
Logger root = Logger.getLogger("");
for (java.util.logging.Handler handler : root.getHandlers()) {
try {
handler.flush();
} catch (RuntimeException ignored) { // NOPMD
// Never throw during shutdown
}
}
// Fallback: direct stderr write
try {
System.err.println(message);
System.err.flush();
} catch (RuntimeException ignored) { // NOPMD
// Never throw during shutdown
}
}
}

View File

@@ -0,0 +1,83 @@
/*******************************************************************************
* 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.pki.api;
import zeroecho.pki.api.backup.BackupArtifact;
import zeroecho.pki.api.backup.BackupRequest;
import zeroecho.pki.api.backup.BackupVerificationReport;
import zeroecho.pki.api.backup.RestoreReport;
import zeroecho.pki.api.backup.RestoreRequest;
/**
* Backup/restore operations for PKI state.
*
* <p>
* Backups must not implicitly include private keys. Private keys are referenced
* via {@link KeyRef} and may be managed by separate components.
* </p>
*/
public interface BackupService {
/**
* Creates a backup of PKI state.
*
* @param request backup request
* @return backup artifact
* @throws IllegalArgumentException if {@code request} is invalid
* @throws PkiException if backup creation fails
*/
BackupArtifact createBackup(BackupRequest request);
/**
* Restores PKI state from a backup artifact.
*
* @param request restore request
* @return restore report
* @throws IllegalArgumentException if {@code request} is invalid
* @throws PkiException if restore fails
*/
RestoreReport restoreBackup(RestoreRequest request);
/**
* Verifies a backup artifact for structural validity and integrity.
*
* @param artifact backup artifact
* @return verification report
* @throws IllegalArgumentException if {@code artifact} is null
* @throws PkiException if verification fails due to IO/backend
* errors
*/
BackupVerificationReport verifyBackup(BackupArtifact artifact);
}

View File

@@ -0,0 +1,172 @@
/*******************************************************************************
* 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.pki.api;
import java.util.List;
import zeroecho.pki.api.ca.CaCreateCommand;
import zeroecho.pki.api.ca.CaImportCommand;
import zeroecho.pki.api.ca.CaKeyRotationCommand;
import zeroecho.pki.api.ca.CaQuery;
import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.ca.CaRolloverCommand;
import zeroecho.pki.api.ca.CaState;
import zeroecho.pki.api.ca.IntermediateCertIssueCommand;
import zeroecho.pki.api.ca.IntermediateCreateCommand;
import zeroecho.pki.api.credential.Credential;
/**
* Manages Certificate Authority (CA) entities and their lifecycle.
*
* <p>
* A CA entity represents an administrative unit capable of issuing credentials.
* A CA entity may own multiple CA credentials over time to support
* cross-signing, rollover, and key rotation.
* </p>
*
* <p>
* Private key material is never handled directly by the PKI module; the CA key
* is referenced by {@link KeyRef} and resolved by runtime wiring.
* </p>
*/
public interface CaService {
/**
* Creates a new root CA entity and issues its initial CA credential.
*
* @param command create command defining subject/profile and optional key
* reference
* @return created CA identifier
* @throws IllegalArgumentException if {@code command} is invalid
* @throws PkiException if creation fails due to policy, storage, or
* framework backend error
*/
PkiId createRoot(CaCreateCommand command);
/**
* Imports an existing root CA into the PKI inventory.
*
* <p>
* This registers a CA entity, associates it with a {@link KeyRef}, and persists
* the existing CA credential. Import does not automatically imply trust; trust
* anchor selection is a consumer decision.
* </p>
*
* @param command import command including CA credential payload and key
* reference
* @return imported CA identifier
* @throws IllegalArgumentException if {@code command} is invalid
* @throws PkiException if import fails (e.g., inconsistent
* metadata, storage failure)
*/
PkiId importRoot(CaImportCommand command);
/**
* Creates a new intermediate CA entity and issues its initial intermediate CA
* credential.
*
* @param command intermediate creation command
* @return created intermediate CA identifier
* @throws IllegalArgumentException if {@code command} is invalid
* @throws PkiException if creation fails
*/
PkiId createIntermediate(IntermediateCreateCommand command);
/**
* Issues a new CA credential for an existing intermediate CA entity.
*
* <p>
* This operation enables cross-signing and renewal scenarios.
* </p>
*
* @param command issuance command specifying issuer and subject CA entity
* @return newly issued CA credential
* @throws IllegalArgumentException if {@code command} is invalid
* @throws PkiException if issuance fails due to policy or backend
* errors
*/
Credential issueIntermediateCertificate(IntermediateCertIssueCommand command);
/**
* Performs a CA credential rollover while keeping the same key reference.
*
* @param command rollover command
* @return CA identifier (same CA id expected; returned for convenience)
* @throws IllegalArgumentException if {@code command} is invalid
* @throws PkiException if rollover fails
*/
PkiId rolloverCaCertificate(CaRolloverCommand command);
/**
* Rotates the CA key reference and issues new corresponding CA credentials.
*
* @param command key rotation command
* @return CA identifier (same CA id expected; returned for convenience)
* @throws IllegalArgumentException if {@code command} is invalid
* @throws PkiException if rotation fails
*/
PkiId rotateCaKey(CaKeyRotationCommand command);
/**
* Updates CA operational state.
*
* @param caId CA identifier
* @param state new CA state
* @param reason non-empty operator-readable reason suitable for audit
* @throws IllegalArgumentException if inputs are invalid
* @throws PkiException if CA does not exist or update fails
*/
void setCaState(PkiId caId, CaState state, String reason);
/**
* Retrieves a CA record.
*
* @param caId CA identifier
* @return CA record
* @throws IllegalArgumentException if {@code caId} is invalid
* @throws PkiException if CA does not exist
*/
CaRecord getCa(PkiId caId);
/**
* Lists CA records matching query constraints.
*
* @param query query constraints
* @return list of CA records
* @throws IllegalArgumentException if {@code query} is invalid
* @throws PkiException if listing fails
*/
List<CaRecord> listCas(CaQuery query);
}

View File

@@ -0,0 +1,123 @@
/*******************************************************************************
* 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.pki.api;
import java.util.List;
import java.util.Optional;
import zeroecho.pki.api.issuance.VerificationPolicy;
import zeroecho.pki.api.request.CertificationRequest;
import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.api.request.ProofOfPossessionResult;
import zeroecho.pki.api.request.RequestQuery;
import zeroecho.pki.api.request.RequestStorePolicy;
/**
* Processes certification requests (CSR-like objects) into a normalized
* representation.
*
* <p>
* This service provides request fingerprinting, parsing, proof-of-possession
* verification, and optional persistence for correlation and auditing. Request
* transport protocols such as ACME are expected to use this service as the core
* processing layer.
* </p>
*/
public interface CertificationRequestService {
/**
* Computes a stable identifier (fingerprint) for the given request payload.
*
* @param request certification request
* @return stable request identifier
* @throws IllegalArgumentException if {@code request} is null
* @throws PkiException if fingerprinting fails
*/
PkiId fingerprint(CertificationRequest request);
/**
* Parses and normalizes a certification request.
*
* @param request certification request
* @return parsed request
* @throws IllegalArgumentException if {@code request} is null
* @throws PkiException if parsing fails (invalid request,
* unsupported format, backend failure)
*/
ParsedCertificationRequest parse(CertificationRequest request);
/**
* Verifies proof-of-possession (PoP) for the private key corresponding to the
* requested public key.
*
* @param parsed parsed request
* @param policy verification policy
* @return PoP verification result
* @throws IllegalArgumentException if inputs are null
* @throws PkiException if verification fails due to backend failure
*/
ProofOfPossessionResult verifyProofOfPossession(ParsedCertificationRequest parsed, VerificationPolicy policy);
/**
* Stores a parsed request for later correlation and audit.
*
* @param parsed parsed request
* @param policy storage policy
* @return stored request id
* @throws IllegalArgumentException if inputs are null
* @throws PkiException if persistence fails
*/
PkiId store(ParsedCertificationRequest parsed, RequestStorePolicy policy);
/**
* Retrieves a stored request.
*
* @param requestId request id
* @return parsed request if present
* @throws IllegalArgumentException if {@code requestId} is null
* @throws PkiException if retrieval fails
*/
Optional<ParsedCertificationRequest> get(PkiId requestId);
/**
* Searches stored requests.
*
* @param query request query
* @return matching requests
* @throws IllegalArgumentException if {@code query} is null
* @throws PkiException if searching fails
*/
List<ParsedCertificationRequest> search(RequestQuery query);
}

Some files were not shown because too many files have changed in this diff Show More