security(lib): enforce single-use encryption contexts

This commit is contained in:
2026-07-29 17:28:32 +02:00
parent 8af75a9508
commit 9bbcab7522
18 changed files with 673 additions and 160 deletions

View File

@@ -154,7 +154,7 @@ public final class Guard {
final Option OPT_TAG_BITS = Option.builder().longOpt("tag-bits").hasArg().argName("96..128") final Option OPT_TAG_BITS = Option.builder().longOpt("tag-bits").hasArg().argName("96..128")
.desc("AES-GCM tag length in bits (default 128)").get(); .desc("AES-GCM tag length in bits (default 128)").get();
final Option OPT_NONCE_HEX = Option.builder().longOpt("nonce-hex").hasArg().argName("hex") 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") final Option OPT_INIT_CTR = Option.builder().longOpt("init-ctr").hasArg().argName("int")
.desc("ChaCha stream initial counter (default 1)").get(); .desc("ChaCha stream initial counter (default 1)").get();
final Option OPT_CTR = Option.builder().longOpt("ctr").hasArg().argName("int") final Option OPT_CTR = Option.builder().longOpt("ctr").hasArg().argName("int")
@@ -303,7 +303,7 @@ public final class Guard {
chacha.withHeader(); chacha.withHeader();
} }
if (chachaNonce != null) { if (chachaNonce != null) {
chacha.withNonce(chachaNonce); chacha.withDecryptionNonce(chachaNonce);
} }
if (ctrOverride != null || initCtr != null) { if (ctrOverride != null || initCtr != null) {
// providing counters together with AAD would be conflicting; builder enforces // providing counters together with AAD would be conflicting; builder enforces
@@ -323,7 +323,7 @@ public final class Guard {
chacha.withHeader(); chacha.withHeader();
} }
if (chachaNonce != null) { if (chachaNonce != null) {
chacha.withNonce(chachaNonce); chacha.withDecryptionNonce(chachaNonce);
} }
if (initCtr != null) { if (initCtr != null) {
chacha.initialCounter(initCtr); chacha.initialCounter(initCtr);

View File

@@ -181,7 +181,7 @@ public final class Kem { // NOPMD
/** AES IV: --aes-iv <hex> */ /** AES IV: --aes-iv <hex> */
public static final Option OPT_AES_IV = Option.builder().longOpt("aes-iv").hasArg().argName("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> */ /** AES tag bits: --aes-tag-bits <int> */
public static final Option OPT_AES_TAG_BITS = Option.builder().longOpt("aes-tag-bits").hasArg().argName("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> */ /** ChaCha nonce: --chacha-nonce <hex> */
public static final Option OPT_CHACHA_NONCE = Option.builder().longOpt("chacha-nonce").hasArg().argName("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> */ /** ChaCha counter value: --chacha-counter <int> */
public static final Option OPT_CHACHA_COUNTER = Option.builder().longOpt("chacha-counter").hasArg().argName("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); byte[] iv = parseHexOpt(cmd, OPT_AES_IV);
if (iv != null) { if (iv != null) {
aes = aes.withIv(iv); aes = aes.withDecryptionIv(iv);
} }
if (aad != null && aad.length > 0) { if (aad != null && aad.length > 0) {
aes = aes.withAad(aad); aes = aes.withAad(aad);
@@ -299,7 +299,7 @@ public final class Kem { // NOPMD
ChaChaDataContentBuilder cc = ChaChaDataContentBuilder.builder(session); ChaChaDataContentBuilder cc = ChaChaDataContentBuilder.builder(session);
byte[] nonce = parseHexOpt(cmd, OPT_CHACHA_NONCE); byte[] nonce = parseHexOpt(cmd, OPT_CHACHA_NONCE);
if (nonce != null) { if (nonce != null) {
cc = cc.withNonce(nonce); cc = cc.withDecryptionNonce(nonce);
} }
// counter is an integer, not bytes; use typed parsed option // counter is an integer, not bytes; use typed parsed option
Integer counter = parsedIntOpt(cmd, OPT_CHACHA_COUNTER); Integer counter = parsedIntOpt(cmd, OPT_CHACHA_COUNTER);

View File

@@ -196,11 +196,10 @@ public class GuardTest {
final int tagBits = 128; final int tagBits = 128;
final String aadAes = "010203"; final String aadAes = "010203";
final String aadCha = "D00DFEED"; final String aadCha = "D00DFEED";
final String chNonce = "00112233445566778899AABB";
System.out.println(method); System.out.println(method);
System.out.println("...params: sizeAes=" + sizeAes + " sizeCha=" + sizeCha + " tagBits=" + tagBits + " aadAes=" System.out.println("...params: sizeAes=" + sizeAes + " sizeCha=" + sizeCha + " tagBits=" + tagBits + " aadAes="
+ aadAes + " aadCha=" + aadCha + " chNonce=" + chNonce); + aadAes + " aadCha=" + aadCha);
// Prepare keyring with RSA pair // Prepare keyring with RSA pair
Path ring = tmp.resolve("ring-rsa.txt"); Path ring = tmp.resolve("ring-rsa.txt");
@@ -237,13 +236,13 @@ public class GuardTest {
Path dec = tmp.resolve("rsa-pt-ch.bin.dec"); Path dec = tmp.resolve("rsa-pt-ch.bin.dec");
String[] encArgs = { "--encrypt", in.toString(), "--output", enc.toString(), "--keyring", ring.toString(), 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)); System.out.println("...ChaCha encrypt: " + Arrays.toString(encArgs));
int e = Guard.main(encArgs, new Options()); int e = Guard.main(encArgs, new Options());
assertEquals(0, e, "... ChaCha encrypt rc"); assertEquals(0, e, "... ChaCha encrypt rc");
String[] decArgs = { "--decrypt", enc.toString(), "--output", dec.toString(), "--keyring", ring.toString(), 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)); System.out.println("...ChaCha decrypt: " + Arrays.toString(decArgs));
int d = Guard.main(decArgs, new Options()); int d = Guard.main(decArgs, new Options());
assertEquals(0, d, "... ChaCha decrypt rc"); assertEquals(0, d, "... ChaCha decrypt rc");

View File

@@ -142,11 +142,10 @@ public class KemTest {
final int gcmTagBits = 128; final int gcmTagBits = 128;
final String aadAes = "A1B2C3"; final String aadAes = "A1B2C3";
final String aadChaCha = "DEADBEEF"; final String aadChaCha = "DEADBEEF";
final String nonceChaCha = "00112233445566778899AABB";
System.out.println(method); System.out.println(method);
System.out.println("...params: aesSize=" + aesSize + " chachaSize=" + chachaSize + " gcmTagBits=" + gcmTagBits 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). // Discover KEM ids via the CLI (ensures we use exactly the ids users will see).
List<String> kemIds = listKemsViaCli(); List<String> kemIds = listKemsViaCli();
@@ -209,14 +208,14 @@ public class KemTest {
System.out.println("...[" + kemId + "] ChaCha encrypt"); System.out.println("...[" + kemId + "] ChaCha encrypt");
int e = Kem.main(new String[] { "--encrypt", plain.toString(), "--output", enc.toString(), int e = Kem.main(new String[] { "--encrypt", plain.toString(), "--output", enc.toString(),
"--keyring", ring.toString(), "--pub", aliases.pub, "--kem", kemId, "--chacha", "--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) { if (e != 0) {
throw new IllegalStateException("ChaCha encrypt rc=" + e); throw new IllegalStateException("ChaCha encrypt rc=" + e);
} }
System.out.println("...[" + kemId + "] ChaCha decrypt"); System.out.println("...[" + kemId + "] ChaCha decrypt");
int d = Kem.main(new String[] { "--decrypt", enc.toString(), "--output", dec.toString(), int d = Kem.main(new String[] { "--decrypt", enc.toString(), "--output", dec.toString(),
"--keyring", ring.toString(), "--priv", aliases.prv, "--kem", kemId, "--chacha", "--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) { if (d != 0) {
throw new IllegalStateException("ChaCha decrypt rc=" + d); throw new IllegalStateException("ChaCha decrypt rc=" + d);
} }

View File

@@ -34,10 +34,12 @@
package zeroecho.core.alg.aes; package zeroecho.core.alg.aes;
import java.io.IOException; import java.io.IOException;
import java.io.FilterInputStream;
import java.io.InputStream; import java.io.InputStream;
import java.security.GeneralSecurityException; import java.security.GeneralSecurityException;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
@@ -54,7 +56,6 @@ import zeroecho.core.context.EncryptionContext;
import zeroecho.core.err.ProviderFailureException; import zeroecho.core.err.ProviderFailureException;
import zeroecho.core.io.CipherTransformInputStreamBuilder; import zeroecho.core.io.CipherTransformInputStreamBuilder;
import zeroecho.core.spi.ContextAware; import zeroecho.core.spi.ContextAware;
import zeroecho.core.util.Strings;
import zeroecho.sdk.util.RandomSupport; import zeroecho.sdk.util.RandomSupport;
/** /**
@@ -62,9 +63,9 @@ import zeroecho.sdk.util.RandomSupport;
* *
* <p> * <p>
* IV and optional AAD are exchanged via a {@link conflux.CtxInterface} set with * IV and optional AAD are exchanged via a {@link conflux.CtxInterface} set with
* {@link #setContext(conflux.CtxInterface)}. On ENCRYPT, a fresh IV is * {@link #setContext(conflux.CtxInterface)}. Encryption always generates a fresh
* generated if absent and stored back; on DECRYPT, IV must be present (from * IV after atomically claiming the context; a caller-provided IV is never used
* context or header). * for encryption. Decryption requires the IV from the context or encoded header.
* </p> * </p>
* *
* <p> * <p>
@@ -86,6 +87,7 @@ public final class AesCipherContext implements EncryptionContext, ContextAware {
private final boolean encrypt; private final boolean encrypt;
private final AesSpec spec; private final AesSpec spec;
private final SecureRandom rnd; private final SecureRandom rnd;
private final AtomicReference<OperationState> operationState = new AtomicReference<>(OperationState.NEW);
private volatile CtxInterface ctx; // NOPMD private volatile CtxInterface ctx; // NOPMD
@@ -172,6 +174,8 @@ public final class AesCipherContext implements EncryptionContext, ContextAware {
@Override @Override
public InputStream attach(InputStream upstream) throws IOException { public InputStream attach(InputStream upstream) throws IOException {
Objects.requireNonNull(upstream, "upstream must not be null"); Objects.requireNonNull(upstream, "upstream must not be null");
claimEncryption();
boolean attached = false;
try { try {
// If both spec.header() and ctx are present, let this context read/write the // If both spec.header() and ctx are present, let this context read/write the
// header. // header.
@@ -186,8 +190,7 @@ public final class AesCipherContext implements EncryptionContext, ContextAware {
} }
Cipher cipher = Cipher.getInstance(jcaTransform(spec)); Cipher cipher = Cipher.getInstance(jcaTransform(spec));
initCipher(cipher); // consumes IV/AAD from ctx if present; generates IV on ENCRYPT and may store it initCipher(cipher);
// back; sets tagBits for GCM
InputStream out = // new Stream(in, cipher, spec); InputStream out = // new Stream(in, cipher, spec);
CipherTransformInputStreamBuilder.builder().withCipher(cipher).withUpstream(in) CipherTransformInputStreamBuilder.builder().withCipher(cipher).withUpstream(in)
@@ -200,9 +203,14 @@ public final class AesCipherContext implements EncryptionContext, ContextAware {
header.writeHeader(hdr, algorithm, ctx); header.writeHeader(hdr, algorithm, ctx);
out = new java.io.SequenceInputStream(new java.io.ByteArrayInputStream(hdr.toByteArray()), out); 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) { } catch (GeneralSecurityException e) {
throw new ProviderFailureException("AES attach/init failed", 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(); 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; final int ivLen = (spec.mode() == AesSpec.Mode.GCM) ? GCM_DEFAULT_IV_BYTES : AES_BLOCK;
if (encrypt) { if (encrypt) {
if (iv == null) { iv = new byte[ivLen];
iv = new byte[ivLen]; rnd.nextBytes(iv);
rnd.nextBytes(iv); putCtxBytes(ConfluxKeys.iv(id), iv.clone());
putCtxBytes(ConfluxKeys.iv(id), iv);
} else if (iv.length != ivLen) {
throw new IOException("IV length mismatch: expected " + ivLen + " bytes, got " + iv.length);
}
} else { } else {
iv = getCtxBytes(ConfluxKeys.iv(id));
if (iv == null) { if (iv == null) {
throw new IOException("IV not found in context for AES " + spec.mode() + " decryption"); 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); cipher.updateAAD(aad);
if (LOG.isLoggable(Level.FINE)) { LOG.log(Level.FINE, "GCM setup complete: tagBits={0}", tagBits);
LOG.log(Level.FINE, "GCM setup: tagBits={0} iv={1} aad={2}",
new Object[] { tagBits, Strings.toShortHexString(iv), Strings.toShortHexString(aad) });
}
break; break;
} }
@@ -294,4 +296,86 @@ public final class AesCipherContext implements EncryptionContext, ContextAware {
ctx.put(key, value); 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);
}
}
}
} }

View File

@@ -35,11 +35,13 @@ package zeroecho.core.alg.chacha;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.FilterInputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.SequenceInputStream; import java.io.SequenceInputStream;
import java.security.GeneralSecurityException; import java.security.GeneralSecurityException;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.concurrent.atomic.AtomicReference;
import javax.crypto.Cipher; import javax.crypto.Cipher;
import javax.crypto.SecretKey; import javax.crypto.SecretKey;
@@ -52,6 +54,7 @@ import zeroecho.core.context.EncryptionContext;
import zeroecho.core.err.ProviderFailureException; import zeroecho.core.err.ProviderFailureException;
import zeroecho.core.io.CipherTransformInputStreamBuilder; import zeroecho.core.io.CipherTransformInputStreamBuilder;
import zeroecho.core.spi.ContextAware; import zeroecho.core.spi.ContextAware;
import zeroecho.sdk.util.RandomSupport;
/** /**
* <h2>Abstract streaming cipher context for ChaCha algorithms</h2> * <h2>Abstract streaming cipher context for ChaCha algorithms</h2>
@@ -109,6 +112,7 @@ abstract class AbstractChaChaCipherContext<S extends ChaChaBaseSpec> implements
protected final S spec; protected final S spec;
/** Secure random source for nonce generation. */ /** Secure random source for nonce generation. */
protected final SecureRandom rnd; protected final SecureRandom rnd;
private final AtomicReference<OperationState> operationState = new AtomicReference<>(OperationState.NEW);
/** Optional per-operation context for exchanging headers, IVs, etc. */ /** Optional per-operation context for exchanging headers, IVs, etc. */
protected CtxInterface ctx; // optional protected CtxInterface ctx; // optional
@@ -127,7 +131,7 @@ abstract class AbstractChaChaCipherContext<S extends ChaChaBaseSpec> implements
this.key = key; this.key = key;
this.encrypt = encrypt; this.encrypt = encrypt;
this.spec = spec; this.spec = spec;
this.rnd = (rnd != null ? rnd : new SecureRandom()); this.rnd = (rnd != null ? rnd : RandomSupport.getRandom());
} }
/** {@inheritDoc} */ /** {@inheritDoc} */
@@ -188,6 +192,9 @@ abstract class AbstractChaChaCipherContext<S extends ChaChaBaseSpec> implements
*/ */
@Override @Override
public InputStream attach(InputStream upstream) throws IOException { public InputStream attach(InputStream upstream) throws IOException {
java.util.Objects.requireNonNull(upstream, "upstream must not be null");
claimEncryption();
boolean attached = false;
try { try {
final SymmetricHeaderCodec header = spec.header(); final SymmetricHeaderCodec header = spec.header();
final boolean hasCtxHeader = ctx != null && header != null; final boolean hasCtxHeader = ctx != null && header != null;
@@ -198,7 +205,7 @@ abstract class AbstractChaChaCipherContext<S extends ChaChaBaseSpec> implements
} }
final Cipher cipher = Cipher.getInstance(jceName()); final Cipher cipher = Cipher.getInstance(jceName());
final byte[] nonce = ensureNonce(); // generate or require from ctx final byte[] nonce = ensureNonce();
initCipher(cipher, nonce); initCipher(cipher, nonce);
InputStream out = // new Stream(in, cipher, jceName()); // same stream pattern as AES InputStream out = // new Stream(in, cipher, jceName()); // same stream pattern as AES
@@ -210,9 +217,14 @@ abstract class AbstractChaChaCipherContext<S extends ChaChaBaseSpec> implements
header.writeHeader(hdr, algorithm, ctx); header.writeHeader(hdr, algorithm, ctx);
out = new SequenceInputStream(new ByteArrayInputStream(hdr.toByteArray()), out); out = new SequenceInputStream(new ByteArrayInputStream(hdr.toByteArray()), out);
} }
return out; attached = true;
return encrypt ? new LifecycleInputStream(out) : out;
} catch (GeneralSecurityException e) { } catch (GeneralSecurityException e) {
throw new ProviderFailureException(jceName() + " attach/init failed", e); throw new ProviderFailureException(jceName() + " attach/init failed", e);
} finally {
if (!attached) {
failEncryption();
}
} }
} }
@@ -231,8 +243,8 @@ abstract class AbstractChaChaCipherContext<S extends ChaChaBaseSpec> implements
* Ensures a nonce is available in the context. * Ensures a nonce is available in the context.
* *
* <ul> * <ul>
* <li>For encryption, generates a new nonce if absent and stores it in * <li>For encryption, always generates a new nonce after the context has
* context.</li> * been atomically claimed and stores a copy in the context.</li>
* <li>For decryption, validates presence and correct length.</li> * <li>For decryption, validates presence and correct length.</li>
* </ul> * </ul>
* *
@@ -241,22 +253,101 @@ abstract class AbstractChaChaCipherContext<S extends ChaChaBaseSpec> implements
*/ */
private byte[] ensureNonce() throws IOException { private byte[] ensureNonce() throws IOException {
final String id = algorithm.id(); final String id = algorithm.id();
byte[] nonce = (ctx == null) ? null : ctx.get(ConfluxKeys.iv(id)); byte[] nonce;
if (encrypt) { if (encrypt) {
if (nonce == null) { nonce = new byte[NONCE_LEN];
nonce = new byte[NONCE_LEN]; rnd.nextBytes(nonce);
rnd.nextBytes(nonce); if (ctx != null) {
if (ctx != null) { // NOPMD ctx.put(ConfluxKeys.iv(id), nonce.clone());
ctx.put(ConfluxKeys.iv(id), nonce);
}
} else if (nonce.length != NONCE_LEN) {
throw new IOException("Nonce length mismatch: expected 12 bytes, got " + nonce.length);
} }
} else { } else {
nonce = (ctx == null) ? null : ctx.get(ConfluxKeys.iv(id));
if (nonce == null || nonce.length != NONCE_LEN) { if (nonce == null || nonce.length != NONCE_LEN) {
throw new IOException("Nonce missing/invalid for " + jceName() + " decryption"); throw new IOException("Nonce missing/invalid for " + jceName() + " decryption");
} }
} }
return nonce; 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);
}
}
}
} }

View File

@@ -34,6 +34,7 @@
package zeroecho.core.alg.chacha; package zeroecho.core.alg.chacha;
import zeroecho.core.SymmetricHeaderCodec; import zeroecho.core.SymmetricHeaderCodec;
import zeroecho.sdk.util.RandomSupport;
/** /**
* <h2>ChaCha20-Poly1305 (AEAD) algorithm</h2> * <h2>ChaCha20-Poly1305 (AEAD) algorithm</h2>
@@ -101,12 +102,12 @@ public final class ChaCha20Poly1305Algorithm extends AbstractChaChaAlgorithm {
super("CHACHA20-POLY1305", "ChaCha20-Poly1305 (AEAD)"); super("CHACHA20-POLY1305", "ChaCha20-Poly1305 (AEAD)");
capability(zeroecho.core.AlgorithmFamily.SYMMETRIC, zeroecho.core.KeyUsage.ENCRYPT, capability(zeroecho.core.AlgorithmFamily.SYMMETRIC, zeroecho.core.KeyUsage.ENCRYPT,
zeroecho.core.context.EncryptionContext.class, javax.crypto.SecretKey.class, ChaCha20Poly1305Spec.class, 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()); () -> ChaCha20Poly1305Spec.builder().header(null).build());
capability(zeroecho.core.AlgorithmFamily.SYMMETRIC, zeroecho.core.KeyUsage.DECRYPT, capability(zeroecho.core.AlgorithmFamily.SYMMETRIC, zeroecho.core.KeyUsage.DECRYPT,
zeroecho.core.context.EncryptionContext.class, javax.crypto.SecretKey.class, ChaCha20Poly1305Spec.class, 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()); () -> ChaCha20Poly1305Spec.builder().header(null).build());
// VoidSpec defaults like AES-GCM // 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.context.EncryptionContext.class, javax.crypto.SecretKey.class,
zeroecho.core.spec.VoidSpec.class, zeroecho.core.spec.VoidSpec.class,
(k, v) -> new ChaCha20Poly1305CipherContext(this, k, true, (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); () -> zeroecho.core.spec.VoidSpec.INSTANCE);
capability(zeroecho.core.AlgorithmFamily.SYMMETRIC, zeroecho.core.KeyUsage.DECRYPT, capability(zeroecho.core.AlgorithmFamily.SYMMETRIC, zeroecho.core.KeyUsage.DECRYPT,
zeroecho.core.context.EncryptionContext.class, javax.crypto.SecretKey.class, zeroecho.core.context.EncryptionContext.class, javax.crypto.SecretKey.class,
zeroecho.core.spec.VoidSpec.class, zeroecho.core.spec.VoidSpec.class,
(k, v) -> new ChaCha20Poly1305CipherContext(this, k, false, (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); () -> zeroecho.core.spec.VoidSpec.INSTANCE);
} }
} }

View File

@@ -67,10 +67,10 @@ import zeroecho.core.CryptoAlgorithm;
* ChaCha20Poly1305Spec spec = ChaCha20Poly1305Spec.builder().header(null).build(); * ChaCha20Poly1305Spec spec = ChaCha20Poly1305Spec.builder().header(null).build();
* *
* // Encrypt * // Encrypt
* EncryptionContext enc = new ChaCha20Poly1305CipherContext(alg, key, true, spec, new SecureRandom()); * EncryptionContext enc = new ChaCha20Poly1305CipherContext(alg, key, true, spec, null);
* *
* // Decrypt * // Decrypt
* EncryptionContext dec = new ChaCha20Poly1305CipherContext(alg, key, false, spec, new SecureRandom()); * EncryptionContext dec = new ChaCha20Poly1305CipherContext(alg, key, false, spec, null);
* }</pre> * }</pre>
* *
* @since 1.0 * @since 1.0

View File

@@ -33,6 +33,7 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.core.alg.chacha; package zeroecho.core.alg.chacha;
import zeroecho.sdk.util.RandomSupport;
/** /**
* <h2>ChaCha20 (stream) algorithm</h2> * <h2>ChaCha20 (stream) algorithm</h2>
* *
@@ -82,12 +83,12 @@ public final class ChaChaAlgorithm extends AbstractChaChaAlgorithm {
// ENCRYPT // ENCRYPT
capability(zeroecho.core.AlgorithmFamily.SYMMETRIC, zeroecho.core.KeyUsage.ENCRYPT, capability(zeroecho.core.AlgorithmFamily.SYMMETRIC, zeroecho.core.KeyUsage.ENCRYPT,
zeroecho.core.context.EncryptionContext.class, javax.crypto.SecretKey.class, ChaChaSpec.class, 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()); () -> ChaChaSpec.builder().initialCounter(1).header(null).build());
// DECRYPT // DECRYPT
capability(zeroecho.core.AlgorithmFamily.SYMMETRIC, zeroecho.core.KeyUsage.DECRYPT, capability(zeroecho.core.AlgorithmFamily.SYMMETRIC, zeroecho.core.KeyUsage.DECRYPT,
zeroecho.core.context.EncryptionContext.class, javax.crypto.SecretKey.class, ChaChaSpec.class, 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()); () -> ChaChaSpec.builder().initialCounter(1).header(null).build());
// VoidSpec defaults (mirrors AES) // 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.context.EncryptionContext.class, javax.crypto.SecretKey.class,
zeroecho.core.spec.VoidSpec.class, zeroecho.core.spec.VoidSpec.class,
(k, v) -> new ChaChaCipherContext(this, k, true, (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); () -> zeroecho.core.spec.VoidSpec.INSTANCE);
capability(zeroecho.core.AlgorithmFamily.SYMMETRIC, zeroecho.core.KeyUsage.DECRYPT, capability(zeroecho.core.AlgorithmFamily.SYMMETRIC, zeroecho.core.KeyUsage.DECRYPT,
zeroecho.core.context.EncryptionContext.class, javax.crypto.SecretKey.class, zeroecho.core.context.EncryptionContext.class, javax.crypto.SecretKey.class,
zeroecho.core.spec.VoidSpec.class, zeroecho.core.spec.VoidSpec.class,
(k, v) -> new ChaChaCipherContext(this, k, false, (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); () -> zeroecho.core.spec.VoidSpec.INSTANCE);
} }
} }

View File

@@ -78,23 +78,22 @@
* <h2>Runtime parameters and context exchange</h2> * <h2>Runtime parameters and context exchange</h2>
* <p> * <p>
* Streaming contexts exchange ephemeral parameters through a Conflux session * Streaming contexts exchange ephemeral parameters through a Conflux session
* context using namespaced keys. For ChaCha20 and ChaCha20-Poly1305, a 12-byte * context using namespaced keys. Each ChaCha20 and ChaCha20-Poly1305 encryption
* nonce is required for each operation. On encryption, if the session context * context is single-use: after an atomic claim it generates a fresh 12-byte
* does not provide a nonce, the context generates a fresh value and stores it * nonce and stores it back into the session. On decryption the nonce must
* back into the session; on decryption the nonce must already be present and * already be present and have the correct length. ChaCha20 also uses an initial
* have the correct length. ChaCha20 also uses an initial counter sourced from * counter sourced from {@link ChaChaSpec} and optionally overridden by the
* {@link ChaChaSpec} and optionally overridden by the session context. When a * session context. When a header codec is configured and a session context is
* header codec is configured and a session context is set, encryption prepends * set, encryption prepends a minimal header and decryption reads it first to
* a minimal header and decryption reads it first to hydrate the session before * hydrate the session before initializing the cipher.
* initializing the cipher.
* </p> * </p>
* *
* <h2>Safety and validation</h2> * <h2>Safety and validation</h2>
* <ul> * <ul>
* <li><b>Nonce uniqueness:</b> Applications must ensure nonces are unique per * <li><b>Nonce lifecycle:</b> Each encryption context is single-use and
* key. The contexts will generate nonces for encryption, but cross-process * generates its nonce internally after an atomic operation claim. Decryption
* uniqueness is the caller's responsibility. Decryption fails if a nonce is * consumes the encoded or out-of-band nonce and fails if it is missing or has
* missing or has an unexpected size.</li> * an unexpected size.</li>
* *
* <li><b>Counter policy (ChaCha20):</b> The default initial counter is 1. A * <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 * context may override the spec value through the session key dedicated to

View File

@@ -94,11 +94,11 @@ import zeroecho.sdk.content.api.PlainContent;
* try (InputStream s = dec.getStream()) { s.transferTo(Files.newOutputStream(ptPath)); } * try (InputStream s = dec.getStream()) { s.transferTo(Files.newOutputStream(ptPath)); }
* }</pre> * }</pre>
* *
* <h2>Runtime parameters</h2> Optional IV and AAD can be provided via * <h2>Runtime parameters</h2> Optional AAD can be provided via
* {@link #withIv(byte[])} and {@link #withAad(byte[])}. If a Conflux context is * {@link #withAad(byte[])}. Encryption always generates its IV internally.
* present (set via {@link #context(conflux.CtxInterface)} or implied by a * Headerless decryption may receive an out-of-band IV through
* header), a fresh IV is generated on encrypt when absent and stored back into * {@link #withDecryptionIv(byte[])}; that configuration is rejected when
* the context. For GCM, AAD defaults to empty. * building an encrypting pipeline. For GCM, AAD defaults to empty.
* *
* <h2>Threadsafety</h2> The builder is not threadsafe. Built * <h2>Threadsafety</h2> The builder is not threadsafe. Built
* {@code DataContent} instances are independent. * {@code DataContent} instances are independent.
@@ -114,7 +114,7 @@ public final class AesDataContentBuilder implements DataContentBuilder<DataConte
private final AesSpec.Builder _spec = AesSpec.builder(); private final AesSpec.Builder _spec = AesSpec.builder();
private byte[] iv; // optional private byte[] decryptionIv; // optional
private byte[] aad; // optional private byte[] aad; // optional
private SymmetricHeaderCodec headerCodec; // optional; carried by AesSpec private SymmetricHeaderCodec headerCodec; // optional; carried by AesSpec
@@ -264,15 +264,14 @@ public final class AesDataContentBuilder implements DataContentBuilder<DataConte
} }
/** /**
* Supplies an explicit IV/nonce. If omitted during ENCRYPT and a context is * Supplies an out-of-band IV for decryption without an encoded header.
* present, a fresh IV is generated and stored in the context.
* *
* @param iv the IV (12 bytes for GCM, 16 bytes for CBC/CTR) * @param iv the IV (12 bytes for GCM, 16 bytes for CBC/CTR)
* @return this builder * @return this builder
* @throws IllegalArgumentException if the length does not match the mode * @throws NullPointerException if {@code iv} is {@code null}
*/ */
public AesDataContentBuilder withIv(byte[] iv) { public AesDataContentBuilder withDecryptionIv(byte[] iv) {
this.iv = iv; this.decryptionIv = Objects.requireNonNull(iv, "iv must not be null").clone();
return this; return this;
} }
@@ -330,7 +329,7 @@ public final class AesDataContentBuilder implements DataContentBuilder<DataConte
* <li>Resolves the key from {@link #withKey(SecretKey)} or the selected * <li>Resolves the key from {@link #withKey(SecretKey)} or the selected
* generate/import spec.</li> * generate/import spec.</li>
* <li>Finalizes {@link AesSpec} (injecting the header if configured).</li> * <li>Finalizes {@link AesSpec} (injecting the header if configured).</li>
* <li>Propagates IV/AAD into the context if supplied.</li> * <li>Propagates a decryption IV and AAD into the context when supplied.</li>
* </ul> * </ul>
* *
* @param encrypt {@code true} for encryption; {@code false} for decryption * @param encrypt {@code true} for encryption; {@code false} for decryption
@@ -340,6 +339,9 @@ public final class AesDataContentBuilder implements DataContentBuilder<DataConte
*/ */
@Override @Override
public DataContent build(boolean encrypt) { public DataContent build(boolean encrypt) {
if (encrypt && decryptionIv != null) {
throw new IllegalStateException("A caller-supplied IV is valid only for AES decryption");
}
final SecretKey key = resolveKey(); final SecretKey key = resolveKey();
// finalize AesSpec from builder + header // finalize AesSpec from builder + header
@@ -358,11 +360,11 @@ public final class AesDataContentBuilder implements DataContentBuilder<DataConte
} }
ctx.put(ConfluxKeys.aad(ALGORITHM_ID), aad); ctx.put(ConfluxKeys.aad(ALGORITHM_ID), aad);
} }
if (iv != null && iv.length > 0) { if (decryptionIv != null && decryptionIv.length > 0) {
if (ctx == null) { if (ctx == null) {
ctx = Ctx.INSTANCE.getContext("aes-ctx-" + System.nanoTime()); 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); return encrypt ? new EncryptContent(key, aesSpec, ctx) : new DecryptContent(key, aesSpec, ctx);
@@ -526,8 +528,7 @@ public final class AesDataContentBuilder implements DataContentBuilder<DataConte
if (!(enc instanceof ContextAware)) { if (!(enc instanceof ContextAware)) {
throw new IllegalStateException("AES context is not ContextAware; cannot pass conflux Ctx"); throw new IllegalStateException("AES context is not ContextAware; cannot pass conflux Ctx");
} }
((ContextAware) enc).setContext(ctx); // ctx may be null → header disabled; IV generated only if ctx ((ContextAware) enc).setContext(ctx);
// present
return enc.attach(upstream.getStream()); return enc.attach(upstream.getStream());
} }
} }

View File

@@ -95,9 +95,9 @@ import zeroecho.sdk.content.api.PlainContent;
* is supplied via {@link #withHeaderCodec(SymmetricHeaderCodec)}, a context is * is supplied via {@link #withHeaderCodec(SymmetricHeaderCodec)}, a context is
* required; if none was provided, a temporary one is created internally. A * required; if none was provided, a temporary one is created internally. A
* default header codec is chosen based on the variant when no custom codec is * default header codec is chosen based on the variant when no custom codec is
* set. The nonce is typically 12 bytes; if absent during encryption and a * set. Encryption generates a 12-byte nonce internally after claiming its
* context is available, an implementation-specific nonce may be generated and * single-use context. Headerless decryption may receive a nonce through the
* stored in the context. * decryption-only configuration method.
* *
* <h2>Usage examples</h2> <pre>{@code * <h2>Usage examples</h2> <pre>{@code
* // 1) ChaCha20 stream encryption with generated key and header * // 1) ChaCha20 stream encryption with generated key and header
@@ -106,10 +106,10 @@ import zeroecho.sdk.content.api.PlainContent;
* .withHeader() * .withHeader()
* .build(true); * .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() * DataContent dec = ChaChaDataContentBuilder.builder()
* .withKey(secretKey) * .withKey(secretKey)
* .withNonce(nonce12) // 12 bytes * .withDecryptionNonce(nonce12) // 12 bytes
* .withAad("meta".getBytes(StandardCharsets.UTF_8)) * .withAad("meta".getBytes(StandardCharsets.UTF_8))
* .build(false); * .build(false);
* *
@@ -150,7 +150,7 @@ public final class ChaChaDataContentBuilder implements DataContentBuilder<DataCo
private ChaChaKeyImportSpec importSpec; private ChaChaKeyImportSpec importSpec;
private CtxInterface ctx; private CtxInterface ctx;
private byte[] nonce; private byte[] decryptionNonce;
private int initialCounter = 1; private int initialCounter = 1;
private boolean initialCounterSet; // = false; private boolean initialCounterSet; // = false;
@@ -300,19 +300,20 @@ public final class ChaChaDataContentBuilder implements DataContentBuilder<DataCo
} }
/** /**
* Sets an optional nonce value. * Sets an out-of-band nonce for headerless decryption.
* *
* <p> * <p>
* For ChaCha20 and ChaCha20-Poly1305 this is typically 12 bytes. If absent * For ChaCha20 and ChaCha20-Poly1305 this must be 12 bytes. The value is
* during encryption and a context is in use, an implementation-specific nonce * rejected when building an encrypting pipeline because encryption always
* may be generated and placed into the context. * generates its nonce internally.
* </p> * </p>
* *
* @param nonce the nonce bytes; may be null * @param nonce the nonce bytes; must not be null
* @return {@code this} builder for chaining * @return {@code this} builder for chaining
* @throws NullPointerException if {@code nonce} is {@code null}
*/ */
public ChaChaDataContentBuilder withNonce(byte[] nonce) { public ChaChaDataContentBuilder withDecryptionNonce(byte[] nonce) {
this.nonce = nonce; this.decryptionNonce = Objects.requireNonNull(nonce, "nonce must not be null").clone();
return this; return this;
} }
@@ -415,8 +416,8 @@ public final class ChaChaDataContentBuilder implements DataContentBuilder<DataCo
* *
* <p> * <p>
* If a header is requested and no context is provided, a temporary context is * 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 * created. Encryption generates its nonce internally; a configured decryption
* implementation may generate one and store it into the context. * nonce is rejected for encrypting pipelines.
* </p> * </p>
* *
* <pre>{@code * <pre>{@code
@@ -435,20 +436,23 @@ public final class ChaChaDataContentBuilder implements DataContentBuilder<DataCo
*/ */
@Override @Override
public DataContent build(boolean encrypt) { // NOPMD public DataContent build(boolean encrypt) { // NOPMD
if (encrypt && decryptionNonce != null) {
throw new IllegalStateException("A caller-supplied nonce is valid only for ChaCha decryption");
}
final Variant v = inferVariant(); final Variant v = inferVariant();
final String algId = v == Variant.AEAD ? "CHACHA20-POLY1305" : "CHACHA20"; final String algId = v == Variant.AEAD ? "CHACHA20-POLY1305" : "CHACHA20";
final SecretKey key = resolveKey(algId); final SecretKey key = resolveKey(algId);
// header → ensure ctx exists // header → ensure ctx exists
if ((headerRequested || headerCodec != null) && ctx == null) { if ((headerRequested || headerCodec != null || decryptionNonce != null) && ctx == null) {
ctx = Ctx.INSTANCE ctx = Ctx.INSTANCE
.getContext("chacha-" + (v == Variant.AEAD ? "aead" : "stream") + "-" + System.nanoTime()); .getContext("chacha-" + (v == Variant.AEAD ? "aead" : "stream") + "-" + System.nanoTime());
} }
// fill ctx with runtime params // fill ctx with runtime params
if (ctx != null) { if (ctx != null) {
if (nonce != null && nonce.length > 0) { if (decryptionNonce != null && decryptionNonce.length > 0) {
ctx.put(ConfluxKeys.iv(algId), nonce); ctx.put(ConfluxKeys.iv(algId), decryptionNonce.clone());
} }
if (v == Variant.AEAD) { if (v == Variant.AEAD) {
if (aad != null) { // NOPMD if (aad != null) { // NOPMD

View File

@@ -61,8 +61,6 @@ import zeroecho.sdk.hybrid.kex.HybridKexExporter;
* </p> * </p>
* <ul> * <ul>
* <li>{@code label + "/key"} for the secret key</li> * <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> * <li>{@code label + "/aad"} for AEAD AAD (optional, if derived)</li>
* </ul> * </ul>
* *
@@ -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. * builder.
* *
* <p> * <p>
@@ -200,15 +198,12 @@ public final class HybridDerived {
* *
* @param aes AES builder to configure (must not be null) * @param aes AES builder to configure (must not be null)
* @param keyBits AES key size in bits (128/192/256) * @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 * @return the provided builder instance
* @throws NullPointerException if aes is null * @throws NullPointerException if aes is null
* @throws IllegalArgumentException if keyBits is invalid * @throws IllegalArgumentException if keyBits is invalid
* @since 1.0 * @since 1.0
*/ */
public AesDataContentBuilder applyToAesGcm(AesDataContentBuilder aes, int keyBits, int ivLenBytes) { public AesDataContentBuilder applyToAesGcm(AesDataContentBuilder aes, int keyBits) {
Objects.requireNonNull(aes, "aes"); Objects.requireNonNull(aes, "aes");
validateBase(); validateBase();
@@ -221,11 +216,6 @@ public final class HybridDerived {
Arrays.fill(keyRaw, (byte) 0); Arrays.fill(keyRaw, (byte) 0);
} }
if (ivLenBytes > 0) {
byte[] iv = exportBytes(label + "/iv", ivLenBytes);
aes.withIv(iv);
}
byte[] aad = resolveAad(); byte[] aad = resolveAad();
if (aad != null) { if (aad != null) {
aes.withAad(aad); 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. * ChaCha builder.
* *
* <p> * <p>
@@ -245,15 +235,12 @@ public final class HybridDerived {
* *
* @param chacha ChaCha builder to configure (must not be null) * @param chacha ChaCha builder to configure (must not be null)
* @param keyBits key size in bits (typically 256) * @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 * @return the provided builder instance
* @throws NullPointerException if chacha is null * @throws NullPointerException if chacha is null
* @throws IllegalArgumentException if keyBits is invalid * @throws IllegalArgumentException if keyBits is invalid
* @since 1.0 * @since 1.0
*/ */
public ChaChaDataContentBuilder applyToChaChaAead(ChaChaDataContentBuilder chacha, int keyBits, int nonceLenBytes) { public ChaChaDataContentBuilder applyToChaChaAead(ChaChaDataContentBuilder chacha, int keyBits) {
Objects.requireNonNull(chacha, "chacha"); Objects.requireNonNull(chacha, "chacha");
validateBase(); validateBase();
@@ -266,11 +253,6 @@ public final class HybridDerived {
Arrays.fill(keyRaw, (byte) 0); Arrays.fill(keyRaw, (byte) 0);
} }
if (nonceLenBytes > 0) {
byte[] nonce = exportBytes(label + "/nonce", nonceLenBytes);
chacha.withNonce(nonce);
}
byte[] aad = resolveAad(); byte[] aad = resolveAad();
if (aad != null) { if (aad != null) {
chacha.withAad(aad); chacha.withAad(aad);

View File

@@ -128,7 +128,6 @@ public class AesGcmCrossCheckTest {
// --- test vectors --- // --- test vectors ---
byte[] msg = rand(SIZE); byte[] msg = rand(SIZE);
byte[] iv = rand(12); // 12-byte IV for GCM
byte[] aad = "test-aad-123".getBytes(); byte[] aad = "test-aad-123".getBytes();
// --- key (either via your builder or direct JCA; both fine) --- // --- key (either via your builder or direct JCA; both fine) ---
@@ -139,10 +138,8 @@ public class AesGcmCrossCheckTest {
// kg.init(256); // kg.init(256);
// SecretKey key = kg.generateKey(); // 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()); CtxInterface session = Ctx.INSTANCE.getContext("aes-gcm-xchk-" + System.nanoTime());
session.put(ConfluxKeys.iv("AES"), iv);
session.put(ConfluxKeys.aad("AES"), aad); session.put(ConfluxKeys.aad("AES"), aad);
AesSpec spec = AesSpec.gcm128(null); AesSpec spec = AesSpec.gcm128(null);
@@ -152,6 +149,7 @@ public class AesGcmCrossCheckTest {
((ContextAware) enc).setContext(session); ((ContextAware) enc).setContext(session);
byte[] ct_stream = readAll(enc.attach(new ByteArrayInputStream(msg))); byte[] ct_stream = readAll(enc.attach(new ByteArrayInputStream(msg)));
enc.close(); enc.close();
byte[] iv = session.get(ConfluxKeys.iv("AES"));
// === JCA ENCRYPT (reference) === // === JCA ENCRYPT (reference) ===
byte[] ct_jca = jcaGcmEncrypt(key, iv, TAG_BITS, aad, msg); byte[] ct_jca = jcaGcmEncrypt(key, iv, TAG_BITS, aad, msg);

View File

@@ -6,18 +6,28 @@ package zeroecho.core.alg.aes;
import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals; 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.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame; 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.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field; import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.concurrent.Callable; import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
import javax.crypto.SecretKey; import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec; import javax.crypto.spec.SecretKeySpec;
@@ -31,6 +41,7 @@ import zeroecho.core.KeyUsage;
import zeroecho.core.context.EncryptionContext; import zeroecho.core.context.EncryptionContext;
import zeroecho.core.spec.VoidSpec; import zeroecho.core.spec.VoidSpec;
import zeroecho.core.spi.ContextAware; import zeroecho.core.spi.ContextAware;
import zeroecho.sdk.builders.alg.AesDataContentBuilder;
import zeroecho.sdk.util.RandomSupport; import zeroecho.sdk.util.RandomSupport;
class AesRandomSupportTest { class AesRandomSupportTest {
@@ -89,7 +100,113 @@ class AesRandomSupportTest {
assertArrayEquals(repeated((byte) 1, 12), firstIv); assertArrayEquals(repeated((byte) 1, 12), firstIv);
assertArrayEquals(repeated((byte) 2, 12), secondIv); assertArrayEquals(repeated((byte) 2, 12), secondIv);
assertNotSame(firstIv, 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<Boolean> 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<Boolean> first = executor.submit(attempt);
Future<Boolean> 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<byte[]> leftResult = executor.submit(() -> encryptEmpty(left));
Future<byte[]> 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<String> 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"); 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) { private static byte[] repeated(byte value, int length) {
byte[] result = new byte[length]; byte[] result = new byte[length];
java.util.Arrays.fill(result, value); java.util.Arrays.fill(result, value);
@@ -138,12 +267,16 @@ class AesRandomSupportTest {
private static final class CountingSecureRandom extends SecureRandom { private static final class CountingSecureRandom extends SecureRandom {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
private int calls; private final AtomicInteger calls = new AtomicInteger();
@Override @Override
public void nextBytes(byte[] bytes) { public void nextBytes(byte[] bytes) {
calls++; int current = calls.incrementAndGet();
java.util.Arrays.fill(bytes, (byte) calls); java.util.Arrays.fill(bytes, (byte) current);
}
private int calls() {
return calls.get();
} }
} }
} }

View File

@@ -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<Boolean> 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<Boolean> first = executor.submit(attempt);
Future<Boolean> 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<byte[]> leftResult = executor.submit(() -> encryptEmpty(left));
Future<byte[]> 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<String> 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();
}
}
}

View File

@@ -79,7 +79,7 @@ public class HybridDerivedTest {
AesDataContentBuilder encAes = AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withHeader().modeGcm(128); AesDataContentBuilder encAes = AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withHeader().modeGcm(128);
AesDataContentBuilder returnedEnc = HybridDerived.from(exporter).label("app/enc/aes").transcript(transcript) 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)); System.out.println("...returnedEncSame=" + (returnedEnc == encAes));
assertSame(encAes, returnedEnc); assertSame(encAes, returnedEnc);
@@ -90,8 +90,8 @@ public class HybridDerivedTest {
AesDataContentBuilder decAes = AesDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withHeader().modeGcm(128); 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, HybridDerived.from(exporter).label("app/enc/aes").transcript(transcript).aad(aad)
12); .applyToAesGcm(decAes, 256);
byte[] out = runDecrypt(decAes, ciphertext); byte[] out = runDecrypt(decAes, ciphertext);
System.out.println("...outPrefix=" + shortHex(out, 32)); 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); 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, HybridDerived.from(exporter).label("app/enc/aes").transcript(transcript).aad(aad)
12); .applyToAesGcm(encAes, 256);
byte[] ciphertext = runEncrypt(encAes, msg); byte[] ciphertext = runEncrypt(encAes, msg);
System.out.println("...ciphertextLen=" + ciphertext.length); System.out.println("...ciphertextLen=" + ciphertext.length);
@@ -121,7 +121,7 @@ public class HybridDerivedTest {
// ...label mismatch -> wrong key/iv/aad -> decryption must fail // ...label mismatch -> wrong key/iv/aad -> decryption must fail
HybridDerived.from(exporter).label("app/enc/aes_WRONG").transcript(transcript).aad(aad) 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)); assertThrows(Exception.class, () -> runDecrypt(decAesWrong, ciphertext));
@@ -140,7 +140,7 @@ public class HybridDerivedTest {
ChaChaDataContentBuilder encChaCha = ChaChaDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withHeader(); ChaChaDataContentBuilder encChaCha = ChaChaDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withHeader();
ChaChaDataContentBuilder returnedEnc = HybridDerived.from(exporter).label("app/enc/chacha") 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)); System.out.println("...returnedEncSame=" + (returnedEnc == encChaCha));
assertSame(encChaCha, returnedEnc); assertSame(encChaCha, returnedEnc);
@@ -152,7 +152,7 @@ public class HybridDerivedTest {
ChaChaDataContentBuilder decChaCha = ChaChaDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withHeader(); ChaChaDataContentBuilder decChaCha = ChaChaDataContentBuilder.builder(new zeroecho.sdk.ZeroEchoSession()).withHeader();
HybridDerived.from(exporter).label("app/enc/chacha").transcript(transcript).aad(aad) HybridDerived.from(exporter).label("app/enc/chacha").transcript(transcript).aad(aad)
.applyToChaChaAead(decChaCha, 256, 12); .applyToChaChaAead(decChaCha, 256);
byte[] out = runDecrypt(decChaCha, ciphertext); byte[] out = runDecrypt(decChaCha, ciphertext);
System.out.println("...outPrefix=" + shortHex(out, 32)); System.out.println("...outPrefix=" + shortHex(out, 32));

View File

@@ -204,12 +204,12 @@ class HybridDerivedAesDemoTest {
.transcript(transcript.toByteArray()) .transcript(transcript.toByteArray())
// ...Inject explicit AAD. // ...Inject explicit AAD.
.aad(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) .applyToAesGcm(AesDataContentBuilder.builder(session)
// ...Store IV in header for decrypt side. // ...Store IV in header for decrypt side.
.withHeader() .withHeader()
// ...Use AES-GCM with 128-bit authentication tag. // ...Use AES-GCM with 128-bit authentication tag.
.modeGcm(128), 256, 12)) .modeGcm(128), 256))
// ...Finalize pipeline. // ...Finalize pipeline.
.build(); .build();
@@ -223,7 +223,7 @@ class HybridDerivedAesDemoTest {
DataContent dec = DataContentChainBuilder.decrypt() DataContent dec = DataContentChainBuilder.decrypt()
// ...Input: ciphertext bytes. // ...Input: ciphertext bytes.
.add(PlainBytesBuilder.builder().bytes(ciphertext)) .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) .add(HybridDerived.from(exporter)
// ...Same purpose label as encryption. // ...Same purpose label as encryption.
.label("app/enc/aes-gcm") .label("app/enc/aes-gcm")
@@ -231,12 +231,12 @@ class HybridDerivedAesDemoTest {
.transcript(transcript.toByteArray()) .transcript(transcript.toByteArray())
// ...Same explicit AAD as encryption. // ...Same explicit AAD as encryption.
.aad(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) .applyToAesGcm(AesDataContentBuilder.builder(session)
// ...Parse IV from header. // ...Parse IV from header.
.withHeader() .withHeader()
// ...Use AES-GCM with 128-bit authentication tag. // ...Use AES-GCM with 128-bit authentication tag.
.modeGcm(128), 256, 12)) .modeGcm(128), 256))
// ...Finalize pipeline. // ...Finalize pipeline.
.build(); .build();
@@ -365,7 +365,7 @@ class HybridDerivedAesDemoTest {
// ...Use AES-GCM with 128-bit authentication tag. // ...Use AES-GCM with 128-bit authentication tag.
aesEnc.modeGcm(128); aesEnc.modeGcm(128);
// ...Inject derived key/IV/AAD into AES builder. // ...Inject the derived key and AAD into the AES builder.
HybridDerived.from(exporter) HybridDerived.from(exporter)
// ...Purpose separation label for AEAD. // ...Purpose separation label for AEAD.
.label("app/enc/aes-gcm") .label("app/enc/aes-gcm")
@@ -373,8 +373,8 @@ class HybridDerivedAesDemoTest {
.transcript(transcript.toByteArray()) .transcript(transcript.toByteArray())
// ...Inject explicit AAD. // ...Inject explicit AAD.
.aad(aad) .aad(aad)
// ...Apply derived key(256b) and IV(12B). // ...Apply the derived 256-bit key; encryption creates the IV.
.applyToAesGcm(aesEnc, 256, 12); .applyToAesGcm(aesEnc, 256);
// ...Build encryption pipeline. // ...Build encryption pipeline.
DataContent enc = DataContentChainBuilder.encrypt() DataContent enc = DataContentChainBuilder.encrypt()
@@ -398,7 +398,7 @@ class HybridDerivedAesDemoTest {
// ...Use AES-GCM with 128-bit authentication tag. // ...Use AES-GCM with 128-bit authentication tag.
aesDec.modeGcm(128); 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) HybridDerived.from(exporter)
// ...Same purpose label. // ...Same purpose label.
.label("app/enc/aes-gcm") .label("app/enc/aes-gcm")
@@ -406,8 +406,8 @@ class HybridDerivedAesDemoTest {
.transcript(transcript.toByteArray()) .transcript(transcript.toByteArray())
// ...Same explicit AAD. // ...Same explicit AAD.
.aad(aad) .aad(aad)
// ...Apply the same derived key and IV. // ...Apply the same derived key; the IV is read from the header.
.applyToAesGcm(aesDec, 256, 12); .applyToAesGcm(aesDec, 256);
// ...Build decryption pipeline. // ...Build decryption pipeline.
DataContent dec = DataContentChainBuilder.decrypt() DataContent dec = DataContentChainBuilder.decrypt()
@@ -512,12 +512,12 @@ class HybridDerivedAesDemoTest {
.transcript(transcript.toByteArray()) .transcript(transcript.toByteArray())
// ...Inject explicit AAD. // ...Inject explicit AAD.
.aad(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) .applyToAesGcm(AesDataContentBuilder.builder(session)
// ...Store IV in header for decrypt side. // ...Store IV in header for decrypt side.
.withHeader() .withHeader()
// ...Use AES-GCM with 128-bit authentication tag. // ...Use AES-GCM with 128-bit authentication tag.
.modeGcm(128), 256, 12)) .modeGcm(128), 256))
// ...Finalize pipeline. // ...Finalize pipeline.
.build(); .build();
@@ -572,7 +572,7 @@ class HybridDerivedAesDemoTest {
DataContent dec = DataContentChainBuilder.decrypt() DataContent dec = DataContentChainBuilder.decrypt()
// ...Input: ciphertext bytes. // ...Input: ciphertext bytes.
.add(PlainBytesBuilder.builder().bytes(ciphertext)) .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) .add(HybridDerived.from(exporterDec)
// ...Same purpose label as encryption. // ...Same purpose label as encryption.
.label("app/local/aes-gcm") .label("app/local/aes-gcm")
@@ -580,12 +580,12 @@ class HybridDerivedAesDemoTest {
.transcript(transcript.toByteArray()) .transcript(transcript.toByteArray())
// ...Same explicit AAD. // ...Same explicit AAD.
.aad(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) .applyToAesGcm(AesDataContentBuilder.builder(session)
// ...Parse IV from header. // ...Parse IV from header.
.withHeader() .withHeader()
// ...Use AES-GCM with 128-bit authentication tag. // ...Use AES-GCM with 128-bit authentication tag.
.modeGcm(128), 256, 12)) .modeGcm(128), 256))
// ...Finalize pipeline. // ...Finalize pipeline.
.build(); .build();