diff --git a/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java b/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java index 87412df..0d686af 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java +++ b/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java @@ -33,7 +33,6 @@ public class AliroApplet extends Applet { private static final byte CLA_EXPEDITED = (byte) 0x80; private static final byte INS_AUTH0 = (byte) 0x80; private static final byte INS_AUTH1 = (byte) 0x81; - private static final byte INS_EXCHANGE = (byte) 0xC9; // §8.3.3.5 / Table 8-14 // Diagnostic INSes (CLA=0x80) for profiling AUTH1 sub-operations. // DEV / PERFORMANCE-DEBUG ONLY. Set DIAGNOSTICS_ENABLED = false for any @@ -134,16 +133,17 @@ public class AliroApplet extends Applet { /** SHA-1 for key_slot = first 8 bytes of SHA-1(uncompressed credential_PubK). */ private MessageDigest sha1; - /** Userland AES-256-GCM (built on AES-ECB-NOPAD + AES-256 key). The - * target card (NXP J3R180) does not expose {@code AEADCipher.ALG_AES_GCM} - * despite advertising JC 3.0.5, so we implement GCM in userland. - * {@link AliroGcm} owns its own persistent {@link AESKey} and - * {@link javacardx.crypto.Cipher} internally — this class no longer - * needs a separate {@code expeditedSKDeviceKey} field. */ - private AliroGcm gcm; + // Userland AES-256-GCM (built on AES-ECB-NOPAD + AES-256 key) is pulled + // from CryptoSingletons.getAliroGcm(). The target card (NXP J3R180) does + // not expose AEADCipher.ALG_AES_GCM despite advertising JC 3.0.5, so we + // implement GCM in userland. Sharing with StepUpApplet via the singleton + // saves ~370 B of transient/EEPROM footprint that a per-applet duplicate + // would otherwise pay. See encryptResponseGcm(). - /** Low-level crypto primitives (ECDH, HKDF, Kdh, expedited key derivation). */ - private AliroCrypto crypto; + // Low-level crypto primitives (ECDH, HKDF, Kdh, expedited key derivation) + // are pulled from CryptoSingletons.getAliroCrypto() so both AliroApplet + // and StepUpApplet share one instance — saves ~352 B transient versus a + // per-applet duplicate. Local refs in deriveSessionKeys / processDiag. /** Persistent (1B EEPROM) — set after {@link #ensureCryptoInitialized()} runs. */ private byte cryptoInitialized; @@ -289,11 +289,16 @@ public class AliroApplet extends Applet { ecdsaSigner = Signature.getInstance(Signature.ALG_ECDSA_SHA_256, false); sha1 = MessageDigest.getInstance(MessageDigest.ALG_SHA, false); try { - gcm = new AliroGcm(); + // Force lazy alloc of the shared AliroGcm so any install-time + // failure (Cipher.getInstance / KeyBuilder.buildKey rejection) + // surfaces with the same greppable diagnostic SW as before. + CryptoSingletons.getAliroGcm(); } catch (ISOException e) { throw e; // preserve inner diagnostic SW } catch (Throwable t) { ISOException.throwIt((short) 0x6FA8); } try { - crypto = new AliroCrypto(); + // Force lazy alloc of the shared AliroCrypto so the same install- + // time failure ladder applies if the constructor throws. + CryptoSingletons.getAliroCrypto(); } catch (ISOException e) { throw e; // preserve inner diagnostic SW (0x6FC1-C5) } catch (Throwable t) { ISOException.throwIt((short) 0x6FA6); } // Seed P-256 curve params on every keypair before any genKeyPair / setS / @@ -416,8 +421,9 @@ public class AliroApplet extends Applet { switch (ins) { case INS_DIAG_HMAC: { + AliroCrypto cryptoHmac = CryptoSingletons.getAliroCrypto(); for (short i = 0; i < n; i++) { - crypto.diagHmac( + cryptoHmac.diagHmac( DIAG_KEY_32, (short) 0, (short) DIAG_KEY_32.length, DIAG_MSG_64, (short) 0, (short) DIAG_MSG_64.length, buf, DIAG_OUT_OFF); @@ -427,8 +433,9 @@ public class AliroApplet extends Applet { case INS_DIAG_ECDH: { javacard.security.ECPrivateKey priv = (javacard.security.ECPrivateKey) diagKeyPair.getPrivate(); + AliroCrypto cryptoEcdh = CryptoSingletons.getAliroCrypto(); for (short i = 0; i < n; i++) { - crypto.computeEcdhSharedX(priv, + cryptoEcdh.computeEcdhSharedX(priv, SECP256R1_G, (short) 0, buf, DIAG_OUT_OFF); } @@ -451,6 +458,7 @@ public class AliroApplet extends Applet { case INS_DIAG_GCM: { // Plaintext: 137 bytes anywhere in buf past the output region. // Contents don't affect timing. + AliroGcm gcm = CryptoSingletons.getAliroGcm(); for (short i = 0; i < n; i++) { gcm.encrypt( DIAG_KEY_32, (short) 0, @@ -512,34 +520,6 @@ public class AliroApplet extends Applet { case INS_AUTH1: processAuth1(apdu); return; - case INS_EXCHANGE: - // EXCHANGE stub for the Reader Status sub-event variant of - // §8.3.3.5 -- the post-AUTH1 "transaction reporting" handshake - // X-CUBE-ALIRO always sends regardless of signaling_bitmap. - // We don't implement the Step-up phase (no CBOR / mdoc / - // ENVELOPE / GET RESPONSE), so we can't decrypt or honour the - // Reader Status payload. But returning 6D00 here makes the - // vendor firmware mark DOOR OPERATION FAILED even though - // AUTH1 succeeded (§10.2 line 5660 says the access decision - // SHOULD be independent of post-AUTH1 reporting -- vendor - // ignores that). So we ACK with 9000 + empty payload to let - // the demo terminate cleanly. Discards the inbound encrypted - // Reader Status bytes without parsing them; they're a - // status report the reader wanted to give us, not an access - // gate we need to satisfy. - // - // TODO (Step-Up impl): once StepUpApplet handles ENVELOPE / - // GET RESPONSE properly and signaling_bitmap honestly - // reflects capability, this stub becomes either dead code - // (spec-conformant readers route EXCHANGE to ACCE5502 after - // the step-up AID SELECT) or replaceable with a proper - // encrypted "Reader Status response sub-event" reply (for - // readers like X-CUBE-ALIRO that violate the §10.2 SHALL - // and bypass the step-up SELECT). See - // docs/plans/2026-06-07-step-up-implementation.md. - apdu.setIncomingAndReceive(); - apdu.setOutgoingAndSend((short) 0, (short) 0); - return; case INS_DIAG_HMAC: case INS_DIAG_ECDH: case INS_DIAG_ECDSA_SIGN: @@ -652,6 +632,7 @@ public class AliroApplet extends Applet { short saltLen = buildSaltVolatile(store, saltVolatile, (short) 0); short infoLen = buildInfo(scratch, SCRATCH_READER_PUB_UNCOMP_OFF); + AliroCrypto crypto = CryptoSingletons.getAliroCrypto(); crypto.deriveKdh( (ECPrivateKey) credentialEphemeralKeyPair.getPrivate(), sessionState, OFF_READER_EPUBK, @@ -899,10 +880,11 @@ public class AliroApplet extends Applet { // Aliro.a errors out when bits 0 + 2 are clear and AD is provisioned // -- it expects "AD present" to be advertised. The bits are // informational about capabilities anyway, not enforceable - // commitments, so 0x0005 satisfies the vendor library while we still - // 6D00 / 9000-stub any EXCHANGE/ENVELOPE that actually arrives. - // Revisit when real Step-up lands or when we test against more - // readers and can lean on the spec literally. + // commitments, so 0x0005 satisfies the vendor library. AliroApplet + // now returns 6D00 for any post-AUTH1 INS like 0xC9 -- StepUpApplet + // at ACCE5502 handles ENVELOPE and EXCHANGE properly per spec §10.2 + // + §8.4. Revisit when we test against more readers and can lean on + // the spec literally. short bitmap = 0; if (store.hasAccessDocument()) { bitmap |= 0x0001; // bit 0 @@ -957,7 +939,7 @@ public class AliroApplet extends Applet { // device_counter big-endian in the last 4 bytes; first AUTH1 = 1 scratch[(short) (ivOff + 11)] = (byte) 0x01; - return gcm.encrypt( + return CryptoSingletons.getAliroGcm().encrypt( derivedKeys, OFF_EXPEDITED_SK_DEVICE, scratch, ivOff, plaintext, ptOff, ptLen, diff --git a/applet/src/main/java/com/dangerousthings/aliro/AliroGcm.java b/applet/src/main/java/com/dangerousthings/aliro/AliroGcm.java index b83546f..a2be448 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/AliroGcm.java +++ b/applet/src/main/java/com/dangerousthings/aliro/AliroGcm.java @@ -261,6 +261,157 @@ final class AliroGcm { return (short) (ptLen + TAG_LEN); } + /** + * AES-256-GCM decrypt with the Aliro parameters: 32-byte key, 12-byte IV, + * empty AAD, 16-byte tag appended. Input is + * {@code in[inOff..inOff+inLen)} laid out as + * {@code ciphertext || tag} where the trailing 16 bytes are the tag and + * the preceding {@code inLen - 16} bytes are the ciphertext. The tag is + * verified before any plaintext is emitted; if verification fails the + * method throws a {@link CryptoException} ({@code ILLEGAL_VALUE}) and does + * NOT write to {@code out}. On success {@code inLen - 16} plaintext bytes + * are written to {@code out[outOff..]} and the same value is returned. + * + *
The buffer-aliasing rules mirror {@link #encrypt}: {@code in} and + * {@code out} may be the same buffer at the same offset (we GHASH the + * ciphertext BEFORE we touch the output region, then GCTR overwrites it + * left-to-right). + * + *
Used by the Step-Up phase EXCHANGE / ENVELOPE handlers for the + * reader→device direction per spec §8.3.1.9. The expedited-phase + * decrypt path on the reader side is not exercised by the applet; this + * exists to verify the GCM tag on inbound traffic so the applet knows + * the reader holds the matching session keys. + * + * @param key 32-byte AES-256 key + * @param iv 12-byte IV + * @param in input buffer, layout {@code ciphertext || tag} + * @param inOff, inLen input region; {@code inLen >= 16} required + * @param out output buffer, must have at least {@code inLen - 16} bytes + * available at {@code outOff} + * @return plaintext length = {@code inLen - 16} + * @throws CryptoException with reason {@code ILLEGAL_VALUE} on bad input + * length or tag mismatch + */ + short decrypt( + byte[] key, short keyOff, + byte[] iv, short ivOff, + byte[] in, short inOff, short inLen, + byte[] out, short outOff) { + + if (inLen < TAG_LEN) { + CryptoException.throwIt(CryptoException.ILLEGAL_VALUE); + } + 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; + + // GHASH over the PROVIDED ciphertext first (so tag verify doesn't + // depend on a successful decrypt). AAD is empty. + Util.arrayFillNonAtomic(scratch, OFF_GHASH, BLOCK_LEN, (byte) 0); + short consumed = 0; + while (consumed < ctLen) { + short chunk = (short) (ctLen - consumed); + if (chunk >= BLOCK_LEN) { + for (short i = 0; i < BLOCK_LEN; i++) { + scratch[(short) (OFF_GHASH + i)] ^= in[(short) (inOff + consumed + i)]; + } + consumed += BLOCK_LEN; + } else { + for (short i = 0; i < chunk; i++) { + scratch[(short) (OFF_GHASH + i)] ^= in[(short) (inOff + consumed + i)]; + } + consumed += chunk; + } + gfMul4Bit(scratch, OFF_GHASH); + Util.arrayCopyNonAtomic(scratch, OFF_ECB_OUT, + scratch, OFF_GHASH, BLOCK_LEN); + } + + // len_block = 0^64 || (8*ctLen)^64. AAD bits = 0; same byte-shift + // dance as encrypt() to dodge JC's int-promotion conversion failure. + Util.arrayFillNonAtomic(scratch, OFF_LEN_BLK, BLOCK_LEN, (byte) 0); + short ctBitsLo = (short) (ctLen << 3); + short ctBitsHi = (short) (((short)(ctLen >>> 13)) & 0x07); + 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_expected = AES_K(J0) XOR GHASH. Compare against received tag in + // constant-ish time (XOR-then-OR; no early exit). On JC this isn't + // truly constant-time at the bytecode level, but the smartcard SE + // doesn't expose timing channels at the resolution that would matter + // for a 128-bit tag forgery anyway. + aesEcb.doFinal(scratch, OFF_J0, BLOCK_LEN, scratch, OFF_ECB_OUT); + byte diff = 0; + for (short i = 0; i < TAG_LEN; i++) { + byte expected = (byte) (scratch[(short) (OFF_ECB_OUT + i)] + ^ scratch[(short) (OFF_GHASH + i)]); + diff |= (byte) (expected ^ in[(short) (tagOff + i)]); + } + if (diff != 0) { + // Wipe scratch before throwing so a tag-mismatch doesn't leave H, + // GHASH state, or AES_K(J0) sitting in transient. + Util.arrayFillNonAtomic(scratch, (short) 0, SCRATCH_LEN, (byte) 0); + CryptoException.throwIt(CryptoException.ILLEGAL_VALUE); + } + + // Tag verified -- now GCTR-decrypt the ciphertext into out[]. cb is + // INC32(J0) just like encrypt(). Reusable OFF_CB slot has been free + // since J0 was last referenced for the tag XOR. + Util.arrayCopyNonAtomic(scratch, OFF_J0, scratch, OFF_CB, BLOCK_LEN); + inc32(scratch, OFF_CB); + + if (usesNativeCtr != 0) { + aesCtr.init(aesKey, Cipher.MODE_ENCRYPT, scratch, OFF_CB, BLOCK_LEN); + // CTR is symmetric: encrypting the ciphertext with the same + // keystream produces the plaintext. + aesCtr.doFinal(in, inOff, ctLen, out, outOff); + } else { + short produced = 0; + while (produced < ctLen) { + short blockLen = (short) (ctLen - produced); + if (blockLen > BLOCK_LEN) blockLen = BLOCK_LEN; + + aesEcb.doFinal(scratch, OFF_CB, BLOCK_LEN, scratch, OFF_ECB_OUT); + + for (short i = 0; i < blockLen; i++) { + out[(short) (outOff + produced + i)] = + (byte) (in[(short) (inOff + produced + i)] + ^ scratch[(short) (OFF_ECB_OUT + i)]); + } + + inc32(scratch, OFF_CB); + produced += blockLen; + } + } + + Util.arrayFillNonAtomic(scratch, (short) 0, SCRATCH_LEN, (byte) 0); + return ctLen; + } + /** * 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/main/java/com/dangerousthings/aliro/CryptoSingletons.java b/applet/src/main/java/com/dangerousthings/aliro/CryptoSingletons.java new file mode 100644 index 0000000..b283406 --- /dev/null +++ b/applet/src/main/java/com/dangerousthings/aliro/CryptoSingletons.java @@ -0,0 +1,45 @@ +package com.dangerousthings.aliro; + +/** + * Lazy holders for crypto objects shared between {@link AliroApplet} and + * {@link StepUpApplet}. Both applets live in the same package and only one + * is selected at a time, so the shared instances are safe — Java Card runs + * one applet at a time and the JCRE serializes APDU dispatch. + * + *
Java Card forbids {@code new} in {@code Also when this lands, revisit {@link AliroApplet}'s {@code INS_EXCHANGE}
- * stub: with real Step-Up at this AID, the stub becomes either (a) unused
- * if {@code signaling_bitmap} bit 2 stays set and spec-conformant readers
- * route EXCHANGE here, or (b) replaceable with a proper "Reader Status
- * sub-event" reply if X-CUBE-ALIRO's vendor bug persists.
+ * The {@code AliroApplet.INS_EXCHANGE} stub was retired once StepUpApplet
+ * landed handling for 0xC9 directly — spec-conformant readers (X-CUBE-ALIRO
+ * included, once the upstream crypto interop is right) route EXCHANGE here
+ * after the step-up AID SELECT per §10.2.
*/
public class StepUpApplet extends Applet {
+ /** ISO 7816 CLA byte (0x00) for ISO-standardized commands. ENVELOPE
+ * (INS=0xC3) per ISO 7816 / Table 8-14 in the Aliro spec uses this CLA,
+ * not the Aliro-proprietary 0x80, because ENVELOPE is the standard
+ * ISO command for carrying chained data. */
+ private static final byte CLA_ISO = (byte) 0x00;
private static final byte CLA_PROPRIETARY = (byte) 0x80;
+ /** EXCHANGE command per spec §8.3.3.5 / Table 8-14. The reader sends a
+ * Reader Status sub-event under this INS once the Step-Up AID is the
+ * active applet. */
+ private static final byte INS_EXCHANGE = (byte) 0xC9;
+ /** ENVELOPE command per ISO 7816 + spec §8.4 (Step-Up entry point). The
+ * X-CUBE-ALIRO firmware sends the encrypted mdoc DeviceRequest in the
+ * ENVELOPE body once the Step-Up AID is the active applet. */
+ private static final byte INS_ENVELOPE = (byte) 0xC3;
+
+ /** Length of each derived Step-Up session key (spec §8.4.3). */
+ private static final short STEP_UP_SK_LEN = 32;
+
+ /** 12-byte AES-256-GCM IV layout (§8.3.1.8/9): 8B prefix + 4B counter. */
+ private static final short GCM_IV_LEN = 12;
+ private static final short GCM_TAG_LEN = 16;
+ private static final short COUNTER_LEN = 4;
+
+ /** {@code StepUpSKDevice} — UD→reader leg of the Step-Up AES-256-GCM
+ * session, derived from {@code StepUpSK} via HKDF (§8.4.3) when SELECT
+ * finds an armed {@link SessionContext}. Transient, cleared on deselect. */
+ private final byte[] stepUpSKDevice;
+
+ /** {@code StepUpSKReader} — reader→UD leg of the Step-Up GCM session.
+ * Same derivation context as {@link #stepUpSKDevice}. */
+ private final byte[] stepUpSKReader;
+
+ /** 32-byte scratch used only during {@link #select()} to stage the
+ * StepUpSK copied out of {@link SessionContext} before HKDF derives the
+ * two session keys. Wiped after derivation so the parked StepUpSK never
+ * outlives the call. */
+ private final byte[] stepUpSKScratch;
+
+ /** Session-bound {@code StepUp_reader_counter} per §8.4.3 + mdoc [6]
+ * §9.1.1.5: 32-bit big-endian counter starting at {@code 0x00000001} the
+ * first time the reader→UD direction is used in this Step-Up session,
+ * incremented after each successful decrypt. CLEAR_ON_DESELECT so each
+ * Step-Up phase entry starts fresh -- the matching SELECT re-initialises
+ * the counter alongside the SK derivation. Shared across ENVELOPE and
+ * EXCHANGE: both commands are reader→device so both consume from the
+ * same counter. */
+ private final byte[] stepUpReaderCounter;
+
+ /** Session-bound {@code StepUp_device_counter} per §8.4.3 + mdoc [6]
+ * §9.1.1.5: 32-bit big-endian counter for the device→reader direction,
+ * starting at {@code 0x00000001} on Step-Up session entry, incremented
+ * after each successful encrypt. In M1 only ENVELOPE returns ciphertext
+ * (EXCHANGE returns empty), so this advances once per ENVELOPE. */
+ private final byte[] stepUpDeviceCounter;
+
+ /** 12-byte scratch for the GCM IV: 8 zero bytes + 4-byte reader counter
+ * per §8.3.1.8. Rebuilt per EXCHANGE; CLEAR_ON_DESELECT. */
+ private final byte[] ivScratch;
+
+ /** Persistent EXCHANGE plaintext sink for the decrypt-and-discard path.
+ * Sized to the largest reasonable Reader Status sub-event we'd see
+ * during M1 (X-CUBE-ALIRO observed values are well under 64 B); we'll
+ * resize when the real Reader Status payload size lands. CLEAR_ON_DESELECT
+ * so post-deselect there's no plaintext residue. */
+ private final byte[] scratchPlaintext;
+ private static final short SCRATCH_PLAINTEXT_LEN = 256;
+
+ /** Transient single-slot flag: 1 once {@link #select()} successfully
+ * derived the Step-Up session keys (i.e. the SELECT found
+ * {@link SessionContext} armed). EXCHANGE / ENVELOPE handlers refuse to
+ * run if this is 0. CLEAR_ON_DESELECT, alongside the keys themselves. */
+ private final byte[] sessionFlags;
+ private static final short FLAG_KEYS_READY = 0;
+ private static final short FLAGS_LEN = 1;
public static void install(byte[] bArray, short bOffset, byte bLength) {
StepUpApplet applet = new StepUpApplet();
@@ -71,8 +144,55 @@ public class StepUpApplet extends Applet {
}
}
+ private StepUpApplet() {
+ // CLEAR_ON_DESELECT: the next deselect (e.g. reader moves back to
+ // EXPEDITED or pulls the field) zeroes the session keys, matching the
+ // "fresh keys per Step-Up phase" invariant we'll need when ENVELOPE /
+ // EXCHANGE handlers run AES-GCM.
+ stepUpSKDevice = JCSystem.makeTransientByteArray(STEP_UP_SK_LEN, JCSystem.CLEAR_ON_DESELECT);
+ stepUpSKReader = JCSystem.makeTransientByteArray(STEP_UP_SK_LEN, JCSystem.CLEAR_ON_DESELECT);
+ stepUpSKScratch = JCSystem.makeTransientByteArray(STEP_UP_SK_LEN, JCSystem.CLEAR_ON_DESELECT);
+ stepUpReaderCounter = JCSystem.makeTransientByteArray(COUNTER_LEN, JCSystem.CLEAR_ON_DESELECT);
+ stepUpDeviceCounter = JCSystem.makeTransientByteArray(COUNTER_LEN, JCSystem.CLEAR_ON_DESELECT);
+ ivScratch = JCSystem.makeTransientByteArray(GCM_IV_LEN, JCSystem.CLEAR_ON_DESELECT);
+ scratchPlaintext = JCSystem.makeTransientByteArray(SCRATCH_PLAINTEXT_LEN, JCSystem.CLEAR_ON_DESELECT);
+ sessionFlags = JCSystem.makeTransientByteArray(FLAGS_LEN, JCSystem.CLEAR_ON_DESELECT);
+ }
+
+ /**
+ * SELECT entry point. If {@link SessionContext} is armed (i.e. AUTH1 on
+ * {@link AliroApplet} just succeeded and parked {@code StepUpSK}), copy
+ * it out and derive {@code StepUpSKDevice}/{@code StepUpSKReader} via
+ * the §8.4.3 HKDF so the ENVELOPE / EXCHANGE handlers in M1B / M1C can
+ * AES-256-GCM with them. Always returns true — an un-armed SELECT (e.g.
+ * a reader that touches the Step-Up AID before AUTH1) still gets a
+ * successful FCI; downstream handlers will reject commands that need a
+ * live session.
+ */
@Override
public boolean select() {
+ if (SessionContext.isStepUpArmed()) {
+ SessionContext.copyStepUpSK(stepUpSKScratch, (short) 0);
+ CryptoSingletons.getAliroCrypto().deriveStepUpSessionKeys(
+ stepUpSKScratch, (short) 0,
+ stepUpSKDevice, (short) 0,
+ stepUpSKReader, (short) 0);
+ // Wipe the staged StepUpSK — the derived keys are sufficient
+ // from here on and we don't want the IKM lingering in transient.
+ Util.arrayFillNonAtomic(stepUpSKScratch, (short) 0, STEP_UP_SK_LEN, (byte) 0);
+ // Spec §8.4.3 -> mdoc [6] §9.1.1.5: session-bound counters
+ // initialized to 0x00000001 on session entry, one per direction.
+ // CLEAR_ON_DESELECT already zeroes them on each fresh select;
+ // rewrite explicitly so a Step-Up SELECT mid-session (without a
+ // deselect in between) also starts both counters at 1.
+ Util.arrayFillNonAtomic(stepUpReaderCounter, (short) 0, COUNTER_LEN, (byte) 0);
+ stepUpReaderCounter[3] = (byte) 0x01;
+ Util.arrayFillNonAtomic(stepUpDeviceCounter, (short) 0, COUNTER_LEN, (byte) 0);
+ stepUpDeviceCounter[3] = (byte) 0x01;
+ sessionFlags[FLAG_KEYS_READY] = (byte) 1;
+ } else {
+ sessionFlags[FLAG_KEYS_READY] = (byte) 0;
+ }
return true;
}
@@ -84,15 +204,206 @@ public class StepUpApplet extends Applet {
}
byte[] buf = apdu.getBuffer();
- if (buf[ISO7816.OFFSET_CLA] != CLA_PROPRIETARY) {
+ byte cla = buf[ISO7816.OFFSET_CLA];
+ byte ins = buf[ISO7816.OFFSET_INS];
+
+ // INS-first dispatch: ENVELOPE is ISO-class (0x00) per ISO 7816 + spec
+ // Table 8-14, EXCHANGE is Aliro-proprietary (0x80). Don't blanket
+ // reject CLA=0x00 -- it's a legitimate ENVELOPE entry point.
+ if (cla == CLA_ISO && ins == INS_ENVELOPE) {
+ processEnvelope(apdu);
+ return;
+ }
+ if (cla == CLA_PROPRIETARY && ins == INS_EXCHANGE) {
+ processExchange(apdu);
+ return;
+ }
+ if (cla != CLA_ISO && cla != CLA_PROPRIETARY) {
ISOException.throwIt(ISO7816.SW_CLA_NOT_SUPPORTED);
}
-
- // Until the mdoc DeviceRequest pipeline lands, every proprietary INS
- // is unrecognised. ENVELOPE + GET RESPONSE handlers plug in here.
+ // GET RESPONSE handler plugs in here in follow-up milestones.
ISOException.throwIt(ISO7816.SW_INS_NOT_SUPPORTED);
}
+ /**
+ * EXCHANGE (CLA=0x80, INS=0xC9) handler — Milestone 1 decrypt-and-discard.
+ *
+ * Spec §8.3.3.5 / Table 8-14: the reader sends
+ * {@code encrypted_payload || authentication_tag} encrypted with
+ * {@code StepUpSKReader} per §8.3.1.8, IV layout
+ * {@code 0x0000000000000000 || stepup_reader_counter (4B BE)} and empty
+ * AAD. M1 only needs to verify the tag (proves matching session keys)
+ * then ACK with 9000 + empty payload so the X-CUBE-ALIRO firmware marks
+ * "DOOR OPERATION SUCCEEDED" and moves on. The real Reader Status
+ * response sub-event (encrypted with StepUpSKDevice) lands in M2.
+ */
+ private void processExchange(APDU apdu) {
+ if (sessionFlags[FLAG_KEYS_READY] == 0) {
+ // SELECT hit StepUpApplet without an armed SessionContext (i.e.
+ // no successful AUTH1 ran on AliroApplet first). Spec §8.4 says
+ // the Step-Up phase is only entered post-AUTH1; reject cleanly.
+ ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED);
+ }
+
+ short lc = apdu.setIncomingAndReceive();
+ byte[] buf = apdu.getBuffer();
+ short dataOff = apdu.getOffsetCdata();
+
+ // Need at least the 16-byte tag.
+ if (lc < GCM_TAG_LEN) {
+ ISOException.throwIt(ISO7816.SW_WRONG_LENGTH);
+ }
+ // M1 sink is fixed-size; reject payloads that wouldn't fit. The real
+ // EXCHANGE payload during M1 ack flow is tiny (X-CUBE-ALIRO sends a
+ // few bytes of CBOR), so this bound is comfortable.
+ short ptLen = (short) (lc - GCM_TAG_LEN);
+ if (ptLen > SCRATCH_PLAINTEXT_LEN) {
+ ISOException.throwIt(ISO7816.SW_WRONG_LENGTH);
+ }
+
+ // Build the IV: 8 zero bytes (reader→device prefix per §8.3.1.8) +
+ // stepup_reader_counter, big-endian, in the trailing 4 bytes.
+ Util.arrayFillNonAtomic(ivScratch, (short) 0, (short) 8, (byte) 0);
+ Util.arrayCopyNonAtomic(stepUpReaderCounter, (short) 0,
+ ivScratch, (short) 8, COUNTER_LEN);
+
+ // Decrypt-and-discard. AliroGcm.decrypt throws CryptoException on
+ // tag mismatch; remap to a security SW so an attacker can't tell
+ // tag-mismatch from any other failure mode.
+ try {
+ CryptoSingletons.getAliroGcm().decrypt(
+ stepUpSKReader, (short) 0,
+ ivScratch, (short) 0,
+ buf, dataOff, lc,
+ scratchPlaintext, (short) 0);
+ } catch (ISOException e) {
+ throw e;
+ } catch (Throwable t) {
+ ISOException.throwIt(ISO7816.SW_SECURITY_STATUS_NOT_SATISFIED);
+ }
+
+ // Wipe the discarded plaintext immediately -- M1 has no use for it,
+ // and CLEAR_ON_DESELECT alone would leave it sitting around until the
+ // reader walks away.
+ Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0);
+
+ // Spec §8.3.1.8: reader_counter <- reader_counter + 1 after use.
+ incrementCounter(stepUpReaderCounter, (short) 0);
+
+ // Ack with SW=9000 and empty payload. If field testing on real
+ // X-CUBE-ALIRO firmware shows the reader rejects an empty payload,
+ // M1E iteration escalates this to "9000 + encrypted-empty-CBOR-map"
+ // per the implementation plan.
+ apdu.setOutgoingAndSend((short) 0, (short) 0);
+ }
+
+ /**
+ * ENVELOPE (CLA=0x00, INS=0xC3) handler — Milestone 1 decrypt-and-discard
+ * the mdoc DeviceRequest, then return a spec-shape encrypted empty CBOR
+ * map (canonical RFC 8949: single byte 0xA0).
+ *
+ * Spec §8.3.1.9: inbound payload (reader→device) is decrypted with
+ * {@code StepUpSKReader}, IV {@code 0x0000000000000000 || stepup_reader_counter}
+ * (8-byte zero prefix + 4-byte BE counter), empty AAD.
+ *
+ * Spec §8.3.1.6: outbound payload (device→reader) is encrypted with
+ * {@code StepUpSKDevice}, IV {@code 0x0000000000000001 || stepup_device_counter}
+ * (8-byte prefix ending in 0x01 + 4-byte BE counter), empty AAD.
+ *
+ * The DeviceRequest body is discarded in M1: we don't build a real
+ * mdoc DeviceResponse yet -- that's M2's job. The 17-byte ciphertext
+ * (1 ct + 16 tag) is enough for X-CUBE-ALIRO to see a spec-shape
+ * encrypted response and move on. Counter sequencing: both
+ * stepup_reader_counter (shared with EXCHANGE) and stepup_device_counter
+ * advance independently after each successful use.
+ */
+ private void processEnvelope(APDU apdu) {
+ if (sessionFlags[FLAG_KEYS_READY] == 0) {
+ // SELECT-Step-Up landed without an armed SessionContext (i.e. no
+ // successful AUTH1 on AliroApplet first). Spec §8.4 keeps Step-Up
+ // strictly post-AUTH1; reject cleanly.
+ ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED);
+ }
+
+ short lc = apdu.setIncomingAndReceive();
+ byte[] buf = apdu.getBuffer();
+ short dataOff = apdu.getOffsetCdata();
+
+ // Need at least the 16-byte tag (zero-byte plaintext is degenerate
+ // but spec-legal); reject anything that can't possibly carry a tag.
+ if (lc < GCM_TAG_LEN) {
+ ISOException.throwIt(ISO7816.SW_WRONG_LENGTH);
+ }
+ short ptLen = (short) (lc - GCM_TAG_LEN);
+ if (ptLen > SCRATCH_PLAINTEXT_LEN) {
+ ISOException.throwIt(ISO7816.SW_WRONG_LENGTH);
+ }
+
+ // Build the reader-side IV: 8 zero bytes (§8.3.1.9) + reader counter.
+ Util.arrayFillNonAtomic(ivScratch, (short) 0, (short) 8, (byte) 0);
+ Util.arrayCopyNonAtomic(stepUpReaderCounter, (short) 0,
+ ivScratch, (short) 8, COUNTER_LEN);
+
+ try {
+ CryptoSingletons.getAliroGcm().decrypt(
+ stepUpSKReader, (short) 0,
+ ivScratch, (short) 0,
+ buf, dataOff, lc,
+ scratchPlaintext, (short) 0);
+ } catch (ISOException e) {
+ throw e;
+ } catch (Throwable t) {
+ ISOException.throwIt(ISO7816.SW_SECURITY_STATUS_NOT_SATISFIED);
+ }
+ // Spec §8.3.1.9: reader_counter <- reader_counter + 1 after use.
+ incrementCounter(stepUpReaderCounter, (short) 0);
+
+ // Wipe the decrypted DeviceRequest -- M1 has no use for it. M2 will
+ // replace this with real mdoc parsing and a real DeviceResponse.
+ Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0);
+
+ // Build the canonical-CBOR empty map plaintext (single byte 0xA0,
+ // RFC 8949 major type 5 (map) with length 0). One byte total.
+ scratchPlaintext[0] = (byte) 0xA0;
+
+ // Build the device-side IV: 0x00*7 || 0x01 || device_counter
+ // (§8.3.1.6 -- 8-byte prefix ending in 0x01, then 4B BE counter).
+ Util.arrayFillNonAtomic(ivScratch, (short) 0, (short) 7, (byte) 0);
+ ivScratch[7] = (byte) 0x01;
+ Util.arrayCopyNonAtomic(stepUpDeviceCounter, (short) 0,
+ ivScratch, (short) 8, COUNTER_LEN);
+
+ // Encrypt into the APDU buffer at offset 0. Safe to overwrite the
+ // inbound command bytes here because we've finished reading them.
+ // Output length = 1 (ct) + 16 (tag) = 17 bytes; well under the 252-byte
+ // APDU buffer ceiling.
+ short ctLen = CryptoSingletons.getAliroGcm().encrypt(
+ stepUpSKDevice, (short) 0,
+ ivScratch, (short) 0,
+ scratchPlaintext, (short) 0, (short) 1,
+ buf, (short) 0);
+ // Spec §8.3.1.6: device_counter <- device_counter + 1 after use.
+ incrementCounter(stepUpDeviceCounter, (short) 0);
+
+ // Wipe the single plaintext byte (CLEAR_ON_DESELECT alone would
+ // leave 0xA0 sitting in transient until reader walks away).
+ scratchPlaintext[0] = 0;
+
+ apdu.setOutgoingAndSend((short) 0, ctLen);
+ }
+
+ /** 32-bit big-endian counter increment with carry across all 4 bytes.
+ * Wraps mod 2^32; spec §8.3.3.5.4 says the counter SHALL never reach
+ * 0xFFFF before increment (note: spec uses 0xFFFF where 0xFFFFFFFF is
+ * clearly meant -- 4-byte BE counter), so wrap is unreachable in
+ * practice during normal protocol flow. */
+ private static void incrementCounter(byte[] buf, short off) {
+ for (short i = (short) (off + 3); i >= off; i--) {
+ buf[i]++;
+ if (buf[i] != 0) return;
+ }
+ }
+
/**
* Minimal FCI for step-up SELECT. The spec (§10.2.1.2) allows the UD to
* advertise supported APDU command/response sizes here; those get added
@@ -117,4 +428,17 @@ public class StepUpApplet extends Applet {
buf[outerLenPos] = (byte) (off - 2);
apdu.setOutgoingAndSend((short) 0, off);
}
+
+ // --- Testing hooks ------------------------------------------------------
+ // Package-private accessors that let tests verify SELECT-time key
+ // derivation without exposing the session keys to any production caller.
+ // NOT for use outside the applet's own test module.
+
+ void copyStepUpSKDeviceForTesting(byte[] out, short outOff) {
+ Util.arrayCopyNonAtomic(stepUpSKDevice, (short) 0, out, outOff, STEP_UP_SK_LEN);
+ }
+
+ void copyStepUpSKReaderForTesting(byte[] out, short outOff) {
+ Util.arrayCopyNonAtomic(stepUpSKReader, (short) 0, out, outOff, STEP_UP_SK_LEN);
+ }
}
diff --git a/applet/src/test/java/com/dangerousthings/aliro/AliroAppletTest.java b/applet/src/test/java/com/dangerousthings/aliro/AliroAppletTest.java
index 543b802..3ee363d 100644
--- a/applet/src/test/java/com/dangerousthings/aliro/AliroAppletTest.java
+++ b/applet/src/test/java/com/dangerousthings/aliro/AliroAppletTest.java
@@ -92,4 +92,19 @@ class AliroAppletTest {
assertEquals(0x01, versions[0] & 0xFF, "expected first protocol version high byte 0x01");
assertEquals(0x00, versions[1] & 0xFF, "expected first protocol version low byte 0x00");
}
+
+ @Test
+ void auth1ExchangeInsRejectedNowThatStepUpAppletOwnsIt() {
+ // Once M1B.1 / M1C.1 land StepUpApplet (ACCE5502) as the proper owner
+ // of INS_EXCHANGE (0xC9), AliroApplet must NOT also answer 0xC9 — a
+ // spec-conformant reader routes EXCHANGE to ACCE5502 after the §10.2
+ // step-up AID SELECT. AliroApplet receiving 0xC9 is a reader bug or
+ // a stale flow, and must be rejected with SW_INS_NOT_SUPPORTED so it
+ // can never accidentally interact with expedited session state.
+ selectExpedited();
+ CommandAPDU exchange = new CommandAPDU(0x80, 0xC9, 0x00, 0x00);
+ ResponseAPDU resp = sim.transmitCommand(exchange);
+ assertEquals(0x6D00, resp.getSW(),
+ "AliroApplet must reject INS_EXCHANGE (0xC9) — StepUpApplet (ACCE5502) owns it now");
+ }
}
diff --git a/applet/src/test/java/com/dangerousthings/aliro/AliroCryptoTest.java b/applet/src/test/java/com/dangerousthings/aliro/AliroCryptoTest.java
index 5bedbfc..05cc9ec 100644
--- a/applet/src/test/java/com/dangerousthings/aliro/AliroCryptoTest.java
+++ b/applet/src/test/java/com/dangerousthings/aliro/AliroCryptoTest.java
@@ -167,6 +167,50 @@ class AliroCryptoTest {
}
}
+ /**
+ * Independent pin against an inline RFC 5869 reference (one HKDF-Expand
+ * block: T(1) = HMAC(PRK, info || 0x01)). Complements
+ * {@link #deriveStepUpSessionKeysMatchesManualHkdf} by anchoring on the
+ * exact ASCII info strings the spec specifies ("SKDevice" / "SKReader")
+ * with a different IKM byte pattern, so a future spec misread can't be
+ * masked by sharing helper code with the equivalent test above.
+ */
+ @Test
+ void deriveStepUpSessionKeysMatchesRfc5869WithSpecInfoStrings() throws Exception {
+ AliroCrypto crypto = new AliroCrypto();
+ byte[] stepUpSK = new byte[32];
+ for (byte i = 0; i < 32; i++) stepUpSK[i] = (byte) (0x10 + i);
+
+ byte[] skDevice = new byte[32];
+ byte[] skReader = new byte[32];
+ crypto.deriveStepUpSessionKeys(
+ stepUpSK, (short) 0,
+ skDevice, (short) 0,
+ skReader, (short) 0);
+
+ // Reference HKDF-SHA-256 with empty salt
+ javax.crypto.Mac extractMac = javax.crypto.Mac.getInstance("HmacSHA256");
+ extractMac.init(new javax.crypto.spec.SecretKeySpec(new byte[32], "HmacSHA256"));
+ byte[] prk = extractMac.doFinal(stepUpSK);
+
+ byte[] expectedDevice = expandOnce(prk, "SKDevice".getBytes("US-ASCII"));
+ byte[] expectedReader = expandOnce(prk, "SKReader".getBytes("US-ASCII"));
+
+ assertArrayEquals(expectedDevice, skDevice,
+ "StepUpSKDevice = HKDF-Expand(PRK, info=ASCII(\"SKDevice\"), L=32)");
+ assertArrayEquals(expectedReader, skReader,
+ "StepUpSKReader = HKDF-Expand(PRK, info=ASCII(\"SKReader\"), L=32)");
+ }
+
+ private static byte[] expandOnce(byte[] prk, byte[] info) throws Exception {
+ // T(1) = HMAC-SHA-256(PRK, info || 0x01); first 32 bytes is the output
+ javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");
+ mac.init(new javax.crypto.spec.SecretKeySpec(prk, "HmacSHA256"));
+ mac.update(info);
+ mac.update((byte) 0x01);
+ return mac.doFinal();
+ }
+
@Test
void hkdfExpandRfc5869TestCase1() {
AliroCrypto crypto = new AliroCrypto();
diff --git a/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java b/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java
index 9ddd23c..502586c 100644
--- a/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java
+++ b/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java
@@ -1,12 +1,19 @@
package com.dangerousthings.aliro;
import com.licel.jcardsim.smartcardio.CardSimulator;
+import java.security.KeyPair;
import javacard.framework.AID;
+import javacard.framework.Applet;
+import javax.crypto.Cipher;
+import javax.crypto.Mac;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
import javax.smartcardio.CommandAPDU;
import javax.smartcardio.ResponseAPDU;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
@@ -66,6 +73,277 @@ class StepUpAppletTest {
"wrong CLA must return SW_CLA_NOT_SUPPORTED");
}
+ /**
+ * After AUTH1 succeeds on {@link AliroApplet}, {@code SessionContext} is
+ * armed with the 32-byte {@code StepUpSK}. When the reader subsequently
+ * SELECTs the Step-Up AID (per spec §10.2.x and the empirical X-CUBE-ALIRO
+ * flow), {@link StepUpApplet#select()} must derive
+ * {@code StepUpSKDevice}/{@code StepUpSKReader} via
+ * {@link AliroCrypto#deriveStepUpSessionKeys} and stage both in
+ * {@code CLEAR_ON_DESELECT} transient fields. This pins the wiring end-to-end.
+ */
+ @Test
+ void selectAfterArmedAuth1DerivesStepUpSessionKeys() throws Exception {
+ // Full applet stack: PersonalizationApplet (via ReaderSide.provision),
+ // AliroApplet under EXPEDITED, StepUpApplet under STEP_UP. The bare
+ // StepUpApplet install done by setUp() is overwritten by re-installing
+ // here; jcardsim treats the AID install as idempotent for our purposes.
+ sim = new CardSimulator();
+ AID expeditedAid = new AID(AliroAids.EXPEDITED, (short) 0, (byte) AliroAids.EXPEDITED.length);
+ sim.installApplet(expeditedAid, AliroApplet.class);
+ AID stepUpAid = new AID(AliroAids.STEP_UP, (short) 0, (byte) AliroAids.STEP_UP.length);
+ sim.installApplet(stepUpAid, StepUpApplet.class);
+
+ KeyPair credentialKeyPair = Auth0Command.generateEphemeralKeyPair();
+ ReaderSide reader = new ReaderSide();
+ reader.provision(sim, credentialKeyPair);
+
+ // SELECT expedited, run AUTH0 + AUTH1 (mirrors
+ // AliroAppletAuth1Test.auth1SuccessArmsStepUpContextWithExpectedStepUpSK).
+ assertEquals(0x9000, sim.transmitCommand(
+ new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256)).getSW(),
+ "SELECT expedited must succeed");
+
+ reader.startTransaction();
+ ResponseAPDU auth0Resp = sim.transmitCommand(new CommandAPDU(
+ Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00,
+ reader.buildAuth0Data(), 256));
+ assertEquals(0x9000, auth0Resp.getSW(), "AUTH0 must succeed");
+ byte[] credentialEphemPubKey = TlvUtil.findTopLevel(auth0Resp.getData(), 0x86);
+
+ ResponseAPDU auth1Resp = sim.transmitCommand(new CommandAPDU(
+ Auth0Command.CLA & 0xFF, 0x81, 0x00, 0x00,
+ reader.buildAuth1Data(credentialEphemPubKey), 256));
+ assertEquals(0x9000, auth1Resp.getSW(), "AUTH1 must succeed");
+
+ // Compute the StepUpSK the test knows the card holds: it's bytes
+ // 64..96 of the expedited-standard derived_keys_volatile material.
+ byte[] stepUpSK = java.util.Arrays.copyOfRange(
+ reader.deriveExpeditedKeyMaterial(credentialEphemPubKey), 64, 96);
+
+ // SELECT the Step-Up AID — this should trigger StepUpApplet.select(),
+ // which sees SessionContext armed, copies out StepUpSK, derives the
+ // two session keys, and stashes them.
+ assertEquals(0x9000, sim.transmitCommand(
+ new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256)).getSW(),
+ "SELECT step-up must succeed regardless of armed state");
+
+ // Reference HKDF-SHA-256 with empty salt — matches AliroCrypto.deriveStepUpSessionKeys.
+ byte[] expectedDevice = hkdfStepUp(stepUpSK, "SKDevice");
+ byte[] expectedReader = hkdfStepUp(stepUpSK, "SKReader");
+
+ byte[] gotDevice = new byte[32];
+ byte[] gotReader = new byte[32];
+ StepUpApplet appletInstance = stepUpAppletInstance(sim, stepUpAid);
+ appletInstance.copyStepUpSKDeviceForTesting(gotDevice, (short) 0);
+ appletInstance.copyStepUpSKReaderForTesting(gotReader, (short) 0);
+
+ assertArrayEquals(expectedDevice, gotDevice,
+ "StepUpSKDevice = HKDF-Expand(HKDF-Extract(empty, StepUpSK), \"SKDevice\", 32)");
+ assertArrayEquals(expectedReader, gotReader,
+ "StepUpSKReader = HKDF-Expand(HKDF-Extract(empty, StepUpSK), \"SKReader\", 32)");
+ }
+
+ /**
+ * After SELECT-Step-Up has armed {@code StepUpSKReader}, the X-CUBE-ALIRO
+ * firmware sends a "Reader Status sub-event" via the EXCHANGE command
+ * (CLA=0x80, INS=0xC9) per spec §8.3.3.5 / Table 8-14. The payload is
+ * AES-256-GCM encrypted with {@code StepUpSKReader}; IV layout from
+ * §8.3.1.8 is {@code 0x0000000000000000 || stepup_reader_counter (4B BE)},
+ * with the counter session-bound and initialized to 1 per §8.4.3 (mdoc
+ * [6] §9.1.1.5 derivation).
+ *
+ * For Milestone 1 the applet only needs to decrypt-and-discard: tag
+ * verification proves the session keys match, then we return SW=9000 with
+ * empty payload. The real Step-Up "Reader Status response sub-event"
+ * (encrypted with StepUpSKDevice) lands in M2.
+ */
+ @Test
+ void exchangeAfterStepUpSelectDecryptsAndAcksWithEmptyPayload() throws Exception {
+ sim = new CardSimulator();
+ AID expeditedAid = new AID(AliroAids.EXPEDITED, (short) 0, (byte) AliroAids.EXPEDITED.length);
+ sim.installApplet(expeditedAid, AliroApplet.class);
+ AID stepUpAid = new AID(AliroAids.STEP_UP, (short) 0, (byte) AliroAids.STEP_UP.length);
+ sim.installApplet(stepUpAid, StepUpApplet.class);
+
+ KeyPair credentialKeyPair = Auth0Command.generateEphemeralKeyPair();
+ ReaderSide reader = new ReaderSide();
+ reader.provision(sim, credentialKeyPair);
+
+ // SELECT expedited + run AUTH0 + AUTH1 -- mirrors the existing
+ // selectAfterArmedAuth1DerivesStepUpSessionKeys test.
+ assertEquals(0x9000, sim.transmitCommand(
+ new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256)).getSW(),
+ "SELECT expedited must succeed");
+
+ reader.startTransaction();
+ ResponseAPDU auth0Resp = sim.transmitCommand(new CommandAPDU(
+ Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00,
+ reader.buildAuth0Data(), 256));
+ assertEquals(0x9000, auth0Resp.getSW(), "AUTH0 must succeed");
+ byte[] credentialEphemPubKey = TlvUtil.findTopLevel(auth0Resp.getData(), 0x86);
+
+ ResponseAPDU auth1Resp = sim.transmitCommand(new CommandAPDU(
+ Auth0Command.CLA & 0xFF, 0x81, 0x00, 0x00,
+ reader.buildAuth1Data(credentialEphemPubKey), 256));
+ assertEquals(0x9000, auth1Resp.getSW(), "AUTH1 must succeed");
+
+ // Compute the StepUpSKReader the card now holds.
+ byte[] stepUpSK = java.util.Arrays.copyOfRange(
+ reader.deriveExpeditedKeyMaterial(credentialEphemPubKey), 64, 96);
+ byte[] stepUpSKReader = hkdfStepUp(stepUpSK, "SKReader");
+
+ // SELECT the Step-Up AID -- arms StepUpApplet's StepUpSKReader and
+ // initializes its stepup_reader_counter session-bound to 0x00000001.
+ assertEquals(0x9000, sim.transmitCommand(
+ new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256)).getSW(),
+ "SELECT step-up must succeed");
+
+ // Reader-side encrypt of a 4-byte fake Reader Status payload under
+ // IV = 00 00 00 00 00 00 00 00 00 00 00 01 (8B zero prefix + counter=1).
+ byte[] iv = new byte[12];
+ iv[11] = 0x01;
+ byte[] plaintext = new byte[] { 0x42, 0x42, 0x42, 0x42 };
+ Cipher gcm = Cipher.getInstance("AES/GCM/NoPadding");
+ gcm.init(Cipher.ENCRYPT_MODE,
+ new SecretKeySpec(stepUpSKReader, "AES"),
+ new GCMParameterSpec(128, iv));
+ byte[] ctAndTag = gcm.doFinal(plaintext); // 4 + 16 = 20 bytes
+ assertEquals(20, ctAndTag.length);
+
+ ResponseAPDU exchangeResp = sim.transmitCommand(
+ new CommandAPDU(0x80, 0xC9, 0x00, 0x00, ctAndTag, 256));
+ assertEquals(0x9000, exchangeResp.getSW(),
+ "EXCHANGE with valid GCM tag must return SW=9000");
+ assertEquals(0, exchangeResp.getData().length,
+ "M1 EXCHANGE handler returns empty payload (decrypt-and-discard)");
+ }
+
+ /**
+ * After SELECT-Step-Up the X-CUBE-ALIRO firmware also sends an ENVELOPE
+ * command (CLA=0x00 INS=0xC3) carrying the encrypted mdoc DeviceRequest
+ * (spec §8.4 + ISO 7816 ENVELOPE). Inbound encryption per §8.3.1.9
+ * (reader-side IV {@code 0x0000000000000000 || stepup_reader_counter},
+ * empty AAD); response encryption per §8.3.1.6 (device-side IV
+ * {@code 0x0000000000000001 || stepup_device_counter}, empty AAD).
+ *
+ * For Milestone 1 we decrypt-and-discard the DeviceRequest, then
+ * return an encrypted single-byte CBOR empty map ({@code 0xA0}) so the
+ * X-CUBE-ALIRO firmware sees a spec-shape encrypted response. Total
+ * response bytes: 1 ct + 16 tag = 17.
+ *
+ * This test also pins counter sequencing: ENVELOPE consumes
+ * stepup_reader_counter=1 (then increments) and emits with
+ * stepup_device_counter=1 (then increments). The reader-counter is
+ * shared with EXCHANGE, but EXCHANGE isn't sent in this test so we
+ * only see counter=1 on each side.
+ */
+ @Test
+ void envelopeAfterStepUpSelectDecryptsAndAcksWithEncryptedEmptyCborMap() throws Exception {
+ sim = new CardSimulator();
+ AID expeditedAid = new AID(AliroAids.EXPEDITED, (short) 0, (byte) AliroAids.EXPEDITED.length);
+ sim.installApplet(expeditedAid, AliroApplet.class);
+ AID stepUpAid = new AID(AliroAids.STEP_UP, (short) 0, (byte) AliroAids.STEP_UP.length);
+ sim.installApplet(stepUpAid, StepUpApplet.class);
+
+ KeyPair credentialKeyPair = Auth0Command.generateEphemeralKeyPair();
+ ReaderSide reader = new ReaderSide();
+ reader.provision(sim, credentialKeyPair);
+
+ // SELECT expedited + run AUTH0 + AUTH1.
+ assertEquals(0x9000, sim.transmitCommand(
+ new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256)).getSW(),
+ "SELECT expedited must succeed");
+
+ reader.startTransaction();
+ ResponseAPDU auth0Resp = sim.transmitCommand(new CommandAPDU(
+ Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00,
+ reader.buildAuth0Data(), 256));
+ assertEquals(0x9000, auth0Resp.getSW(), "AUTH0 must succeed");
+ byte[] credentialEphemPubKey = TlvUtil.findTopLevel(auth0Resp.getData(), 0x86);
+
+ ResponseAPDU auth1Resp = sim.transmitCommand(new CommandAPDU(
+ Auth0Command.CLA & 0xFF, 0x81, 0x00, 0x00,
+ reader.buildAuth1Data(credentialEphemPubKey), 256));
+ assertEquals(0x9000, auth1Resp.getSW(), "AUTH1 must succeed");
+
+ // Compute both StepUpSK leg keys the card now holds.
+ byte[] stepUpSK = java.util.Arrays.copyOfRange(
+ reader.deriveExpeditedKeyMaterial(credentialEphemPubKey), 64, 96);
+ byte[] stepUpSKReader = hkdfStepUp(stepUpSK, "SKReader");
+ byte[] stepUpSKDevice = hkdfStepUp(stepUpSK, "SKDevice");
+
+ // SELECT the Step-Up AID -- arms StepUpApplet's session keys and
+ // initialises both counters to 0x00000001.
+ assertEquals(0x9000, sim.transmitCommand(
+ new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256)).getSW(),
+ "SELECT step-up must succeed");
+
+ // Reader-side encrypt of a 4-byte plaintext under the reader IV
+ // (00 00 00 00 00 00 00 00 || 00 00 00 01). The applet decrypts and
+ // discards; the actual plaintext is irrelevant for M1.
+ byte[] readerIv = new byte[12];
+ readerIv[11] = 0x01;
+ byte[] plaintext = new byte[] { 0x42, 0x42, 0x42, 0x42 };
+ Cipher gcmEnc = Cipher.getInstance("AES/GCM/NoPadding");
+ gcmEnc.init(Cipher.ENCRYPT_MODE,
+ new SecretKeySpec(stepUpSKReader, "AES"),
+ new GCMParameterSpec(128, readerIv));
+ byte[] envelopeBody = gcmEnc.doFinal(plaintext); // 4 + 16 = 20 bytes
+ assertEquals(20, envelopeBody.length);
+
+ // Send ENVELOPE: CLA=0x00 INS=0xC3 (ISO-class command per Table 8-14).
+ ResponseAPDU envelopeResp = sim.transmitCommand(
+ new CommandAPDU(0x00, 0xC3, 0x00, 0x00, envelopeBody, 256));
+ assertEquals(0x9000, envelopeResp.getSW(),
+ "ENVELOPE with valid GCM tag must return SW=9000");
+
+ byte[] respBody = envelopeResp.getData();
+ assertEquals(17, respBody.length,
+ "M1 ENVELOPE response = 1 ciphertext byte + 16 GCM tag");
+
+ // Device-side decrypt under IV = 00 00 00 00 00 00 00 01 || 00 00 00 01
+ // (device-prefix per §8.3.1.6 + device_counter=1).
+ byte[] deviceIv = new byte[12];
+ deviceIv[7] = 0x01;
+ deviceIv[11] = 0x01;
+ Cipher gcmDec = Cipher.getInstance("AES/GCM/NoPadding");
+ gcmDec.init(Cipher.DECRYPT_MODE,
+ new SecretKeySpec(stepUpSKDevice, "AES"),
+ new GCMParameterSpec(128, deviceIv));
+ byte[] decoded = gcmDec.doFinal(respBody);
+ assertArrayEquals(new byte[] { (byte) 0xA0 }, decoded,
+ "M1 ENVELOPE response plaintext = canonical CBOR empty map (0xA0)");
+ }
+
+ /** HKDF-SHA-256 with empty salt and single-block Expand (L=32). Mirrors
+ * AliroCrypto.deriveStepUpSessionKeys's spec-pinned HKDF computation. */
+ private static byte[] hkdfStepUp(byte[] ikm, String info) throws Exception {
+ Mac extract = Mac.getInstance("HmacSHA256");
+ extract.init(new SecretKeySpec(new byte[32], "HmacSHA256"));
+ byte[] prk = extract.doFinal(ikm);
+ Mac expand = Mac.getInstance("HmacSHA256");
+ expand.init(new SecretKeySpec(prk, "HmacSHA256"));
+ expand.update(info.getBytes("US-ASCII"));
+ expand.update((byte) 0x01);
+ return expand.doFinal();
+ }
+
+ /** Pull the live StepUpApplet instance out of jcardsim so the test can
+ * reach the package-private testing accessors. Mirrors the reflection
+ * pattern in {@code PersonalizationAppletTest#getInstalledApplet}. */
+ private static StepUpApplet stepUpAppletInstance(CardSimulator sim, AID aid) throws Exception {
+ java.lang.reflect.Field runtimeField = null;
+ for (Class> c = sim.getClass(); c != null && runtimeField == null; c = c.getSuperclass()) {
+ try { runtimeField = c.getDeclaredField("runtime"); } catch (NoSuchFieldException ignore) {}
+ }
+ runtimeField.setAccessible(true);
+ Object runtime = runtimeField.get(sim);
+ java.lang.reflect.Method getApplet = runtime.getClass().getDeclaredMethod("getApplet", AID.class);
+ getApplet.setAccessible(true);
+ return (StepUpApplet) (Applet) getApplet.invoke(runtime, aid);
+ }
+
/** Unwraps the outer 6F FCI template to expose its nested TLVs. */
private static byte[] unwrap6F(byte[] fci) {
if (fci.length < 2 || fci[0] != 0x6F) {