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

Three components, all bench-validated to varying depths:

- applet/: CSA Aliro v1.0 Java Card applet for J3R180. AUTH0 + AUTH1
  expedited-standard flow end-to-end green via PC/SC bench reader
  (aliro-bench-test). Userland AES-256-GCM and HMAC-SHA-256 layered
  on top of J3R180's primitives because the card lacks both natively.
  P-256 curve params seeded explicitly per J3R180's quirk.

- harness/: Python orchestrator (aliro-trustgen, aliro-personalize,
  aliro-bench-test) for trust-bundle generation, card personalization
  via PersonalizationApplet, and PC/SC AUTH0+AUTH1 transactions. 126
  pytest cases passing.

- reader/STM32CubeExpansion_ALIRO_V1_0_0/: ST X-CUBE-ALIRO V1.0.0
  with our NFC10A1 port (NUCLEO-U545RE-Q + X-NUCLEO-NFC10A1, ST25R200
  shared with NFC09A1). nfc10-only/ project, NFC10A1 BSP shim,
  ALIRO_TRUST_OVERRIDE include into vendor's provisioning.c, and an
  ALIRO_APDU_TRACE wrapper around demoTransceiveBlocking. Boots,
  detects the J3R180, completes SELECT + AUTH0; AUTH1 currently fails
  with RFAL ERR_PROTO (0xB) — under investigation, see
  docs/plans/2026-04-20-nucleo-nfc10a1-port.md and bench-notes/.

Excluded: x-cube-aliro.zip vendor archive, harness/.venv, build dirs,
generated aliro_trust.h (contains private reader scalar), all PEMs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 10:17:46 -07:00
commit 782074f6ae
8786 changed files with 2902373 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
"""Produce the APDU command sequences that drive PersonalizationApplet.
This module intentionally only builds byte-level APDU commands; actually
sending them is the caller's job (pyscard for real cards, or the Java test
harness via CardSimulator). Keeping transport out means these functions are
trivially unit-testable without any card or simulator available.
"""
from aliro_harness.personalizer.access_document import access_document_apdus
from aliro_harness.personalizer.orchestrator import (
PersonalizationError,
TrustArtifacts,
personalize_card,
)
__all__ = [
"access_document_apdus",
"personalize_card",
"PersonalizationError",
"TrustArtifacts",
]

View File

@@ -0,0 +1,51 @@
"""APDU sequence for provisioning an Access Document via PersonalizationApplet.
The applet's PersonalizationApplet (DT-proprietary AID) accepts the AD via
two INSes:
INS 0x23 WRITE_ACCESS_DOC — P1|P2 = destination offset, data = chunk
INS 0x24 FINALIZE_ACCESS_DOC — P1|P2 = total length, Lc = 0
The AD must fit within CredentialStore.ACCESS_DOC_MAX_LEN (1024 bytes).
"""
CLA_PROPRIETARY = 0x80
INS_WRITE_ACCESS_DOC = 0x23
INS_FINALIZE_ACCESS_DOC = 0x24
# Max command data field in a short-form APDU (ISO 7816-4).
APDU_CHUNK_MAX = 255
# Must match CredentialStore.ACCESS_DOC_MAX_LEN on the applet side.
ACCESS_DOC_MAX_LEN = 1024
def access_document_apdus(ad_bytes: bytes, chunk_size: int = APDU_CHUNK_MAX) -> list[bytes]:
"""Returns the ordered list of APDU commands that load ``ad_bytes`` onto
a provisioning-channel-selected applet.
The caller should have already SELECTed the PersonalizationApplet AID
and not yet called COMMIT.
"""
if len(ad_bytes) > ACCESS_DOC_MAX_LEN:
raise ValueError(
f"Access Document is {len(ad_bytes)} bytes; exceeds {ACCESS_DOC_MAX_LEN}-byte applet budget"
)
if not (1 <= chunk_size <= APDU_CHUNK_MAX):
raise ValueError(f"chunk_size must be in [1, {APDU_CHUNK_MAX}]")
apdus: list[bytes] = []
for offset in range(0, len(ad_bytes), chunk_size):
chunk = ad_bytes[offset : offset + chunk_size]
apdus.append(_apdu(INS_WRITE_ACCESS_DOC, offset >> 8, offset & 0xFF, chunk))
total = len(ad_bytes)
apdus.append(_apdu(INS_FINALIZE_ACCESS_DOC, total >> 8, total & 0xFF, b""))
return apdus
def _apdu(ins: int, p1: int, p2: int, data: bytes) -> bytes:
# Short-form APDU: CLA INS P1 P2 Lc data
header = bytes([CLA_PROPRIETARY, ins, p1, p2])
if data:
return header + bytes([len(data)]) + data
return header

View File

@@ -0,0 +1,59 @@
"""APDU constructors for PersonalizationApplet.
Mirrors the INS table in applet/INSTALL.md. See AliroAids.PROVISIONING for
the AID and PersonalizationApplet.java for the INS handlers.
"""
CLA_PROPRIETARY = 0x80
INS_SET_CRED_PRIV = 0x20
INS_SET_CRED_PUBK = 0x21
INS_SET_READER_PUBK = 0x22
INS_WRITE_ACCESS_DOC = 0x23
INS_FINALIZE_ACCESS_DOC = 0x24
INS_COMMIT = 0x2C
# Provisioning AID — must match AliroAids.PROVISIONING in the applet.
# (CSA RID + ACCE 55 99 01 — DT-internal, namespaced under the CSA RID so
# all three applets fit in one CAP.)
PROVISIONING_AID = bytes.fromhex("A000000909ACCE559901")
CRED_PRIV_LEN = 32
CRED_PUBK_LEN = 64
READER_PUBK_LEN = 64
def select_provisioning_apdu() -> bytes:
"""ISO 7816-4 SELECT by name: 00 A4 04 00 Lc <AID>."""
return bytes([0x00, 0xA4, 0x04, 0x00, len(PROVISIONING_AID)]) + PROVISIONING_AID
def set_credential_priv_apdu(priv: bytes) -> bytes:
if len(priv) != CRED_PRIV_LEN:
raise ValueError(f"credential_PrivK must be {CRED_PRIV_LEN}B, got {len(priv)}")
return _short_apdu(INS_SET_CRED_PRIV, 0, 0, priv)
def set_credential_pubk_apdu(pubk_xy: bytes) -> bytes:
if len(pubk_xy) != CRED_PUBK_LEN:
raise ValueError(f"credential_PubK must be {CRED_PUBK_LEN}B (x||y), got {len(pubk_xy)}")
return _short_apdu(INS_SET_CRED_PUBK, 0, 0, pubk_xy)
def set_reader_pubk_apdu(pubk_xy: bytes) -> bytes:
if len(pubk_xy) != READER_PUBK_LEN:
raise ValueError(f"reader_PubK must be {READER_PUBK_LEN}B (x||y), got {len(pubk_xy)}")
return _short_apdu(INS_SET_READER_PUBK, 0, 0, pubk_xy)
def commit_apdu() -> bytes:
return bytes([CLA_PROPRIETARY, INS_COMMIT, 0x00, 0x00])
def _short_apdu(ins: int, p1: int, p2: int, data: bytes) -> bytes:
"""Short-form APDU: CLA INS P1 P2 Lc data."""
if not data:
return bytes([CLA_PROPRIETARY, ins, p1, p2])
if len(data) > 255:
raise ValueError(f"data too long for short-form APDU: {len(data)}B")
return bytes([CLA_PROPRIETARY, ins, p1, p2, len(data)]) + data

View File

@@ -0,0 +1,101 @@
"""``aliro-personalize`` — load credential keys + Access Document onto a
freshly-installed Aliro applet via a PC/SC reader.
The card must already have the CAP installed and all three applets created
(see ``applet/INSTALL.md``). This tool drives the PersonalizationApplet
(AID ``A0000009 09 ACCE 55 99 01``) through its INS sequence:
SELECT → SET_CRED_PRIV → SET_CRED_PUBK → SET_READER_PUBK
→ WRITE_ACCESS_DOC × N → FINALIZE_ACCESS_DOC → COMMIT
After COMMIT the applet refuses every further write, so this is a one-shot
per card. Uncommit isn't supported.
"""
from pathlib import Path
import click
from aliro_harness.personalizer.orchestrator import (
PersonalizationError,
TrustArtifacts,
personalize_card,
)
@click.command()
@click.option(
"--trust-dir",
required=False,
default=None,
type=click.Path(exists=True, file_okay=False, path_type=Path),
help="Directory written by 'aliro-trustgen init'. Must contain "
"access_credential.pem, reader.pem, access_document.bin. "
"Required unless --list-readers is given.",
)
@click.option(
"--reader",
"reader_index",
type=int,
default=0,
show_default=True,
help="Index into the list of PC/SC readers (use --list-readers to see).",
)
@click.option(
"--list-readers",
is_flag=True,
help="List PC/SC readers visible to the host and exit.",
)
def main(trust_dir: Path | None, reader_index: int, list_readers: bool) -> None:
"""Personalize an Aliro card with the trust artifacts in TRUST_DIR."""
# pyscard is imported lazily so --help and unit tests don't require pcscd.
from smartcard.System import readers
available = readers()
if list_readers:
if not available:
click.echo("No PC/SC readers found.", err=True)
raise SystemExit(1)
for i, r in enumerate(available):
click.echo(f"[{i}] {r}")
return
if trust_dir is None:
raise click.UsageError(
"--trust-dir is required unless --list-readers is given."
)
if not available:
raise click.ClickException("No PC/SC readers found. Is pcscd running?")
if reader_index >= len(available):
raise click.ClickException(
f"--reader {reader_index} out of range; only {len(available)} reader(s) available."
)
reader = available[reader_index]
click.echo(f"Using reader: {reader}")
artifacts = TrustArtifacts.from_trust_dir(trust_dir)
click.echo(
f"Loaded artifacts: credential_PrivK={len(artifacts.credential_priv)}B, "
f"credential_PubK={len(artifacts.credential_pubk_xy)}B, "
f"reader_PubK={len(artifacts.reader_pubk_xy)}B, "
f"Access Document={len(artifacts.access_document)}B"
)
connection = reader.createConnection()
connection.connect()
click.echo(f"Connected. ATR: {bytes(connection.getATR()).hex()}")
def transmit(apdu: bytes) -> tuple[bytes, int]:
data, sw1, sw2 = connection.transmit(list(apdu))
return bytes(data), (sw1 << 8) | sw2
try:
personalize_card(transmit, artifacts)
except PersonalizationError as e:
raise click.ClickException(str(e)) from e
finally:
connection.disconnect()
click.echo("Personalization complete. Card is locked (no further writes accepted).")

View File

@@ -0,0 +1,31 @@
"""Extract the byte representations PersonalizationApplet expects from
PEM-encoded P-256 keys produced by ``aliro-trustgen init``.
The applet stores raw scalars / raw uncompressed-point coordinates with
fixed lengths (32 / 64 bytes), so we strip PEM/DER framing and BigInteger
sign-padding here.
"""
from cryptography.hazmat.primitives.asymmetric import ec
P256_COORD_LEN = 32
def extract_priv_scalar(private_key: ec.EllipticCurvePrivateKey) -> bytes:
"""Return the 32-byte big-endian P-256 private scalar."""
if not isinstance(private_key.curve, ec.SECP256R1):
raise ValueError(f"expected P-256 (secp256r1), got {private_key.curve.name}")
return _to_fixed_be(private_key.private_numbers().private_value, P256_COORD_LEN)
def extract_pub_xy(public_key: ec.EllipticCurvePublicKey) -> bytes:
"""Return the 64-byte uncompressed point (x || y) without the 0x04 tag."""
if not isinstance(public_key.curve, ec.SECP256R1):
raise ValueError(f"expected P-256 (secp256r1), got {public_key.curve.name}")
nums = public_key.public_numbers()
return _to_fixed_be(nums.x, P256_COORD_LEN) + _to_fixed_be(nums.y, P256_COORD_LEN)
def _to_fixed_be(value: int, length: int) -> bytes:
"""Big-endian unsigned, zero-padded to exactly ``length`` bytes."""
return value.to_bytes(length, "big")

View File

@@ -0,0 +1,87 @@
"""Orchestrate the full personalization sequence over an abstract APDU
transport. Decoupled from pyscard so unit tests can drive a mock transport
and verify the exact byte sequence produced.
"""
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from aliro_harness.personalizer.access_document import access_document_apdus
from aliro_harness.personalizer.apdus import (
commit_apdu,
select_provisioning_apdu,
set_credential_priv_apdu,
set_credential_pubk_apdu,
set_reader_pubk_apdu,
)
from aliro_harness.personalizer.credentials import (
extract_priv_scalar,
extract_pub_xy,
)
SW_OK = 0x9000
# A transmit callable takes one APDU (bytes) and returns (response_data, sw).
Transmit = Callable[[bytes], tuple[bytes, int]]
class PersonalizationError(RuntimeError):
"""Raised when an APDU returns a non-9000 status word."""
def __init__(self, step: str, sw: int):
super().__init__(f"{step} failed: SW=0x{sw:04X}")
self.step = step
self.sw = sw
@dataclass(frozen=True)
class TrustArtifacts:
"""Bytes the applet expects, extracted from a ``trustgen init`` output dir."""
credential_priv: bytes # 32B
credential_pubk_xy: bytes # 64B (x || y, no 0x04 prefix)
reader_pubk_xy: bytes # 64B
access_document: bytes # COSE_Sign1 serialized
@classmethod
def from_trust_dir(cls, trust_dir: Path) -> "TrustArtifacts":
cred_pem = (trust_dir / "access_credential.pem").read_bytes()
reader_pem = (trust_dir / "reader.pem").read_bytes()
ad = (trust_dir / "access_document.bin").read_bytes()
cred_key = serialization.load_pem_private_key(cred_pem, password=None)
reader_key = serialization.load_pem_private_key(reader_pem, password=None)
return cls(
credential_priv=extract_priv_scalar(cred_key),
credential_pubk_xy=extract_pub_xy(cred_key.public_key()),
reader_pubk_xy=extract_pub_xy(reader_key.public_key()),
access_document=ad,
)
def personalize_card(transmit: Transmit, artifacts: TrustArtifacts) -> None:
"""Drive the full provisioning sequence on a card already powered up
behind ``transmit``. Raises :class:`PersonalizationError` on the first
APDU that doesn't return 0x9000.
"""
_send(transmit, "SELECT provisioning AID", select_provisioning_apdu())
_send(transmit, "SET credential_PrivK", set_credential_priv_apdu(artifacts.credential_priv))
_send(transmit, "SET credential_PubK", set_credential_pubk_apdu(artifacts.credential_pubk_xy))
_send(transmit, "SET reader_PubK", set_reader_pubk_apdu(artifacts.reader_pubk_xy))
for i, apdu in enumerate(access_document_apdus(artifacts.access_document)):
_send(transmit, f"Access Document APDU {i}", apdu)
_send(transmit, "COMMIT", commit_apdu())
def _send(transmit: Transmit, step: str, apdu: bytes) -> bytes:
data, sw = transmit(apdu)
if sw != SW_OK:
raise PersonalizationError(step, sw)
return data