feat: EXCHANGE Reader Status request validation + spec-shape response (M2E.1, M2E.2)

M2E.1: parse the EXCHANGE plaintext as Aliro §8.3.3.5 / Table 8-19
[sub_event_id, payload_len, payload], reject SW_WRONG_LENGTH on truncation
or length mismatch and SW_DATA_INVALID on unknown sub_event_id (M2 only
supports 0x01 = ReaderStatusRequest).

M2E.2: emit a Reader Status sub-event RESPONSE (Table 8-20) plaintext
[0x01, 0x00, 0x00] in place in the APDU buffer, GCM-encrypted under
StepUpSKDevice + deviceIv(stepup_device_counter) per §8.3.1.6. Ciphertext+
tag = 19 B, single APDU, no chaining.

Harness verify_step_up_m2 + the test mock now ship the spec request shape,
decrypt the 19 B EXCHANGE response under deviceCounter=1, and decrypt the
subsequent ENVELOPE response under deviceCounter=2 (EXCHANGE consumed 1).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
michael
2026-06-17 16:50:47 -07:00
parent f0765eb329
commit 0c7d4c642e
4 changed files with 236 additions and 77 deletions

View File

@@ -145,21 +145,24 @@ class StepUpAppletTest {
}
/**
* 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).
* After SELECT-Step-Up has armed {@code StepUpSKReader}/{@code StepUpSKDevice},
* the X-CUBE-ALIRO firmware sends a "Reader Status sub-event request" via
* the EXCHANGE command (CLA=0x80, INS=0xC9) per spec §8.3.3.5 / Table 8-19.
*
* <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.
* <p>M2 wire shape (plaintext under GCM):
* <pre>
* sub_event_id : 1B ; 0x01 = ReaderStatusRequest
* payload_len : 1B
* payload : Lb ; empty for ReaderStatusRequest
* </pre>
*
* <p>The applet validates the structure, then emits a Reader Status response
* sub-event (Table 8-20) plaintext {@code [sub_event_id=0x01, status=0x00,
* payload_len=0x00]}, GCM-encrypted under {@code StepUpSKDevice} +
* device IV (counter=1). Ciphertext+tag = 3 + 16 = 19 B; fits in one APDU.
*/
@Test
void exchangeAfterStepUpSelectDecryptsAndAcksWithEmptyPayload() throws Exception {
void exchangeReturnsEncryptedReaderStatusResponse() throws Exception {
sim = new CardSimulator();
AID expeditedAid = new AID(AliroAids.EXPEDITED, (short) 0, (byte) AliroAids.EXPEDITED.length);
sim.installApplet(expeditedAid, AliroApplet.class);
@@ -170,8 +173,6 @@ class StepUpAppletTest {
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");
@@ -188,35 +189,106 @@ class StepUpAppletTest {
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");
byte[] stepUpSKDevice = hkdfStepUp(stepUpSK, "SKDevice");
// SELECT the Step-Up AID -- arms StepUpApplet's StepUpSKReader and
// initializes its stepup_reader_counter session-bound to 0x00000001.
// SELECT the Step-Up AID -- arms session keys + 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 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,
// Reader-side encrypt of the spec Reader Status request plaintext:
// sub_event_id=0x01, payload_len=0x00 (no payload bytes follow).
byte[] readerIv = new byte[12];
readerIv[11] = 0x01;
byte[] requestPt = new byte[] { 0x01, 0x00 };
Cipher gcmEnc = Cipher.getInstance("AES/GCM/NoPadding");
gcmEnc.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);
new GCMParameterSpec(128, readerIv));
byte[] ctAndTag = gcmEnc.doFinal(requestPt); // 2 + 16 = 18 bytes
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)");
"EXCHANGE with valid Reader Status request must return SW=9000");
byte[] respCt = exchangeResp.getData();
assertEquals(19, respCt.length,
"Reader Status response plaintext is 3 B + 16 B GCM tag = 19 B");
// Decrypt under StepUpSKDevice + device IV (counter=1 — this is the
// first device-side message in the Step-Up session).
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[] respPt = gcmDec.doFinal(respCt);
assertArrayEquals(new byte[] { 0x01, 0x00, 0x00 }, respPt,
"Reader Status response plaintext = [sub_event_id=0x01, status=0x00, payload_len=0x00]");
}
/**
* EXCHANGE with an unknown {@code sub_event_id} must return
* {@code SW_DATA_INVALID (0x6984)} per M2 spec §8.3.3.5 — M2 only supports
* sub_event_id 0x01 (ReaderStatusRequest); 0x02 (TransactionEnd) and
* higher are reserved / not implemented.
*/
@Test
void exchangeWithUnknownSubEventIdReturnsDataInvalid() 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);
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");
byte[] stepUpSK = java.util.Arrays.copyOfRange(
reader.deriveExpeditedKeyMaterial(credentialEphemPubKey), 64, 96);
byte[] stepUpSKReader = hkdfStepUp(stepUpSK, "SKReader");
assertEquals(0x9000, sim.transmitCommand(
new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256)).getSW(),
"SELECT step-up must succeed");
// Unknown sub_event_id (0x02 = TransactionEnd, reserved for M3+).
byte[] readerIv = new byte[12];
readerIv[11] = 0x01;
byte[] requestPt = new byte[] { 0x02, 0x00 };
Cipher gcmEnc = Cipher.getInstance("AES/GCM/NoPadding");
gcmEnc.init(Cipher.ENCRYPT_MODE,
new SecretKeySpec(stepUpSKReader, "AES"),
new GCMParameterSpec(128, readerIv));
byte[] ctAndTag = gcmEnc.doFinal(requestPt);
ResponseAPDU exchangeResp = sim.transmitCommand(
new CommandAPDU(0x80, 0xC9, 0x00, 0x00, ctAndTag, 256));
assertEquals(0x6984, exchangeResp.getSW(),
"unknown sub_event_id must return SW_DATA_INVALID");
}
/**