From d5945be60ec0cf2ccc5a80c39dffc84f3dcc9307 Mon Sep 17 00:00:00 2001 From: michael Date: Wed, 17 Jun 2026 16:14:53 -0700 Subject: [PATCH] refactor(applet): AliroGcm.setKeyAndIv consolidates re-keying (M2C.2) --- .../com/dangerousthings/aliro/AliroGcm.java | 64 +++++++++---------- .../dangerousthings/aliro/AliroGcmTest.java | 59 +++++++++++++++++ 2 files changed, 90 insertions(+), 33 deletions(-) diff --git a/applet/src/main/java/com/dangerousthings/aliro/AliroGcm.java b/applet/src/main/java/com/dangerousthings/aliro/AliroGcm.java index a2be448..7eeb3c8 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/AliroGcm.java +++ b/applet/src/main/java/com/dangerousthings/aliro/AliroGcm.java @@ -144,22 +144,7 @@ final class AliroGcm { byte[] pt, short ptOff, short ptLen, byte[] out, short outOff) { - aesKey.setKey(key, keyOff); - aesEcb.init(aesKey, Cipher.MODE_ENCRYPT); - - // H = AES_K(0^128). Zero scratch[OFF_H..OFF_H+16) and encrypt in place. - Util.arrayFillNonAtomic(scratch, OFF_H, BLOCK_LEN, (byte) 0); - aesEcb.doFinal(scratch, OFF_H, BLOCK_LEN, scratch, OFF_H); - // Build the 4-bit GHASH M-table from the fresh H. One-time cost per - // encrypt (~700 JC bytecodes), amortized over the ~10 gfMul4Bit calls. - buildMTable(); - - // J0 = IV || 0x00000001 (12B IV + 4B counter). 96-bit IV canonical case. - Util.arrayCopyNonAtomic(iv, ivOff, scratch, OFF_J0, IV_LEN); - scratch[(short) (OFF_J0 + 12)] = 0x00; - scratch[(short) (OFF_J0 + 13)] = 0x00; - scratch[(short) (OFF_J0 + 14)] = 0x00; - scratch[(short) (OFF_J0 + 15)] = 0x01; + setKeyAndIv(key, keyOff, iv, ivOff); // cb = INC32(J0) — first counter block for the plaintext stream. Util.arrayCopyNonAtomic(scratch, OFF_J0, scratch, OFF_CB, BLOCK_LEN); @@ -305,23 +290,7 @@ final class AliroGcm { short ctLen = (short) (inLen - TAG_LEN); short tagOff = (short) (inOff + ctLen); - aesKey.setKey(key, keyOff); - aesEcb.init(aesKey, Cipher.MODE_ENCRYPT); - - // H = AES_K(0^128). Same as encrypt() -- GCM is one-direction at the - // primitive level: encrypt and decrypt both run GCTR + GHASH and - // differ only in whether GHASH consumes provided ciphertext or - // freshly-emitted ciphertext, plus the tag compare/emit step. - Util.arrayFillNonAtomic(scratch, OFF_H, BLOCK_LEN, (byte) 0); - aesEcb.doFinal(scratch, OFF_H, BLOCK_LEN, scratch, OFF_H); - buildMTable(); - - // J0 = IV || 0x00000001 (96-bit IV canonical case). - Util.arrayCopyNonAtomic(iv, ivOff, scratch, OFF_J0, IV_LEN); - scratch[(short) (OFF_J0 + 12)] = 0x00; - scratch[(short) (OFF_J0 + 13)] = 0x00; - scratch[(short) (OFF_J0 + 14)] = 0x00; - scratch[(short) (OFF_J0 + 15)] = 0x01; + setKeyAndIv(key, keyOff, iv, ivOff); // GHASH over the PROVIDED ciphertext first (so tag verify doesn't // depend on a successful decrypt). AAD is empty. @@ -412,6 +381,35 @@ final class AliroGcm { return ctLen; } + /** + * Per-(key, IV) GCM setup, shared by {@link #encrypt} and {@link #decrypt}: + * loads the AES-256 key, derives H = AES_K(0^128), rebuilds the 4-bit + * GHASH M-table, and lays down J0 = IV || 0x00000001 at OFF_J0. M2D.3's + * stream-encrypt path calls {@link #encrypt} twice per Step-Up session + * with a different (key, IV) each time; consolidating the rekey path + * keeps that contract pinned to one method. + */ + private void setKeyAndIv( + byte[] key, short keyOff, + byte[] iv, short ivOff) { + aesKey.setKey(key, keyOff); + aesEcb.init(aesKey, Cipher.MODE_ENCRYPT); + + // H = AES_K(0^128). Zero scratch[OFF_H..OFF_H+16) and encrypt in place. + Util.arrayFillNonAtomic(scratch, OFF_H, BLOCK_LEN, (byte) 0); + aesEcb.doFinal(scratch, OFF_H, BLOCK_LEN, scratch, OFF_H); + // Build the 4-bit GHASH M-table from the fresh H. One-time cost per + // encrypt (~700 JC bytecodes), amortized over the ~10 gfMul4Bit calls. + buildMTable(); + + // J0 = IV || 0x00000001 (12B IV + 4B counter). 96-bit IV canonical case. + Util.arrayCopyNonAtomic(iv, ivOff, scratch, OFF_J0, IV_LEN); + scratch[(short) (OFF_J0 + 12)] = 0x00; + scratch[(short) (OFF_J0 + 13)] = 0x00; + scratch[(short) (OFF_J0 + 14)] = 0x00; + scratch[(short) (OFF_J0 + 15)] = 0x01; + } + /** * INC32 per NIST SP 800-38D §6.2: increments the last 4 bytes of the * 16-byte block, big-endian, modulo 2^32. diff --git a/applet/src/test/java/com/dangerousthings/aliro/AliroGcmTest.java b/applet/src/test/java/com/dangerousthings/aliro/AliroGcmTest.java index 4a77ad2..4e97449 100644 --- a/applet/src/test/java/com/dangerousthings/aliro/AliroGcmTest.java +++ b/applet/src/test/java/com/dangerousthings/aliro/AliroGcmTest.java @@ -191,6 +191,65 @@ class AliroGcmTest { "different keys must produce different tags"); } + /** + * Rekey-across-encrypts: M2D.3's stream-encrypt path calls + * {@link AliroGcm#encrypt} twice in a single session (once for the + * EXCHANGE Reader Status ack, once for the ENVELOPE DeviceResponse) with + * a different (key, IV) pair each time. This test pins that no leftover + * state from the first call (cached H, M-table, AES key slot) corrupts + * the second. M2C.2 extracts the rekey path into {@code setKeyAndIv}; + * this test is the regression spec for that refactor. + */ + @Test + void setKeyAndIvAllowsRekeyAcrossMultipleEncrypts() { + AliroGcm gcm = new AliroGcm(); + + // Encrypt block A under key1 + iv1 + byte[] keyA = makeKey((byte) 0xA0); + byte[] ivA = makeIv((byte) 0xAA); + byte[] ptA = bytes("hello A"); + byte[] outA = new byte[ptA.length + 16]; + gcm.encrypt(keyA, (short) 0, ivA, (short) 0, + ptA, (short) 0, (short) ptA.length, outA, (short) 0); + + // Rekey + encrypt block B under key2 + iv2 + byte[] keyB = makeKey((byte) 0xB0); + byte[] ivB = makeIv((byte) 0xBB); + byte[] ptB = bytes("hello B"); + byte[] outB = new byte[ptB.length + 16]; + gcm.encrypt(keyB, (short) 0, ivB, (short) 0, + ptB, (short) 0, (short) ptB.length, outB, (short) 0); + + // Both must round-trip cleanly through decrypt under their own key+iv + byte[] ptAroundTrip = new byte[ptA.length]; + gcm.decrypt(keyA, (short) 0, ivA, (short) 0, + outA, (short) 0, (short) outA.length, ptAroundTrip, (short) 0); + assertArrayEquals(ptA, ptAroundTrip, + "block A must decrypt under (keyA, ivA) after gcm has been re-keyed to B"); + + byte[] ptBroundTrip = new byte[ptB.length]; + gcm.decrypt(keyB, (short) 0, ivB, (short) 0, + outB, (short) 0, (short) outB.length, ptBroundTrip, (short) 0); + assertArrayEquals(ptB, ptBroundTrip, + "block B must decrypt under (keyB, ivB) — no leftover state from A"); + } + + private static byte[] makeKey(byte seed) { + byte[] k = new byte[32]; + for (int i = 0; i < 32; i++) k[i] = (byte) (seed + i); + return k; + } + + private static byte[] makeIv(byte seed) { + byte[] iv = new byte[12]; + for (int i = 0; i < 12; i++) iv[i] = (byte) (seed + i); + return iv; + } + + private static byte[] bytes(String s) { + return s.getBytes(java.nio.charset.StandardCharsets.UTF_8); + } + @Test void differentIvsProduceDifferentCiphertexts() { byte[] key = new byte[32];