Initial snapshot: Aliro applet, harness, Nucleo NFC10A1 reader port
Three components, all bench-validated to varying depths: - applet/: CSA Aliro v1.0 Java Card applet for J3R180. AUTH0 + AUTH1 expedited-standard flow end-to-end green via PC/SC bench reader (aliro-bench-test). Userland AES-256-GCM and HMAC-SHA-256 layered on top of J3R180's primitives because the card lacks both natively. P-256 curve params seeded explicitly per J3R180's quirk. - harness/: Python orchestrator (aliro-trustgen, aliro-personalize, aliro-bench-test) for trust-bundle generation, card personalization via PersonalizationApplet, and PC/SC AUTH0+AUTH1 transactions. 126 pytest cases passing. - reader/STM32CubeExpansion_ALIRO_V1_0_0/: ST X-CUBE-ALIRO V1.0.0 with our NFC10A1 port (NUCLEO-U545RE-Q + X-NUCLEO-NFC10A1, ST25R200 shared with NFC09A1). nfc10-only/ project, NFC10A1 BSP shim, ALIRO_TRUST_OVERRIDE include into vendor's provisioning.c, and an ALIRO_APDU_TRACE wrapper around demoTransceiveBlocking. Boots, detects the J3R180, completes SELECT + AUTH0; AUTH1 currently fails with RFAL ERR_PROTO (0xB) — under investigation, see docs/plans/2026-04-20-nucleo-nfc10a1-port.md and bench-notes/. Excluded: x-cube-aliro.zip vendor archive, harness/.venv, build dirs, generated aliro_trust.h (contains private reader scalar), all PEMs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
286
applet/src/main/java/com/dangerousthings/aliro/AliroGcm.java
Normal file
286
applet/src/main/java/com/dangerousthings/aliro/AliroGcm.java
Normal file
@@ -0,0 +1,286 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
import javacard.framework.JCSystem;
|
||||
import javacard.framework.Util;
|
||||
import javacard.security.AESKey;
|
||||
import javacard.security.KeyBuilder;
|
||||
import javacardx.crypto.Cipher;
|
||||
|
||||
/**
|
||||
* Userland AES-256-GCM, built on top of the JC 3.0.5 single-block AES-ECB
|
||||
* primitive. Needed because the target card (NXP J3R180) does not expose
|
||||
* {@code AEADCipher.ALG_AES_GCM} despite advertising JC 3.0.5 support, while
|
||||
* {@code Cipher.ALG_AES_BLOCK_128_ECB_NOPAD} and AES-256 keys are both
|
||||
* present and functional.
|
||||
*
|
||||
* <p>Tailored to the Aliro AUTH1 response use case (spec §8.3.1.6):
|
||||
* AES-256 with a 12-byte IV, empty AAD, 16-byte tag, and plaintext on the
|
||||
* order of 137 bytes. The NIST SP 800-38D canonical 96-bit-IV shortcut is
|
||||
* used (IV is taken as {@code IV || 0x00000001} for J<sub>0</sub>, no IV
|
||||
* hashing). This is the only IV length we ever produce — see
|
||||
* {@link AliroApplet#encryptResponseGcm}.
|
||||
*
|
||||
* <p>GHASH follows NIST SP 800-38D §6.4's bit-reflected convention:
|
||||
* byte 0 bit 7 (MSB) is the highest-degree polynomial coefficient; the
|
||||
* reduction polynomial R = x^128 + x^7 + x^2 + x + 1 is represented as the
|
||||
* 16-byte array {0xE1, 0x00, ..., 0x00}; the GHASH shift-right shifts toward
|
||||
* higher byte indices (lower polynomial degrees).
|
||||
*
|
||||
* <p>Not constant-time — the GF(2^128) multiply branches on bits of the
|
||||
* authentication tag partial state. On a secure JC platform this is
|
||||
* acceptable (the key material is smartcard-protected), but callers should
|
||||
* not pass attacker-chosen GHASH state.
|
||||
*/
|
||||
final class AliroGcm {
|
||||
|
||||
private static final short BLOCK_LEN = 16;
|
||||
private static final short TAG_LEN = 16;
|
||||
private static final short IV_LEN = 12; // 96-bit IV shortcut (NIST SP 800-38D §7.1)
|
||||
private static final short AES_256_KEY_BYTES = 32;
|
||||
|
||||
// Scratch layout — all 16-byte slots, single transient allocation.
|
||||
private static final short OFF_H = 0; // hash subkey = AES_K(0^128)
|
||||
private static final short OFF_J0 = 16; // pre-counter block
|
||||
private static final short OFF_CB = 32; // running counter block
|
||||
private static final short OFF_GHASH = 48; // accumulated GHASH state
|
||||
private static final short OFF_ECB_OUT = 64; // AES keystream block (also used for J0 encryption)
|
||||
private static final short OFF_TMP = 80; // misc: GF multiply workspace (V)
|
||||
private static final short OFF_LEN_BLK = 96; // length block for GHASH finalization
|
||||
private static final short SCRATCH_LEN = 112;
|
||||
|
||||
/** Persistent ECB cipher (AES-128/192/256 single-block-NOPAD). AES-256
|
||||
* is driven by the size of the {@link AESKey} passed to {@link Cipher#init}. */
|
||||
private final Cipher aesEcb;
|
||||
|
||||
/** Reusable 256-bit AES key slot. {@link AESKey#setKey} is called per encrypt. */
|
||||
private final AESKey aesKey;
|
||||
|
||||
/** Transient scratch — see OFF_* constants above. */
|
||||
private final byte[] scratch;
|
||||
|
||||
AliroGcm() {
|
||||
aesEcb = Cipher.getInstance(Cipher.ALG_AES_BLOCK_128_ECB_NOPAD, false);
|
||||
aesKey = (AESKey) KeyBuilder.buildKey(
|
||||
KeyBuilder.TYPE_AES, KeyBuilder.LENGTH_AES_256, false);
|
||||
scratch = JCSystem.makeTransientByteArray(SCRATCH_LEN, JCSystem.CLEAR_ON_DESELECT);
|
||||
}
|
||||
|
||||
/**
|
||||
* AES-256-GCM encrypt with the Aliro parameters: 32-byte key, 12-byte IV,
|
||||
* empty AAD, 16-byte tag appended. Writes {@code ptLen} ciphertext bytes
|
||||
* followed by 16 tag bytes to {@code out[outOff..]} and returns
|
||||
* {@code ptLen + 16}.
|
||||
*
|
||||
* <p>The {@code out} buffer may overlap {@code pt} (same buffer, same
|
||||
* offset is supported — we encrypt block-by-block left-to-right, the
|
||||
* GHASH pass reads from {@code out}, and the tag lands past the end of
|
||||
* the ciphertext).
|
||||
*
|
||||
* @param key 32-byte AES-256 key, at {@code key[keyOff..keyOff+32)}
|
||||
* @param iv 12-byte IV, at {@code iv[ivOff..ivOff+12)}
|
||||
* @param pt plaintext bytes
|
||||
* @param ptOff, ptLen plaintext region
|
||||
* @param out output buffer, must have at least {@code ptLen + 16} bytes
|
||||
* available at {@code outOff}
|
||||
* @param outOff output start offset
|
||||
* @return {@code ptLen + 16}
|
||||
*/
|
||||
short encrypt(
|
||||
byte[] key, short keyOff,
|
||||
byte[] iv, short ivOff,
|
||||
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);
|
||||
|
||||
// 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;
|
||||
|
||||
// cb = INC32(J0) — first counter block for the plaintext stream.
|
||||
Util.arrayCopyNonAtomic(scratch, OFF_J0, scratch, OFF_CB, BLOCK_LEN);
|
||||
inc32(scratch, OFF_CB);
|
||||
|
||||
// GCTR loop: stream plaintext through AES-CTR, writing ciphertext to out.
|
||||
short produced = 0;
|
||||
while (produced < ptLen) {
|
||||
short blockLen = (short) (ptLen - produced);
|
||||
if (blockLen > BLOCK_LEN) blockLen = BLOCK_LEN;
|
||||
|
||||
// keystream = AES_K(cb)
|
||||
aesEcb.doFinal(scratch, OFF_CB, BLOCK_LEN, scratch, OFF_ECB_OUT);
|
||||
|
||||
// ct[i] = pt[i] XOR keystream[i] — partial-block safe.
|
||||
for (short i = 0; i < blockLen; i++) {
|
||||
out[(short) (outOff + produced + i)] =
|
||||
(byte) (pt[(short) (ptOff + produced + i)]
|
||||
^ scratch[(short) (OFF_ECB_OUT + i)]);
|
||||
}
|
||||
|
||||
inc32(scratch, OFF_CB);
|
||||
produced += blockLen;
|
||||
}
|
||||
|
||||
// GHASH over (AAD=empty || ciphertext || len_block).
|
||||
// state = 0^128
|
||||
Util.arrayFillNonAtomic(scratch, OFF_GHASH, BLOCK_LEN, (byte) 0);
|
||||
|
||||
// Fold ciphertext blocks into GHASH, zero-padding the final partial block.
|
||||
short consumed = 0;
|
||||
while (consumed < ptLen) {
|
||||
short chunk = (short) (ptLen - consumed);
|
||||
if (chunk >= BLOCK_LEN) {
|
||||
// XOR full 16-byte block directly from the output buffer.
|
||||
for (short i = 0; i < BLOCK_LEN; i++) {
|
||||
scratch[(short) (OFF_GHASH + i)] ^= out[(short) (outOff + consumed + i)];
|
||||
}
|
||||
consumed += BLOCK_LEN;
|
||||
} else {
|
||||
// Partial block: XOR the available bytes, implicit zero-pad for the rest.
|
||||
for (short i = 0; i < chunk; i++) {
|
||||
scratch[(short) (OFF_GHASH + i)] ^= out[(short) (outOff + consumed + i)];
|
||||
}
|
||||
consumed += chunk;
|
||||
}
|
||||
gfMul(scratch, OFF_GHASH, scratch, OFF_H);
|
||||
}
|
||||
|
||||
// len_block = (8*|AAD|)^64 || (8*|C|)^64, big-endian. AAD is always empty
|
||||
// for this use case; ptLen fits in 16 bits, so only the low 2 bytes of
|
||||
// the 8-byte plaintext-bit-length field can be non-zero.
|
||||
Util.arrayFillNonAtomic(scratch, OFF_LEN_BLK, BLOCK_LEN, (byte) 0);
|
||||
// ptLen·8 in bits, big-endian, into the last 4 bytes of the 16-byte
|
||||
// length block (bits 0-95 are zero — AAD-bits=0 + high bytes of CT-bits).
|
||||
// Java Card 3.0.5 forbids `int` literals, so we compute as `short` and
|
||||
// split the byte-shift into two stages to dodge the implicit `int`
|
||||
// promotion that the converter rejects ("unsupported int type constant").
|
||||
// ptLen ≤ 8191 ⇒ ptLen·8 ≤ 65528, fits in unsigned 16 bits. The top two
|
||||
// bytes of the length field stay zero.
|
||||
short ctBitsLo = (short) (ptLen << 3); // low 16 bits of bit-count
|
||||
short ctBitsHi = (short) (((short)(ptLen >>> 13)) & 0x07); // bits 16-18 of bit-count
|
||||
scratch[(short) (OFF_LEN_BLK + 13)] = (byte) (ctBitsHi & 0xFF);
|
||||
scratch[(short) (OFF_LEN_BLK + 14)] = (byte) ((ctBitsLo >>> 8) & 0xFF);
|
||||
scratch[(short) (OFF_LEN_BLK + 15)] = (byte) (ctBitsLo & 0xFF);
|
||||
|
||||
for (short i = 0; i < BLOCK_LEN; i++) {
|
||||
scratch[(short) (OFF_GHASH + i)] ^= scratch[(short) (OFF_LEN_BLK + i)];
|
||||
}
|
||||
gfMul(scratch, OFF_GHASH, scratch, OFF_H);
|
||||
|
||||
// T = AES_K(J0) XOR GHASH
|
||||
aesEcb.doFinal(scratch, OFF_J0, BLOCK_LEN, scratch, OFF_ECB_OUT);
|
||||
short tagOut = (short) (outOff + ptLen);
|
||||
for (short i = 0; i < TAG_LEN; i++) {
|
||||
out[(short) (tagOut + i)] =
|
||||
(byte) (scratch[(short) (OFF_ECB_OUT + i)]
|
||||
^ scratch[(short) (OFF_GHASH + i)]);
|
||||
}
|
||||
|
||||
// Wipe scratch: H, J0 (with IV bytes), GHASH state, and the keystream
|
||||
// remnant in OFF_ECB_OUT all touched the AES key directly. CLEAR_ON_DESELECT
|
||||
// would clean up at deselect, but multi-APDU sessions could otherwise
|
||||
// see stale H/keystream until then.
|
||||
Util.arrayFillNonAtomic(scratch, (short) 0, SCRATCH_LEN, (byte) 0);
|
||||
|
||||
return (short) (ptLen + TAG_LEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* INC32 per NIST SP 800-38D §6.2: increments the last 4 bytes of the
|
||||
* 16-byte block, big-endian, modulo 2^32.
|
||||
*/
|
||||
private static void inc32(byte[] buf, short off) {
|
||||
short i = (short) (off + 15);
|
||||
// Carry propagation across 4 bytes.
|
||||
if ((byte) (++buf[i]) != (byte) 0) return;
|
||||
i--;
|
||||
if ((byte) (++buf[i]) != (byte) 0) return;
|
||||
i--;
|
||||
if ((byte) (++buf[i]) != (byte) 0) return;
|
||||
i--;
|
||||
buf[i]++;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-place GF(2^128) multiplication per NIST SP 800-38D §6.3, storing
|
||||
* X ·_GF Y into X. Uses the bit-reflected convention:
|
||||
* <ul>
|
||||
* <li>byte 0 MSB = x^127 (highest-degree polynomial coefficient)
|
||||
* <li>byte 15 LSB = x^0
|
||||
* <li>reduction polynomial R = x^128 + x^7 + x^2 + x + 1 represented
|
||||
* as {0xE1, 0x00, ..., 0x00}
|
||||
* <li>"shift right" means shift toward higher byte indices
|
||||
* </ul>
|
||||
*
|
||||
* <p>Algorithm 1 from NIST SP 800-38D:
|
||||
* <pre>
|
||||
* Z = 0; V = Y
|
||||
* for i = 0..127:
|
||||
* if bit i of X is 1: Z ^= V
|
||||
* if lowest bit of V (byte 15 bit 0) is 1: V = (V >> 1) ^ R
|
||||
* else: V = V >> 1
|
||||
* return Z
|
||||
* </pre>
|
||||
*
|
||||
* <p>This is the simple, portable NIST shift-XOR form. A 4-bit-table
|
||||
* variant would be ~4x faster but adds 32 bytes of per-H precomputation
|
||||
* and a more intricate shift; we encrypt ~137B per AUTH1, so the extra
|
||||
* cycles are acceptable versus the complexity cost.
|
||||
*/
|
||||
private void gfMul(byte[] xBuf, short xOff, byte[] yBuf, short yOff) {
|
||||
// V = Y (copied into OFF_TMP scratch region; gets shifted in place)
|
||||
Util.arrayCopyNonAtomic(yBuf, yOff, scratch, OFF_TMP, BLOCK_LEN);
|
||||
|
||||
// Z = 0 (we'll accumulate in a second 16-byte region — reuse OFF_LEN_BLK,
|
||||
// which is free until after the main GHASH loop finishes). gfMul is
|
||||
// called both during GHASH accumulation AND during final-length mixing;
|
||||
// at final-length mixing OFF_LEN_BLK has already been consumed, so it's
|
||||
// safe to overwrite. But to avoid an aliasing bug if OFF_LEN_BLK is
|
||||
// ever reused differently, we stage Z in OFF_ECB_OUT instead.
|
||||
//
|
||||
// Wait — OFF_ECB_OUT holds the AES keystream we need later for the
|
||||
// tag (T = AES_K(J0) XOR GHASH). However, gfMul is never called
|
||||
// between producing OFF_ECB_OUT (the AES_K(J0) output) and consuming
|
||||
// it for the tag XOR. The last gfMul is for (state ^ len_block) ·_GF H,
|
||||
// and only AFTER that do we compute AES_K(J0) into OFF_ECB_OUT. So
|
||||
// using OFF_ECB_OUT as the Z accumulator is safe.
|
||||
Util.arrayFillNonAtomic(scratch, OFF_ECB_OUT, BLOCK_LEN, (byte) 0);
|
||||
|
||||
for (short byteIdx = 0; byteIdx < BLOCK_LEN; byteIdx++) {
|
||||
byte xByte = xBuf[(short) (xOff + byteIdx)];
|
||||
for (short bit = 7; bit >= 0; bit--) {
|
||||
// bit of X at polynomial position (byteIdx*8 + (7-bit)): byte-high bit first.
|
||||
if (((xByte >> bit) & 0x01) != 0) {
|
||||
for (short k = 0; k < BLOCK_LEN; k++) {
|
||||
scratch[(short) (OFF_ECB_OUT + k)] ^= scratch[(short) (OFF_TMP + k)];
|
||||
}
|
||||
}
|
||||
// V = V >> 1; if the bit that fell off (byte 15 low bit BEFORE shift) is 1,
|
||||
// also XOR the reduction polynomial {0xE1, 0x00, ..., 0x00}.
|
||||
boolean lsb = (scratch[(short) (OFF_TMP + 15)] & 0x01) != 0;
|
||||
// Shift V right by 1 bit (toward higher byte indices).
|
||||
short carry = 0;
|
||||
for (short k = 0; k < BLOCK_LEN; k++) {
|
||||
short cur = (short) (scratch[(short) (OFF_TMP + k)] & 0xFF);
|
||||
short newCur = (short) ((cur >>> 1) | carry);
|
||||
carry = (short) ((cur & 0x01) << 7);
|
||||
scratch[(short) (OFF_TMP + k)] = (byte) newCur;
|
||||
}
|
||||
if (lsb) {
|
||||
scratch[OFF_TMP] ^= (byte) 0xE1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy Z back into X in place.
|
||||
Util.arrayCopyNonAtomic(scratch, OFF_ECB_OUT, xBuf, xOff, BLOCK_LEN);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user