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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
74
harness/src/aliro_harness/reader/step_up.py
Normal file
74
harness/src/aliro_harness/reader/step_up.py
Normal 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)"
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
39
harness/tests/test_reader_step_up.py
Normal file
39
harness/tests/test_reader_step_up.py
Normal 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)
|
||||
Reference in New Issue
Block a user