From 9081990b0eb9cc86b3125c61b2e0aa605bffe82a Mon Sep 17 00:00:00 2001 From: michael Date: Wed, 17 Jun 2026 12:55:16 -0700 Subject: [PATCH] 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) --- applet/INSTALL.md | 27 +++++-- harness/src/aliro_harness/reader/cli.py | 23 +++++- harness/src/aliro_harness/reader/crypto.py | 13 ++++ harness/src/aliro_harness/reader/step_up.py | 74 +++++++++++++++++++ .../src/aliro_harness/reader/transaction.py | 4 + harness/tests/test_reader_step_up.py | 39 ++++++++++ 6 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 harness/src/aliro_harness/reader/step_up.py create mode 100644 harness/tests/test_reader_step_up.py diff --git a/applet/INSTALL.md b/applet/INSTALL.md index decf698..02dc831 100644 --- a/applet/INSTALL.md +++ b/applet/INSTALL.md @@ -38,7 +38,7 @@ java -jar gp.jar --info # confirm reader sees the card # Multi-applet CAPs: load the package once, then create each instance. # --create needs the instance AID, the applet class AID (same as instance # here), and the package AID (printed by --load). -PKG=A0000009094454414C49524F +PKG=A0000009094454414C49524F02 java -jar gp.jar --load applet/target/aliro-applet-j3r180.cap java -jar gp.jar --create A000000909ACCE5501 --applet A000000909ACCE5501 --package $PKG java -jar gp.jar --create A000000909ACCE5502 --applet A000000909ACCE5502 --package $PKG @@ -50,6 +50,21 @@ specify the right one with --[applet]"`, and `--create AID` without `--applet`/`--package` fails with `"Need --[package, pkg] and --[applet] or --[cap]"`.) +**Partial install / recovery**: if a `--create` call fails mid-sequence +(card got reset, USB hiccup, RF dropout), the package stays loaded. Just +re-run the failed `--create` — no need to redo `--load` or the earlier +`--create` calls. The package only needs `--delete` + `--load` again if +the package itself is in a bad state. + +**Credential store is package-static**: keys and the Access Document +written via `aliro-personalize` live on a static class shared by all +three applets. Personalization only requires `ACCE559901` to be +SELECTABLE; `5501` and `5502` can be `--create`d after personalize ran +and they'll see the same credentials. (Note: `aliro-personalize` +returning "Personalization complete" only confirms `559901` accepted the +writes — always cross-check `gp --list` to confirm `5501` and `5502` +are also SELECTABLE.) + Default GP test keys (`404142434445464748494A4B4C4D4E4F`) are tried automatically. For non-default keys, pass `--key ` to every command. @@ -61,10 +76,10 @@ Three applets get registered, one CAP: | `A0000009 09 ACCE 55 02` | `StepUpApplet` | Aliro STEP_UP phase (spec; scaffold only ATM)| | `A0000009 09 ACCE 55 99 01` | `PersonalizationApplet`| DT-internal provisioning channel | -All three share the package AID `A0000009 09 4454414C49524F` (CSA RID + -ASCII "DTALIRO"). JavaCard requires applets in one CAP to share the -package's RID, hence the proprietary AID for personalization is also -under the CSA RID with a non-spec PIX (`99 01`). +All three share the package AID `A0000009 09 4454414C49524F 02` (CSA RID ++ ASCII "DTALIRO" + version byte). JavaCard requires applets in one CAP +to share the package's RID, hence the proprietary AID for personalization +is also under the CSA RID with a non-spec PIX (`99 01`). ## Verify @@ -184,7 +199,7 @@ then the package: java -jar gp.jar --delete A000000909ACCE5501 java -jar gp.jar --delete A000000909ACCE5502 java -jar gp.jar --delete A000000909ACCE559901 -java -jar gp.jar --delete A0000009094454414C49524F +java -jar gp.jar --delete A0000009094454414C49524F02 ``` Then `--load` + the three `--create` commands above to reinstall the diff --git a/harness/src/aliro_harness/reader/cli.py b/harness/src/aliro_harness/reader/cli.py index 24948fb..837f404 100644 --- a/harness/src/aliro_harness/reader/cli.py +++ b/harness/src/aliro_harness/reader/cli.py @@ -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 diff --git a/harness/src/aliro_harness/reader/crypto.py b/harness/src/aliro_harness/reader/crypto.py index e66a70c..e2c3124 100644 --- a/harness/src/aliro_harness/reader/crypto.py +++ b/harness/src/aliro_harness/reader/crypto.py @@ -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 diff --git a/harness/src/aliro_harness/reader/step_up.py b/harness/src/aliro_harness/reader/step_up.py new file mode 100644 index 0000000..a7caaec --- /dev/null +++ b/harness/src/aliro_harness/reader/step_up.py @@ -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)" diff --git a/harness/src/aliro_harness/reader/transaction.py b/harness/src/aliro_harness/reader/transaction.py index 853465b..742fe78 100644 --- a/harness/src/aliro_harness/reader/transaction.py +++ b/harness/src/aliro_harness/reader/transaction.py @@ -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, ) diff --git a/harness/tests/test_reader_step_up.py b/harness/tests/test_reader_step_up.py new file mode 100644 index 0000000..108feb0 --- /dev/null +++ b/harness/tests/test_reader_step_up.py @@ -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)