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

@@ -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]