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:
242
docs/plans/2026-04-17-aliro-credential-design.md
Normal file
242
docs/plans/2026-04-17-aliro-credential-design.md
Normal 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.
|
||||
874
docs/plans/2026-04-19-pcsc-bench-reader.md
Normal file
874
docs/plans/2026-04-19-pcsc-bench-reader.md
Normal 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.
|
||||
513
docs/plans/2026-04-20-nucleo-nfc10a1-port.md
Normal file
513
docs/plans/2026-04-20-nucleo-nfc10a1-port.md
Normal 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
|
||||
Reference in New Issue
Block a user