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>
31 KiB
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=0x5ESALT_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
mkdir -p harness/src/aliro_harness/reader
Step 2: Write the failing test
# 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
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
# 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
pytest tests/test_reader_session.py::test_session_starts_with_no_transaction_state -v
Step 6: Commit
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
# 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
# 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
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
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
# 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)
# 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.
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
# 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
# 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
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
# 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
# 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.
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
# 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
# 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.
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
# 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
# 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.
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.
# 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(addaliro-bench-testentry 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
# 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
[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
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
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-readersenumerates the PC/SC readeraliro-bench-test --trust-dir ~/aliro-trustruns a full transaction against the personalized J3R180 and prints "RESULT: OK"- The Java applet test suite (
./scripts/dt-mvn.sh testfromapplet/) still passes — this plan touches no applet code
Risks / known unknowns
- 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. - Card timing. ECDSA sign on the J3R180 takes 100-300ms. The
connection.transmitcall 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. - Salt_volatile drift. If we change
AliroApplet.PROPRIETARY_A5_TLV/INTERFACE_BYTE_NFC/SALT_VOLATILE_TAGon 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.