From 0c7d4c642ebae1acac9710ae188c332e58eaa6b4 Mon Sep 17 00:00:00 2001 From: michael Date: Wed, 17 Jun 2026 16:50:47 -0700 Subject: [PATCH] feat: EXCHANGE Reader Status request validation + spec-shape response (M2E.1, M2E.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../dangerousthings/aliro/StepUpApplet.java | 97 +++++++++---- .../aliro/StepUpAppletTest.java | 132 ++++++++++++++---- harness/src/aliro_harness/reader/step_up.py | 42 ++++-- harness/tests/test_reader_step_up.py | 42 ++++-- 4 files changed, 236 insertions(+), 77 deletions(-) diff --git a/applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java b/applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java index 35cc410..afab6a6 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java +++ b/applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java @@ -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). * - *

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. + *

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: + *

+     * sub_event_id : 1B   ; 0x01 = ReaderStatusRequest (M2 only supports this)
+     * payload_len  : 1B
+     * payload      : Lb   ; empty for 0x01
+     * 
+ * + *

The applet validates the structure, then emits a Reader Status + * sub-event RESPONSE (Table 8-20) plaintext: + *

+     * sub_event_id : 1B   ; echoes 0x01
+     * status       : 1B   ; 0x00 = OK
+     * payload_len  : 1B   ; 0 for M2
+     * 
+ * 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); } /** diff --git a/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java b/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java index 1b29ac7..be75a3d 100644 --- a/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java +++ b/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java @@ -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. * - *

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. + *

M2 wire shape (plaintext under GCM): + *

+     * sub_event_id : 1B   ; 0x01 = ReaderStatusRequest
+     * payload_len  : 1B
+     * payload      : Lb   ; empty for ReaderStatusRequest
+     * 
+ * + *

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"); } /** diff --git a/harness/src/aliro_harness/reader/step_up.py b/harness/src/aliro_harness/reader/step_up.py index 0d40b88..67ed61f 100644 --- a/harness/src/aliro_harness/reader/step_up.py +++ b/harness/src/aliro_harness/reader/step_up.py @@ -2,13 +2,17 @@ Drives the M2 applet path post-AUTH1: - SELECT ACCE5502 (StepUpApplet) - - INS=0xC9 EXCHANGE encrypted with StepUpSKReader -> expect SW=9000, empty body. - (M1 behavior, unchanged in this commit; M2E.x will rework it.) + - INS=0xC9 EXCHANGE encrypted with StepUpSKReader carrying a Reader Status + sub-event REQUEST plaintext (spec §8.3.3.5 / Table 8-19): + sub_event_id = 0x01 (ReaderStatusRequest), payload_len = 0 + -> applet returns 19 B = 3 B response plaintext + 16 B GCM tag, decrypts + under StepUpSKDevice + deviceIv(1) to: + sub_event_id = 0x01, status = 0x00, payload_len = 0 (Table 8-20). - INS=0xC3 ENVELOPE encrypted with StepUpSKReader carrying a canonical CBOR mdoc DeviceRequest -> applet returns the first chunk of an encrypted DeviceResponse with SW=61xx. - INS=0xC0 GET RESPONSE repeated until SW=9000 — concatenated body decrypts - under StepUpSKDevice + deviceIv(1). + under StepUpSKDevice + deviceIv(2) (EXCHANGE consumed deviceIv(1)). - Plaintext is a canonical CBOR DeviceResponse; we walk ``documents[0].issuerSigned.issuerAuth`` and assert it round-trips the Access Document the reader was provisioned with. @@ -16,8 +20,9 @@ Drives the M2 applet path post-AUTH1: IV layout per applet (StepUpApplet.processExchange / processEnvelope): reader -> device : 0x00*8 || counter(4B BE) device -> reader : 0x00*7 || 0x01 || counter(4B BE) -Both counters init to 1; each advances by 1 after use. The ENVELOPE response -uses deviceCounter=1 (it's the first device-side message). +Both counters init to 1; each advances by 1 after use. EXCHANGE now consumes +deviceCounter=1 (encrypted Reader Status response), so the ENVELOPE response +ciphertext decrypts under deviceCounter=2. """ import cbor2 @@ -105,21 +110,32 @@ def verify_step_up_m2( if sw != SW_OK: return False, f"SELECT ACCE5502 failed: SW=0x{sw:04X}" - # M1B.1 -- EXCHANGE: M1 behavior (any plaintext, expect 9000+empty). M2E.x - # will rework this leg; until then we keep the M1 shape so the round-trip - # exercises both crypto contexts (EXCHANGE + ENVELOPE) under fresh keys. - pt = b"\x00" - ct = AESGCM(sk_reader).encrypt(_iv_reader(reader_counter), pt, None) + # M2E.1 + M2E.2 -- EXCHANGE: ship a Reader Status sub-event REQUEST + # ([sub_event_id=0x01, payload_len=0x00]), expect a 19 B encrypted Reader + # Status sub-event RESPONSE back ([sub_event_id=0x01, status=0x00, + # payload_len=0x00] under SKDevice + deviceIv(1)). + request_pt = b"\x01\x00" + ct = AESGCM(sk_reader).encrypt(_iv_reader(reader_counter), request_pt, None) apdu = bytes([CLA_PROPRIETARY, INS_EXCHANGE, 0x00, 0x00, len(ct)]) + ct + b"\x00" data, sw = transmit(apdu) if sw != SW_OK: return False, f"M2 EXCHANGE failed: SW=0x{sw:04X}" - if len(data) != 0: - return False, f"M2 EXCHANGE expected empty body, got {len(data)}B: {data.hex()}" + if len(data) != 19: + return False, f"M2 EXCHANGE expected 19B response, got {len(data)}B: {data.hex()}" + try: + exchange_pt = AESGCM(sk_device).decrypt(_iv_device(device_counter), data, None) + except Exception as e: + return False, f"M2 EXCHANGE response decrypt failed (tag/key mismatch): {e}" + if exchange_pt != b"\x01\x00\x00": + return False, ( + "M2 EXCHANGE response plaintext mismatch: expected " + f"[0x01, 0x00, 0x00], got {exchange_pt.hex()}" + ) reader_counter += 1 + device_counter += 1 # M2 -- ENVELOPE: ship a valid CBOR DeviceRequest, drain GET RESPONSE - # chaining, decrypt, and assert the round-tripped AD. + # chaining, decrypt under deviceCounter=2, and assert the round-tripped AD. ct = AESGCM(sk_reader).encrypt(_iv_reader(reader_counter), _DEVICE_REQUEST, None) apdu = bytes([CLA_ISO, INS_ENVELOPE, 0x00, 0x00, len(ct)]) + ct + b"\x00" first_body, first_sw = transmit(apdu) diff --git a/harness/tests/test_reader_step_up.py b/harness/tests/test_reader_step_up.py index 318a11b..c232619 100644 --- a/harness/tests/test_reader_step_up.py +++ b/harness/tests/test_reader_step_up.py @@ -114,10 +114,18 @@ def _iv_device(counter: int) -> bytes: class _MockTransmit: """Records APDUs and replies from a scripted SELECT/EXCHANGE/ENVELOPE/ - GET RESPONSE sequence. ENVELOPE replies with the first 252 B of a - pre-encrypted DeviceResponse and SW=61xx; GET RESPONSE drains the rest.""" + GET RESPONSE sequence. + + EXCHANGE replies with the spec Reader Status sub-event RESPONSE + ([0x01, 0x00, 0x00]) encrypted under SKDevice + deviceIv(1). The applet + consumes deviceCounter=1 on this encrypt, so the subsequent ENVELOPE + response uses deviceCounter=2. + + ENVELOPE replies with the first 252 B of a pre-encrypted DeviceResponse + and SW=61xx; GET RESPONSE drains the rest.""" CHUNK_LEN = 252 + EXCHANGE_RESPONSE_PT = b"\x01\x00\x00" def __init__( self, @@ -127,13 +135,22 @@ class _MockTransmit: ): self.sk_device = sk_device self.sk_reader = sk_reader - # Pre-encrypt the DeviceResponse under SKDevice + device-IV(counter=1). + # Pre-encrypt the EXCHANGE response under SKDevice + deviceIv(1). The + # applet's EXCHANGE handler consumes deviceCounter=1 first. + self.exchange_ct = AESGCM(sk_device).encrypt( + _iv_device(1), self.EXCHANGE_RESPONSE_PT, None + ) + # Pre-encrypt the DeviceResponse under SKDevice + deviceIv(2) — after + # EXCHANGE advanced the device counter from 1 -> 2. self.ct = AESGCM(sk_device).encrypt( - _iv_device(1), device_response_plaintext, None + _iv_device(2), device_response_plaintext, None ) self.ct_off = 0 self.apdus: list[bytes] = [] self.reader_counter = 1 + self.device_counter = 1 + # The Reader Status sub-event REQUEST the verifier is expected to send. + self.expected_exchange_pt = b"\x01\x00" # The DeviceRequest the verifier is expected to send. self.expected_request_pt = cbor2.dumps( { @@ -161,16 +178,23 @@ class _MockTransmit: if cla == 0x00 and ins == 0xA4: return b"", 0x9000 - # EXCHANGE — M1 behavior: decrypt one-byte plaintext, ack 9000+empty. + # EXCHANGE — decrypt [0x01, 0x00], reply with [0x01, 0x00, 0x00] + # encrypted under SKDevice + deviceIv(device_counter=1). if cla == 0x80 and ins == 0xC9: - # Sanity-check: payload should decrypt under reader-counter=1. lc = apdu[4] body = apdu[5 : 5 + lc] - AESGCM(self.sk_reader).decrypt(_iv_reader(self.reader_counter), body, None) + pt = AESGCM(self.sk_reader).decrypt( + _iv_reader(self.reader_counter), body, None + ) + assert pt == self.expected_exchange_pt, ( + f"EXCHANGE pt mismatch:\n got={pt.hex()}\n want={self.expected_exchange_pt.hex()}" + ) self.reader_counter += 1 - return b"", 0x9000 + self.device_counter += 1 + return self.exchange_ct, 0x9000 - # ENVELOPE — decrypt DeviceRequest under reader-counter=2, ship first chunk. + # ENVELOPE — decrypt DeviceRequest under reader-counter=2, ship first + # chunk of the response encrypted under deviceCounter=2. if cla == 0x00 and ins == 0xC3: lc = apdu[4] body = apdu[5 : 5 + lc]