feat(applet): StepUpApplet INS_EXCHANGE decrypt-and-discard with 9000 ack

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 <noreply@anthropic.com>
This commit is contained in:
michael
2026-06-11 10:45:22 -07:00
parent 8e999b770d
commit 96816bc232
5 changed files with 398 additions and 11 deletions

View File

@@ -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).
*
* <p>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 {