From 96816bc2323aea75e01886eb0692be34cc5105fc Mon Sep 17 00:00:00 2001 From: michael Date: Thu, 11 Jun 2026 10:45:22 -0700 Subject: [PATCH] feat(applet): StepUpApplet INS_EXCHANGE decrypt-and-discard with 9000 ack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X-CUBE-ALIRO firmware sends a Reader Status sub-event via EXCHANGE (CLA=0x80, INS=0xC9) after the step-up AID SELECT, per Aliro §8.3.3.5 Table 8-14. The payload is encrypted with StepUpSKReader using AES-256-GCM with IV = 0x0000000000000000 || stepup_reader_counter (4B BE) per §8.3.1.8. For Milestone 1 we decrypt-and-discard: tag verification proves we have matching session keys, then we return SW=9000 with empty payload. M2 will add a proper encrypted Reader Status response sub-event. Extends CryptoSingletons to hold the shared AliroGcm instance too -- opt 1 sibling of the AliroCrypto sharing from M1A.2. Adds AliroGcm.decrypt since the prior pipeline only encrypted (AUTH1 response path). Counter starts at 1 per session-bound init (spec §8.4.3 -> mdoc [6] §9.1.1.5), incremented after each successful decrypt. Co-Authored-By: Claude Opus 4.7 --- .../dangerousthings/aliro/AliroApplet.java | 21 +-- .../com/dangerousthings/aliro/AliroGcm.java | 151 ++++++++++++++++++ .../aliro/CryptoSingletons.java | 16 ++ .../dangerousthings/aliro/StepUpApplet.java | 144 ++++++++++++++++- .../aliro/StepUpAppletTest.java | 77 +++++++++ 5 files changed, 398 insertions(+), 11 deletions(-) diff --git a/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java b/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java index 9904401..6935c45 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java +++ b/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java @@ -134,13 +134,12 @@ 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) // are pulled from CryptoSingletons.getAliroCrypto() so both AliroApplet @@ -291,7 +290,10 @@ 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 { @@ -457,6 +459,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, @@ -964,7 +967,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 index 859d56f..b283406 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/CryptoSingletons.java +++ b/applet/src/main/java/com/dangerousthings/aliro/CryptoSingletons.java @@ -15,6 +15,7 @@ package com.dangerousthings.aliro; final class CryptoSingletons { private static AliroCrypto aliroCrypto; + private static AliroGcm aliroGcm; private CryptoSingletons() { } @@ -26,4 +27,19 @@ final class CryptoSingletons { } return aliroCrypto; } + + /** Returns the process-wide {@link AliroGcm} instance. Same Java-Card + * {@code }-cannot-{@code new} rationale as {@link #getAliroCrypto()}: + * lazy-allocate on first call so both {@link AliroApplet} and + * {@link StepUpApplet} share one userland-GCM machine. Each shared + * instance saves ~370 B of transient/EEPROM footprint that a per-applet + * duplicate would otherwise pay. The two applets are never selected + * simultaneously and the JCRE serializes APDU dispatch, so the shared + * scratch and {@code AESKey} slot don't race. */ + static AliroGcm getAliroGcm() { + if (aliroGcm == null) { + aliroGcm = new AliroGcm(); + } + return aliroGcm; + } } diff --git a/applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java b/applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java index f1e415a..5d279e2 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java +++ b/applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java @@ -62,10 +62,19 @@ import javacard.framework.Util; public class StepUpApplet extends Applet { 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; /** 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. */ @@ -81,6 +90,34 @@ public class StepUpApplet extends Applet { * 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. */ + private final byte[] stepUpReaderCounter; + + /** 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(); if (bArray == null || bLength == 0) { @@ -98,6 +135,10 @@ public class StepUpApplet extends Applet { 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); + 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); } /** @@ -121,6 +162,16 @@ public class StepUpApplet extends Applet { // 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 reader counter + // initialized to 0x00000001 on session entry. CLEAR_ON_DESELECT + // already zeroes it on each fresh select; rewrite explicitly so a + // Step-Up SELECT mid-session (without a deselect in between) also + // starts the counter at 1. + Util.arrayFillNonAtomic(stepUpReaderCounter, (short) 0, COUNTER_LEN, (byte) 0); + stepUpReaderCounter[3] = (byte) 0x01; + sessionFlags[FLAG_KEYS_READY] = (byte) 1; + } else { + sessionFlags[FLAG_KEYS_READY] = (byte) 0; } return true; } @@ -137,11 +188,100 @@ public class StepUpApplet extends Applet { 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. + byte ins = buf[ISO7816.OFFSET_INS]; + if (ins == INS_EXCHANGE) { + processExchange(apdu); + return; + } + + // ENVELOPE + GET RESPONSE handlers plug 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); + } + + /** 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 diff --git a/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java b/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java index 4f33059..09a78ca 100644 --- a/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java +++ b/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java @@ -4,7 +4,9 @@ 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; @@ -142,6 +144,81 @@ class StepUpAppletTest { "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)"); + } + /** 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 {