From 9bbcab7522bbd5264894e080d061cdc5f95d02b0 Mon Sep 17 00:00:00 2001 From: Leo Galambos Date: Wed, 29 Jul 2026 17:28:32 +0200 Subject: [PATCH] security(lib): enforce single-use encryption contexts --- app/src/main/java/zeroecho/Guard.java | 6 +- app/src/main/java/zeroecho/Kem.java | 8 +- app/src/test/java/zeroecho/GuardTest.java | 7 +- app/src/test/java/zeroecho/KemTest.java | 7 +- .../core/alg/aes/AesCipherContext.java | 124 ++++++++-- .../chacha/AbstractChaChaCipherContext.java | 119 ++++++++-- .../alg/chacha/ChaCha20Poly1305Algorithm.java | 9 +- .../chacha/ChaCha20Poly1305CipherContext.java | 4 +- .../core/alg/chacha/ChaChaAlgorithm.java | 9 +- .../core/alg/chacha/package-info.java | 25 +- .../builders/alg/AesDataContentBuilder.java | 33 +-- .../alg/ChaChaDataContentBuilder.java | 40 ++-- .../sdk/hybrid/derived/HybridDerived.java | 26 +-- .../core/alg/aes/AesGcmCrossCheckTest.java | 6 +- .../core/alg/aes/AesRandomSupportTest.java | 141 ++++++++++- .../alg/chacha/ChaChaNonceLifecycleTest.java | 221 ++++++++++++++++++ .../sdk/hybrid/derived/HybridDerivedTest.java | 16 +- .../java/demo/HybridDerivedAesDemoTest.java | 32 +-- 18 files changed, 673 insertions(+), 160 deletions(-) create mode 100644 lib/src/test/java/zeroecho/core/alg/chacha/ChaChaNonceLifecycleTest.java diff --git a/app/src/main/java/zeroecho/Guard.java b/app/src/main/java/zeroecho/Guard.java index 35ec7e9..aece07b 100644 --- a/app/src/main/java/zeroecho/Guard.java +++ b/app/src/main/java/zeroecho/Guard.java @@ -154,7 +154,7 @@ public final class Guard { final Option OPT_TAG_BITS = Option.builder().longOpt("tag-bits").hasArg().argName("96..128") .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)").get(); + .desc("ChaCha decryption nonce (12-byte hex; rejected for encryption)").get(); final Option OPT_INIT_CTR = Option.builder().longOpt("init-ctr").hasArg().argName("int") .desc("ChaCha stream initial counter (default 1)").get(); final Option OPT_CTR = Option.builder().longOpt("ctr").hasArg().argName("int") @@ -303,7 +303,7 @@ public final class Guard { chacha.withHeader(); } if (chachaNonce != null) { - chacha.withNonce(chachaNonce); + chacha.withDecryptionNonce(chachaNonce); } if (ctrOverride != null || initCtr != null) { // providing counters together with AAD would be conflicting; builder enforces @@ -323,7 +323,7 @@ public final class Guard { chacha.withHeader(); } if (chachaNonce != null) { - chacha.withNonce(chachaNonce); + chacha.withDecryptionNonce(chachaNonce); } if (initCtr != null) { chacha.initialCounter(initCtr); diff --git a/app/src/main/java/zeroecho/Kem.java b/app/src/main/java/zeroecho/Kem.java index 4ed2b24..bbc3629 100644 --- a/app/src/main/java/zeroecho/Kem.java +++ b/app/src/main/java/zeroecho/Kem.java @@ -181,7 +181,7 @@ public final class Kem { // NOPMD /** AES IV: --aes-iv <hex> */ public static final Option OPT_AES_IV = Option.builder().longOpt("aes-iv").hasArg().argName("hex") - .desc("AES IV/nonce (hex)").get(); + .desc("AES decryption IV/nonce (hex; rejected for encryption)").get(); /** AES tag bits: --aes-tag-bits <int> */ public static final Option OPT_AES_TAG_BITS = Option.builder().longOpt("aes-tag-bits").hasArg().argName("int") @@ -189,7 +189,7 @@ public final class Kem { // NOPMD /** ChaCha nonce: --chacha-nonce <hex> */ public static final Option OPT_CHACHA_NONCE = Option.builder().longOpt("chacha-nonce").hasArg().argName("hex") - .desc("ChaCha nonce (hex, usually 12 bytes)").get(); + .desc("ChaCha decryption nonce (hex, usually 12 bytes; rejected for encryption)").get(); /** ChaCha counter value: --chacha-counter <int> */ public static final Option OPT_CHACHA_COUNTER = Option.builder().longOpt("chacha-counter").hasArg().argName("int") @@ -283,7 +283,7 @@ public final class Kem { // NOPMD } byte[] iv = parseHexOpt(cmd, OPT_AES_IV); if (iv != null) { - aes = aes.withIv(iv); + aes = aes.withDecryptionIv(iv); } if (aad != null && aad.length > 0) { aes = aes.withAad(aad); @@ -299,7 +299,7 @@ public final class Kem { // NOPMD ChaChaDataContentBuilder cc = ChaChaDataContentBuilder.builder(session); byte[] nonce = parseHexOpt(cmd, OPT_CHACHA_NONCE); if (nonce != null) { - cc = cc.withNonce(nonce); + cc = cc.withDecryptionNonce(nonce); } // counter is an integer, not bytes; use typed parsed option Integer counter = parsedIntOpt(cmd, OPT_CHACHA_COUNTER); diff --git a/app/src/test/java/zeroecho/GuardTest.java b/app/src/test/java/zeroecho/GuardTest.java index 59436aa..c5a2da2 100644 --- a/app/src/test/java/zeroecho/GuardTest.java +++ b/app/src/test/java/zeroecho/GuardTest.java @@ -196,11 +196,10 @@ public class GuardTest { final int tagBits = 128; final String aadAes = "010203"; final String aadCha = "D00DFEED"; - final String chNonce = "00112233445566778899AABB"; System.out.println(method); System.out.println("...params: sizeAes=" + sizeAes + " sizeCha=" + sizeCha + " tagBits=" + tagBits + " aadAes=" - + aadAes + " aadCha=" + aadCha + " chNonce=" + chNonce); + + aadAes + " aadCha=" + aadCha); // Prepare keyring with RSA pair Path ring = tmp.resolve("ring-rsa.txt"); @@ -237,13 +236,13 @@ public class GuardTest { Path dec = tmp.resolve("rsa-pt-ch.bin.dec"); String[] encArgs = { "--encrypt", in.toString(), "--output", enc.toString(), "--keyring", ring.toString(), - "--to-alias", rsa.pub, "--alg", "chacha-aead", "--aad-hex", aadCha, "--nonce-hex", chNonce }; + "--to-alias", rsa.pub, "--alg", "chacha-aead", "--aad-hex", aadCha }; System.out.println("...ChaCha encrypt: " + Arrays.toString(encArgs)); int e = Guard.main(encArgs, new Options()); assertEquals(0, e, "... ChaCha encrypt rc"); String[] decArgs = { "--decrypt", enc.toString(), "--output", dec.toString(), "--keyring", ring.toString(), - "--priv-alias", rsa.prv, "--alg", "chacha-aead", "--aad-hex", aadCha, "--nonce-hex", chNonce }; + "--priv-alias", rsa.prv, "--alg", "chacha-aead", "--aad-hex", aadCha }; System.out.println("...ChaCha decrypt: " + Arrays.toString(decArgs)); int d = Guard.main(decArgs, new Options()); assertEquals(0, d, "... ChaCha decrypt rc"); diff --git a/app/src/test/java/zeroecho/KemTest.java b/app/src/test/java/zeroecho/KemTest.java index 8f97297..208489d 100644 --- a/app/src/test/java/zeroecho/KemTest.java +++ b/app/src/test/java/zeroecho/KemTest.java @@ -142,11 +142,10 @@ public class KemTest { final int gcmTagBits = 128; final String aadAes = "A1B2C3"; final String aadChaCha = "DEADBEEF"; - final String nonceChaCha = "00112233445566778899AABB"; System.out.println(method); System.out.println("...params: aesSize=" + aesSize + " chachaSize=" + chachaSize + " gcmTagBits=" + gcmTagBits - + " aesAAD=" + aadAes + " chachaAAD=" + aadChaCha + " chachaNonce=" + nonceChaCha); + + " aesAAD=" + aadAes + " chachaAAD=" + aadChaCha); // Discover KEM ids via the CLI (ensures we use exactly the ids users will see). List kemIds = listKemsViaCli(); @@ -209,14 +208,14 @@ public class KemTest { System.out.println("...[" + kemId + "] ChaCha encrypt"); int e = Kem.main(new String[] { "--encrypt", plain.toString(), "--output", enc.toString(), "--keyring", ring.toString(), "--pub", aliases.pub, "--kem", kemId, "--chacha", - "--chacha-nonce", nonceChaCha, "--aad", aadChaCha, "--header" }, new Options()); + "--aad", aadChaCha, "--header" }, new Options()); if (e != 0) { throw new IllegalStateException("ChaCha encrypt rc=" + e); } System.out.println("...[" + kemId + "] ChaCha decrypt"); int d = Kem.main(new String[] { "--decrypt", enc.toString(), "--output", dec.toString(), "--keyring", ring.toString(), "--priv", aliases.prv, "--kem", kemId, "--chacha", - "--chacha-nonce", nonceChaCha, "--aad", aadChaCha, "--header" }, new Options()); + "--aad", aadChaCha, "--header" }, new Options()); if (d != 0) { throw new IllegalStateException("ChaCha decrypt rc=" + d); } diff --git a/lib/src/main/java/zeroecho/core/alg/aes/AesCipherContext.java b/lib/src/main/java/zeroecho/core/alg/aes/AesCipherContext.java index f6c48e7..3ee602d 100644 --- a/lib/src/main/java/zeroecho/core/alg/aes/AesCipherContext.java +++ b/lib/src/main/java/zeroecho/core/alg/aes/AesCipherContext.java @@ -34,10 +34,12 @@ package zeroecho.core.alg.aes; import java.io.IOException; +import java.io.FilterInputStream; import java.io.InputStream; import java.security.GeneralSecurityException; import java.security.SecureRandom; import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Level; import java.util.logging.Logger; @@ -54,7 +56,6 @@ import zeroecho.core.context.EncryptionContext; import zeroecho.core.err.ProviderFailureException; import zeroecho.core.io.CipherTransformInputStreamBuilder; import zeroecho.core.spi.ContextAware; -import zeroecho.core.util.Strings; import zeroecho.sdk.util.RandomSupport; /** @@ -62,9 +63,9 @@ import zeroecho.sdk.util.RandomSupport; * *

* IV and optional AAD are exchanged via a {@link conflux.CtxInterface} set with - * {@link #setContext(conflux.CtxInterface)}. On ENCRYPT, a fresh IV is - * generated if absent and stored back; on DECRYPT, IV must be present (from - * context or header). + * {@link #setContext(conflux.CtxInterface)}. Encryption always generates a fresh + * IV after atomically claiming the context; a caller-provided IV is never used + * for encryption. Decryption requires the IV from the context or encoded header. *

* *

@@ -86,6 +87,7 @@ public final class AesCipherContext implements EncryptionContext, ContextAware { private final boolean encrypt; private final AesSpec spec; private final SecureRandom rnd; + private final AtomicReference operationState = new AtomicReference<>(OperationState.NEW); private volatile CtxInterface ctx; // NOPMD @@ -172,6 +174,8 @@ public final class AesCipherContext implements EncryptionContext, ContextAware { @Override public InputStream attach(InputStream upstream) throws IOException { Objects.requireNonNull(upstream, "upstream must not be null"); + claimEncryption(); + boolean attached = false; try { // If both spec.header() and ctx are present, let this context read/write the // header. @@ -186,8 +190,7 @@ public final class AesCipherContext implements EncryptionContext, ContextAware { } Cipher cipher = Cipher.getInstance(jcaTransform(spec)); - initCipher(cipher); // consumes IV/AAD from ctx if present; generates IV on ENCRYPT and may store it - // back; sets tagBits for GCM + initCipher(cipher); InputStream out = // new Stream(in, cipher, spec); CipherTransformInputStreamBuilder.builder().withCipher(cipher).withUpstream(in) @@ -200,9 +203,14 @@ public final class AesCipherContext implements EncryptionContext, ContextAware { header.writeHeader(hdr, algorithm, ctx); out = new java.io.SequenceInputStream(new java.io.ByteArrayInputStream(hdr.toByteArray()), out); } - return out; + attached = true; + return encrypt ? new LifecycleInputStream(out) : out; } catch (GeneralSecurityException e) { throw new ProviderFailureException("AES attach/init failed", e); + } finally { + if (!attached) { + failEncryption(); + } } } @@ -221,21 +229,18 @@ public final class AesCipherContext implements EncryptionContext, ContextAware { }; } - private void initCipher(Cipher cipher) throws GeneralSecurityException, IOException { // NOPMD + private void initCipher(Cipher cipher) throws GeneralSecurityException, IOException { final String id = algorithm.id(); - byte[] iv = getCtxBytes(ConfluxKeys.iv(id)); + byte[] iv; final int ivLen = (spec.mode() == AesSpec.Mode.GCM) ? GCM_DEFAULT_IV_BYTES : AES_BLOCK; if (encrypt) { - if (iv == null) { - iv = new byte[ivLen]; - rnd.nextBytes(iv); - putCtxBytes(ConfluxKeys.iv(id), iv); - } else if (iv.length != ivLen) { - throw new IOException("IV length mismatch: expected " + ivLen + " bytes, got " + iv.length); - } + iv = new byte[ivLen]; + rnd.nextBytes(iv); + putCtxBytes(ConfluxKeys.iv(id), iv.clone()); } else { + iv = getCtxBytes(ConfluxKeys.iv(id)); if (iv == null) { throw new IOException("IV not found in context for AES " + spec.mode() + " decryption"); } @@ -267,10 +272,7 @@ public final class AesCipherContext implements EncryptionContext, ContextAware { } cipher.updateAAD(aad); - if (LOG.isLoggable(Level.FINE)) { - LOG.log(Level.FINE, "GCM setup: tagBits={0} iv={1} aad={2}", - new Object[] { tagBits, Strings.toShortHexString(iv), Strings.toShortHexString(aad) }); - } + LOG.log(Level.FINE, "GCM setup complete: tagBits={0}", tagBits); break; } @@ -294,4 +296,86 @@ public final class AesCipherContext implements EncryptionContext, ContextAware { ctx.put(key, value); } } + + private void claimEncryption() throws IOException { + if (encrypt && !operationState.compareAndSet(OperationState.NEW, OperationState.ENCRYPTING)) { + throw new IOException("AES encryption context is single-use"); + } + } + + private void failEncryption() { + if (encrypt) { + operationState.set(OperationState.FAILED); + } + } + + /** Single-use encryption lifecycle states. */ + private enum OperationState { + NEW, + ENCRYPTING, + COMPLETED, + FAILED + } + + /** Marks the owning encryption context terminal as its stream is consumed. */ + private final class LifecycleInputStream extends FilterInputStream { + private boolean endOfInput; + + private LifecycleInputStream(InputStream delegate) { + super(delegate); + } + + @Override + public int read() throws IOException { + boolean successful = false; + try { + int value = super.read(); + successful = true; + completeAtEnd(value < 0); + return value; + } finally { + if (!successful) { + failEncryption(); + } + } + } + + @Override + public int read(byte[] bytes, int offset, int length) throws IOException { + boolean successful = false; + try { + int count = super.read(bytes, offset, length); + successful = true; + completeAtEnd(count < 0); + return count; + } finally { + if (!successful) { + failEncryption(); + } + } + } + + @Override + public void close() throws IOException { + boolean successful = false; + try { + super.close(); + successful = true; + if (!endOfInput) { + failEncryption(); + } + } finally { + if (!successful) { + failEncryption(); + } + } + } + + private void completeAtEnd(boolean ended) { + if (ended) { + endOfInput = true; + operationState.compareAndSet(OperationState.ENCRYPTING, OperationState.COMPLETED); + } + } + } } diff --git a/lib/src/main/java/zeroecho/core/alg/chacha/AbstractChaChaCipherContext.java b/lib/src/main/java/zeroecho/core/alg/chacha/AbstractChaChaCipherContext.java index 8ce5252..d3ed6af 100644 --- a/lib/src/main/java/zeroecho/core/alg/chacha/AbstractChaChaCipherContext.java +++ b/lib/src/main/java/zeroecho/core/alg/chacha/AbstractChaChaCipherContext.java @@ -35,11 +35,13 @@ package zeroecho.core.alg.chacha; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.FilterInputStream; import java.io.IOException; import java.io.InputStream; import java.io.SequenceInputStream; import java.security.GeneralSecurityException; import java.security.SecureRandom; +import java.util.concurrent.atomic.AtomicReference; import javax.crypto.Cipher; import javax.crypto.SecretKey; @@ -52,6 +54,7 @@ import zeroecho.core.context.EncryptionContext; import zeroecho.core.err.ProviderFailureException; import zeroecho.core.io.CipherTransformInputStreamBuilder; import zeroecho.core.spi.ContextAware; +import zeroecho.sdk.util.RandomSupport; /** *

Abstract streaming cipher context for ChaCha algorithms

@@ -109,6 +112,7 @@ abstract class AbstractChaChaCipherContext implements protected final S spec; /** Secure random source for nonce generation. */ protected final SecureRandom rnd; + private final AtomicReference operationState = new AtomicReference<>(OperationState.NEW); /** Optional per-operation context for exchanging headers, IVs, etc. */ protected CtxInterface ctx; // optional @@ -127,7 +131,7 @@ abstract class AbstractChaChaCipherContext implements this.key = key; this.encrypt = encrypt; this.spec = spec; - this.rnd = (rnd != null ? rnd : new SecureRandom()); + this.rnd = (rnd != null ? rnd : RandomSupport.getRandom()); } /** {@inheritDoc} */ @@ -188,6 +192,9 @@ abstract class AbstractChaChaCipherContext implements */ @Override public InputStream attach(InputStream upstream) throws IOException { + java.util.Objects.requireNonNull(upstream, "upstream must not be null"); + claimEncryption(); + boolean attached = false; try { final SymmetricHeaderCodec header = spec.header(); final boolean hasCtxHeader = ctx != null && header != null; @@ -198,7 +205,7 @@ abstract class AbstractChaChaCipherContext implements } final Cipher cipher = Cipher.getInstance(jceName()); - final byte[] nonce = ensureNonce(); // generate or require from ctx + final byte[] nonce = ensureNonce(); initCipher(cipher, nonce); InputStream out = // new Stream(in, cipher, jceName()); // same stream pattern as AES @@ -210,9 +217,14 @@ abstract class AbstractChaChaCipherContext implements header.writeHeader(hdr, algorithm, ctx); out = new SequenceInputStream(new ByteArrayInputStream(hdr.toByteArray()), out); } - return out; + attached = true; + return encrypt ? new LifecycleInputStream(out) : out; } catch (GeneralSecurityException e) { throw new ProviderFailureException(jceName() + " attach/init failed", e); + } finally { + if (!attached) { + failEncryption(); + } } } @@ -231,8 +243,8 @@ abstract class AbstractChaChaCipherContext implements * Ensures a nonce is available in the context. * *
    - *
  • For encryption, generates a new nonce if absent and stores it in - * context.
  • + *
  • For encryption, always generates a new nonce after the context has + * been atomically claimed and stores a copy in the context.
  • *
  • For decryption, validates presence and correct length.
  • *
* @@ -241,22 +253,101 @@ abstract class AbstractChaChaCipherContext implements */ private byte[] ensureNonce() throws IOException { final String id = algorithm.id(); - byte[] nonce = (ctx == null) ? null : ctx.get(ConfluxKeys.iv(id)); + byte[] nonce; if (encrypt) { - if (nonce == null) { - nonce = new byte[NONCE_LEN]; - rnd.nextBytes(nonce); - if (ctx != null) { // NOPMD - ctx.put(ConfluxKeys.iv(id), nonce); - } - } else if (nonce.length != NONCE_LEN) { - throw new IOException("Nonce length mismatch: expected 12 bytes, got " + nonce.length); + nonce = new byte[NONCE_LEN]; + rnd.nextBytes(nonce); + if (ctx != null) { + ctx.put(ConfluxKeys.iv(id), nonce.clone()); } } else { + nonce = (ctx == null) ? null : ctx.get(ConfluxKeys.iv(id)); if (nonce == null || nonce.length != NONCE_LEN) { throw new IOException("Nonce missing/invalid for " + jceName() + " decryption"); } } return nonce; } + + private void claimEncryption() throws IOException { + if (encrypt && !operationState.compareAndSet(OperationState.NEW, OperationState.ENCRYPTING)) { + throw new IOException(jceName() + " encryption context is single-use"); + } + } + + private void failEncryption() { + if (encrypt) { + operationState.set(OperationState.FAILED); + } + } + + /** Single-use encryption lifecycle states. */ + private enum OperationState { + NEW, + ENCRYPTING, + COMPLETED, + FAILED + } + + /** Marks the owning encryption context terminal as its stream is consumed. */ + private final class LifecycleInputStream extends FilterInputStream { + private boolean endOfInput; + + private LifecycleInputStream(InputStream delegate) { + super(delegate); + } + + @Override + public int read() throws IOException { + boolean successful = false; + try { + int value = super.read(); + successful = true; + completeAtEnd(value < 0); + return value; + } finally { + if (!successful) { + failEncryption(); + } + } + } + + @Override + public int read(byte[] bytes, int offset, int length) throws IOException { + boolean successful = false; + try { + int count = super.read(bytes, offset, length); + successful = true; + completeAtEnd(count < 0); + return count; + } finally { + if (!successful) { + failEncryption(); + } + } + } + + @Override + public void close() throws IOException { + boolean successful = false; + try { + super.close(); + successful = true; + if (!endOfInput) { + failEncryption(); + } + } finally { + if (!successful) { + failEncryption(); + } + } + } + + private void completeAtEnd(boolean ended) { + if (ended) { + endOfInput = true; + operationState.compareAndSet(OperationState.ENCRYPTING, OperationState.COMPLETED); + } + } + } } diff --git a/lib/src/main/java/zeroecho/core/alg/chacha/ChaCha20Poly1305Algorithm.java b/lib/src/main/java/zeroecho/core/alg/chacha/ChaCha20Poly1305Algorithm.java index ba07442..ba1259c 100644 --- a/lib/src/main/java/zeroecho/core/alg/chacha/ChaCha20Poly1305Algorithm.java +++ b/lib/src/main/java/zeroecho/core/alg/chacha/ChaCha20Poly1305Algorithm.java @@ -34,6 +34,7 @@ package zeroecho.core.alg.chacha; import zeroecho.core.SymmetricHeaderCodec; +import zeroecho.sdk.util.RandomSupport; /** *

ChaCha20-Poly1305 (AEAD) algorithm

@@ -101,12 +102,12 @@ public final class ChaCha20Poly1305Algorithm extends AbstractChaChaAlgorithm { super("CHACHA20-POLY1305", "ChaCha20-Poly1305 (AEAD)"); capability(zeroecho.core.AlgorithmFamily.SYMMETRIC, zeroecho.core.KeyUsage.ENCRYPT, zeroecho.core.context.EncryptionContext.class, javax.crypto.SecretKey.class, ChaCha20Poly1305Spec.class, - (k, s) -> new ChaCha20Poly1305CipherContext(this, k, true, s, new java.security.SecureRandom()), + (k, s) -> new ChaCha20Poly1305CipherContext(this, k, true, s, RandomSupport.getRandom()), () -> ChaCha20Poly1305Spec.builder().header(null).build()); capability(zeroecho.core.AlgorithmFamily.SYMMETRIC, zeroecho.core.KeyUsage.DECRYPT, zeroecho.core.context.EncryptionContext.class, javax.crypto.SecretKey.class, ChaCha20Poly1305Spec.class, - (k, s) -> new ChaCha20Poly1305CipherContext(this, k, false, s, new java.security.SecureRandom()), + (k, s) -> new ChaCha20Poly1305CipherContext(this, k, false, s, RandomSupport.getRandom()), () -> ChaCha20Poly1305Spec.builder().header(null).build()); // VoidSpec defaults like AES-GCM @@ -114,14 +115,14 @@ public final class ChaCha20Poly1305Algorithm extends AbstractChaChaAlgorithm { zeroecho.core.context.EncryptionContext.class, javax.crypto.SecretKey.class, zeroecho.core.spec.VoidSpec.class, (k, v) -> new ChaCha20Poly1305CipherContext(this, k, true, - ChaCha20Poly1305Spec.builder().header(null).build(), new java.security.SecureRandom()), + ChaCha20Poly1305Spec.builder().header(null).build(), RandomSupport.getRandom()), () -> zeroecho.core.spec.VoidSpec.INSTANCE); capability(zeroecho.core.AlgorithmFamily.SYMMETRIC, zeroecho.core.KeyUsage.DECRYPT, zeroecho.core.context.EncryptionContext.class, javax.crypto.SecretKey.class, zeroecho.core.spec.VoidSpec.class, (k, v) -> new ChaCha20Poly1305CipherContext(this, k, false, - ChaCha20Poly1305Spec.builder().header(null).build(), new java.security.SecureRandom()), + ChaCha20Poly1305Spec.builder().header(null).build(), RandomSupport.getRandom()), () -> zeroecho.core.spec.VoidSpec.INSTANCE); } } diff --git a/lib/src/main/java/zeroecho/core/alg/chacha/ChaCha20Poly1305CipherContext.java b/lib/src/main/java/zeroecho/core/alg/chacha/ChaCha20Poly1305CipherContext.java index 27a3f6b..82ea2e4 100644 --- a/lib/src/main/java/zeroecho/core/alg/chacha/ChaCha20Poly1305CipherContext.java +++ b/lib/src/main/java/zeroecho/core/alg/chacha/ChaCha20Poly1305CipherContext.java @@ -67,10 +67,10 @@ import zeroecho.core.CryptoAlgorithm; * ChaCha20Poly1305Spec spec = ChaCha20Poly1305Spec.builder().header(null).build(); * * // Encrypt - * EncryptionContext enc = new ChaCha20Poly1305CipherContext(alg, key, true, spec, new SecureRandom()); + * EncryptionContext enc = new ChaCha20Poly1305CipherContext(alg, key, true, spec, null); * * // Decrypt - * EncryptionContext dec = new ChaCha20Poly1305CipherContext(alg, key, false, spec, new SecureRandom()); + * EncryptionContext dec = new ChaCha20Poly1305CipherContext(alg, key, false, spec, null); * } * * @since 1.0 diff --git a/lib/src/main/java/zeroecho/core/alg/chacha/ChaChaAlgorithm.java b/lib/src/main/java/zeroecho/core/alg/chacha/ChaChaAlgorithm.java index 6ccad99..6c6c84b 100644 --- a/lib/src/main/java/zeroecho/core/alg/chacha/ChaChaAlgorithm.java +++ b/lib/src/main/java/zeroecho/core/alg/chacha/ChaChaAlgorithm.java @@ -33,6 +33,7 @@ ******************************************************************************/ package zeroecho.core.alg.chacha; +import zeroecho.sdk.util.RandomSupport; /** *

ChaCha20 (stream) algorithm

* @@ -82,12 +83,12 @@ public final class ChaChaAlgorithm extends AbstractChaChaAlgorithm { // ENCRYPT capability(zeroecho.core.AlgorithmFamily.SYMMETRIC, zeroecho.core.KeyUsage.ENCRYPT, zeroecho.core.context.EncryptionContext.class, javax.crypto.SecretKey.class, ChaChaSpec.class, - (k, s) -> new ChaChaCipherContext(this, k, true, s, new java.security.SecureRandom()), + (k, s) -> new ChaChaCipherContext(this, k, true, s, RandomSupport.getRandom()), () -> ChaChaSpec.builder().initialCounter(1).header(null).build()); // DECRYPT capability(zeroecho.core.AlgorithmFamily.SYMMETRIC, zeroecho.core.KeyUsage.DECRYPT, zeroecho.core.context.EncryptionContext.class, javax.crypto.SecretKey.class, ChaChaSpec.class, - (k, s) -> new ChaChaCipherContext(this, k, false, s, new java.security.SecureRandom()), + (k, s) -> new ChaChaCipherContext(this, k, false, s, RandomSupport.getRandom()), () -> ChaChaSpec.builder().initialCounter(1).header(null).build()); // VoidSpec defaults (mirrors AES) @@ -95,14 +96,14 @@ public final class ChaChaAlgorithm extends AbstractChaChaAlgorithm { zeroecho.core.context.EncryptionContext.class, javax.crypto.SecretKey.class, zeroecho.core.spec.VoidSpec.class, (k, v) -> new ChaChaCipherContext(this, k, true, - ChaChaSpec.builder().initialCounter(1).header(null).build(), new java.security.SecureRandom()), + ChaChaSpec.builder().initialCounter(1).header(null).build(), RandomSupport.getRandom()), () -> zeroecho.core.spec.VoidSpec.INSTANCE); capability(zeroecho.core.AlgorithmFamily.SYMMETRIC, zeroecho.core.KeyUsage.DECRYPT, zeroecho.core.context.EncryptionContext.class, javax.crypto.SecretKey.class, zeroecho.core.spec.VoidSpec.class, (k, v) -> new ChaChaCipherContext(this, k, false, - ChaChaSpec.builder().initialCounter(1).header(null).build(), new java.security.SecureRandom()), + ChaChaSpec.builder().initialCounter(1).header(null).build(), RandomSupport.getRandom()), () -> zeroecho.core.spec.VoidSpec.INSTANCE); } } diff --git a/lib/src/main/java/zeroecho/core/alg/chacha/package-info.java b/lib/src/main/java/zeroecho/core/alg/chacha/package-info.java index 680289b..0220272 100644 --- a/lib/src/main/java/zeroecho/core/alg/chacha/package-info.java +++ b/lib/src/main/java/zeroecho/core/alg/chacha/package-info.java @@ -78,23 +78,22 @@ *

Runtime parameters and context exchange

*

* 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. + * context using namespaced keys. Each ChaCha20 and ChaCha20-Poly1305 encryption + * context is single-use: after an atomic claim it generates a fresh 12-byte + * nonce 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. *

* *

Safety and validation

*
    - *
  • Nonce uniqueness: 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.
  • + *
  • Nonce lifecycle: Each encryption context is single-use and + * generates its nonce internally after an atomic operation claim. Decryption + * consumes the encoded or out-of-band nonce and fails if it is missing or has + * an unexpected size.
  • * *
  • Counter policy (ChaCha20): The default initial counter is 1. A * context may override the spec value through the session key dedicated to diff --git a/lib/src/main/java/zeroecho/sdk/builders/alg/AesDataContentBuilder.java b/lib/src/main/java/zeroecho/sdk/builders/alg/AesDataContentBuilder.java index ae57445..fd0a380 100644 --- a/lib/src/main/java/zeroecho/sdk/builders/alg/AesDataContentBuilder.java +++ b/lib/src/main/java/zeroecho/sdk/builders/alg/AesDataContentBuilder.java @@ -94,11 +94,11 @@ import zeroecho.sdk.content.api.PlainContent; * try (InputStream s = dec.getStream()) { s.transferTo(Files.newOutputStream(ptPath)); } * } * - *

    Runtime parameters

    Optional IV and AAD can be provided via - * {@link #withIv(byte[])} and {@link #withAad(byte[])}. If a Conflux context is - * present (set via {@link #context(conflux.CtxInterface)} or implied by a - * header), a fresh IV is generated on encrypt when absent and stored back into - * the context. For GCM, AAD defaults to empty. + *

    Runtime parameters

    Optional AAD can be provided via + * {@link #withAad(byte[])}. Encryption always generates its IV internally. + * Headerless decryption may receive an out-of-band IV through + * {@link #withDecryptionIv(byte[])}; that configuration is rejected when + * building an encrypting pipeline. For GCM, AAD defaults to empty. * *

    Thread‑safety

    The builder is not thread‑safe. Built * {@code DataContent} instances are independent. @@ -114,7 +114,7 @@ public final class AesDataContentBuilder implements DataContentBuilderResolves the key from {@link #withKey(SecretKey)} or the selected * generate/import spec.
  • *
  • Finalizes {@link AesSpec} (injecting the header if configured).
  • - *
  • Propagates IV/AAD into the context if supplied.
  • + *
  • Propagates a decryption IV and AAD into the context when supplied.
  • *
* * @param encrypt {@code true} for encryption; {@code false} for decryption @@ -340,6 +339,9 @@ public final class AesDataContentBuilder implements DataContentBuilder 0) { + if (decryptionIv != null && decryptionIv.length > 0) { if (ctx == null) { ctx = Ctx.INSTANCE.getContext("aes-ctx-" + System.nanoTime()); } - ctx.put(ConfluxKeys.iv(ALGORITHM_ID), iv); + ctx.put(ConfluxKeys.iv(ALGORITHM_ID), decryptionIv.clone()); } return encrypt ? new EncryptContent(key, aesSpec, ctx) : new DecryptContent(key, aesSpec, ctx); @@ -526,8 +528,7 @@ public final class AesDataContentBuilder implements DataContentBuilderUsage examples
{@code
  * // 1) ChaCha20 stream encryption with generated key and header
@@ -106,10 +106,10 @@ import zeroecho.sdk.content.api.PlainContent;
  *     .withHeader()
  *     .build(true);
  *
- * // 2) ChaCha20-Poly1305 decryption with supplied key, nonce, and AAD
+ * // 2) Headerless ChaCha20-Poly1305 decryption with supplied key, nonce, and AAD
  * DataContent dec = ChaChaDataContentBuilder.builder()
  *     .withKey(secretKey)
- *     .withNonce(nonce12)                  // 12 bytes
+ *     .withDecryptionNonce(nonce12)        // 12 bytes
  *     .withAad("meta".getBytes(StandardCharsets.UTF_8))
  *     .build(false);
  *
@@ -150,7 +150,7 @@ public final class ChaChaDataContentBuilder implements DataContentBuilder
-     * For ChaCha20 and ChaCha20-Poly1305 this is typically 12 bytes. If absent
-     * during encryption and a context is in use, an implementation-specific nonce
-     * may be generated and placed into the context.
+     * For ChaCha20 and ChaCha20-Poly1305 this must be 12 bytes. The value is
+     * rejected when building an encrypting pipeline because encryption always
+     * generates its nonce internally.
      * 

* - * @param nonce the nonce bytes; may be null + * @param nonce the nonce bytes; must not be null * @return {@code this} builder for chaining + * @throws NullPointerException if {@code nonce} is {@code null} */ - public ChaChaDataContentBuilder withNonce(byte[] nonce) { - this.nonce = nonce; + public ChaChaDataContentBuilder withDecryptionNonce(byte[] nonce) { + this.decryptionNonce = Objects.requireNonNull(nonce, "nonce must not be null").clone(); return this; } @@ -415,8 +416,8 @@ public final class ChaChaDataContentBuilder implements DataContentBuilder * If a header is requested and no context is provided, a temporary context is - * created. When encrypting with a context, if no nonce is supplied, the - * implementation may generate one and store it into the context. + * created. Encryption generates its nonce internally; a configured decryption + * nonce is rejected for encrypting pipelines. *

* *
{@code
@@ -435,20 +436,23 @@ public final class ChaChaDataContentBuilder implements DataContentBuilder 0) {
-                ctx.put(ConfluxKeys.iv(algId), nonce);
+            if (decryptionNonce != null && decryptionNonce.length > 0) {
+                ctx.put(ConfluxKeys.iv(algId), decryptionNonce.clone());
             }
             if (v == Variant.AEAD) {
                 if (aad != null) { // NOPMD
diff --git a/lib/src/main/java/zeroecho/sdk/hybrid/derived/HybridDerived.java b/lib/src/main/java/zeroecho/sdk/hybrid/derived/HybridDerived.java
index dfa02a4..8ecd03a 100644
--- a/lib/src/main/java/zeroecho/sdk/hybrid/derived/HybridDerived.java
+++ b/lib/src/main/java/zeroecho/sdk/hybrid/derived/HybridDerived.java
@@ -61,8 +61,6 @@ import zeroecho.sdk.hybrid.kex.HybridKexExporter;
  * 

*
    *
  • {@code label + "/key"} for the secret key
  • - *
  • {@code label + "/iv"} for AES IV
  • - *
  • {@code label + "/nonce"} for ChaCha nonce
  • *
  • {@code label + "/aad"} for AEAD AAD (optional, if derived)
  • *
* @@ -190,7 +188,7 @@ public final class HybridDerived { } /** - * Derives an AES key and applies it (and optional IV/AAD) to the provided AES + * Derives an AES key and applies it with optional AAD to the provided AES * builder. * *

@@ -200,15 +198,12 @@ public final class HybridDerived { * * @param aes AES builder to configure (must not be null) * @param keyBits AES key size in bits (128/192/256) - * @param ivLenBytes if > 0, derive IV of this length and inject it via - * {@code withIv(...)}; if 0, do not set IV (header/ctx may - * generate it) * @return the provided builder instance * @throws NullPointerException if aes is null * @throws IllegalArgumentException if keyBits is invalid * @since 1.0 */ - public AesDataContentBuilder applyToAesGcm(AesDataContentBuilder aes, int keyBits, int ivLenBytes) { + public AesDataContentBuilder applyToAesGcm(AesDataContentBuilder aes, int keyBits) { Objects.requireNonNull(aes, "aes"); validateBase(); @@ -221,11 +216,6 @@ public final class HybridDerived { Arrays.fill(keyRaw, (byte) 0); } - if (ivLenBytes > 0) { - byte[] iv = exportBytes(label + "/iv", ivLenBytes); - aes.withIv(iv); - } - byte[] aad = resolveAad(); if (aad != null) { aes.withAad(aad); @@ -235,7 +225,7 @@ public final class HybridDerived { } /** - * Derives a ChaCha key and applies it (and optional nonce/AAD) to the provided + * Derives a ChaCha key and applies it with optional AAD to the provided * ChaCha builder. * *

@@ -245,15 +235,12 @@ public final class HybridDerived { * * @param chacha ChaCha builder to configure (must not be null) * @param keyBits key size in bits (typically 256) - * @param nonceLenBytes if > 0, derive nonce of this length and inject it via - * {@code withNonce(...)}; if 0, do not set nonce - * (header/ctx may generate it) * @return the provided builder instance * @throws NullPointerException if chacha is null * @throws IllegalArgumentException if keyBits is invalid * @since 1.0 */ - public ChaChaDataContentBuilder applyToChaChaAead(ChaChaDataContentBuilder chacha, int keyBits, int nonceLenBytes) { + public ChaChaDataContentBuilder applyToChaChaAead(ChaChaDataContentBuilder chacha, int keyBits) { Objects.requireNonNull(chacha, "chacha"); validateBase(); @@ -266,11 +253,6 @@ public final class HybridDerived { Arrays.fill(keyRaw, (byte) 0); } - if (nonceLenBytes > 0) { - byte[] nonce = exportBytes(label + "/nonce", nonceLenBytes); - chacha.withNonce(nonce); - } - byte[] aad = resolveAad(); if (aad != null) { chacha.withAad(aad); diff --git a/lib/src/test/java/zeroecho/core/alg/aes/AesGcmCrossCheckTest.java b/lib/src/test/java/zeroecho/core/alg/aes/AesGcmCrossCheckTest.java index 2f79c8f..2701113 100644 --- a/lib/src/test/java/zeroecho/core/alg/aes/AesGcmCrossCheckTest.java +++ b/lib/src/test/java/zeroecho/core/alg/aes/AesGcmCrossCheckTest.java @@ -128,7 +128,6 @@ public class AesGcmCrossCheckTest { // --- test vectors --- byte[] msg = rand(SIZE); - byte[] iv = rand(12); // 12-byte IV for GCM byte[] aad = "test-aad-123".getBytes(); // --- key (either via your builder or direct JCA; both fine) --- @@ -139,10 +138,8 @@ public class AesGcmCrossCheckTest { // kg.init(256); // SecretKey key = kg.generateKey(); - // --- per-test context; store IV/AAD under the names AesCipherContext expects - // --- + // --- per-test context; encryption generates IV and stores it here --- CtxInterface session = Ctx.INSTANCE.getContext("aes-gcm-xchk-" + System.nanoTime()); - session.put(ConfluxKeys.iv("AES"), iv); session.put(ConfluxKeys.aad("AES"), aad); AesSpec spec = AesSpec.gcm128(null); @@ -152,6 +149,7 @@ public class AesGcmCrossCheckTest { ((ContextAware) enc).setContext(session); byte[] ct_stream = readAll(enc.attach(new ByteArrayInputStream(msg))); enc.close(); + byte[] iv = session.get(ConfluxKeys.iv("AES")); // === JCA ENCRYPT (reference) === byte[] ct_jca = jcaGcmEncrypt(key, iv, TAG_BITS, aad, msg); diff --git a/lib/src/test/java/zeroecho/core/alg/aes/AesRandomSupportTest.java b/lib/src/test/java/zeroecho/core/alg/aes/AesRandomSupportTest.java index 3d55c6f..0c5e34d 100644 --- a/lib/src/test/java/zeroecho/core/alg/aes/AesRandomSupportTest.java +++ b/lib/src/test/java/zeroecho/core/alg/aes/AesRandomSupportTest.java @@ -6,18 +6,28 @@ package zeroecho.core.alg.aes; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertNotNull; 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.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.security.SecureRandom; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; @@ -31,6 +41,7 @@ import zeroecho.core.KeyUsage; import zeroecho.core.context.EncryptionContext; import zeroecho.core.spec.VoidSpec; import zeroecho.core.spi.ContextAware; +import zeroecho.sdk.builders.alg.AesDataContentBuilder; import zeroecho.sdk.util.RandomSupport; class AesRandomSupportTest { @@ -89,7 +100,113 @@ class AesRandomSupportTest { assertArrayEquals(repeated((byte) 1, 12), firstIv); assertArrayEquals(repeated((byte) 2, 12), secondIv); assertNotSame(firstIv, secondIv); - System.out.println("...randomCalls=" + random.calls); + System.out.println("...randomCalls=" + random.calls()); + System.out.println("ok"); + } + + @Test + void encryptionIgnoresPreseededIvAndContextIsSingleUse() throws Exception { + System.out.print("AesRandomSupport/encryptionIgnoresPreseededIvAndContextIsSingleUse..."); + CountingSecureRandom random = new CountingSecureRandom(); + CtxInterface session = Ctx.INSTANCE.getContext("aes-preseeded"); + session.put(ConfluxKeys.iv("AES"), repeated((byte) 9, 12)); + AesCipherContext context = new AesCipherContext(new AesAlgorithm(), KEY, true, AesSpec.gcm128(null), random); + ((ContextAware) context).setContext(session); + + try (InputStream stream = context.attach(new ByteArrayInputStream(new byte[0]))) { + stream.readAllBytes(); + } + IOException failure = assertThrows(IOException.class, + () -> context.attach(new ByteArrayInputStream(new byte[0]))); + + assertArrayEquals(repeated((byte) 1, 12), session.get(ConfluxKeys.iv("AES"))); + assertEquals(1, random.calls()); + assertEquals("AES encryption context is single-use", failure.getMessage()); + System.out.println("...randomCalls=" + random.calls()); + System.out.println("ok"); + } + + @Test + void streamFailureConsumesEncryptionContext() throws Exception { + System.out.print("AesRandomSupport/streamFailureConsumesEncryptionContext..."); + CountingSecureRandom random = new CountingSecureRandom(); + AesCipherContext context = new AesCipherContext(new AesAlgorithm(), KEY, true, AesSpec.gcm128(null), random); + ((ContextAware) context).setContext(Ctx.INSTANCE.getContext("aes-stream-failure")); + + InputStream failing = new InputStream() { + @Override + public int read() throws IOException { + throw new IOException("controlled input failure"); + } + }; + try (InputStream stream = context.attach(failing)) { + assertThrows(IOException.class, stream::readAllBytes); + } + IOException reuse = assertThrows(IOException.class, + () -> context.attach(new ByteArrayInputStream(new byte[0]))); + + assertEquals("AES encryption context is single-use", reuse.getMessage()); + assertEquals(1, random.calls()); + System.out.println("...terminalAfterFailure=true"); + System.out.println("ok"); + } + + @Test + void concurrentEncryptionClaimHasExactlyOneWinner() throws Exception { + System.out.print("AesRandomSupport/concurrentEncryptionClaimHasExactlyOneWinner..."); + CountingSecureRandom random = new CountingSecureRandom(); + AesCipherContext context = new AesCipherContext(new AesAlgorithm(), KEY, true, AesSpec.gcm128(null), random); + ((ContextAware) context).setContext(Ctx.INSTANCE.getContext("aes-concurrent-claim")); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Callable attempt = () -> { + ready.countDown(); + start.await(); + try (InputStream stream = context.attach(new ByteArrayInputStream(new byte[0]))) { + stream.readAllBytes(); + return true; + } catch (IOException expected) { + return false; + } + }; + Future first = executor.submit(attempt); + Future second = executor.submit(attempt); + ready.await(); + start.countDown(); + + assertTrue(first.get() ^ second.get()); + assertEquals(1, random.calls()); + + CountingSecureRandom independentRandom = new CountingSecureRandom(); + AesCipherContext left = encryptingContext(independentRandom, "aes-independent-left"); + AesCipherContext right = encryptingContext(independentRandom, "aes-independent-right"); + Future leftResult = executor.submit(() -> encryptEmpty(left)); + Future rightResult = executor.submit(() -> encryptEmpty(right)); + leftResult.get(); + rightResult.get(); + assertEquals(2, independentRandom.calls()); + } finally { + executor.close(); + } + System.out.println("...winnerCount=1"); + System.out.println("...independentRandomCalls=2"); + System.out.println("ok"); + } + + @Test + void publicBuilderExposesOnlyDecryptionIvConfiguration() throws Exception { + System.out.print("AesRandomSupport/publicBuilderExposesOnlyDecryptionIvConfiguration..."); + List methodNames = Arrays.stream(AesDataContentBuilder.class.getMethods()).map(Method::getName).toList(); + + assertFalse(methodNames.contains("withIv")); + assertTrue(methodNames.contains("withDecryptionIv")); + AesDataContentBuilder builder = AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()) + .withDecryptionIv(repeated((byte) 1, 12)); + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> builder.build(true)); + assertEquals("A caller-supplied IV is valid only for AES decryption", failure.getMessage()); + System.out.println("...encryptionIvSetter=false"); System.out.println("ok"); } @@ -130,6 +247,18 @@ class AesRandomSupportTest { } } + private static AesCipherContext encryptingContext(SecureRandom random, String contextName) { + AesCipherContext context = new AesCipherContext(new AesAlgorithm(), KEY, true, AesSpec.gcm128(null), random); + ((ContextAware) context).setContext(Ctx.INSTANCE.getContext(contextName)); + return context; + } + + private static byte[] encryptEmpty(AesCipherContext context) throws IOException { + try (InputStream stream = context.attach(new ByteArrayInputStream(new byte[0]))) { + return stream.readAllBytes(); + } + } + private static byte[] repeated(byte value, int length) { byte[] result = new byte[length]; java.util.Arrays.fill(result, value); @@ -138,12 +267,16 @@ class AesRandomSupportTest { private static final class CountingSecureRandom extends SecureRandom { private static final long serialVersionUID = 1L; - private int calls; + private final AtomicInteger calls = new AtomicInteger(); @Override public void nextBytes(byte[] bytes) { - calls++; - java.util.Arrays.fill(bytes, (byte) calls); + int current = calls.incrementAndGet(); + java.util.Arrays.fill(bytes, (byte) current); + } + + private int calls() { + return calls.get(); } } } diff --git a/lib/src/test/java/zeroecho/core/alg/chacha/ChaChaNonceLifecycleTest.java b/lib/src/test/java/zeroecho/core/alg/chacha/ChaChaNonceLifecycleTest.java new file mode 100644 index 0000000..afb7e85 --- /dev/null +++ b/lib/src/test/java/zeroecho/core/alg/chacha/ChaChaNonceLifecycleTest.java @@ -0,0 +1,221 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.core.alg.chacha; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Method; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.junit.jupiter.api.Test; + +import conflux.Ctx; +import conflux.CtxInterface; +import zeroecho.core.ConfluxKeys; +import zeroecho.core.io.Util; +import zeroecho.core.spi.ContextAware; +import zeroecho.sdk.builders.alg.ChaChaDataContentBuilder; + +class ChaChaNonceLifecycleTest { + private static final String ALGORITHM_ID = "CHACHA20-POLY1305"; + private static final SecretKey KEY = new SecretKeySpec(new byte[32], "ChaCha20"); + private static final ChaCha20Poly1305Spec HEADER_SPEC = ChaCha20Poly1305Spec.builder() + .header(new ChaCha20Poly1305HeaderCodec()).build(); + private static final ChaCha20Poly1305Spec CONTEXT_SPEC = ChaCha20Poly1305Spec.builder().header(null).build(); + + @Test + void encryptionIgnoresPreseededNonceAndContextIsSingleUse() throws Exception { + System.out.print("ChaChaNonceLifecycle/encryptionIgnoresPreseededNonceAndContextIsSingleUse..."); + CountingSecureRandom random = new CountingSecureRandom(); + CtxInterface operation = Ctx.INSTANCE.getContext("chacha-preseeded"); + operation.put(ConfluxKeys.iv(ALGORITHM_ID), repeated((byte) 9, 12)); + ChaCha20Poly1305CipherContext context = encryptingContext(CONTEXT_SPEC, random, operation); + + try (InputStream stream = context.attach(new ByteArrayInputStream(new byte[0]))) { + stream.readAllBytes(); + } + IOException failure = assertThrows(IOException.class, + () -> context.attach(new ByteArrayInputStream(new byte[0]))); + + assertArrayEquals(repeated((byte) 1, 12), operation.get(ConfluxKeys.iv(ALGORITHM_ID))); + assertEquals(1, random.calls()); + assertEquals("ChaCha20-Poly1305 encryption context is single-use", failure.getMessage()); + System.out.println("...randomCalls=" + random.calls()); + System.out.println("ok"); + } + + @Test + void streamFailureConsumesEncryptionContext() throws Exception { + System.out.print("ChaChaNonceLifecycle/streamFailureConsumesEncryptionContext..."); + CountingSecureRandom random = new CountingSecureRandom(); + ChaCha20Poly1305CipherContext context = encryptingContext(CONTEXT_SPEC, random, + Ctx.INSTANCE.getContext("chacha-stream-failure")); + InputStream failing = new InputStream() { + @Override + public int read() throws IOException { + throw new IOException("controlled input failure"); + } + }; + + try (InputStream stream = context.attach(failing)) { + assertThrows(IOException.class, stream::readAllBytes); + } + IOException reuse = assertThrows(IOException.class, + () -> context.attach(new ByteArrayInputStream(new byte[0]))); + + assertEquals("ChaCha20-Poly1305 encryption context is single-use", reuse.getMessage()); + assertEquals(1, random.calls()); + System.out.println("...terminalAfterFailure=true"); + System.out.println("ok"); + } + + @Test + void concurrentClaimHasOneWinnerWhileSeparateContextsProceed() throws Exception { + System.out.print("ChaChaNonceLifecycle/concurrentClaimHasOneWinnerWhileSeparateContextsProceed..."); + CountingSecureRandom sharedRandom = new CountingSecureRandom(); + ChaCha20Poly1305CipherContext shared = encryptingContext(CONTEXT_SPEC, sharedRandom, + Ctx.INSTANCE.getContext("chacha-concurrent-shared")); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(4); + try { + Callable attempt = () -> { + ready.countDown(); + start.await(); + try (InputStream stream = shared.attach(new ByteArrayInputStream(new byte[0]))) { + stream.readAllBytes(); + return true; + } catch (IOException expected) { + return false; + } + }; + Future first = executor.submit(attempt); + Future second = executor.submit(attempt); + ready.await(); + start.countDown(); + assertTrue(first.get() ^ second.get()); + assertEquals(1, sharedRandom.calls()); + + CountingSecureRandom independentRandom = new CountingSecureRandom(); + ChaCha20Poly1305CipherContext left = encryptingContext(CONTEXT_SPEC, independentRandom, + Ctx.INSTANCE.getContext("chacha-independent-left")); + ChaCha20Poly1305CipherContext right = encryptingContext(CONTEXT_SPEC, independentRandom, + Ctx.INSTANCE.getContext("chacha-independent-right")); + Future leftResult = executor.submit(() -> encryptEmpty(left)); + Future rightResult = executor.submit(() -> encryptEmpty(right)); + leftResult.get(); + rightResult.get(); + assertEquals(2, independentRandom.calls()); + } finally { + executor.close(); + } + System.out.println("...sharedWinners=1"); + System.out.println("...independentRandomCalls=2"); + System.out.println("ok"); + } + + @Test + void encodedNonceDrivesDecryptionAndMalformedNonceFailsClosed() throws Exception { + System.out.print("ChaChaNonceLifecycle/encodedNonceDrivesDecryptionAndMalformedNonceFailsClosed..."); + CountingSecureRandom random = new CountingSecureRandom(); + CtxInterface encryptionOperation = Ctx.INSTANCE.getContext("chacha-header-encrypt"); + byte[] plaintext = "nonce lifecycle".getBytes(java.nio.charset.StandardCharsets.UTF_8); + byte[] ciphertext; + try (InputStream stream = encryptingContext(HEADER_SPEC, random, encryptionOperation) + .attach(new ByteArrayInputStream(plaintext))) { + ciphertext = stream.readAllBytes(); + } + + assertArrayEquals(repeated((byte) 1, 12), Util.read(new ByteArrayInputStream(ciphertext), 12)); + assertArrayEquals(plaintext, decryptHeader(ciphertext)); + + byte[] modified = ciphertext.clone(); + modified[1] ^= 1; + assertThrows(IOException.class, () -> decryptHeader(modified)); + assertThrows(IOException.class, () -> decryptHeader(Arrays.copyOf(ciphertext, 11))); + assertEquals(1, random.calls()); + System.out.println("...roundTrip=true"); + System.out.println("...malformedRejected=true"); + System.out.println("ok"); + } + + @Test + void publicBuilderExposesOnlyDecryptionNonceConfiguration() { + System.out.print("ChaChaNonceLifecycle/publicBuilderExposesOnlyDecryptionNonceConfiguration..."); + List methodNames = Arrays.stream(ChaChaDataContentBuilder.class.getMethods()).map(Method::getName) + .toList(); + + assertFalse(methodNames.contains("withNonce")); + assertTrue(methodNames.contains("withDecryptionNonce")); + ChaChaDataContentBuilder builder = ChaChaDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()) + .withDecryptionNonce(repeated((byte) 1, 12)); + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> builder.build(true)); + assertEquals("A caller-supplied nonce is valid only for ChaCha decryption", failure.getMessage()); + System.out.println("...encryptionNonceSetter=false"); + System.out.println("ok"); + } + + private static ChaCha20Poly1305CipherContext encryptingContext(ChaCha20Poly1305Spec spec, SecureRandom random, + CtxInterface operation) { + ChaCha20Poly1305CipherContext context = new ChaCha20Poly1305CipherContext( + new ChaCha20Poly1305Algorithm(), KEY, true, spec, random); + ((ContextAware) context).setContext(operation); + return context; + } + + private static byte[] encryptEmpty(ChaCha20Poly1305CipherContext context) throws IOException { + try (InputStream stream = context.attach(new ByteArrayInputStream(new byte[0]))) { + return stream.readAllBytes(); + } + } + + private static byte[] decryptHeader(byte[] ciphertext) throws Exception { + ChaCha20Poly1305CipherContext context = new ChaCha20Poly1305CipherContext( + new ChaCha20Poly1305Algorithm(), KEY, false, HEADER_SPEC, null); + ((ContextAware) context).setContext(Ctx.INSTANCE.getContext("chacha-header-decrypt-" + System.nanoTime())); + try (InputStream stream = context.attach(new ByteArrayInputStream(ciphertext))) { + return stream.readAllBytes(); + } + } + + private static byte[] repeated(byte value, int length) { + byte[] result = new byte[length]; + Arrays.fill(result, value); + return result; + } + + private static final class CountingSecureRandom extends SecureRandom { + private static final long serialVersionUID = 1L; + private final AtomicInteger calls = new AtomicInteger(); + + @Override + public void nextBytes(byte[] bytes) { + int current = calls.incrementAndGet(); + Arrays.fill(bytes, (byte) current); + } + + private int calls() { + return calls.get(); + } + } +} diff --git a/lib/src/test/java/zeroecho/sdk/hybrid/derived/HybridDerivedTest.java b/lib/src/test/java/zeroecho/sdk/hybrid/derived/HybridDerivedTest.java index 93539b4..a0e08b9 100644 --- a/lib/src/test/java/zeroecho/sdk/hybrid/derived/HybridDerivedTest.java +++ b/lib/src/test/java/zeroecho/sdk/hybrid/derived/HybridDerivedTest.java @@ -79,7 +79,7 @@ public class HybridDerivedTest { AesDataContentBuilder encAes = AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withHeader().modeGcm(128); AesDataContentBuilder returnedEnc = HybridDerived.from(exporter).label("app/enc/aes").transcript(transcript) - .aad(aad).applyToAesGcm(encAes, 256, 12); + .aad(aad).applyToAesGcm(encAes, 256); System.out.println("...returnedEncSame=" + (returnedEnc == encAes)); assertSame(encAes, returnedEnc); @@ -90,8 +90,8 @@ public class HybridDerivedTest { AesDataContentBuilder decAes = AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withHeader().modeGcm(128); - HybridDerived.from(exporter).label("app/enc/aes").transcript(transcript).aad(aad).applyToAesGcm(decAes, 256, - 12); + HybridDerived.from(exporter).label("app/enc/aes").transcript(transcript).aad(aad) + .applyToAesGcm(decAes, 256); byte[] out = runDecrypt(decAes, ciphertext); System.out.println("...outPrefix=" + shortHex(out, 32)); @@ -111,8 +111,8 @@ public class HybridDerivedTest { AesDataContentBuilder encAes = AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withHeader().modeGcm(128); - HybridDerived.from(exporter).label("app/enc/aes").transcript(transcript).aad(aad).applyToAesGcm(encAes, 256, - 12); + HybridDerived.from(exporter).label("app/enc/aes").transcript(transcript).aad(aad) + .applyToAesGcm(encAes, 256); byte[] ciphertext = runEncrypt(encAes, msg); System.out.println("...ciphertextLen=" + ciphertext.length); @@ -121,7 +121,7 @@ public class HybridDerivedTest { // ...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); + .applyToAesGcm(decAesWrong, 256); assertThrows(Exception.class, () -> runDecrypt(decAesWrong, ciphertext)); @@ -140,7 +140,7 @@ public class HybridDerivedTest { ChaChaDataContentBuilder encChaCha = ChaChaDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withHeader(); ChaChaDataContentBuilder returnedEnc = HybridDerived.from(exporter).label("app/enc/chacha") - .transcript(transcript).aad(aad).applyToChaChaAead(encChaCha, 256, 12); + .transcript(transcript).aad(aad).applyToChaChaAead(encChaCha, 256); System.out.println("...returnedEncSame=" + (returnedEnc == encChaCha)); assertSame(encChaCha, returnedEnc); @@ -152,7 +152,7 @@ public class HybridDerivedTest { ChaChaDataContentBuilder decChaCha = ChaChaDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withHeader(); HybridDerived.from(exporter).label("app/enc/chacha").transcript(transcript).aad(aad) - .applyToChaChaAead(decChaCha, 256, 12); + .applyToChaChaAead(decChaCha, 256); byte[] out = runDecrypt(decChaCha, ciphertext); System.out.println("...outPrefix=" + shortHex(out, 32)); diff --git a/samples/src/test/java/demo/HybridDerivedAesDemoTest.java b/samples/src/test/java/demo/HybridDerivedAesDemoTest.java index c9e2598..930e5df 100644 --- a/samples/src/test/java/demo/HybridDerivedAesDemoTest.java +++ b/samples/src/test/java/demo/HybridDerivedAesDemoTest.java @@ -204,12 +204,12 @@ class HybridDerivedAesDemoTest { .transcript(transcript.toByteArray()) // ...Inject explicit AAD. .aad(aad) - // ...Apply derived key(256b) and IV(12B) to AES-GCM with header. + // ...Apply the derived key to AES-GCM; encryption creates the IV. .applyToAesGcm(AesDataContentBuilder.builder(session) // ...Store IV in header for decrypt side. .withHeader() // ...Use AES-GCM with 128-bit authentication tag. - .modeGcm(128), 256, 12)) + .modeGcm(128), 256)) // ...Finalize pipeline. .build(); @@ -223,7 +223,7 @@ class HybridDerivedAesDemoTest { DataContent dec = DataContentChainBuilder.decrypt() // ...Input: ciphertext bytes. .add(PlainBytesBuilder.builder().bytes(ciphertext)) - // ...AEAD: apply the same label/transcript/AAD to get identical key/IV. + // ...AEAD: apply the same label/transcript/AAD to get the identical key. .add(HybridDerived.from(exporter) // ...Same purpose label as encryption. .label("app/enc/aes-gcm") @@ -231,12 +231,12 @@ class HybridDerivedAesDemoTest { .transcript(transcript.toByteArray()) // ...Same explicit AAD as encryption. .aad(aad) - // ...Apply derived key and IV to AES-GCM with header. + // ...Apply the derived key; decryption reads the IV from the header. .applyToAesGcm(AesDataContentBuilder.builder(session) // ...Parse IV from header. .withHeader() // ...Use AES-GCM with 128-bit authentication tag. - .modeGcm(128), 256, 12)) + .modeGcm(128), 256)) // ...Finalize pipeline. .build(); @@ -365,7 +365,7 @@ class HybridDerivedAesDemoTest { // ...Use AES-GCM with 128-bit authentication tag. aesEnc.modeGcm(128); - // ...Inject derived key/IV/AAD into AES builder. + // ...Inject the derived key and AAD into the AES builder. HybridDerived.from(exporter) // ...Purpose separation label for AEAD. .label("app/enc/aes-gcm") @@ -373,8 +373,8 @@ class HybridDerivedAesDemoTest { .transcript(transcript.toByteArray()) // ...Inject explicit AAD. .aad(aad) - // ...Apply derived key(256b) and IV(12B). - .applyToAesGcm(aesEnc, 256, 12); + // ...Apply the derived 256-bit key; encryption creates the IV. + .applyToAesGcm(aesEnc, 256); // ...Build encryption pipeline. DataContent enc = DataContentChainBuilder.encrypt() @@ -398,7 +398,7 @@ class HybridDerivedAesDemoTest { // ...Use AES-GCM with 128-bit authentication tag. aesDec.modeGcm(128); - // ...Inject the same derived key/IV/AAD into decryption builder. + // ...Inject the same derived key and AAD into the decryption builder. HybridDerived.from(exporter) // ...Same purpose label. .label("app/enc/aes-gcm") @@ -406,8 +406,8 @@ class HybridDerivedAesDemoTest { .transcript(transcript.toByteArray()) // ...Same explicit AAD. .aad(aad) - // ...Apply the same derived key and IV. - .applyToAesGcm(aesDec, 256, 12); + // ...Apply the same derived key; the IV is read from the header. + .applyToAesGcm(aesDec, 256); // ...Build decryption pipeline. DataContent dec = DataContentChainBuilder.decrypt() @@ -512,12 +512,12 @@ class HybridDerivedAesDemoTest { .transcript(transcript.toByteArray()) // ...Inject explicit AAD. .aad(aad) - // ...Apply derived key(256b) and IV(12B) to AES-GCM with header. + // ...Apply the derived key to AES-GCM; encryption creates the IV. .applyToAesGcm(AesDataContentBuilder.builder(session) // ...Store IV in header for decrypt side. .withHeader() // ...Use AES-GCM with 128-bit authentication tag. - .modeGcm(128), 256, 12)) + .modeGcm(128), 256)) // ...Finalize pipeline. .build(); @@ -572,7 +572,7 @@ class HybridDerivedAesDemoTest { DataContent dec = DataContentChainBuilder.decrypt() // ...Input: ciphertext bytes. .add(PlainBytesBuilder.builder().bytes(ciphertext)) - // ...AEAD: apply the same label/transcript/AAD to get identical key/IV. + // ...AEAD: apply the same label/transcript/AAD to get the identical key. .add(HybridDerived.from(exporterDec) // ...Same purpose label as encryption. .label("app/local/aes-gcm") @@ -580,12 +580,12 @@ class HybridDerivedAesDemoTest { .transcript(transcript.toByteArray()) // ...Same explicit AAD. .aad(aad) - // ...Apply derived key and IV to AES-GCM with header. + // ...Apply the derived key; decryption reads the IV from the header. .applyToAesGcm(AesDataContentBuilder.builder(session) // ...Parse IV from header. .withHeader() // ...Use AES-GCM with 128-bit authentication tag. - .modeGcm(128), 256, 12)) + .modeGcm(128), 256)) // ...Finalize pipeline. .build();