# 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.
