From 62ceef89e9c6738d6527b7e3d066792ec0bf3c93 Mon Sep 17 00:00:00 2001 From: michael Date: Wed, 17 Jun 2026 21:22:46 -0700 Subject: [PATCH] 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. --- .../dangerousthings/aliro/AliroGcmTest.java | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/applet/src/test/java/com/dangerousthings/aliro/AliroGcmTest.java b/applet/src/test/java/com/dangerousthings/aliro/AliroGcmTest.java index 4e97449..717e4df 100644 --- a/applet/src/test/java/com/dangerousthings/aliro/AliroGcmTest.java +++ b/applet/src/test/java/com/dangerousthings/aliro/AliroGcmTest.java @@ -1,6 +1,7 @@ package com.dangerousthings.aliro; import javacard.security.AESKey; +import javacard.security.CryptoException; import javacard.security.KeyBuilder; import javacardx.crypto.AEADCipher; 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.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; /** * 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)"); } + // ---- 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) { s = s.replaceAll("\\s+", ""); byte[] out = new byte[s.length() / 2];