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:
21
harness/src/aliro_harness/personalizer/__init__.py
Normal file
21
harness/src/aliro_harness/personalizer/__init__.py
Normal 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",
|
||||
]
|
||||
51
harness/src/aliro_harness/personalizer/access_document.py
Normal file
51
harness/src/aliro_harness/personalizer/access_document.py
Normal 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
|
||||
59
harness/src/aliro_harness/personalizer/apdus.py
Normal file
59
harness/src/aliro_harness/personalizer/apdus.py
Normal 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
|
||||
101
harness/src/aliro_harness/personalizer/cli.py
Normal file
101
harness/src/aliro_harness/personalizer/cli.py
Normal 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).")
|
||||
31
harness/src/aliro_harness/personalizer/credentials.py
Normal file
31
harness/src/aliro_harness/personalizer/credentials.py
Normal 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")
|
||||
87
harness/src/aliro_harness/personalizer/orchestrator.py
Normal file
87
harness/src/aliro_harness/personalizer/orchestrator.py
Normal 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
|
||||
Reference in New Issue
Block a user