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

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

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]