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)