# PC/SC Bench Reader Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Build a Python "fake reader" that drives the personalized J3R180 through a complete Aliro AUTH0+AUTH1 transaction over the user's existing PC/SC reader, decrypts the response, verifies the UD signature, and reports pass/fail. Validates the applet end-to-end on real hardware with zero firmware work. **Architecture:** Port the Java reference reader (`applet/src/test/java/com/dangerousthings/aliro/ReaderSide.java`, 410 LOC) to Python, wire it to the existing harness's pyscard transport (already used by `aliro-personalize`), expose as `aliro-bench-test` CLI. The protocol is agnostic to whether bytes flow over PC/SC contactless or via embedded NFC firmware — same APDUs either way. **Tech Stack:** Python 3.11+, `cryptography` (P-256 ECDH/ECDSA, AES-GCM, HKDF, MessageDigest), `pyscard` (already in deps), `click` (CLI). All deps already present in `harness/pyproject.toml`. --- ## Pre-work: read the reference **Required reading before implementing:** - `applet/src/test/java/com/dangerousthings/aliro/ReaderSide.java` — the Java reference. Every method has a Python equivalent in this plan. - `applet/src/test/java/com/dangerousthings/aliro/Auth0Command.java` — AUTH0 TLV builder. - `applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java` — `processAuth0`, `processAuth1`, `buildSaltVolatile`, `buildTable813`. The reader mirrors this exactly. - `harness/src/aliro_harness/personalizer/orchestrator.py` — pyscard transport pattern to reuse. - 26-42802-001_Aliro_1.0_specification §8.3.1.13 (salt_volatile composition), §8.3.1.6 (AES-GCM details), §8.3.3.4.3 (Tables 8-12, 8-13). **Reference trust constants (from our applet — used to compute salt_volatile bit-for-bit identically):** - `PROPRIETARY_A5_TLV` = `A5 08 80 02 00 00 5C 02 01 00` (10 bytes) - `INTERFACE_BYTE_NFC` = `0x5E` - `SALT_VOLATILE_TAG` = ASCII `"Volatile****"` (12 bytes) - `UD_SIGN_USAGE` = `4E 88 7B 4C` (Table 8-13 usage tag) - Reader sig usage = `41 5D 95 69` (Table 8-12 usage tag) - Protocol version = `0x0100` --- ## Task 1: ReaderSession scaffold + project layout **Files:** - Create: `harness/src/aliro_harness/reader/__init__.py` - Create: `harness/src/aliro_harness/reader/session.py` - Create: `harness/tests/test_reader_session.py` **Step 1: Create the package directory** ```bash mkdir -p harness/src/aliro_harness/reader ``` **Step 2: Write the failing test** ```python # harness/tests/test_reader_session.py from aliro_harness.reader.session import ReaderSession def test_session_starts_with_no_transaction_state(): s = ReaderSession.fresh() assert s.reader_ephem_priv is None assert s.transaction_id is None assert s.credential_ephem_pubk is None ``` **Step 3: Run — expect ImportError** ```bash cd harness && . .venv/bin/activate && pytest tests/test_reader_session.py -v ``` Expected: `ModuleNotFoundError: No module named 'aliro_harness.reader.session'` **Step 4: Minimal implementation** ```python # harness/src/aliro_harness/reader/session.py """Per-transaction reader-side state for an Aliro NFC transaction.""" from dataclasses import dataclass, field from typing import Optional from cryptography.hazmat.primitives.asymmetric import ec @dataclass class ReaderSession: """Mutable per-transaction state. Mirrors fields kept in the Java ReaderSide test fixture (applet/src/test/.../ReaderSide.java).""" reader_ephem_priv: Optional[ec.EllipticCurvePrivateKey] = None transaction_id: Optional[bytes] = None credential_ephem_pubk: Optional[bytes] = None # 65B uncompressed, captured from AUTH0 response @classmethod def fresh(cls) -> "ReaderSession": return cls() ``` **Step 5: Run — expect PASS** ```bash pytest tests/test_reader_session.py::test_session_starts_with_no_transaction_state -v ``` **Step 6: Commit** ```bash git add harness/src/aliro_harness/reader/ harness/tests/test_reader_session.py git commit -m "feat(reader): scaffold ReaderSession dataclass for Aliro bench reader" ``` --- ## Task 2: Auth0 command builder **Files:** - Create: `harness/src/aliro_harness/reader/auth0.py` - Create: `harness/tests/test_reader_auth0.py` **Step 1: Failing test for the data-field shape** ```python # harness/tests/test_reader_auth0.py from aliro_harness.reader.auth0 import build_auth0_data def test_auth0_data_field_carries_six_mandatory_tlvs_in_spec_order(): reader_ephem_uncomp = bytes([0x04]) + b"\x11" * 32 + b"\x22" * 32 data = build_auth0_data( reader_ephem_pub_uncompressed=reader_ephem_uncomp, reader_group_id=b"\xaa" * 16, reader_group_sub_id=b"\xbb" * 16, transaction_id=b"\xcc" * 16, command_parameters=0x00, authentication_policy=0x00, ) assert data[0:3] == bytes([0x41, 0x01, 0x00]) # command_parameters assert data[3:6] == bytes([0x42, 0x01, 0x00]) # authentication_policy assert data[6:10] == bytes([0x5C, 0x02, 0x01, 0x00]) # protocol_version assert data[10:12] == bytes([0x87, 0x41]) # reader_ePubK header (65B) assert data[12:77] == reader_ephem_uncomp assert data[77:79] == bytes([0x4C, 0x10]) # transaction_id header assert data[79:95] == b"\xcc" * 16 assert data[95:97] == bytes([0x4D, 0x20]) # reader_identifier (32B = group||sub) assert data[97:113] == b"\xaa" * 16 assert data[113:129] == b"\xbb" * 16 assert len(data) == 129 ``` **Step 2: Run — expect ImportError → fail** **Step 3: Implementation** ```python # harness/src/aliro_harness/reader/auth0.py """AUTH0 command-data-field builder (spec §8.3.3.2.1, Table 8-4). Mirrors applet/src/test/java/com/dangerousthings/aliro/Auth0Command.java buildStandardData(). The applet validates this in AliroApplet.validateAuth0Data — both sides must agree byte-for-byte. """ from io import BytesIO CLA = 0x80 INS_AUTH0 = 0x80 PROTOCOL_VERSION_1_0 = 0x0100 def build_auth0_data( *, reader_ephem_pub_uncompressed: bytes, reader_group_id: bytes, reader_group_sub_id: bytes, transaction_id: bytes, command_parameters: int = 0x00, authentication_policy: int = 0x00, ) -> bytes: if len(reader_ephem_pub_uncompressed) != 65 or reader_ephem_pub_uncompressed[0] != 0x04: raise ValueError("reader_ephem_pub must be 65B uncompressed (0x04 || x || y)") if len(reader_group_id) != 16 or len(reader_group_sub_id) != 16: raise ValueError("reader_group_{id,sub_id} must each be 16B") if len(transaction_id) != 16: raise ValueError("transaction_identifier must be 16B") out = BytesIO() _write_tlv(out, 0x41, bytes([command_parameters])) _write_tlv(out, 0x42, bytes([authentication_policy])) _write_tlv(out, 0x5C, PROTOCOL_VERSION_1_0.to_bytes(2, "big")) _write_tlv(out, 0x87, reader_ephem_pub_uncompressed) _write_tlv(out, 0x4C, transaction_id) _write_tlv(out, 0x4D, reader_group_id + reader_group_sub_id) return out.getvalue() def _write_tlv(out: BytesIO, tag: int, value: bytes) -> None: out.write(bytes([tag, len(value)])) out.write(value) def build_auth0_apdu(data: bytes) -> bytes: """Wraps AUTH0 data-field in a short-form APDU: 80 80 00 00 Lc data.""" return bytes([CLA, INS_AUTH0, 0x00, 0x00, len(data)]) + data ``` **Step 4: Run — expect PASS** **Step 5: Add APDU-shape test + length-validation tests, run, expect PASS** ```python def test_build_auth0_apdu_wraps_data_in_short_form(): apdu = build_auth0_apdu(b"\x41\x01\x00") assert apdu == bytes([0x80, 0x80, 0x00, 0x00, 0x03, 0x41, 0x01, 0x00]) def test_reader_ephem_must_be_uncompressed(): import pytest with pytest.raises(ValueError, match="65B uncompressed"): build_auth0_data( reader_ephem_pub_uncompressed=b"\x02" + b"\x11" * 32, # compressed reader_group_id=b"\xaa" * 16, reader_group_sub_id=b"\xbb" * 16, transaction_id=b"\xcc" * 16, ) ``` **Step 6: Commit** ```bash git add harness/src/aliro_harness/reader/auth0.py harness/tests/test_reader_auth0.py git commit -m "feat(reader): AUTH0 command builder per spec §8.3.3.2.1 Table 8-4" ``` --- ## Task 3: TLV reader for AUTH0 response parsing **Files:** - Create: `harness/src/aliro_harness/reader/tlv.py` - Create: `harness/tests/test_reader_tlv.py` **Step 1: Failing test** ```python # harness/tests/test_reader_tlv.py from aliro_harness.reader.tlv import find_top_level def test_find_top_level_returns_value_bytes(): # 86 41 [65B value] 9D 02 [2B value] blob = bytes([0x86, 0x41]) + b"\xaa" * 65 + bytes([0x9D, 0x02, 0xff, 0xee]) assert find_top_level(blob, 0x86) == b"\xaa" * 65 assert find_top_level(blob, 0x9D) == bytes([0xff, 0xee]) assert find_top_level(blob, 0x42) is None ``` **Step 2: Run, expect FAIL** **Step 3: Implementation (mirror `applet/src/test/java/com/dangerousthings/aliro/TlvUtil.java`)** ```python # harness/src/aliro_harness/reader/tlv.py """Minimal TLV reader for AUTH0/AUTH1 response parsing. Mirrors applet/src/test/.../TlvUtil.java. Single-byte tags only (Aliro uses BER-TLV but in v1 every tag we care about is single-byte). Short-form lengths plus 0x81 / 0x82 long-form for sizes ≥ 128. """ def find_top_level(data: bytes, tag: int) -> bytes | None: """Returns the value bytes of the first TLV whose tag matches, or None. Does not recurse into constructed (0xA*) values.""" i = 0 while i < len(data): cur_tag = data[i] i += 1 length = data[i] i += 1 if length == 0x81: length = data[i] i += 1 elif length == 0x82: length = (data[i] << 8) | data[i + 1] i += 2 if cur_tag == tag: return data[i : i + length] i += length return None ``` **Step 4: Run, expect PASS. Add length-edge tests (0x81 long-form, 0x82 long-form, missing tag), commit.** ```bash git add harness/src/aliro_harness/reader/tlv.py harness/tests/test_reader_tlv.py git commit -m "feat(reader): minimal TLV reader for response parsing" ``` --- ## Task 4: HKDF, ECDH, signing primitives wrapper **Files:** - Create: `harness/src/aliro_harness/reader/crypto.py` - Create: `harness/tests/test_reader_crypto.py` These thin wrappers exist purely so the rest of the reader code can do `derive_kdh(...)` instead of repeating `cryptography` boilerplate everywhere and so we have one place to lock the formula against a known vector. **Step 1: Failing tests against RFC 5869 vectors + a Kdh consistency check** ```python # harness/tests/test_reader_crypto.py from cryptography.hazmat.primitives.asymmetric import ec from aliro_harness.reader.crypto import ( derive_kdh, ecdh_shared_x, hkdf_sha256, ) # RFC 5869 Test Case 1 RFC5869_T1_IKM = bytes.fromhex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b") RFC5869_T1_SALT = bytes.fromhex("000102030405060708090a0b0c") RFC5869_T1_INFO = bytes.fromhex("f0f1f2f3f4f5f6f7f8f9") RFC5869_T1_OKM_42 = bytes.fromhex( "3cb25f25faacd57a90434f64d0362f2a" "2d2d0a90cf1a5a4c5db02d56ecc4c5bf" "34007208d5b887185865" ) def test_hkdf_sha256_matches_rfc5869_test_case_1(): assert hkdf_sha256(RFC5869_T1_IKM, RFC5869_T1_SALT, RFC5869_T1_INFO, 42) == RFC5869_T1_OKM_42 def test_ecdh_commutative(): a = ec.generate_private_key(ec.SECP256R1()) b = ec.generate_private_key(ec.SECP256R1()) a_pub_uncomp = bytes([0x04]) + a.public_key().public_numbers().x.to_bytes(32, "big") + a.public_key().public_numbers().y.to_bytes(32, "big") b_pub_uncomp = bytes([0x04]) + b.public_key().public_numbers().x.to_bytes(32, "big") + b.public_key().public_numbers().y.to_bytes(32, "big") assert ecdh_shared_x(a, b_pub_uncomp) == ecdh_shared_x(b, a_pub_uncomp) def test_derive_kdh_agrees_on_both_sides(): a = ec.generate_private_key(ec.SECP256R1()) b = ec.generate_private_key(ec.SECP256R1()) a_pub = bytes([0x04]) + a.public_key().public_numbers().x.to_bytes(32, "big") + a.public_key().public_numbers().y.to_bytes(32, "big") b_pub = bytes([0x04]) + b.public_key().public_numbers().x.to_bytes(32, "big") + b.public_key().public_numbers().y.to_bytes(32, "big") txn = b"\x01" * 16 assert derive_kdh(a, b_pub, txn) == derive_kdh(b, a_pub, txn) ``` **Step 2: Run — expect ImportError** **Step 3: Implementation** ```python # harness/src/aliro_harness/reader/crypto.py """Reader-side crypto primitives for the Aliro NFC transaction. Wraps cryptography's HKDF, ECDH, ECDSA, AES-GCM into Aliro-shaped helpers. Intentionally NOT a re-export — these wrappers fix the algorithm choices specified by Aliro §8.3.1 so callers can't pick the wrong hash, padding, etc. by mistake. """ from cryptography.hazmat.primitives import hashes, hmac, serialization from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.kdf.hkdf import HKDF def hkdf_sha256(ikm: bytes, salt: bytes, info: bytes, length: int) -> bytes: """HKDF-SHA-256 per RFC 5869. Empty salt is handled per §2.2 of the RFC (treated as HashLen zeros).""" return HKDF( algorithm=hashes.SHA256(), length=length, salt=salt or b"\x00" * 32, info=info, ).derive(ikm) def ecdh_shared_x(priv: ec.EllipticCurvePrivateKey, peer_pub_uncompressed_65: bytes) -> bytes: """ECDH P-256: returns the 32-byte x-coordinate of the shared point.""" if len(peer_pub_uncompressed_65) != 65 or peer_pub_uncompressed_65[0] != 0x04: raise ValueError("peer pubkey must be 65B uncompressed") peer = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), peer_pub_uncompressed_65) return priv.exchange(ec.ECDH(), peer) # cryptography returns the 32B x-coord def derive_kdh( reader_ephem_priv: ec.EllipticCurvePrivateKey, credential_ephem_pub_uncompressed: bytes, transaction_id: bytes, ) -> bytes: """§8.3.1.4 (with §8.3.1.5 substitution): Kdh = HKDF(IKM=ECDH_x, salt=transaction_id, info=∅, L=32).""" z_ab = ecdh_shared_x(reader_ephem_priv, credential_ephem_pub_uncompressed) return hkdf_sha256(z_ab, transaction_id, b"", 32) ``` **Step 4: Run, expect PASS for all three tests.** **Step 5: Commit** ```bash git add harness/src/aliro_harness/reader/crypto.py harness/tests/test_reader_crypto.py git commit -m "feat(reader): HKDF/ECDH/Kdh primitives, locked to Aliro algorithm choices" ``` --- ## Task 5: salt_volatile + ExpeditedSKDevice derivation **Files:** - Create: `harness/src/aliro_harness/reader/key_derivation.py` - Create: `harness/tests/test_reader_key_derivation.py` **Important:** salt_volatile MUST be byte-identical to what the applet builds in `AliroApplet.buildSaltVolatile`. If even one byte differs, the keys diverge and decryption fails. Cross-reference both implementations as you write. **Step 1: Failing test that asserts salt_volatile shape per §8.3.1.13** ```python # harness/tests/test_reader_key_derivation.py from aliro_harness.reader.key_derivation import build_salt_volatile def test_salt_volatile_layout_per_spec_8_3_1_13(): salt = build_salt_volatile( reader_long_term_pub_x=b"\x01" * 32, reader_group_id=b"\xaa" * 16, reader_group_sub_id=b"\xbb" * 16, reader_ephem_pub_x=b"\x33" * 32, transaction_id=b"\xcc" * 16, command_parameters=0x00, authentication_policy=0x00, credential_long_term_pub_x=b"\x55" * 32, ) p = 0 assert salt[p : p + 32] == b"\x01" * 32; p += 32 # x(reader_group_identifier_key) assert salt[p : p + 12] == b"Volatile****"; p += 12 assert salt[p : p + 16] == b"\xaa" * 16; p += 16 # group_id assert salt[p : p + 16] == b"\xbb" * 16; p += 16 # sub_id assert salt[p] == 0x5E; p += 1 # interface_byte = NFC assert salt[p : p + 2] == bytes([0x5C, 0x02]); p += 2 # literal assert salt[p : p + 2] == bytes([0x01, 0x00]); p += 2 # protocol_version assert salt[p : p + 32] == b"\x33" * 32; p += 32 # x(reader_ephem) assert salt[p : p + 16] == b"\xcc" * 16; p += 16 # txn_id assert salt[p : p + 2] == bytes([0x00, 0x00]); p += 2 # flag = cmd_params || auth_policy assert salt[p : p + 10] == bytes.fromhex("A50880020000 5C020100".replace(" ", "")); p += 10 assert salt[p : p + 32] == b"\x55" * 32; p += 32 # x(credential_long_term) assert len(salt) == p == 173 ``` **Step 2: Run, expect FAIL** **Step 3: Implementation** ```python # harness/src/aliro_harness/reader/key_derivation.py """Reader-side mirror of AliroApplet.buildSaltVolatile / deriveSessionKeys. Keep this BYTE-IDENTICAL to the applet implementation in applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java (buildSaltVolatile). Drift here = decryption fails on real card. """ from io import BytesIO from aliro_harness.reader.crypto import hkdf_sha256 SALT_VOLATILE_TAG = b"Volatile****" INTERFACE_BYTE_NFC = 0x5E PROPRIETARY_A5_TLV = bytes.fromhex("A50880020000" "5C020100") PROTOCOL_VERSION_1_0 = bytes([0x01, 0x00]) # Offsets into the 160B derived_keys_volatile (§8.3.1.13). OFF_EXPEDITED_SK_READER = 0 OFF_EXPEDITED_SK_DEVICE = 32 OFF_STEP_UP_SK = 64 def build_salt_volatile( *, reader_long_term_pub_x: bytes, reader_group_id: bytes, reader_group_sub_id: bytes, reader_ephem_pub_x: bytes, transaction_id: bytes, command_parameters: int, authentication_policy: int, credential_long_term_pub_x: bytes, ) -> bytes: """Per spec §8.3.1.13. Mirrors AliroApplet.buildSaltVolatile.""" out = BytesIO() out.write(reader_long_term_pub_x) # 32 out.write(SALT_VOLATILE_TAG) # 12 out.write(reader_group_id) # 16 out.write(reader_group_sub_id) # 16 out.write(bytes([INTERFACE_BYTE_NFC])) # 1 out.write(bytes([0x5C, 0x02])) # 2 out.write(PROTOCOL_VERSION_1_0) # 2 out.write(reader_ephem_pub_x) # 32 out.write(transaction_id) # 16 out.write(bytes([command_parameters, authentication_policy])) # 2 out.write(PROPRIETARY_A5_TLV) # 10 out.write(credential_long_term_pub_x) # 32 return out.getvalue() def derive_expedited_session_keys( kdh: bytes, salt_volatile: bytes, info_credential_ephem_pub_x: bytes, ) -> bytes: """§8.3.1.13: derived_keys_volatile (160B). info = x(credential_ephem_pub) + AUTH0 vendor extensions (none in v1).""" return hkdf_sha256(kdh, salt_volatile, info_credential_ephem_pub_x, 160) ``` **Step 4: Run, expect PASS. Add a derive_expedited_session_keys test that round-trips against a fixed kdh+salt+info, expect PASS, commit.** ```bash git add harness/src/aliro_harness/reader/key_derivation.py harness/tests/test_reader_key_derivation.py git commit -m "feat(reader): salt_volatile + expedited session key derivation" ``` --- ## Task 6: AUTH1 command builder + Table 8-12 signing **Files:** - Create: `harness/src/aliro_harness/reader/auth1.py` - Create: `harness/tests/test_reader_auth1.py` **Step 1: Failing test for Table 8-12 byte-shape** ```python # harness/tests/test_reader_auth1.py from aliro_harness.reader.auth1 import build_table_812 def test_table_812_layout_per_spec_table_8_12(): t = build_table_812( reader_id_32=b"\x4d" * 32, credential_ephem_pub_x=b"\x86" * 32, reader_ephem_pub_x=b"\x87" * 32, transaction_id=b"\x4c" * 16, ) assert t[0:2] == bytes([0x4D, 0x20]) assert t[2:34] == b"\x4d" * 32 assert t[34:36] == bytes([0x86, 0x20]) assert t[36:68] == b"\x86" * 32 assert t[68:70] == bytes([0x87, 0x20]) assert t[70:102] == b"\x87" * 32 assert t[102:104] == bytes([0x4C, 0x10]) assert t[104:120] == b"\x4c" * 16 assert t[120:122] == bytes([0x93, 0x04]) assert t[122:126] == bytes.fromhex("415D9569") # reader sig usage assert len(t) == 126 # 5 TLVs: 34+34+34+18+6 (NOT 128 — earlier draft was off-by-2) ``` **Step 2: Run, FAIL** **Step 3: Implementation** ```python # harness/src/aliro_harness/reader/auth1.py """AUTH1 command builder + Table 8-12 reader-signature input. Mirrors applet/src/test/.../ReaderSide.java buildTable812 + buildAuth1Data. """ from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature CLA = 0x80 INS_AUTH1 = 0x81 USAGE_READER_AUTH1 = bytes([0x41, 0x5D, 0x95, 0x69]) USAGE_UD_AUTH1 = bytes([0x4E, 0x88, 0x7B, 0x4C]) def build_table_812( *, reader_id_32: bytes, credential_ephem_pub_x: bytes, reader_ephem_pub_x: bytes, transaction_id: bytes, ) -> bytes: return _build_table_8_12_or_13( reader_id_32, credential_ephem_pub_x, reader_ephem_pub_x, transaction_id, USAGE_READER_AUTH1, ) def build_table_813( *, reader_id_32: bytes, credential_ephem_pub_x: bytes, reader_ephem_pub_x: bytes, transaction_id: bytes, ) -> bytes: """Same TLV shape as Table 8-12, only the usage tag differs.""" return _build_table_8_12_or_13( reader_id_32, credential_ephem_pub_x, reader_ephem_pub_x, transaction_id, USAGE_UD_AUTH1, ) def _build_table_8_12_or_13(rid, cred_x, reader_x, txn, usage) -> bytes: return ( bytes([0x4D, 0x20]) + rid + bytes([0x86, 0x20]) + cred_x + bytes([0x87, 0x20]) + reader_x + bytes([0x4C, 0x10]) + txn + bytes([0x93, 0x04]) + usage ) def sign_table_812_raw(table_812: bytes, reader_priv: ec.EllipticCurvePrivateKey) -> bytes: """Returns 64B raw r||s ECDSA-SHA-256 signature.""" der = reader_priv.sign(table_812, ec.ECDSA(hashes.SHA256())) r, s = decode_dss_signature(der) return r.to_bytes(32, "big") + s.to_bytes(32, "big") def build_auth1_data(raw_sig_64: bytes, command_parameters: int = 0x01) -> bytes: """AUTH1 command data field per spec Table 8-10. cmd_params bit 0 = 1 means 'return credential_PubK' (simpler than the SHA-1 key_slot path).""" if len(raw_sig_64) != 64: raise ValueError("reader signature must be 64B raw r||s") return ( bytes([0x41, 0x01, command_parameters]) + bytes([0x9E, 0x40]) + raw_sig_64 ) def build_auth1_apdu(data: bytes) -> bytes: return bytes([CLA, INS_AUTH1, 0x00, 0x00, len(data)]) + data ``` **Step 4: Run + add tests for the sign-then-verify round trip and apdu wrapping. Commit.** ```bash git add harness/src/aliro_harness/reader/auth1.py harness/tests/test_reader_auth1.py git commit -m "feat(reader): Table 8-12/8-13 builders + reader sig + AUTH1 APDU" ``` --- ## Task 7: AUTH1 response decryption + UD signature verification **Files:** - Create: `harness/src/aliro_harness/reader/auth1_response.py` - Create: `harness/tests/test_reader_auth1_response.py` **Step 1: Failing test that round-trips a stub plaintext** ```python # harness/tests/test_reader_auth1_response.py from cryptography.hazmat.primitives.ciphers.aead import AESGCM from aliro_harness.reader.auth1_response import decrypt_auth1_response def test_decrypt_auth1_response_roundtrips_with_device_counter_1(): sk = b"\x42" * 32 iv = b"\x00" * 7 + b"\x01" + b"\x00\x00\x00\x01" plaintext = b"\xAB" * 17 ct_and_tag = AESGCM(sk).encrypt(iv, plaintext, associated_data=None) assert decrypt_auth1_response(sk, ct_and_tag) == plaintext ``` **Step 2: Run, FAIL** **Step 3: Implementation** ```python # harness/src/aliro_harness/reader/auth1_response.py """AUTH1 response: AES-256-GCM decrypt + UD signature verification. device_counter == 1 because we are the FIRST step-up-eligible response in the session (spec §8.3.1.13 initializes expedited_device_counter = 1). """ from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature from cryptography.hazmat.primitives.ciphers.aead import AESGCM def decrypt_auth1_response(sk_device: bytes, ciphertext_with_tag: bytes) -> bytes: """IV per §8.3.1.6: 0x00..00 0x01 || expedited_device_counter (4B BE).""" iv = b"\x00" * 7 + b"\x01" + (1).to_bytes(4, "big") return AESGCM(sk_device).decrypt(iv, ciphertext_with_tag, associated_data=None) def verify_ud_signature( credential_long_term_pub_uncompressed: bytes, table_813: bytes, raw_sig_64: bytes, ) -> bool: if len(raw_sig_64) != 64: raise ValueError("UD signature must be 64B raw r||s") pub = ec.EllipticCurvePublicKey.from_encoded_point( ec.SECP256R1(), credential_long_term_pub_uncompressed ) r = int.from_bytes(raw_sig_64[:32], "big") s = int.from_bytes(raw_sig_64[32:], "big") der = encode_dss_signature(r, s) try: pub.verify(der, table_813, ec.ECDSA(hashes.SHA256())) return True except Exception: return False ``` **Step 4: Run, PASS. Add a verify-roundtrip test (sign with random priv, verify with matching pub). Commit.** ```bash git add harness/src/aliro_harness/reader/auth1_response.py harness/tests/test_reader_auth1_response.py git commit -m "feat(reader): AUTH1 response decrypt + UD signature verification" ``` --- ## Task 8: Top-level transaction orchestrator (offline / mockable transport) **Files:** - Create: `harness/src/aliro_harness/reader/transaction.py` - Create: `harness/tests/test_reader_transaction_offline.py` **Step 1: Failing test driven by an in-process fake card** The fake card mirrors AliroApplet.processAuth0/processAuth1 in Python so we can validate the full reader pipeline without ANY card connected. This is the first end-to-end proof the reader is correct independent of pyscard. ```python # harness/tests/test_reader_transaction_offline.py from cryptography.hazmat.primitives.asymmetric import ec from aliro_harness.reader.transaction import ( TrustBundle, run_aliro_transaction, ) from tests.fake_card import FakeAliroCard # built next def test_transaction_against_in_process_fake_card_succeeds(): issuer = ec.generate_private_key(ec.SECP256R1()) cred = ec.generate_private_key(ec.SECP256R1()) reader = ec.generate_private_key(ec.SECP256R1()) bundle = TrustBundle.synthesize(reader_priv=reader, credential_pub=cred.public_key()) fake = FakeAliroCard.provisioned( reader_pub=reader.public_key(), credential_priv=cred, credential_pub=cred.public_key(), ) result = run_aliro_transaction(transmit=fake.transmit, bundle=bundle) assert result.ok assert result.signaling_bitmap == bytes([0x00, 0x00]) # no AD provisioned in fake ``` **Step 2-N:** Build the orchestrator step by step. Each substep is its own commit: - 8a: TrustBundle dataclass loading PEMs from a trust dir (mirror personalizer's TrustArtifacts pattern) - 8b: TransactionResult dataclass (ok, plaintext_bytes, ud_sig, signaling_bitmap, error?) - 8c: run_aliro_transaction() that orchestrates: SELECT EXPEDITED → AUTH0 → parse 0x86 → derive_kdh → derive_session_keys → build Table 8-12 → sign → AUTH1 → decrypt response → extract 0x9E → build Table 8-13 → verify_ud_signature - 8d: tests/fake_card.py — Python port of AliroApplet just for testing (does enough of processAuth0/processAuth1 to validate the reader) The fake card is non-trivial (~200 LOC) but it's the only way to validate the reader before flashing real card. Without it, the first time we'd know the reader works is on the J3R180 — too late to debug efficiently. **Verification:** `pytest harness/tests/test_reader_transaction_offline.py -v` → green. **Commit:** one commit per substep. --- ## Task 9: pyscard CLI: `aliro-bench-test` **Files:** - Create: `harness/src/aliro_harness/reader/cli.py` - Modify: `harness/pyproject.toml` (add `aliro-bench-test` entry point) - Create: `harness/tests/test_reader_cli.py` **Step 1: Mirror the personalizer CLI's `--reader / --list-readers` shape** Use `aliro_harness.personalizer.cli` as reference — it already does pyscard discovery, lazy import, and click error mapping. Reuse that pattern. **Step 2: CLI structure** ```python # harness/src/aliro_harness/reader/cli.py @click.command() @click.option("--trust-dir", required=False, ...) @click.option("--reader", "reader_index", default=0) @click.option("--list-readers", is_flag=True) def main(trust_dir, reader_index, list_readers): """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.""" # ... lazy import smartcard.System, build TrustBundle, connect, # wrap connection.transmit as Transmit callable, call # run_aliro_transaction, pretty-print the result. ``` **Step 3-N:** Tests via CliRunner + monkeypatched smartcard (mirror `tests/test_personalizer_cli.py`). **Step 4: Add entry point to pyproject.toml** ```toml [project.scripts] aliro-trustgen = "aliro_harness.trustgen.cli:main" aliro-personalize = "aliro_harness.personalizer.cli:main" aliro-bench-test = "aliro_harness.reader.cli:main" ``` **Step 5: Reinstall + verify CLI registers** ```bash pip install -e . -q aliro-bench-test --help ``` **Commit each substep.** --- ## Task 10: Real-card smoke test + documentation **Pre-req:** card has been personalized via `aliro-personalize --trust-dir ~/aliro-trust`. **Step 1: Run against real J3R180** ```bash aliro-bench-test --trust-dir ~/aliro-trust ``` Expected output (approx): ``` Using reader: ACS ACR1252 1S CL Reader 0 Connected. ATR: 3b8a80014a546178436f7265563150 SELECT EXPEDITED: 9000 (FCI 21 bytes) AUTH0: 9000 (credential_ePubK 65B) AUTH1: 9000 (encrypted response 153B) Decrypted plaintext: 137B 0x5A credential_PubK: 65B 0x9E UD signature: 64B → verifies under credential_PubK 0x5E signaling_bitmap: 0x0005 (access_doc available, requires step-up) RESULT: OK — applet round-trip on real hardware. ``` **Step 2: Update `applet/INSTALL.md`** with a "Validate after personalization" section pointing at `aliro-bench-test`. **Step 3: Save a memory entry** (`~/.claude/projects/.../memory/`) documenting that `aliro-bench-test` is the real-hardware validation path until firmware is ready. **Step 4: Commit + open the discussion about Track 2 (firmware port).** --- ## Verification (whole-plan acceptance) After all tasks: - `cd harness && . .venv/bin/activate && pytest -q` → all tests pass (existing 80 + new ~25 from Tasks 1-9 = ~105 total) - `aliro-bench-test --list-readers` enumerates the PC/SC reader - `aliro-bench-test --trust-dir ~/aliro-trust` runs a full transaction against the personalized J3R180 and prints "RESULT: OK" - The Java applet test suite (`./scripts/dt-mvn.sh test` from `applet/`) still passes — this plan touches no applet code ## Risks / known unknowns 1. **PC/SC reader contactless capabilities.** Some readers (ACR122U) have known APDU-length quirks at the T=CL layer. If our 153B encrypted AUTH1 response is fragmented or rejected, we'll need to investigate `--bs` / extended-length handling. Mitigation: use ACR1252U or similar modern reader. 2. **Card timing.** ECDSA sign on the J3R180 takes 100-300ms. The `connection.transmit` call will block; that's normal. If we hit a 2s+ delay it's a sign genKeyPair() is being called per AUTH1 instead of once-only — bug in our lazy init. 3. **Salt_volatile drift.** If we change `AliroApplet.PROPRIETARY_A5_TLV` / `INTERFACE_BYTE_NFC` / `SALT_VOLATILE_TAG` on one side without the other, decryption fails with no useful error. Cross-reference both implementations whenever either is touched. This plan uses the constants that exist in the applet today; verify they haven't drifted before running Task 10.