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

@@ -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);

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.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<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");
}
@@ -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();
}
}
}

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 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));