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

@@ -237,16 +237,30 @@ public class StepUpApplet extends Applet {
}
/**
* EXCHANGE (CLA=0x80, INS=0xC9) handler — Milestone 1 decrypt-and-discard.
* EXCHANGE (CLA=0x80, INS=0xC9) handler — M2E.1 (request validation) +
* M2E.2 (encrypted Reader Status response).
*
* <p>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.
* <p>Spec §8.3.3.5 / Tables 8-19 + 8-20. The reader sends
* {@code encrypted_payload || authentication_tag} under
* {@code StepUpSKReader} (IV {@code 0x00*8 || stepup_reader_counter},
* §8.3.1.8), empty AAD. The decrypted plaintext is the Reader Status
* sub-event REQUEST:
* <pre>
* sub_event_id : 1B ; 0x01 = ReaderStatusRequest (M2 only supports this)
* payload_len : 1B
* payload : Lb ; empty for 0x01
* </pre>
*
* <p>The applet validates the structure, then emits a Reader Status
* sub-event RESPONSE (Table 8-20) plaintext:
* <pre>
* sub_event_id : 1B ; echoes 0x01
* status : 1B ; 0x00 = OK
* payload_len : 1B ; 0 for M2
* </pre>
* GCM-encrypted under {@code StepUpSKDevice} + device IV
* ({@code 0x00*7 || 0x01 || stepup_device_counter}, §8.3.1.6). Ciphertext+
* tag is 3 + 16 = 19 B — fits in one APDU, no chaining needed.
*/
private void processExchange(APDU apdu) {
if (sessionFlags[FLAG_KEYS_READY] == 0) {
@@ -264,9 +278,6 @@ public class StepUpApplet extends Applet {
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);
@@ -276,9 +287,9 @@ public class StepUpApplet extends Applet {
// stepup_reader_counter, big-endian, in the trailing 4 bytes.
session.readerIv(ivScratch, (short) 0);
// 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.
// Decrypt. 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(
session.skReader, (short) 0,
@@ -291,19 +302,55 @@ public class StepUpApplet extends Applet {
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.
// Advance now so an early exit from request validation still leaves
// the counter at the post-decrypt value (the reader counter advances
// on every successful decrypt regardless of whether the request
// semantically validates).
session.advanceReaderCounter();
// 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);
// M2E.1: parse the request shape: [sub_event_id, payload_len, payload].
// Need at least sub_event_id + payload_len = 2 bytes.
if (ptLen < 2) {
Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0);
ISOException.throwIt(ISO7816.SW_WRONG_LENGTH);
}
byte subEventId = scratchPlaintext[0];
short payloadLen = (short) (scratchPlaintext[1] & 0xFF);
if (payloadLen != (short) (ptLen - 2)) {
Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0);
ISOException.throwIt(ISO7816.SW_WRONG_LENGTH);
}
// Only sub_event_id 0x01 (ReaderStatusRequest) is supported in M2.
// M2 ignores the payload contents for 0x01 (just length-validated above).
if (subEventId != (byte) 0x01) {
Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0);
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
}
// Wipe the request plaintext — content not needed past validation.
Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0);
// M2E.2: build the Reader Status response plaintext directly into
// the APDU buffer at offset 0, then encrypt in place. AliroGcm.encrypt
// supports out == pt at the same offset (CTR mode + appended tag).
buf[0] = (byte) 0x01; // sub_event_id (echoes request)
buf[1] = (byte) 0x00; // status = OK
buf[2] = (byte) 0x00; // payload_len = 0
short respPtLen = 3;
// Device-side IV: 0x00*7 || 0x01 || stepup_device_counter (§8.3.1.6).
session.deviceIv(ivScratch, (short) 0);
short ctLen = CryptoSingletons.getAliroGcm().encrypt(
session.skDevice, (short) 0,
ivScratch, (short) 0,
buf, (short) 0, respPtLen,
buf, (short) 0);
// Spec §8.3.1.6: device_counter <- device_counter + 1 after use.
session.advanceDeviceCounter();
apdu.setOutgoingAndSend((short) 0, ctLen);
}
/**