feat(harness): aliro-bench-test --step-up + INSTALL.md install-recovery notes

Adds a PC/SC verification path for Step-Up Milestone 1 so M1 can be
validated against a personalized card without depending on Nucleo /
X-CUBE-ALIRO bring-up. Verdict run on J3R452 04555A4A0B2190 with M1
CAP installed: AUTH1 OK (3.4s), EXCHANGE 0xC9 OK, ENVELOPE 0xC3 OK
with response decrypting under StepUpSKDevice to the spec 0xA0 ack.

- crypto.py: derive_step_up_session_keys (HKDF parity with
  AliroCrypto.deriveStepUpSessionKeys)
- transaction.py: expose step_up_sk on TransactionResult
- step_up.py: verify_step_up_m1 -- SELECT 5502 + GCM-encrypted
  C9/C3 round-trip, IVs per StepUpApplet (0x00*8 || counter
  reader-side; 0x00*7 || 0x01 || counter device-side)
- cli.py: --step-up flag on aliro-bench-test
- tests: stdlib-RFC-5869 cross-check on the new KDF (122/122 green)
- INSTALL.md: fix multi-place PKG AID typo (missing 02 version byte),
  document partial-install recovery, document package-static
  credential store (aliro-personalize success !=> 5501/5502 installed)
This commit is contained in:
michael
2026-06-17 12:55:16 -07:00
parent 9189b41e7f
commit 9081990b0e
6 changed files with 173 additions and 7 deletions

View File

@@ -12,6 +12,7 @@ from pathlib import Path
import click
from aliro_harness.reader.step_up import verify_step_up_m1
from aliro_harness.reader.transaction import TrustBundle, run_aliro_transaction
@@ -38,7 +39,16 @@ from aliro_harness.reader.transaction import TrustBundle, run_aliro_transaction
is_flag=True,
help="List PC/SC readers visible to the host and exit.",
)
def main(trust_dir: Path | None, reader_index: int, list_readers: bool) -> None:
@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.",
)
def main(
trust_dir: Path | None, reader_index: int, list_readers: bool, step_up: bool
) -> None:
"""Run a single Aliro AUTH0+AUTH1 transaction against a personalized
card via PC/SC. Decrypts the response and verifies the UD signature.
Exits 0 on success, non-zero with a clear error on any failure."""
@@ -82,6 +92,9 @@ def main(trust_dir: Path | None, reader_index: int, list_readers: bool) -> None:
return bytes(data), (sw1 << 8) | sw2
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)
finally:
connection.disconnect()
@@ -115,6 +128,14 @@ def main(trust_dir: Path | None, reader_index: int, list_readers: bool) -> None:
_echo_tlv_breakdown(result)
_echo_latencies(result)
if step_up_verdict is not None:
ok, msg = step_up_verdict
if ok:
click.echo(f"STEP-UP M1: OK \u2014 {msg}")
else:
click.echo(f"STEP-UP M1: FAIL \u2014 {msg}", err=True)
raise click.exceptions.Exit(1)
def _echo_tlv_breakdown(result) -> None:
cred_pub_len = len(result.credential_pub) if result.credential_pub else 0

View File

@@ -59,3 +59,16 @@ def derive_kdh(
h.update(b"\x00\x00\x00\x01") # counter = 1 (4 B BE)
h.update(transaction_id) # SharedInfo (16 B)
return h.digest() # 32 B
def derive_step_up_session_keys(step_up_sk: bytes) -> tuple[bytes, bytes]:
"""§8.4.3 (mdoc 9.1.1.5 with Aliro changes): HKDF-SHA-256 with IKM =
StepUpSK, empty salt, info = ``b"SKDevice"`` / ``b"SKReader"``, L = 32.
Returns ``(sk_device, sk_reader)``. Mirrors
``AliroCrypto.deriveStepUpSessionKeys`` byte-for-byte.
"""
if len(step_up_sk) != 32:
raise ValueError(f"StepUpSK must be 32B, got {len(step_up_sk)}")
sk_device = hkdf_sha256(step_up_sk, b"", b"SKDevice", 32)
sk_reader = hkdf_sha256(step_up_sk, b"", b"SKReader", 32)
return sk_device, sk_reader

View File

@@ -0,0 +1,74 @@
"""Step-Up Milestone 1 PC/SC verification.
Verifies the M1 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.
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.
"""
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from aliro_harness.reader.crypto import derive_step_up_session_keys
from aliro_harness.reader.transaction import Transmit
STEP_UP_AID = bytes.fromhex("A000000909ACCE5502")
SW_OK = 0x9000
CLA_ISO = 0x00
CLA_PROPRIETARY = 0x80
INS_EXCHANGE = 0xC9
INS_ENVELOPE = 0xC3
def _iv_reader(counter: int) -> bytes:
return b"\x00" * 8 + counter.to_bytes(4, "big")
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]:
sk_device, sk_reader = derive_step_up_session_keys(step_up_sk)
reader_counter = 1
device_counter = 1
# SELECT ACCE5502 -- M1A.2 derives StepUpSKReader/Device on this select().
apdu = bytes([CLA_ISO, 0xA4, 0x04, 0x00, len(STEP_UP_AID)]) + STEP_UP_AID
_, sw = transmit(apdu)
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
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}"
if len(data) != 0:
return False, f"M1B.1 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)
apdu = bytes([CLA_ISO, INS_ENVELOPE, 0x00, 0x00, len(ct)]) + ct + b"\x00"
data, sw = transmit(apdu)
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 True, "M1 step-up verified (EXCHANGE+ENVELOPE round-trip)"

View File

@@ -31,6 +31,7 @@ from aliro_harness.reader.auth1_response import (
from aliro_harness.reader.crypto import derive_kdh
from aliro_harness.reader.key_derivation import (
OFF_EXPEDITED_SK_DEVICE,
OFF_STEP_UP_SK,
build_salt_volatile,
derive_expedited_session_keys,
)
@@ -123,6 +124,7 @@ class TransactionResult:
credential_pub: bytes | None = None # 0x5A value (65B uncompressed) when present
ud_sig: bytes | None = None # 0x9E value (64B raw r||s)
signaling_bitmap: bytes | None = None # 0x5E value (2B)
step_up_sk: bytes | None = None # 32B StepUpSK from derived_keys_volatile[64..96)
error: str | None = None # populated on failure
# Wall-clock per-APDU latencies in milliseconds. Populated as each step
# runs, so a partial result on failure still surfaces what we measured
@@ -223,6 +225,7 @@ def run_aliro_transaction(
kdh, salt_volatile, cred_ephem_pub_x
)
sk_device = derived_keys[OFF_EXPEDITED_SK_DEVICE : OFF_EXPEDITED_SK_DEVICE + 32]
step_up_sk = derived_keys[OFF_STEP_UP_SK : OFF_STEP_UP_SK + 32]
# AUTH1
reader_id_32 = bundle.reader_group_id + bundle.reader_group_sub_id
@@ -301,5 +304,6 @@ def run_aliro_transaction(
credential_pub=cred_pub_5a,
ud_sig=ud_sig,
signaling_bitmap=bitmap,
step_up_sk=step_up_sk,
latencies_ms=latencies,
)

View File

@@ -0,0 +1,39 @@
"""Smoke test for step-up key derivation parity with the applet."""
import hashlib
import hmac
from aliro_harness.reader.crypto import derive_step_up_session_keys
def _hkdf_manual(ikm: bytes, salt: bytes, info: bytes, length: int) -> bytes:
"""Bare-stdlib RFC 5869 reference. Independent of `cryptography` so the
cross-check actually catches a wrong-info / wrong-salt regression in
derive_step_up_session_keys."""
use_salt = salt if salt else b"\x00" * 32
prk = hmac.new(use_salt, ikm, hashlib.sha256).digest()
out = b""
t = b""
counter = 1
while len(out) < length:
t = hmac.new(prk, t + info + bytes([counter]), hashlib.sha256).digest()
out += t
counter += 1
return out[:length]
def test_derive_step_up_session_keys_matches_rfc5869():
# Same IKM as AliroCryptoTest.deriveStepUpSessionKeysMatchesManualHkdf:
# bytes 0xC0..0xDF -- so this Python test pins the same vector the
# applet test pins.
step_up_sk = bytes(range(0xC0, 0xE0))
sk_device, sk_reader = derive_step_up_session_keys(step_up_sk)
assert len(sk_device) == 32
assert len(sk_reader) == 32
assert sk_device != sk_reader
assert sk_device != step_up_sk
assert sk_device == _hkdf_manual(step_up_sk, b"", b"SKDevice", 32)
assert sk_reader == _hkdf_manual(step_up_sk, b"", b"SKReader", 32)