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:
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user