Initial snapshot: Aliro applet, harness, Nucleo NFC10A1 reader port

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>
This commit is contained in:
2026-05-02 10:17:46 -07:00
commit 782074f6ae
8786 changed files with 2902373 additions and 0 deletions

View File

@@ -0,0 +1,242 @@
# Aliro Credential — Design
**Date:** 2026-04-17
**Status:** Design agreed; ready for implementation planning.
## 1. Goal
Build a Java Card applet that implements the CSA **Aliro v1.0** User Device role over NFC, plus a hardware test fixture capable of exercising it end-to-end. Target deployment is dangerousthings.com's implantable Java Card products (VivoKey Apex family); generic J3R180 cards and jcardsim serve as intermediate validation stages.
## 2. Scope
### In scope (v1)
- **Transport:** NFC only (ISO-DEP / T4AT over NFC-A, per Aliro spec §10).
- **Protocol phases:** Expedited-standard, Expedited-fast, Step-up — the full NFC User Device role.
- **Roles provided by this project:**
- User Device (Java Card applet).
- Reader (instrumented test fixture on ST hardware).
- Credential Issuer (PC-side test CA).
- **Test fixture:** NUCLEO-U545RE-Q + X-NUCLEO-NFC09A1 (ST25R200) running a modified build of ST's X-CUBE-ALIRO reference. (Earlier draft of this doc said NFC10A1 — that was a part-number mistake. NFC10A1 carries ST25R3916, which is *not* supported by X-CUBE-ALIRO V1.0.0; only NFC09A1/ST25R200 and NFC12A1/ST25R500 ship with BSP drivers.)
- **Development progression for the applet:** jcardsim (fast loop) → J3R180 dev card (real hardware) → VivoKey Apex (final target).
### Explicitly out of scope (v1)
- BLE and BLE+UWB transports (spec §11, §12).
- Production-grade reader infrastructure. The reader is a test rig, not a product.
- Runtime reader provisioning. Trust anchors are compiled into reader firmware.
- Access Manager, back-end systems.
- Revocation Document generation as a CI-integrated workflow (generated manually from the test CA when a revocation test case is needed).
- **X.509 certificates of any kind.** v1 uses:
- `reader_PubK` authentication (spec §6.3) — the applet is provisioned with the raw Reader public key. No Reader certificate, no Reader System Issuer CA.
- `kid` header in the Access Document's IssuerAuth (spec §7.2.1) — the Reader is provisioned with the raw Credential Issuer public key. No `x5chain`, no Credential Issuer certificate.
- The `create_self_signed_ca` helper in `harness/issuer/ca.py` is retained for future v2 use but is not exercised in the v1 flow.
## 3. Architecture
Three cleanly separated components:
```
┌─────────────────────┐ NFC-A / T4AT ┌────────────────────┐
│ PC Harness (Py) │ │ Reader │
│ - Test CA / Issuer │◀── USB-CDC (logs) ──────────│ Nucleo-U545RE-Q │
│ - Personalizer │ │ + NFC09A1 │
│ - Trust-anchor gen │ │ mod X-CUBE-ALIRO │
│ - Reader log diff │ │ │
│ - E2E pytest │ └──────────┬─────────┘
└──────────┬──────────┘ │
│ PC/SC (pyscard) │ 13.56 MHz
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ User Device (Java Card applet) │
│ - AliroApplet (AIDs …5501 / …5502) │
│ - PersonalizationApplet (separate AID, locks after COMMIT) │
│ - jcardsim / J3R180 / Apex │
└─────────────────────────────────────────────────────────────┘
```
## 4. The Reader (test device)
### Hardware
- **MCU board:** NUCLEO-U545RE-Q (STM32U545RE).
- **NFC board:** X-NUCLEO-NFC09A1 (ST25R200), stacked via Arduino UNO R3 headers.
- Both officially supported by X-CUBE-ALIRO V1.0.0 (`Projects/nucleo-u5/Applications/Aliro/nfc-only/`); no wiring.
> **Hardware caveat:** earlier drafts of this doc paired NUCLEO-U545RE-Q with X-NUCLEO-**NFC10A1** (ST25R3916). That pairing was wrong — X-CUBE-ALIRO V1.0.0 does **not** include an ST25R3916 driver. The supported shields for the nucleo-u5 project are NFC09A1 (ST25R200, `nfc-only/`) and NFC12A1 (ST25R500, `nfc12-only/`).
### Firmware baseline
Start from `STM32CubeExpansion_ALIRO_V1_0_0/Projects/nucleo-u5/Applications/Aliro/nfc-only`. Vendor the whole tree under `reader/vendor/` untouched. Modifications live beside it; a separate unmodified build (`reader/vendor-stock/`) stays flashable so the applet can be smoke-tested against reference firmware when instrumentation is suspect.
### Modifications
1. **Build-time trust anchors.** The PC harness generates `reader/aliro_trust.h` containing:
- Credential Issuer CA public key (`issuer_ca_pubkey_x/y`, 32 bytes each).
- Reader key pair (raw ECC P-256 private / public).
- Optional Reader System Issuer CA public key for reader_Cert validation.
Upstream hardcoded values in `Projects/Common/Aliro/Src/provisioning.c` are replaced via a `#include` hook. CMake depends on the generated file so regenerating forces a rebuild. `aliro_trust.h` is gitignored; only the generating inputs are version-controlled.
2. **Structured logging.** One tagged line per protocol event, emitted over the ST-LINK virtual COM port at 115200 8N1:
```
[ISO8601] [tag] [hex-payload] [short-note]
```
Hook points (all at layer boundaries, none inside crypto):
- Raw APDU in/out at T4AT.
- Phase transitions in `app_aliro.c`: `AUTH0_START`, `AUTH0_SENT`, `AUTH1_SENT`, `PRIVATE_CHANNEL_ESTABLISHED`, `USER_DEVICE_VERIFIED`, `EXCHANGE_IN/OUT`, `STEP_UP_START`, `DEVICE_REQUEST`, `DEVICE_RESPONSE`, `CONTROL_FLOW_SENT`.
- Plaintext of private_channel payloads, captured just after the stock code's AES-GCM decrypt and just before dispatch. Accepted because this is a test rig; never ship this path to production.
Implementation: a single `log_event(tag, buf, len, fmt, …)` wrapper funneling to `CDC_Transmit_FS`.
3. **LEDs preserved.** LD1 = grant (green blink), LD2 = deny (red blink), LD3 = transaction active. Eyes-only smoke testing without the PC.
## 5. The Applet (system under test)
### Targets
| Target | Role | JC version | Notes |
|---|---|---|---|
| jcardsim | Development / fast loop | 3.0.5 API | No hardware needed. |
| J3R180 | On-metal validation | 3.0.5 | Contactless dev card. |
| VivoKey Apex | Production | 3.0.5 | Same `.cap` where possible. |
### Structure
One applet class (`AliroApplet`), registered twice per spec §10.2.1:
- `A000000909ACCE5501` — Expedited Phase AID.
- `A000000909ACCE5502` — Step-up Phase AID.
A second, separate applet (`PersonalizationApplet`) under a non-Aliro AID handles provisioning. It accepts `SET_CRED_KEY`, `SET_ISSUER_PUBKEY`, `SET_ACCESS_DOC`, `COMMIT`, and `RESET` commands, rejects all state-modifying commands after `COMMIT`, and re-unlocks only via `RESET` authenticated with a personalization secret.
### State
- **Transient (RAM):** Active phase, per-transaction ephemeral keypair, derived session keys, GCM nonce counters.
- **Persistent (EEPROM/Flash):** Access Credential keypair (ECC P-256), Credential Issuer trust anchors, pre-signed Access Document bytes, up to 16 Kpersistent slots (each: reader_group_identifier 16 B + Kpersistent 32 B + iteration + expiry). Spec §6.2 mandates a minimum of 16 Kpersistent bindings per Access Credential.
### Crypto primitives
All of the following are required by Aliro v1.0 and present on Java Card 3.0.5 platforms of interest:
| Primitive | Spec use | JC API |
|---|---|---|
| ECC P-256 keygen | Ephemeral & long-term keys | `KeyBuilder.TYPE_EC_FP_*` |
| ECDH (raw point out) | §8.3.1.4 shared-key derivation | `KeyAgreement.ALG_EC_SVDP_DH_PLAIN_XY` |
| ECDSA-SHA-256 | §8.3.1.2 signing in AUTH1 | `Signature.ALG_ECDSA_SHA_256` |
| SHA-256 | Digests, HKDF compressor | `MessageDigest.ALG_SHA_256` |
| HMAC-SHA-256 | HKDF primitive | `Signature.ALG_HMAC_SHA_256` |
| HKDF | §8.3.1.5 key derivation | Built from HMAC-SHA-256 (~30 LOC). |
| AES-128-GCM | private_channel confidentiality/integrity | `Cipher.ALG_AES_GCM` (Apex support to confirm; CTR+GMAC fallback planned) |
| TRNG | §8.3.1.1 RNG | `RandomData.ALG_TRNG` |
### CBOR strategy
The applet does **not** sign CBOR. The Access Document arrives at personalization time already signed by the test CA; the applet stores the bytes and reuses the IssuerAuth verbatim.
During step-up, the applet parses just enough of the reader's `DeviceRequest` (CBOR map lookup by NameSpace + ElementIdentifier) to emit the matching `IssuerSignedItem`s and reuses the stored IssuerAuth unchanged. A hand-rolled decoder (~200 LOC, deterministic-encoding subset only, per spec §7.2 / RFC 8949 §4.2.1) avoids pulling a library into the applet.
### Flash budget (estimate)
- Applet code: ~12 KB.
- Persistent state: ~3 KB (keys + 16 Kpersistent slots + ~1.5 KB Access Document).
- Well within J3R180's 180 KB.
## 6. The PC Harness
### Language choices
- **Python 3.11** for everything PC-side (issuer / personalizer / log capture / E2E tests).
- **Java + JUnit** for the jcardsim fast loop (bolting Python onto jcardsim adds cost without value).
### Python libraries
| Purpose | Library |
|---|---|
| ECC P-256, ECDSA-SHA256, ECDH, HKDF, AES-GCM | `cryptography` |
| CBOR with deterministic encoding | `cbor2` |
| COSE_Sign1 for IssuerAuth | `pycose` |
| Direct PC/SC applet I/O | `pyscard` |
| Reader VCP capture | `pyserial` |
| Test CA X.509 | `cryptography.x509` |
### Test CA (`harness/issuer/`)
Deterministic and on-disk. Inputs version-controlled; outputs clearly marked TEST-ONLY. A single `trustgen` command regenerates:
- Credential Issuer CA key pair + root cert.
- Reader System Issuer CA key pair + root cert.
- A test Reader cert.
- A test Access Credential key pair.
- A default Access Document: permissive access rule, no schedule restriction, `TimeVerificationRequired=false`.
Outputs drop into `trustgen/out/`, where both (a) the applet personalizer and (b) the reader trust-anchor header generator consume them.
## 7. Repo layout
```
aliro_project/
├── applet/ # Java Card source (Gradle + ant-javacard)
│ ├── src/main/java/... # AliroApplet, PersonalizationApplet, CBOR, HKDF
│ └── src/test/java/... # JUnit + jcardsim tests
├── reader/
│ ├── vendor/ # STM32CubeExpansion_ALIRO_V1_0_0/ (modified)
│ ├── vendor-stock/ # Unmodified reference copy for diagnosis
│ └── aliro_trust.h # Generated, gitignored
├── harness/ # Python 3.11 + uv
│ ├── issuer/ # Test CA & Access Document builder
│ ├── personalize/ # pyscard-based loader for PersonalizationApplet
│ ├── reader_log/ # pyserial capture + tag parser + trace differ
│ ├── trustgen/ # Emits reader/aliro_trust.h from CA + reader keys
│ └── tests/ # End-to-end pytest (hardware required)
├── spec/ # Aliro 1.0 PDF + extracted AID/OID cheatsheet
└── docs/plans/ # This file.
```
## 8. Test strategy
Two complementary loops.
### Fast loop — jcardsim + JUnit
- Runs on every applet change. Seconds. No hardware.
- Covers protocol correctness, crypto math, state machine transitions, CBOR edge cases, Kpersistent slot eviction, step-up `DeviceRequest` variants.
- Drives the applet through the same APDUs the real reader would send.
### Hardware loop — pytest
- Personalize a card via PC/SC (pyscard) → place at reader antenna → capture reader VCP log → assert expected trace.
- Slow. Catches jcardsim-vs-real-card divergence (GCM availability, transient-object lifetimes, T=CL framing quirks).
- Gatekeeper before every card port (J3R180 → Apex).
### Top-level build targets
- `make applet` — JUnit + jcardsim tests, build `.cap`.
- `make reader` — regenerate trust header, build firmware, flash.
- `make e2e` — expects card on desk and reader enumerated on USB.
## 9. Security & safety notes
- The reader firmware logs private_channel plaintext. **Test-only.** The production branch (if any is ever cut from this code) must `#ifdef` those logs out. Flagged here so a future reader isn't tempted to ship this image.
- Test CA keys live in-repo, clearly marked. They exist to make flashes and resets reproducible; they have no production use.
- Personalization secret for the `PersonalizationApplet` `RESET` command lives in the harness config alongside the test CA — also test-only.
## 10. Open / deferred
- **AES-GCM availability on VivoKey Apex.** Needs verification before J3R180 → Apex port. If absent, the CTR+GMAC fallback path activates; that fallback is listed in §5 crypto primitives but not yet prototyped.
- **Exact `PersonalizationApplet` wire format.** Intentionally deferred; it's a private interface between our harness and our applet, not spec-driven, so the format gets designed during implementation with no external constraints.
- **CI.** No CI in v1; everything runs on the developer workstation. Revisit once the JUnit suite is substantial enough to want on every push.
- **Expedited-fast Kpersistent expiration policy.** Spec leaves this largely to the User Device. Default: LRU eviction at the 16-slot cap, no time-based expiry. Revisit if deployments need one.
## 11. Glossary (for quick reference)
- **User Device** — the thing being tapped (our Java Card applet).
- **Reader** — the thing tapping (our test fixture).
- **Access Credential** — the keypair unique to the User Device.
- **Access Document** — the CBOR/COSE blob signed by the Credential Issuer, carrying the Access Credential public key and any access rules.
- **IssuerAuth** — COSE_Sign1 structure inside the Access Document (spec §7).
- **Kpersistent** — symmetric key shared with a specific Reader group, used for the Expedited-fast cryptogram path.
- **reader_group_identifier** — 16-byte value identifying a set of Readers that share a trust context.

View File

@@ -0,0 +1,874 @@
# 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.

View File

@@ -0,0 +1,513 @@
# Bring up X-NUCLEO-NFC10A1 as an Aliro Reader on NUCLEO-U545RE-Q Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Produce a second independent end-to-end validation of the Aliro applet by running ST's X-CUBE-ALIRO reader firmware on NUCLEO-U545RE-Q + X-NUCLEO-NFC10A1, hitting AUTH0 + AUTH1 green against our personalized J3R180. The PC/SC bench reader (`aliro-bench-test`) already proves the applet — this adds an MCU-driven reader path.
**Architecture:** X-NUCLEO-NFC10A1 and X-NUCLEO-NFC09A1 both carry the **same** ST25R200 NFC reader IC (ST UM3414 covers both boards). X-CUBE-ALIRO already ships a working NFC09A1 project at `Projects/nucleo-u5/Applications/Aliro/nfc-only/` that uses `Drivers/BSP/Components/st25r200/` — the chip driver is reusable verbatim. The port is board-level only: clone the `nfc-only/` project, remap pin/LED assignments from NFC09A1's Arduino header layout to NFC10A1's (plus add an explicit reset pin that NFC10A1 exposes), wire `aliro-trustgen`'s generated header into the build so the reader's long-term keys match what the J3R180 was personalized with, and add instrumentation. **No new chip driver fetch is required** (an earlier draft of this plan wrongly claimed NFC10A1 was ST25R3916 and would need X-CUBE-NFC7/NFC6 ported in — memory [j3r180_ec_curve_seeding.md](.claude/projects/-home-work-VSCodeProjects-aliro-project/memory/x_cube_aliro_supported_boards.md) now captures the correct chip pairings).
**Tech Stack:** NUCLEO-U545RE-Q (STM32U545RE MCU), X-NUCLEO-NFC10A1 (ST25R200 reader IC), CMake + `arm-none-eabi-gcc` 13.2+, STM32CubeProgrammer (or `st-flash`/OpenOCD) for flashing, ST-LINK onboard the Nucleo, RFAL + ST25R200 driver (both already in the X-CUBE-ALIRO vendor tree).
---
## Pre-flight toolchain gaps
Before Task 4 (build) the host needs:
```bash
sudo apt install ninja-build
```
Before Task 5 (flash) the host needs one of:
- **STM32CubeProgrammer** (preferred — ST-LINK out of the box): [st.com/en/development-tools/stm32cubeprog.html](https://www.st.com/en/development-tools/stm32cubeprog.html)
- `sudo apt install stlink-tools` (provides `st-flash`)
- `sudo apt install openocd`
`arm-none-eabi-gcc` 13.2.1, `cmake` 3.28 are already present (verified).
---
## Critical files and locations
**Template project (DO NOT MODIFY — clone from here):**
- [reader/STM32CubeExpansion_ALIRO_V1_0_0/Projects/nucleo-u5/Applications/Aliro/nfc-only/](reader/STM32CubeExpansion_ALIRO_V1_0_0/Projects/nucleo-u5/Applications/Aliro/nfc-only/)
**Shared code (DO NOT MODIFY — inherited as-is by new project):**
- `reader/STM32CubeExpansion_ALIRO_V1_0_0/Drivers/BSP/Components/st25r200/` — chip driver
- `reader/STM32CubeExpansion_ALIRO_V1_0_0/Drivers/BSP/NFC09A1/` — BSP shim (the current project uses NFC09A1's macros; we'll add an NFC10A1 one)
- `reader/STM32CubeExpansion_ALIRO_V1_0_0/Middlewares/ST/rfal/` — RFAL middleware
- `reader/STM32CubeExpansion_ALIRO_V1_0_0/Projects/Common/Aliro/` — Aliro protocol layer (where trust is injected in Task 4)
**New dirs we will create:**
- `reader/STM32CubeExpansion_ALIRO_V1_0_0/Drivers/BSP/NFC10A1/` — BSP shim copied + renamed from NFC09A1
- `reader/STM32CubeExpansion_ALIRO_V1_0_0/Projects/nucleo-u5/Applications/Aliro/nfc10-only/` — project root cloned from `nfc-only/`
**NFC10A1 pin-map reference (stm32duino):**
- `/tmp/x-nucleo-nfc10a1-ref/examples/X_NUCLEO_NFC10A1_HelloWorld/X_NUCLEO_NFC10A1_HelloWorld.ino` (already cloned this session)
- The `#define` block in that file is the authoritative Arduino-header pin map for NFC10A1.
**Trust bundle:** `~/aliro-trust/` (from `aliro-trustgen init`, same bundle the J3R180 was personalized with).
**Harness-side comparison point:** `aliro-bench-test --trust-dir ~/aliro-trust``RESULT: OK` is the known-good reference.
---
## Task 1: Clone the BSP shim for NFC10A1
**Files:**
- Create: `reader/STM32CubeExpansion_ALIRO_V1_0_0/Drivers/BSP/NFC10A1/nfc10a1.h`
- Create: `reader/STM32CubeExpansion_ALIRO_V1_0_0/Drivers/BSP/NFC10A1/nfc10a1.c`
- Reference (read-only): `reader/STM32CubeExpansion_ALIRO_V1_0_0/Drivers/BSP/NFC09A1/nfc09a1.{h,c}`
**Step 1: Copy the NFC09A1 shim**
```bash
cd reader/STM32CubeExpansion_ALIRO_V1_0_0/Drivers/BSP
cp -r NFC09A1 NFC10A1
cd NFC10A1
mv nfc09a1.h nfc10a1.h
mv nfc09a1.c nfc10a1.c
```
**Step 2: Rename identifiers inside the files**
```bash
sed -i 's/NFC09A1/NFC10A1/g; s/nfc09a1/nfc10a1/g' nfc10a1.h nfc10a1.c
```
The chip driver include (`st25r200.h`) must STAY unchanged — same chip. Grep to confirm:
```bash
grep -c st25r200 nfc10a1.{h,c} # should be ≥1 in each file
grep -c st25r500 nfc10a1.{h,c} # must be 0
```
**Step 3: Verify BSP API surface is unchanged**
The top-level `Projects/Common/Aliro/Src/app_nfc.c` calls e.g. `BSP_NFC0XCOMM_SendRecv(...)` — those symbol names are generic (NFC0X prefix) and don't change between NFC09A1 and NFC10A1. Confirm by grepping `nfc10a1.c` for `BSP_NFC0XCOMM_` — symbols must match the NFC09A1 original.
**Step 4: Commit (if reader/ is under git; currently the project isn't a git repo — create a note file instead)**
Since [/home/work/VSCodeProjects/aliro_project](.) isn't a git repository, track changes in a plain ledger file at `reader/bench-notes/nfc10a1-port-log.md`: append a line noting Task 1 completion with a timestamp.
---
## Task 2: Clone `nfc-only/` → `nfc10-only/` project
**Files:**
- Create: `reader/STM32CubeExpansion_ALIRO_V1_0_0/Projects/nucleo-u5/Applications/Aliro/nfc10-only/` (whole subtree)
**Step 1: Recursive copy**
```bash
cd reader/STM32CubeExpansion_ALIRO_V1_0_0/Projects/nucleo-u5/Applications/Aliro
cp -r nfc-only nfc10-only
```
**Step 2: Rename the per-board Target directory**
```bash
cd nfc10-only
mv X-CUBE-NFC9 X-CUBE-NFC10
```
**Step 3: Update nfc_conf.h for NFC10A1 layout**
Edit `nfc10-only/X-CUBE-NFC10/Target/nfc_conf.h`:
- Change include guard: `__NFC09A1_CONF_H__``__NFC10A1_CONF_H__`
- Rename every `NFC09A1_*` macro to `NFC10A1_*` (LED1-LED6 RCC clock enables + pin definitions)
- **Pin remap per NFC10A1 Arduino header:**
| Function | NFC09A1 (current) | NFC10A1 (new) | Nucleo-U545RE-Q pin |
|----------|-------------------|---------------|---------------------|
| SPI CS (`ST25R_SS`) | PC9 (line 87-88) | D10 → PB6 (verify against `stm32u5xx_nucleo_bus.h`) | **update** |
| SPI IRQ (`ST25R_INT`) | PA0 (line 89-90) | A0 → PA0 (same) | unchanged |
| **Reset** (NEW — NFC10A1 exposes) | — | D8 → PA9 (typical U5 mapping; verify) | **add new** |
| LED A | PB6 | A2 → PA4 (verify) | **update** |
| LED B | PB0 | A3 → PB0 (may be same) | **verify** |
| LED F | PB10 | D5 → PB4 (verify) | **update** |
| LED V | PA8 | D7 → PA8 (may be same) | **verify** |
| LED AP2P | PB10 | D6 → PB10 (may be same) | **verify** |
| LED FIELD | PA1 | A1 → PA1 (may be same) | **verify** |
**Authoritative source for Arduino→MCU mapping:** `reader/STM32CubeExpansion_ALIRO_V1_0_0/Drivers/BSP/NUCLEO-U545RE-Q/stm32u5xx_nucleo_bus.h` — open it and grep for `ARDUINO_D10_PIN`, `ARDUINO_A0_PIN`, etc. If the header uses macros like `ARDUINO_Dx_Pin` / `ARDUINO_Ax_Pin`, reference those directly (avoids hardcoding PAx literals that could drift).
- Add reset pin definitions (NFC10A1-specific, absent from NFC09A1):
```c
#define NFC10A1_RESET_PIN GPIO_PIN_9 /* or whatever D8 resolves to */
#define NFC10A1_RESET_PIN_PORT GPIOA
#define NFC10A1_RESET_PIN_CLK_ENABLE() __HAL_RCC_GPIOA_CLK_ENABLE()
```
Plumb the reset line through to the `st25r200` driver if the driver has a reset hook (check `st25r200.c` for a function like `st25r200_Reset()` or similar; the NFC12 template uses `BUS_SPI1_RST_GPIO_PIN` per the explorer's diff).
- Include path update: `#include "rfal_defConfig.h"` stays the same (shared); the board-specific bits should now reference `nfc10a1.h` (not `nfc09a1.h`) if included.
**Step 4: Update rfal_platform.h**
Open `nfc10-only/X-CUBE-NFC10/Target/rfal_platform.h`. The platform macros (`platformSpiSelect`, `platformSpiTxRx`, `platformProtectST25RComm`) bind to `ST25R_SS_PIN/PORT` etc. — those come from nfc_conf.h so if the pins there are right, this file just needs the header guard rename (and any `NFC09A1`/`NFC9` references → `NFC10A1`/`NFC10`).
**Step 5: Update CMake**
`nfc10-only/cmake/stm32cubemx/CMakeLists.txt`:
- Change project/target name: any `nfc-only` or `nfc_only` → `nfc10-only` / `nfc10_only`
- Swap include dir: `Drivers/BSP/NFC09A1` → `Drivers/BSP/NFC10A1`
- Update Target include: `X-CUBE-NFC9/Target` → `X-CUBE-NFC10/Target`
The `ST25R200` CMake define stays — same chip.
`nfc10-only/CMakeLists.txt` (top-level): update project name.
**Step 6: Update main.c init**
In `nfc10-only/Core/Src/main.c`, search for `NFC09A1_` or `BSP_NFC09A1_` and replace with `NFC10A1_` / `BSP_NFC10A1_` equivalents. The `BSP_NFC0XCOMM_*` generic names stay.
**Step 7: Append to the port-log ledger**
```
reader/bench-notes/nfc10a1-port-log.md
```
---
## Task 3: First build — iterate on errors
**Files:** no new files; fix whatever the build surfaces.
**Step 1: Configure**
```bash
cd reader/STM32CubeExpansion_ALIRO_V1_0_0/Projects/nucleo-u5/Applications/Aliro/nfc10-only
cmake -B build -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=cmake/stm32cubemx/gcc-arm-none-eabi.cmake \
-DCMAKE_BUILD_TYPE=Debug
```
(If ninja wasn't installed, fall back to `-G "Unix Makefiles"` and use `cmake --build build` / `make -j`.)
**Step 2: Build and triage**
```bash
cmake --build build 2>&1 | tee build.log
```
Expected first-pass errors (iterate):
- **Undefined reference to `BSP_NFC10A1_Init`** or similar → Task 1 rename incomplete; grep for stale `NFC09A1` in the new shim.
- **`nfc09a1.h: No such file`** → stale include somewhere in nfc10-only; replace with `nfc10a1.h`.
- **`st25r200.h: No such file`** should NOT appear — if it does, the Components include path was accidentally dropped in Task 2 Step 5.
- **`ARDUINO_Dx_Pin undefined`** → the pin macro doesn't exist on this board's `stm32u5xx_nucleo_bus.h`; fall back to hardcoded PAx literals per NUCLEO-U545RE-Q schematic (UM3414 App A for pinout tables).
- **Reset pin symbol unused** → you defined `NFC10A1_RESET_*` but didn't wire it into init. Either remove the def if the driver doesn't need it, or add a `HAL_GPIO_WritePin` pulse in `nfc10a1.c::BSP_NFC10A1_Init()`.
Fix one class of error at a time.
**Step 3: Confirm artifacts produced**
```bash
ls -la build/nfc10-only.elf build/nfc10-only.bin
```
Expected: both exist, `.bin` ~100-200 KB.
**Step 4: Record build success**
Append to `reader/bench-notes/nfc10a1-port-log.md`.
---
## Task 4: Wire `aliro-trustgen` output into the firmware build
**Files:**
- Possibly modify: `reader/STM32CubeExpansion_ALIRO_V1_0_0/Projects/Common/Aliro/Src/provisioning.c` (or equivalent — find the hardcoded trust first)
- Create: `reader/STM32CubeExpansion_ALIRO_V1_0_0/Projects/nucleo-u5/Applications/Aliro/nfc10-only/Core/Inc/aliro_trust.h` (gitignored, generated)
- Possibly modify: `harness/src/aliro_harness/trustgen/header.py` (to add an `emit-header` CLI subcommand if missing)
- Modify: `nfc10-only/cmake/stm32cubemx/CMakeLists.txt` (add custom_command)
**Step 1: Find the hardcoded trust**
```bash
grep -rln "reader_priv\|reader_pub\|issuer_pub\|credential_issuer" \
reader/STM32CubeExpansion_ALIRO_V1_0_0/Projects/Common/Aliro/
```
Expected: one or two files in `Src/` with `static const uint8_t` byte arrays. Note extern names — the generated header must match.
**Step 2: Verify `aliro-trustgen` can emit the expected header**
```bash
aliro-trustgen --help
aliro-trustgen init --out-dir /tmp/trust-test
ls /tmp/trust-test/
```
If there's already an `emit-header` subcommand (or `init` writes `aliro_trust.h`), use it. If not, add a one-liner subcommand that reads `reader.pem` + `access_credential.pem` + `reader_group_id.bin` + `reader_group_sub_id.bin` and writes a C header matching the vendor's extern names.
A bare-minimum template at `harness/src/aliro_harness/trustgen/header.py::emit_c_header(trust_dir, out_path)`:
```python
def emit_c_header(trust_dir: Path, out_path: Path) -> None:
rp = load_priv(trust_dir / "reader.pem") # 32 bytes
rP = load_pubk(trust_dir / "reader.pem") # 64 bytes x||y
iP = load_pubk(trust_dir / "access_credential.pem") # 64 bytes x||y
gid = (trust_dir / "reader_group_id.bin").read_bytes() # 16 bytes
sid = (trust_dir / "reader_group_sub_id.bin").read_bytes() # 16 bytes
with out_path.open("w") as f:
f.write("/* Auto-generated by aliro-trustgen — do not edit */\n")
f.write("#pragma once\n#include <stdint.h>\n\n")
f.write(f"static const uint8_t reader_priv_key[32] = {{ {fmt(rp)} }};\n")
f.write(f"static const uint8_t reader_pub_key[64] = {{ {fmt(rP)} }};\n")
f.write(f"static const uint8_t issuer_pub_key[64] = {{ {fmt(iP)} }};\n")
f.write(f"static const uint8_t reader_group_id[16] = {{ {fmt(gid)} }};\n")
f.write(f"static const uint8_t reader_group_sub_id[16] = {{ {fmt(sid)} }};\n")
```
Plus a Click command that calls it. Write tests in `harness/tests/test_trustgen_header.py`.
**Step 3: Replace vendor hardcoded bytes with the generated header**
In the file found in Step 1:
```c
// was: static const uint8_t reader_priv_key[32] = { 0xAA, 0xBB, ... };
#include "aliro_trust.h"
```
(If vendor declared its trust bytes as `static const` and consumed them locally, the header defining the same names with `static const` linkage is a drop-in. If vendor declared them `extern`, swap the header to match.)
**Step 4: CMake custom_command to regenerate on trust change**
In `nfc10-only/cmake/stm32cubemx/CMakeLists.txt`:
```cmake
set(ALIRO_TRUST_DIR "$ENV{HOME}/aliro-trust" CACHE PATH "Trust bundle directory")
set(ALIRO_TRUST_HEADER "${CMAKE_CURRENT_SOURCE_DIR}/../../Core/Inc/aliro_trust.h")
add_custom_command(
OUTPUT "${ALIRO_TRUST_HEADER}"
COMMAND aliro-trustgen emit-header --trust-dir "${ALIRO_TRUST_DIR}" --out "${ALIRO_TRUST_HEADER}"
DEPENDS "${ALIRO_TRUST_DIR}/reader.pem" "${ALIRO_TRUST_DIR}/access_credential.pem"
COMMENT "Regenerating aliro_trust.h from ${ALIRO_TRUST_DIR}"
VERBATIM
)
add_custom_target(aliro_trust_header DEPENDS "${ALIRO_TRUST_HEADER}")
add_dependencies(${CMAKE_PROJECT_NAME}.elf aliro_trust_header)
```
**Step 5: Ignore the generated header**
```bash
# Since project isn't git, create a LOCAL .gitignore-equivalent ledger:
echo "Projects/nucleo-u5/Applications/Aliro/nfc10-only/Core/Inc/aliro_trust.h" \
>> reader/STM32CubeExpansion_ALIRO_V1_0_0/.distignore
```
(Documentational — mostly a reminder.)
**Step 6: Rebuild + verify the bytes made it into the ELF**
```bash
rm -rf build && cmake -B build -G Ninja ...
cmake --build build
arm-none-eabi-objdump -s -j .rodata build/nfc10-only.elf | grep -A2 reader_priv
```
Expected: 32 bytes matching the first 32 bytes of the PEM-decoded reader private scalar.
---
## Task 5: Flash + first-boot verification (HARDWARE REQUIRED)
**Files:** none — on-device only.
**Step 1: Connect hardware**
- NUCLEO-U545RE-Q to host USB (enumerates as `/dev/ttyACM0` for VCP + ST-LINK DFU)
- X-NUCLEO-NFC10A1 plugged onto Arduino header (ensure correct orientation — D0 pin aligned)
**Step 2: Flash**
```bash
# With STM32CubeProgrammer:
STM32_Programmer_CLI -c port=SWD -w build/nfc10-only.elf -v -rst
# Or with st-flash:
arm-none-eabi-objcopy -O binary build/nfc10-only.elf build/nfc10-only.bin
st-flash write build/nfc10-only.bin 0x08000000
```
**Step 3: Open UART console**
```bash
minicom -D /dev/ttyACM0 -b 115200
# or: picocom -b 115200 /dev/ttyACM0
```
Expected: within a second of reset, banner text like `Aliro Reader — NFC10A1 / ST25R200` followed by init messages and a polling loop.
**Step 4: Verify NFC field activates**
Within a few seconds of boot the ST25R200 should drive the antenna. NFC10A1 has a FIELD LED — expect it to light solid (or blink during active polling).
**Step 5: If the FIELD LED doesn't light**
Common causes — debug in order:
1. Chip-ID read returns 0xFF → SPI isn't talking (check CS pin resolves to the right MCU GPIO; probe with logic analyzer if available)
2. Reset pin held low → the reset wiring added in Task 2 isn't pulsed correctly
3. Antenna tuning failed → check UART log for specific RFAL init error code
**Step 6: Stop here if field isn't coming up — no point chasing the protocol layer yet.**
---
## Task 6: Empty-field + random-card discovery (HARDWARE REQUIRED)
**Step 1: With no card present** — UART should log periodic `polling, no target` (or equivalent). Every ~500ms.
**Step 2: Present a random NFC-A card (transit, hotel keycard)** — UART should log `target detected: NFC-A, UID=...`. Proves polling + anti-collision.
**Step 3: Present the J3R180** — UART should log target detected and attempt a SELECT of the Aliro AID. Expected either success (proceed to Task 7) or an SW that points at which APDU exchange failed.
**Step 4: Capture UART log to `reader/bench-notes/nfc10a1-discovery.log`.**
---
## Task 7: Full AUTH0+AUTH1 against J3R180 (HARDWARE REQUIRED)
**Step 1: Confirm baseline still green**
```bash
aliro-bench-test --trust-dir ~/aliro-trust
```
Expected: `RESULT: OK`. If not, stop — something regressed in the applet.
**Step 2: Ensure card personalized from same trust bundle**
(No action if card hasn't been re-personalized since Task 4 wired the same bundle into the firmware. If uncertain, re-run `aliro-personalize --trust-dir ~/aliro-trust` on a fresh card.)
**Step 3: Present J3R180 to the Nucleo reader**
Expected UART log sequence:
```
target detected: NFC-A
SELECT Aliro AID → SW=9000
AUTH0 (lc=N) → response N bytes, SW=9000
AUTH1 (lc=N) → response N bytes, SW=9000
AUTH1 decrypt OK
UD signature verify OK
GRANT — signaling_bitmap=0x0005
```
**Step 4: Failure triage**
- **`SW=6A82` on SELECT** — AID mismatch. Vendor firmware's SELECT target must be `A0000009 09 ACCE 55 01`.
- **`SW=6F00` on AUTH0** — uncaught applet exception; correlate with bench-test behavior, should NOT appear if bench-test is green.
- **AUTH1 decrypt fail** — ExpeditedSKDevice mismatch. Either firmware's trust bundle differs from the card's, or Table 8-12 bytes are assembled differently. Compare Table 8-12 hex from firmware UART log vs bench-test `--debug` output.
- **Signature verify fail after decrypt** — firmware's `issuer_pub_key` / `credential_pub_key` extern doesn't match the trust bundle used to personalize the card.
**Step 5: On success**
Capture UART log to `reader/bench-notes/nfc10a1-auth1-green.log`. Update memory [pcsc_bench_reader.md](.claude/projects/-home-work-VSCodeProjects-aliro-project/memory/pcsc_bench_reader.md): append a note that Nucleo-NFC10A1 is now a second validation path.
---
## Task 8: Instrumentation — UART APDU trace + LED state machine
**Files:**
- Create: `reader/STM32CubeExpansion_ALIRO_V1_0_0/Projects/Common/Aliro/Inc/aliro_log.h`
- Modify: `reader/STM32CubeExpansion_ALIRO_V1_0_0/Projects/Common/Aliro/Src/app_nfc.c` — add APDU log points around each transceive
- Modify: `nfc10-only/Core/Src/main.c` — LED helpers
**Step 1: aliro_log.h**
```c
#pragma once
#include <stdio.h>
#include <stdint.h>
#define ALIRO_LOG_INFO(fmt, ...) printf("[INFO] " fmt "\n", ##__VA_ARGS__)
#define ALIRO_LOG_ERROR(fmt, ...) printf("[ERROR] " fmt "\n", ##__VA_ARGS__)
static inline void aliro_log_hex(const char *dir, const uint8_t *buf, uint16_t len) {
printf("[APDU %s] ", dir);
for (uint16_t i = 0; i < len; i++) printf("%02x", buf[i]);
printf("\n");
}
```
**Step 2: Instrument the APDU pump**
In `app_nfc.c` (or wherever ISO-DEP TX/RX happens), wrap:
```c
aliro_log_hex(">", txBuf, txLen);
// ... transceive
aliro_log_hex("<", rxBuf, rxLen);
```
Match the format the bench-test logs emit (see [harness/src/aliro_harness/reader/transaction.py](harness/src/aliro_harness/reader/transaction.py)).
**Step 3: LED helpers**
In `nfc10-only/Core/Src/main.c`:
```c
static void aliro_led_grant(void) { HAL_GPIO_WritePin(PLATFORM_LED_A_PORT, PLATFORM_LED_A_PIN, GPIO_PIN_SET); }
static void aliro_led_deny(void) { HAL_GPIO_WritePin(PLATFORM_LED_B_PORT, PLATFORM_LED_B_PIN, GPIO_PIN_SET); }
static void aliro_led_active(bool on) { HAL_GPIO_WritePin(PLATFORM_LED_FIELD_PORT, PLATFORM_LED_FIELD_PIN, on ? GPIO_PIN_SET : GPIO_PIN_RESET); }
```
Call from the Aliro state machine: `aliro_led_active(true)` at AUTH0 start, grant/deny at AUTH1 finish.
**Step 4: Verify visually**
J3R180 → FIELD LED (A1) lights during transaction, LED A (A2) lights for ~1s on grant, clears.
Random card → LED B (A3) lights on AUTH0 failure.
---
## Task 9: A/B compare — Nucleo vs PC/SC bench reader
**Files:**
- Create: `reader/bench-notes/ab-compare-2026-04-XX.md`
- Possibly modify: [harness/src/aliro_harness/reader/cli.py](harness/src/aliro_harness/reader/cli.py) — add `--debug` flag that dumps APDU TX/RX to stderr
**Step 1: Bench-test transcript with APDU trace**
If `--debug` doesn't exist, add it (one or two lines in the transmit wrapper at cli.py to `click.echo(f"[APDU >] {apdu.hex()}", err=True)` + same for response).
```bash
aliro-bench-test --trust-dir ~/aliro-trust --debug 2> /tmp/bench-trace.log
```
**Step 2: Nucleo transcript**
```bash
# Present card DURING this capture:
(timeout 10 cat /dev/ttyACM0) > /tmp/nucleo-trace.log
```
**Step 3: Diff APDU streams**
```bash
diff <(grep '^\[APDU' /tmp/bench-trace.log) <(grep '^\[APDU' /tmp/nucleo-trace.log)
```
Expected: differences only in randomized fields (reader ephemeral pubkey, transaction_identifier, encrypted payload ciphertext). Fixed fields (SELECT AID, command_parameters, protocol_version, reader_identifier, response TLV tags) match byte-for-byte.
**Step 4: Document findings**
`reader/bench-notes/ab-compare-2026-04-XX.md` captures:
- Same card + same trust bundle → both GRANT
- APDU diff: expected-only deltas
- Any surprise (extra TLV, unexpected field order, SW difference)
---
## Follow-up work NOT in this plan
- Step-up phase on the Nucleo (SELECT STEP_UP + CBOR ENVELOPE — large, separate plan)
- Antenna tuning against DT's implantable form factor (different tuning than external cards)
- CI build: compile the ELF in CI to catch regressions (no probe needed, just compile)
- Structured binary telemetry log format (replace `printf` — nice-to-have, not needed for bench)
---
## Verification checkpoint after all tasks complete
- [ ] `nfc10-only.elf` builds cleanly from the vendor tree
- [ ] Flasher writes and reader boots with NFC field active
- [ ] `aliro-bench-test --trust-dir ~/aliro-trust` still green (no applet regressions)
- [ ] Nucleo reader → J3R180 → GRANT + full APDU log + LED state
- [ ] A/B diff shows only expected randomness
- [ ] Memory entry updated: Nucleo-NFC10A1 is a second validation path alongside PC/SC