test(applet): adversarial GCM regression tests (post-M2 security pass)

Pin five negative-space properties of AliroGcm.decrypt that the NIST KAT
suite doesn't exercise. Catches the implementation flaws most likely to
slip into a userland AEAD impl:

- decrypt_tamperedCiphertextByte: single bit flip in ct rejects + out[]
  unchanged (proves tag verify runs BEFORE GCTR-decrypt)
- decrypt_tamperedTagByte: single bit flip in tag rejects + out[]
  unchanged (proves no early-exit / no conditional plaintext leak)
- decrypt_wrongKey: single-bit key difference rejects (key confusion)
- decrypt_wrongIv: single-bit IV difference rejects (replay-with-rebind)
- decrypt_inputShorterThanTag: <16 B input throws ILLEGAL_VALUE
  without touching out[] (early bounds check)

13/13 AliroGcmTest tests pass (was 8 + 5 new). Full suite 152/0/0.
This commit is contained in:
michael
2026-06-17 21:22:46 -07:00
parent c85ecd2f73
commit 62ceef89e9

View File

@@ -1,6 +1,7 @@
package com.dangerousthings.aliro; package com.dangerousthings.aliro;
import javacard.security.AESKey; import javacard.security.AESKey;
import javacard.security.CryptoException;
import javacard.security.KeyBuilder; import javacard.security.KeyBuilder;
import javacardx.crypto.AEADCipher; import javacardx.crypto.AEADCipher;
import javacardx.crypto.Cipher; import javacardx.crypto.Cipher;
@@ -9,6 +10,8 @@ import org.junit.jupiter.api.Test;
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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** /**
* Tests for {@link AliroGcm} — our userland AES-256-GCM, built on the JC * Tests for {@link AliroGcm} — our userland AES-256-GCM, built on the JC
@@ -277,6 +280,148 @@ class AliroGcmTest {
"different IVs must produce different ciphertexts (CTR stream differs)"); "different IVs must produce different ciphertexts (CTR stream differs)");
} }
// ---- Adversarial regression tests (post-M2 security pass) ---------------
//
// These pin the documented defensive properties of AliroGcm.decrypt:
// 1. Any single-byte mutation in the ciphertext rejects (forgery).
// 2. Any single-byte mutation in the tag rejects (forgery).
// 3. Wrong key under same (IV, ct, tag) rejects (key confusion).
// 4. Wrong IV under same (key, ct, tag) rejects (replay-with-rebind).
// 5. Truncated input (< TAG_LEN) rejects without touching the output.
//
// None of these are NIST KAT cases; they are negative-space coverage for
// implementation flaws like early-exit tag compare, conditional writes
// before tag verify, or missing bounds checks.
/** Encrypt a fixed plaintext under fixed (key, IV) and return ct||tag. */
private static byte[] freshCiphertext(byte[] key, byte[] iv, byte[] pt) {
byte[] out = new byte[pt.length + 16];
AliroGcm gcm = new AliroGcm();
gcm.encrypt(key, (short) 0, iv, (short) 0,
pt, (short) 0, (short) pt.length,
out, (short) 0);
return out;
}
@Test
void decrypt_tamperedCiphertextByte_throwsAndOutputUntouched() {
byte[] key = new byte[32];
for (int i = 0; i < 32; i++) key[i] = (byte) (0x10 + i);
byte[] iv = new byte[12];
for (int i = 0; i < 12; i++) iv[i] = (byte) (0xA0 + i);
byte[] pt = hex("00112233445566778899aabbccddeeff" + "0102030405");
byte[] ctTag = freshCiphertext(key, iv, pt);
// Flip one bit in the ciphertext (NOT the tag).
ctTag[7] ^= (byte) 0x01;
byte[] outSentinel = new byte[pt.length];
java.util.Arrays.fill(outSentinel, (byte) 0xCC);
AliroGcm gcm = new AliroGcm();
assertThrows(CryptoException.class, () -> gcm.decrypt(
key, (short) 0, iv, (short) 0,
ctTag, (short) 0, (short) ctTag.length,
outSentinel, (short) 0));
// Tag verify happens BEFORE GCTR-decrypt into out[]; output buffer
// must be byte-identical to what we filled before the call.
for (int i = 0; i < pt.length; i++) {
assertEquals((byte) 0xCC, outSentinel[i],
"tampered-ct decrypt must not write plaintext to out[" + i + "]");
}
}
@Test
void decrypt_tamperedTagByte_throwsAndOutputUntouched() {
byte[] key = new byte[32];
for (int i = 0; i < 32; i++) key[i] = (byte) (0x20 + i);
byte[] iv = new byte[12];
for (int i = 0; i < 12; i++) iv[i] = (byte) (0xB0 + i);
byte[] pt = hex("deadbeefcafe1234");
byte[] ctTag = freshCiphertext(key, iv, pt);
// Flip one bit in the LAST byte of the 16-byte tag.
ctTag[ctTag.length - 1] ^= (byte) 0x80;
byte[] outSentinel = new byte[pt.length];
java.util.Arrays.fill(outSentinel, (byte) 0xDD);
AliroGcm gcm = new AliroGcm();
assertThrows(CryptoException.class, () -> gcm.decrypt(
key, (short) 0, iv, (short) 0,
ctTag, (short) 0, (short) ctTag.length,
outSentinel, (short) 0));
for (int i = 0; i < pt.length; i++) {
assertEquals((byte) 0xDD, outSentinel[i],
"tampered-tag decrypt must not write plaintext to out[" + i + "]");
}
}
@Test
void decrypt_wrongKey_throws() {
byte[] key = new byte[32];
for (int i = 0; i < 32; i++) key[i] = (byte) (0x30 + i);
byte[] iv = new byte[12];
for (int i = 0; i < 12; i++) iv[i] = (byte) (0xC0 + i);
byte[] pt = hex("11223344");
byte[] ctTag = freshCiphertext(key, iv, pt);
byte[] wrongKey = key.clone();
wrongKey[5] ^= (byte) 0x01; // single-bit difference
byte[] out = new byte[pt.length];
AliroGcm gcm = new AliroGcm();
assertThrows(CryptoException.class, () -> gcm.decrypt(
wrongKey, (short) 0, iv, (short) 0,
ctTag, (short) 0, (short) ctTag.length,
out, (short) 0));
}
@Test
void decrypt_wrongIv_throws() {
byte[] key = new byte[32];
for (int i = 0; i < 32; i++) key[i] = (byte) (0x40 + i);
byte[] iv = new byte[12];
for (int i = 0; i < 12; i++) iv[i] = (byte) (0xD0 + i);
byte[] pt = hex("55667788");
byte[] ctTag = freshCiphertext(key, iv, pt);
byte[] wrongIv = iv.clone();
wrongIv[3] ^= (byte) 0x01;
byte[] out = new byte[pt.length];
AliroGcm gcm = new AliroGcm();
assertThrows(CryptoException.class, () -> gcm.decrypt(
key, (short) 0, wrongIv, (short) 0,
ctTag, (short) 0, (short) ctTag.length,
out, (short) 0));
}
@Test
void decrypt_inputShorterThanTag_throwsIllegalValueWithoutOutput() {
byte[] key = new byte[32];
byte[] iv = new byte[12];
// 15 bytes — one short of the minimum (16-byte tag with empty pt).
byte[] tooShort = new byte[15];
byte[] outSentinel = new byte[1];
outSentinel[0] = (byte) 0xEE;
AliroGcm gcm = new AliroGcm();
CryptoException ex = assertThrows(CryptoException.class, () -> gcm.decrypt(
key, (short) 0, iv, (short) 0,
tooShort, (short) 0, (short) tooShort.length,
outSentinel, (short) 0));
assertEquals(CryptoException.ILLEGAL_VALUE, ex.getReason(),
"input < TAG_LEN must surface CryptoException.ILLEGAL_VALUE");
assertEquals((byte) 0xEE, outSentinel[0],
"early reject must not touch the output buffer");
}
private static byte[] hex(String s) { private static byte[] hex(String s) {
s = s.replaceAll("\\s+", ""); s = s.replaceAll("\\s+", "");
byte[] out = new byte[s.length() / 2]; byte[] out = new byte[s.length() / 2];