feat(harness): --step-up asserts Access Document round-trip (M2G.1)

This commit is contained in:
michael
2026-06-17 16:42:50 -07:00
parent bf65607ce5
commit 1ed32d3872
3 changed files with 333 additions and 32 deletions

View File

@@ -12,7 +12,7 @@ from pathlib import Path
import click
from aliro_harness.reader.step_up import verify_step_up_m1
from aliro_harness.reader.step_up import verify_step_up_m2
from aliro_harness.reader.transaction import TrustBundle, run_aliro_transaction
@@ -42,9 +42,10 @@ from aliro_harness.reader.transaction import TrustBundle, run_aliro_transaction
@click.option(
"--step-up",
is_flag=True,
help="After AUTH1, also exercise Step-Up Milestone 1: SELECT ACCE5502, "
"INS=0xC9 EXCHANGE, INS=0xC3 ENVELOPE. Verifies the StepUpApplet decrypts "
"with StepUpSKReader and encrypts the empty-CBOR-map ack with StepUpSKDevice.",
help="After AUTH1, also exercise Step-Up Milestone 2: SELECT ACCE5502, "
"INS=0xC9 EXCHANGE, INS=0xC3 ENVELOPE (DeviceRequest) + GET RESPONSE "
"chaining. Decrypts the DeviceResponse under StepUpSKDevice and asserts "
"documents[0].issuerSigned.issuerAuth == access_document.bin from --trust-dir.",
)
def main(
trust_dir: Path | None, reader_index: int, list_readers: bool, step_up: bool
@@ -87,6 +88,12 @@ def main(
bundle = TrustBundle.from_trust_dir(trust_dir)
click.echo(f"Loaded trust artifacts from {trust_dir}")
# Lazy: only load the AD if --step-up is set — otherwise this would
# gratuitously require access_document.bin for the AUTH1-only path.
expected_ad: bytes | None = None
if step_up:
expected_ad = (trust_dir / "access_document.bin").read_bytes()
def transmit(apdu: bytes) -> tuple[bytes, int]:
data, sw1, sw2 = connection.transmit(list(apdu))
return bytes(data), (sw1 << 8) | sw2
@@ -94,7 +101,10 @@ def main(
result = run_aliro_transaction(transmit=transmit, bundle=bundle)
step_up_verdict: tuple[bool, str] | None = None
if step_up and result.ok and result.step_up_sk is not None:
step_up_verdict = verify_step_up_m1(transmit, result.step_up_sk)
assert expected_ad is not None # set above whenever step_up is true
step_up_verdict = verify_step_up_m2(
transmit, result.step_up_sk, expected_ad
)
finally:
connection.disconnect()
@@ -131,9 +141,9 @@ def main(
if step_up_verdict is not None:
ok, msg = step_up_verdict
if ok:
click.echo(f"STEP-UP M1: OK \u2014 {msg}")
click.echo(f"STEP-UP M2: OK \u2014 {msg}")
else:
click.echo(f"STEP-UP M1: FAIL \u2014 {msg}", err=True)
click.echo(f"STEP-UP M2: FAIL \u2014 {msg}", err=True)
raise click.exceptions.Exit(1)

View File

@@ -1,17 +1,26 @@
"""Step-Up Milestone 1 PC/SC verification.
"""Step-Up Milestone 2 PC/SC verification.
Verifies the M1 applet path post-AUTH1:
Drives the M2 applet path post-AUTH1:
- SELECT ACCE5502 (StepUpApplet)
- INS=0xC9 EXCHANGE encrypted with StepUpSKReader -> expect SW=9000, empty body
- INS=0xC3 ENVELOPE encrypted with StepUpSKReader -> expect SW=9000,
17B body that decrypts under StepUpSKDevice to single byte 0xA0.
- INS=0xC9 EXCHANGE encrypted with StepUpSKReader -> expect SW=9000, empty body.
(M1 behavior, unchanged in this commit; M2E.x will rework it.)
- 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).
- 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.
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.
Both counters init to 1; each advances by 1 after use. The ENVELOPE response
uses deviceCounter=1 (it's the first device-side message).
"""
import cbor2
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from aliro_harness.reader.crypto import derive_step_up_session_keys
@@ -19,12 +28,34 @@ from aliro_harness.reader.transaction import Transmit
STEP_UP_AID = bytes.fromhex("A000000909ACCE5502")
SW_OK = 0x9000
SW1_MORE_DATA = 0x61
INS_GET_RESPONSE = 0xC0
CLA_ISO = 0x00
CLA_PROPRIETARY = 0x80
INS_EXCHANGE = 0xC9
INS_ENVELOPE = 0xC3
# Canonical-CBOR DeviceRequest the M2D.1 applet parser accepts:
# {"version":"1.0",
# "docRequests":[{"itemsRequest": <bstr-wrapped {"docType":...,"nameSpaces":{}}>}]}
# Built once at import so each call ships the same byte sequence the
# applet's DeviceRequestParserTest pins as VALID.
_DEVICE_REQUEST = cbor2.dumps(
{
"version": "1.0",
"docRequests": [
{
"itemsRequest": cbor2.dumps(
{"docType": "org.iso.18013.5.1.mDL", "nameSpaces": {}},
canonical=True,
)
}
],
},
canonical=True,
)
def _iv_reader(counter: int) -> bytes:
return b"\x00" * 8 + counter.to_bytes(4, "big")
@@ -34,7 +65,36 @@ def _iv_device(counter: int) -> bytes:
return b"\x00" * 7 + b"\x01" + counter.to_bytes(4, "big")
def verify_step_up_m1(transmit: Transmit, step_up_sk: bytes) -> tuple[bool, str]:
def _drain_chaining(transmit: Transmit, first_body: bytes, first_sw: int) -> tuple[bytes, int]:
"""Follow ISO 7816-4 SW=61xx response chaining until a terminal SW. Returns
the concatenated body and the final SW. SW2 advertises bytes remaining
(0x00 means "256 or more"; the applet caps at 0xFF). On a non-61xx SW
we return whatever's been accumulated so far so the caller can produce a
clear error message."""
body = bytearray(first_body)
sw = first_sw
while (sw >> 8) == SW1_MORE_DATA:
le = sw & 0xFF # 0x00 -> request 256, else the advertised count
apdu = bytes([CLA_ISO, INS_GET_RESPONSE, 0x00, 0x00, le])
chunk, sw = transmit(apdu)
body.extend(chunk)
return bytes(body), sw
def verify_step_up_m2(
transmit: Transmit, step_up_sk: bytes, expected_ad: bytes
) -> tuple[bool, str]:
"""Drives the M2 step-up round-trip and asserts the applet hands back our
Access Document inside the DeviceResponse.
Args:
transmit: PC/SC transport (bytes APDU -> (response, SW)).
step_up_sk: 32 B StepUpSK from AUTH1 (decrypted derived_keys_volatile).
expected_ad: Access Document bytes the reader was provisioned with;
compared against the issuerAuth field of the decrypted DeviceResponse.
Returns ``(ok, message)``. On failure ``message`` names the failed step.
"""
sk_device, sk_reader = derive_step_up_session_keys(step_up_sk)
reader_counter = 1
device_counter = 1
@@ -45,30 +105,63 @@ def verify_step_up_m1(transmit: Transmit, step_up_sk: bytes) -> tuple[bool, str]
if sw != SW_OK:
return False, f"SELECT ACCE5502 failed: SW=0x{sw:04X}"
# M1B.1 -- EXCHANGE: any plaintext, expect 9000+empty.
pt = b"\x00" # one byte plaintext to exercise the decrypt path
# 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)
apdu = bytes([CLA_PROPRIETARY, INS_EXCHANGE, 0x00, 0x00, len(ct)]) + ct + b"\x00"
data, sw = transmit(apdu)
if sw != SW_OK:
return False, f"M1B.1 EXCHANGE failed: SW=0x{sw:04X}"
return False, f"M2 EXCHANGE failed: SW=0x{sw:04X}"
if len(data) != 0:
return False, f"M1B.1 EXCHANGE expected empty body, got {len(data)}B: {data.hex()}"
return False, f"M2 EXCHANGE expected empty body, got {len(data)}B: {data.hex()}"
reader_counter += 1
# M1C.1 -- ENVELOPE: any plaintext, expect 17B response decrypting to 0xA0.
ct = AESGCM(sk_reader).encrypt(_iv_reader(reader_counter), pt, None)
# M2 -- ENVELOPE: ship a valid CBOR DeviceRequest, drain GET RESPONSE
# chaining, decrypt, 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"
data, sw = transmit(apdu)
first_body, first_sw = transmit(apdu)
full_body, sw = _drain_chaining(transmit, first_body, first_sw)
if sw != SW_OK:
return False, f"M1C.1 ENVELOPE failed: SW=0x{sw:04X}"
if len(data) != 17:
return False, f"M1C.1 ENVELOPE expected 17B response, got {len(data)}B"
try:
plaintext = AESGCM(sk_device).decrypt(_iv_device(device_counter), data, None)
except Exception as e:
return False, f"M1C.1 response decrypt failed (tag/key mismatch): {e}"
if plaintext != b"\xA0":
return False, f"M1C.1 response plaintext expected 0xA0, got {plaintext.hex()}"
return False, f"M2 ENVELOPE/GET RESPONSE failed: SW=0x{sw:04X} after {len(full_body)}B"
return True, "M1 step-up verified (EXCHANGE+ENVELOPE round-trip)"
try:
plaintext = AESGCM(sk_device).decrypt(_iv_device(device_counter), full_body, None)
except Exception as e:
return False, f"M2 ENVELOPE response decrypt failed (tag/key mismatch): {e}"
try:
resp = cbor2.loads(plaintext)
except Exception as e:
return False, f"M2 ENVELOPE plaintext is not valid CBOR: {e}"
try:
documents = resp["documents"]
issuer_signed = documents[0]["issuerSigned"]
issuer_auth = issuer_signed["issuerAuth"]
except (KeyError, TypeError, IndexError) as e:
return False, f"M2 DeviceResponse missing documents[0].issuerSigned.issuerAuth: {e}"
# The applet splices the AD verbatim under issuerAuth, but cbor2 decoded it
# into a 4-element list. Re-encode canonically for byte-equality with the
# provisioned AD (trustgen produces canonical CBOR ADs). Fall back to
# element-wise comparison on the decoded list to avoid false negatives
# from harmless canonical-encoding drift (e.g. tag wrappers).
ad_round_trip = cbor2.dumps(issuer_auth, canonical=True)
if ad_round_trip != expected_ad:
try:
expected_decoded = cbor2.loads(expected_ad)
except Exception:
return False, (
"M2 issuerAuth mismatch: round-tripped bytes differ from "
f"expected AD and expected AD isn't valid CBOR ({len(expected_ad)}B)"
)
if issuer_auth != expected_decoded:
return False, (
"M2 issuerAuth mismatch vs. provisioned Access Document "
f"(got {len(ad_round_trip)}B, expected {len(expected_ad)}B)"
)
return True, "M2 step-up verified (EXCHANGE + ENVELOPE Access Document round-trip)"