Three components, all bench-validated to varying depths: - applet/: CSA Aliro v1.0 Java Card applet for J3R180. AUTH0 + AUTH1 expedited-standard flow end-to-end green via PC/SC bench reader (aliro-bench-test). Userland AES-256-GCM and HMAC-SHA-256 layered on top of J3R180's primitives because the card lacks both natively. P-256 curve params seeded explicitly per J3R180's quirk. - harness/: Python orchestrator (aliro-trustgen, aliro-personalize, aliro-bench-test) for trust-bundle generation, card personalization via PersonalizationApplet, and PC/SC AUTH0+AUTH1 transactions. 126 pytest cases passing. - reader/STM32CubeExpansion_ALIRO_V1_0_0/: ST X-CUBE-ALIRO V1.0.0 with our NFC10A1 port (NUCLEO-U545RE-Q + X-NUCLEO-NFC10A1, ST25R200 shared with NFC09A1). nfc10-only/ project, NFC10A1 BSP shim, ALIRO_TRUST_OVERRIDE include into vendor's provisioning.c, and an ALIRO_APDU_TRACE wrapper around demoTransceiveBlocking. Boots, detects the J3R180, completes SELECT + AUTH0; AUTH1 currently fails with RFAL ERR_PROTO (0xB) — under investigation, see docs/plans/2026-04-20-nucleo-nfc10a1-port.md and bench-notes/. Excluded: x-cube-aliro.zip vendor archive, harness/.venv, build dirs, generated aliro_trust.h (contains private reader scalar), all PEMs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
875 lines
31 KiB
Markdown
875 lines
31 KiB
Markdown
# 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.
|