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:
2026-05-02 10:17:46 -07:00
commit 782074f6ae
8786 changed files with 2902373 additions and 0 deletions

View 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 &sect;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 &sect;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);
}
}