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 }, so the getter lazy- + * creates on first call. Subsequent calls return the cached instance. The + * combined transient footprint of one shared {@link AliroCrypto} is roughly + * 352 B (kdfWorkbuf 64 + hkdfPrevT 32 + expandScratch 256); without this + * sharing each applet would allocate its own. + */ +final class CryptoSingletons { + + private static AliroCrypto aliroCrypto; + private static AliroGcm aliroGcm; + + private CryptoSingletons() { } + + /** Returns the process-wide {@link AliroCrypto} instance. Allocates on + * first call; cheap thereafter. */ + static AliroCrypto getAliroCrypto() { + if (aliroCrypto == null) { + aliroCrypto = new AliroCrypto(); + } + 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 7559182..69dc6e7 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java +++ b/applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java @@ -4,6 +4,7 @@ import javacard.framework.APDU; import javacard.framework.Applet; import javacard.framework.ISO7816; import javacard.framework.ISOException; +import javacard.framework.JCSystem; import javacard.framework.Util; /** @@ -52,15 +53,87 @@ import javacard.framework.Util; * implantable target but document the limitation. * * - *

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) {