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

View File

@@ -0,0 +1,82 @@
"""Minimal Aliro Access Document builder.
v1 scope: an Access Document with no Access Data Elements. The MSO carries
the Access Credential public key (the User Device's long-term key) signed
by the Credential Issuer. A Reader provisioned with the Credential Issuer's
public key can verify the Access Document and trust the contained device key.
Aliro spec \u00a77.2 mandates integer-replaced map keys encoded as text strings
(Table 7-1). The COSE_Key encoding (RFC 8152) inside deviceKeyInfo uses its
own integer keys unchanged.
"""
from datetime import UTC, datetime, timedelta
import cbor2
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.issuer.cose import sign_cose_sign1
from aliro_harness.issuer.kid import compute_issuer_kid
MSO_KEY_VERSION = "1"
MSO_KEY_DIGEST_ALG = "2"
MSO_KEY_VALUE_DIGESTS = "3"
MSO_KEY_DEVICE_KEY_INFO = "4"
MSO_KEY_DOC_TYPE = "5"
MSO_KEY_VALIDITY_INFO = "6"
MSO_KEY_TIME_VERIFICATION_REQUIRED = "7"
DEVICE_KEY_INFO_KEY_DEVICE_KEY = "1"
VALIDITY_KEY_SIGNED = "1"
VALIDITY_KEY_VALID_FROM = "2"
VALIDITY_KEY_VALID_UNTIL = "3"
ALIRO_DOCTYPE_ACCESS = "aliro-a"
ALIRO_NAMESPACE_ACCESS = "aliro-a"
_COSE_KEY_KTY = 1
_COSE_KEY_KTY_EC2 = 2
_COSE_KEY_CRV = -1
_COSE_KEY_CRV_P256 = 1
_COSE_KEY_X = -2
_COSE_KEY_Y = -3
_P256_COORD_LEN = 32
def _cose_key_p256(public_key: ec.EllipticCurvePublicKey) -> dict:
numbers = public_key.public_numbers()
return {
_COSE_KEY_KTY: _COSE_KEY_KTY_EC2,
_COSE_KEY_CRV: _COSE_KEY_CRV_P256,
_COSE_KEY_X: numbers.x.to_bytes(_P256_COORD_LEN, "big"),
_COSE_KEY_Y: numbers.y.to_bytes(_P256_COORD_LEN, "big"),
}
def build_access_document(
*,
issuer_private_key: ec.EllipticCurvePrivateKey,
access_credential_public_key: ec.EllipticCurvePublicKey,
validity_days: int = 365,
) -> bytes:
now = datetime.now(UTC).replace(microsecond=0)
mso = {
MSO_KEY_VERSION: "1.0",
MSO_KEY_DIGEST_ALG: "SHA-256",
MSO_KEY_VALUE_DIGESTS: {},
MSO_KEY_DEVICE_KEY_INFO: {
DEVICE_KEY_INFO_KEY_DEVICE_KEY: _cose_key_p256(access_credential_public_key),
},
MSO_KEY_DOC_TYPE: ALIRO_DOCTYPE_ACCESS,
MSO_KEY_VALIDITY_INFO: {
VALIDITY_KEY_SIGNED: now,
VALIDITY_KEY_VALID_FROM: now,
VALIDITY_KEY_VALID_UNTIL: now + timedelta(days=validity_days),
},
MSO_KEY_TIME_VERIFICATION_REQUIRED: False,
}
payload = cbor2.dumps(mso, canonical=True)
kid = compute_issuer_kid(issuer_private_key.public_key())
return sign_cose_sign1(private_key=issuer_private_key, payload=payload, kid=kid)

View File

@@ -0,0 +1,45 @@
from datetime import UTC, datetime, timedelta
from cryptography import x509
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.issuer.keys import generate_p256_keypair
def create_self_signed_ca(
*,
common_name: str,
validity_days: int = 3650,
) -> tuple[x509.Certificate, ec.EllipticCurvePrivateKey]:
key = generate_p256_keypair()
subject = issuer = x509.Name(
[x509.NameAttribute(x509.NameOID.COMMON_NAME, common_name)]
)
now = datetime.now(UTC)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(minutes=1))
.not_valid_after(now + timedelta(days=validity_days))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.add_extension(
x509.KeyUsage(
digital_signature=True,
content_commitment=False,
key_encipherment=False,
data_encipherment=False,
key_agreement=False,
key_cert_sign=True,
crl_sign=True,
encipher_only=False,
decipher_only=False,
),
critical=True,
)
.sign(private_key=key, algorithm=hashes.SHA256())
)
return cert, key

View File

@@ -0,0 +1,90 @@
"""Minimal COSE_Sign1 implementation sufficient for Aliro IssuerAuth.
Reference: RFC 8152 (COSE), \u00a74.4 for the Sig_structure.
COSE_Sign1 wire format:
[ protected_bstr, unprotected_map, payload_bstr, signature_bstr ]
Sig_structure (what gets signed):
[ "Signature1", body_protected, external_aad, payload ]
serialised with deterministic CBOR, then ECDSA-SHA-256.
COSE ECDSA signatures are raw r||s (64 bytes for P-256), not DER.
"""
import cbor2
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import (
decode_dss_signature,
encode_dss_signature,
)
COSE_ALG_ES256 = -7
COSE_HDR_ALG = 1
COSE_HDR_KID = 4
_P256_COORD_LEN = 32
class CoseVerificationError(Exception):
pass
def _sig_structure(protected_bstr: bytes, payload: bytes) -> bytes:
return cbor2.dumps(["Signature1", protected_bstr, b"", payload], canonical=True)
def _der_to_raw(der_sig: bytes) -> bytes:
r, s = decode_dss_signature(der_sig)
return r.to_bytes(_P256_COORD_LEN, "big") + s.to_bytes(_P256_COORD_LEN, "big")
def _raw_to_der(raw_sig: bytes) -> bytes:
if len(raw_sig) != 2 * _P256_COORD_LEN:
raise CoseVerificationError(f"expected 64-byte raw signature, got {len(raw_sig)}")
r = int.from_bytes(raw_sig[:_P256_COORD_LEN], "big")
s = int.from_bytes(raw_sig[_P256_COORD_LEN:], "big")
return encode_dss_signature(r, s)
def sign_cose_sign1(
*,
private_key: ec.EllipticCurvePrivateKey,
payload: bytes,
kid: bytes,
) -> bytes:
protected_bstr = cbor2.dumps({COSE_HDR_ALG: COSE_ALG_ES256}, canonical=True)
unprotected = {COSE_HDR_KID: kid}
to_be_signed = _sig_structure(protected_bstr, payload)
der_sig = private_key.sign(to_be_signed, ec.ECDSA(hashes.SHA256()))
raw_sig = _der_to_raw(der_sig)
return cbor2.dumps([protected_bstr, unprotected, payload, raw_sig], canonical=True)
def verify_cose_sign1(
signed: bytes,
public_key: ec.EllipticCurvePublicKey,
) -> bytes:
try:
decoded = cbor2.loads(signed)
except Exception as e:
raise CoseVerificationError(f"not valid CBOR: {e}") from e
if not isinstance(decoded, list) or len(decoded) != 4:
raise CoseVerificationError("COSE_Sign1 must be a 4-element array")
protected_bstr, _unprotected, payload, raw_sig = decoded
if not isinstance(protected_bstr, bytes):
raise CoseVerificationError("protected header must be bstr")
if not isinstance(payload, bytes):
raise CoseVerificationError("payload must be bstr")
if not isinstance(raw_sig, bytes):
raise CoseVerificationError("signature must be bstr")
to_be_signed = _sig_structure(protected_bstr, payload)
der_sig = _raw_to_der(raw_sig)
try:
public_key.verify(der_sig, to_be_signed, ec.ECDSA(hashes.SHA256()))
except InvalidSignature as e:
raise CoseVerificationError("signature verification failed") from e
return payload

View File

@@ -0,0 +1,61 @@
"""Aliro-flavored mdoc DeviceResponse for the step-up phase.
Per Aliro §8.4.2, the User Device returns an ISO 18013-5 DeviceResponse
with two changes: readerAuth / documentErrors / errors / deviceSigned MUST
NOT be present, and map keys are replaced by the integer aliases in
Table 8-22 (still encoded as text strings).
For v1 (no Access Data Elements; Access Document carries only the
credential long-term public key inside IssuerAuth), the DeviceResponse
is effectively static once the AD is generated:
{
"1": "1.0", # version
"2": [ # documents
{
"1": { # issuerSigned
"1": {}, # nameSpaces (empty — no ADEs)
"2": <AD> # IssuerAuth — raw COSE_Sign1 bytes
},
"5": "aliro-a" # docType
}
],
"3": 0 # status (0 = OK)
}
The Access Document is already a serialized COSE_Sign1, which is itself
valid CBOR (a 4-element array). Splicing its bytes into the DeviceResponse
at the IssuerAuth position yields valid CBOR — no decode/re-encode needed.
"""
import cbor2
def build_device_response(access_document_bytes: bytes) -> bytes:
"""Returns a deterministic-CBOR DeviceResponse wrapping the given
Access Document as IssuerAuth.
``access_document_bytes`` must be the serialized COSE_Sign1 produced
by :func:`build_access_document` — it is embedded verbatim.
"""
# cbor2.dumps with canonical=True produces deterministic CBOR. The AD is
# loaded first so it lives in the structure as real Python objects that
# cbor2 re-encodes — this gives us canonical ordering guarantees even if
# the source AD was encoded differently.
ad = cbor2.loads(access_document_bytes)
return cbor2.dumps(
{
"1": "1.0",
"2": [
{
"1": {
"1": {},
"2": ad,
},
"5": "aliro-a",
}
],
"3": 0,
},
canonical=True,
)

View File

@@ -0,0 +1,24 @@
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
def generate_p256_keypair() -> ec.EllipticCurvePrivateKey:
return ec.generate_private_key(ec.SECP256R1())
def save_private_key_pem(path: Path, key: ec.EllipticCurvePrivateKey) -> None:
pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
path.write_bytes(pem)
def load_private_key_pem(path: Path) -> ec.EllipticCurvePrivateKey:
key = serialization.load_pem_private_key(path.read_bytes(), password=None)
if not isinstance(key, ec.EllipticCurvePrivateKey):
raise ValueError(f"expected an EC private key at {path}, got {type(key).__name__}")
return key

View File

@@ -0,0 +1,16 @@
"""Aliro IssuerAuth `kid` computation (spec \u00a77.2.1).
kid = SHA256("key-identifier" || 0x04 || IssuerKey_PubK.x || IssuerKey_PubK.y)[:8]
"""
import hashlib
from cryptography.hazmat.primitives.asymmetric import ec
def compute_issuer_kid(public_key: ec.EllipticCurvePublicKey) -> bytes:
numbers = public_key.public_numbers()
x_bytes = numbers.x.to_bytes(32, "big")
y_bytes = numbers.y.to_bytes(32, "big")
payload = b"key-identifier" + b"\x04" + x_bytes + y_bytes
return hashlib.sha256(payload).digest()[:8]

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

View File

@@ -0,0 +1,48 @@
"""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

View File

@@ -0,0 +1,86 @@
"""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: bytes, cred_x: bytes, reader_x: bytes, txn: bytes, usage: bytes,
) -> bytes:
# Match auth0.py / build_auth1_data discipline: validate fixed-length
# inputs up front. Java callers get an ArrayIndexOutOfBoundsException
# naturally; Python slicing would silently produce a wrong-length blob.
if len(rid) != 32:
raise ValueError(f"reader_id_32 must be 32B (got {len(rid)})")
if len(cred_x) != 32:
raise ValueError(f"credential_ephem_pub_x must be 32B (got {len(cred_x)})")
if len(reader_x) != 32:
raise ValueError(f"reader_ephem_pub_x must be 32B (got {len(reader_x)})")
if len(txn) != 16:
raise ValueError(f"transaction_id must be 16B (got {len(txn)})")
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

View File

@@ -0,0 +1,54 @@
"""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.exceptions import InvalidSignature
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:
"""Decrypt the FIRST AUTH1 response per spec §8.3.1.6.
IV = 0x00*7 || 0x01 || expedited_device_counter (4B BE).
device_counter is HARDCODED to 1: v1 does exactly one AUTH1 per session
(the applet clears FLAG_AUTH0_DONE after a successful AUTH1 — see
AliroApplet.processAuth1 — so the next AUTH1 needs a fresh AUTH0,
fresh sk_device, and the counter resets to 1). If a future revision
loops AUTH1 within a session, this must take counter as a parameter.
Raises ``cryptography.exceptions.InvalidTag`` on auth-tag mismatch.
"""
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:
"""Verify the UD's ECDSA-SHA-256 signature over Table 8-13.
Returns True on valid sig, False on invalid sig.
Raises ``ValueError`` on malformed inputs (wrong sig length, malformed
pubkey encoding — the latter from ``from_encoded_point`` on a non-65B
or off-curve point).
"""
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 InvalidSignature:
return False

View File

@@ -0,0 +1,118 @@
"""``aliro-bench-test`` — run a single Aliro AUTH0+AUTH1 transaction against
a personalized card via a PC/SC reader.
Drives the Aliro EXPEDITED phase end-to-end:
SELECT EXPEDITED -> AUTH0 -> AUTH1 -> decrypt -> verify UD signature
Exits 0 on success, non-zero with a clear error on any failure.
"""
from pathlib import Path
import click
from aliro_harness.reader.transaction import TrustBundle, run_aliro_transaction
@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 "
"reader.pem, access_credential.pem, reader_group_id.bin, "
"reader_group_sub_id.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:
"""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."""
# 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 < 0 or 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}")
connection = reader.createConnection()
connection.connect()
try:
click.echo(f"Connected. ATR: {bytes(connection.getATR()).hex()}")
bundle = TrustBundle.from_trust_dir(trust_dir)
click.echo(f"Loaded trust artifacts from {trust_dir}")
def transmit(apdu: bytes) -> tuple[bytes, int]:
data, sw1, sw2 = connection.transmit(list(apdu))
return bytes(data), (sw1 << 8) | sw2
result = run_aliro_transaction(transmit=transmit, bundle=bundle)
finally:
connection.disconnect()
if not result.ok:
# If decrypt succeeded but verify (or another late step) failed,
# the orchestrator populates the TLV fields anyway — print them so
# the user at the bench can see WHERE in the pipeline the failure
# happened rather than just the last error message.
if result.plaintext is not None:
click.echo(
f"RESULT: FAIL \u2014 {result.error}", err=True
)
_echo_tlv_breakdown(result)
click.echo(
"Hint: the AUTH1 response decrypted, so session keys are "
"correct. The signature verification failed — check that the "
"credential public key in the trust dir matches the one "
"provisioned on the card.",
err=True,
)
raise click.exceptions.Exit(1)
raise click.ClickException(result.error or "Aliro transaction failed.")
click.echo("RESULT: OK \u2014 applet round-trip on real hardware.")
_echo_tlv_breakdown(result)
def _echo_tlv_breakdown(result) -> None:
cred_pub_len = len(result.credential_pub) if result.credential_pub else 0
ud_sig_len = len(result.ud_sig) if result.ud_sig else 0
bitmap_hex = result.signaling_bitmap.hex() if result.signaling_bitmap else ""
click.echo(f" 0x5A credential_PubK: {cred_pub_len}B")
click.echo(f" 0x9E UD signature: {ud_sig_len}B")
click.echo(f" 0x5E signaling_bitmap: 0x{bitmap_hex}")

View File

@@ -0,0 +1,42 @@
"""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
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. An empty (or None) salt is substituted
with HashLen (32) zero bytes, per RFC 5869 §2.2."""
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.
Point-on-curve validation is enforced by ``from_encoded_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)

View File

@@ -0,0 +1,68 @@
"""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 (lines 406-446)."""
if not (0 <= command_parameters <= 0xFF and 0 <= authentication_policy <= 0xFF):
raise ValueError(
"command_parameters and authentication_policy must each fit in one byte"
)
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
salt = out.getvalue()
if len(salt) != 173:
raise ValueError(
f"salt_volatile must be 173 bytes (caller passed wrong-length x-coord or ID); "
f"got {len(salt)}. Each 32B field must be exactly 32B; each 16B field must be 16B."
)
return salt
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)

View File

@@ -0,0 +1,20 @@
"""Per-transaction reader-side state for an Aliro NFC transaction."""
from dataclasses import dataclass
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()

View File

@@ -0,0 +1,38 @@
"""Minimal TLV reader for AUTH0/AUTH1 response parsing.
Mirrors applet/src/test/.../TlvUtil.java. Single-byte tags only
(Aliro uses BER-TLV but every tag in AUTH0/AUTH1 responses — 0x86, 0x9E,
0x5A, 0x5E, 0x4D, 0x4B, 0x4C, 0x91, 0x92 — is single-byte). The Java
reference additionally supports BER multi-byte tag continuation
((tag & 0x1F) == 0x1F); revisit if a future Aliro spec revision starts
emitting extended tags.
Short-form lengths plus 0x81 / 0x82 long-form for sizes >= 128. Returned
slice is a fresh ``bytes`` object (not aliased into ``data``).
Inputs are assumed well-formed BER-TLV from a trusted card response;
truncated input raises ``IndexError`` (left unwrapped because the
upstream transaction orchestrator treats any parse failure as a protocol
error and surfaces a clear message there).
"""
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

View File

@@ -0,0 +1,269 @@
"""Top-level Aliro reader transaction orchestrator.
Runs SELECT EXPEDITED -> AUTH0 -> AUTH1 against an abstract APDU transport,
decrypts the AUTH1 response, and verifies the UD signature against the
configured credential_PubK. Decoupled from pyscard so unit tests can drive
a mocked/in-process card.
"""
import secrets
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.personalizer.credentials import extract_pub_xy
from aliro_harness.reader.auth0 import build_auth0_apdu, build_auth0_data
from aliro_harness.reader.auth1 import (
build_auth1_apdu,
build_auth1_data,
build_table_812,
build_table_813,
sign_table_812_raw,
)
from aliro_harness.reader.auth1_response import (
decrypt_auth1_response,
verify_ud_signature,
)
from aliro_harness.reader.crypto import derive_kdh
from aliro_harness.reader.key_derivation import (
OFF_EXPEDITED_SK_DEVICE,
build_salt_volatile,
derive_expedited_session_keys,
)
from aliro_harness.reader.tlv import find_top_level
# Aliro EXPEDITED phase AID -- must match AliroAids.EXPEDITED in the applet.
EXPEDITED_AID = bytes.fromhex("A000000909ACCE5501")
Transmit = Callable[[bytes], tuple[bytes, int]]
SW_OK = 0x9000
@dataclass(frozen=True)
class TrustBundle:
"""Trust artifacts the reader needs for a transaction. Mirrors what
personalizer.TrustArtifacts loads but adds reader_priv (which the
personalizer doesn't need, since it never signs)."""
reader_priv: ec.EllipticCurvePrivateKey
reader_long_term_pub_x: bytes # 32B (used in salt_volatile)
reader_group_id: bytes # 16B
reader_group_sub_id: bytes # 16B
credential_long_term_pub_uncompressed: bytes # 65B (for verify_ud_signature)
credential_long_term_pub_x: bytes # 32B (for salt_volatile)
@classmethod
def synthesize(
cls,
*,
reader_priv: ec.EllipticCurvePrivateKey,
credential_pub: ec.EllipticCurvePublicKey,
reader_group_id: bytes | None = None,
reader_group_sub_id: bytes | None = None,
) -> "TrustBundle":
"""Convenience for tests: synthesize a TrustBundle from in-process keys."""
reader_pub_xy = extract_pub_xy(reader_priv.public_key()) # 64B (x||y)
reader_pub_x = reader_pub_xy[:32]
credential_pub_xy = extract_pub_xy(credential_pub) # 64B
credential_pub_x = credential_pub_xy[:32]
credential_pub_uncompressed = b"\x04" + credential_pub_xy
if reader_group_id is None:
reader_group_id = secrets.token_bytes(16)
if reader_group_sub_id is None:
reader_group_sub_id = secrets.token_bytes(16)
if len(reader_group_id) != 16:
raise ValueError("reader_group_id must be 16B")
if len(reader_group_sub_id) != 16:
raise ValueError("reader_group_sub_id must be 16B")
return cls(
reader_priv=reader_priv,
reader_long_term_pub_x=reader_pub_x,
reader_group_id=reader_group_id,
reader_group_sub_id=reader_group_sub_id,
credential_long_term_pub_uncompressed=credential_pub_uncompressed,
credential_long_term_pub_x=credential_pub_x,
)
@classmethod
def from_trust_dir(cls, trust_dir: Path) -> "TrustBundle":
"""Loads from ``aliro-trustgen init`` output:
- reader.pem -> reader_priv
- access_credential.pem -> credential public key
- reader_group_id.bin -> reader_group_id
- reader_group_sub_id.bin -> reader_group_sub_id
"""
reader_pem = (trust_dir / "reader.pem").read_bytes()
cred_pem = (trust_dir / "access_credential.pem").read_bytes()
reader_group_id = (trust_dir / "reader_group_id.bin").read_bytes()
reader_group_sub_id = (trust_dir / "reader_group_sub_id.bin").read_bytes()
reader_priv = serialization.load_pem_private_key(reader_pem, password=None)
cred_priv = serialization.load_pem_private_key(cred_pem, password=None)
return cls.synthesize(
reader_priv=reader_priv,
credential_pub=cred_priv.public_key(),
reader_group_id=reader_group_id,
reader_group_sub_id=reader_group_sub_id,
)
@dataclass(frozen=True)
class TransactionResult:
ok: bool
plaintext: bytes | None = None # decrypted Table 8-11 bytes
credential_pub: bytes | None = None # 0x5A value (65B uncompressed) when present
ud_sig: bytes | None = None # 0x9E value (64B raw r||s)
signaling_bitmap: bytes | None = None # 0x5E value (2B)
error: str | None = None # populated on failure
def run_aliro_transaction(
*, transmit: Transmit, bundle: TrustBundle
) -> TransactionResult:
"""Orchestrate one Aliro AUTH0+AUTH1 transaction.
Steps:
1. SELECT EXPEDITED
2. AUTH0 -- generate ephemeral, send, parse 0x86 (credential_ePubK)
3. Derive Kdh + ExpeditedSKDevice
4. Build + sign Table 8-12 -> AUTH1 command
5. Decrypt response, extract 0x5A/0x9E/0x5E TLVs
6. Verify UD signature over Table 8-13 against credential_PubK
Any non-9000 SW or signature mismatch returns ok=False with ``error`` set.
"""
# SELECT EXPEDITED
select_apdu = (
bytes([0x00, 0xA4, 0x04, 0x00, len(EXPEDITED_AID)]) + EXPEDITED_AID
)
_, sw = transmit(select_apdu)
if sw != SW_OK:
return TransactionResult(
ok=False, error=f"SELECT EXPEDITED failed: SW=0x{sw:04X}"
)
# Generate reader ephemeral + transaction state
reader_ephem = ec.generate_private_key(ec.SECP256R1())
reader_ephem_pub_uncompressed = reader_ephem.public_key().public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
reader_ephem_pub_x = reader_ephem_pub_uncompressed[1:33]
txn_id = secrets.token_bytes(16)
# AUTH0
auth0_data = build_auth0_data(
reader_ephem_pub_uncompressed=reader_ephem_pub_uncompressed,
reader_group_id=bundle.reader_group_id,
reader_group_sub_id=bundle.reader_group_sub_id,
transaction_id=txn_id,
command_parameters=0x00,
authentication_policy=0x00,
)
auth0_resp_data, sw = transmit(build_auth0_apdu(auth0_data))
if sw != SW_OK:
return TransactionResult(ok=False, error=f"AUTH0 failed: SW=0x{sw:04X}")
cred_ephem_pub_uncompressed = find_top_level(auth0_resp_data, 0x86)
if (
cred_ephem_pub_uncompressed is None
or len(cred_ephem_pub_uncompressed) != 65
):
return TransactionResult(
ok=False,
error="AUTH0 response missing or malformed 0x86 credential_ePubK",
)
cred_ephem_pub_x = cred_ephem_pub_uncompressed[1:33]
# Key derivation
kdh = derive_kdh(reader_ephem, cred_ephem_pub_uncompressed, txn_id)
salt_volatile = build_salt_volatile(
reader_long_term_pub_x=bundle.reader_long_term_pub_x,
reader_group_id=bundle.reader_group_id,
reader_group_sub_id=bundle.reader_group_sub_id,
reader_ephem_pub_x=reader_ephem_pub_x,
transaction_id=txn_id,
command_parameters=0x00,
authentication_policy=0x00,
credential_long_term_pub_x=bundle.credential_long_term_pub_x,
)
derived_keys = derive_expedited_session_keys(
kdh, salt_volatile, cred_ephem_pub_x
)
sk_device = derived_keys[OFF_EXPEDITED_SK_DEVICE : OFF_EXPEDITED_SK_DEVICE + 32]
# AUTH1
reader_id_32 = bundle.reader_group_id + bundle.reader_group_sub_id
table_812 = build_table_812(
reader_id_32=reader_id_32,
credential_ephem_pub_x=cred_ephem_pub_x,
reader_ephem_pub_x=reader_ephem_pub_x,
transaction_id=txn_id,
)
raw_sig = sign_table_812_raw(table_812, bundle.reader_priv)
auth1_data = build_auth1_data(raw_sig, command_parameters=0x01)
auth1_resp_data, sw = transmit(build_auth1_apdu(auth1_data))
if sw != SW_OK:
return TransactionResult(ok=False, error=f"AUTH1 failed: SW=0x{sw:04X}")
# Decrypt + verify
try:
plaintext = decrypt_auth1_response(sk_device, auth1_resp_data)
except Exception as e:
return TransactionResult(
ok=False, error=f"AUTH1 response decrypt failed: {e}"
)
cred_pub_5a = find_top_level(plaintext, 0x5A)
ud_sig = find_top_level(plaintext, 0x9E)
bitmap = find_top_level(plaintext, 0x5E)
if ud_sig is None or len(ud_sig) != 64:
return TransactionResult(
ok=False,
error="Table 8-11 missing 0x9E UD signature",
plaintext=plaintext,
)
if bitmap is None:
return TransactionResult(
ok=False,
error="Table 8-11 missing 0x5E signaling_bitmap",
plaintext=plaintext,
)
table_813 = build_table_813(
reader_id_32=reader_id_32,
credential_ephem_pub_x=cred_ephem_pub_x,
reader_ephem_pub_x=reader_ephem_pub_x,
transaction_id=txn_id,
)
ud_pub = (
cred_pub_5a
if cred_pub_5a
else bundle.credential_long_term_pub_uncompressed
)
if not verify_ud_signature(ud_pub, table_813, ud_sig):
return TransactionResult(
ok=False,
error="UD signature verification failed",
plaintext=plaintext,
credential_pub=cred_pub_5a,
ud_sig=ud_sig,
signaling_bitmap=bitmap,
)
return TransactionResult(
ok=True,
plaintext=plaintext,
credential_pub=cred_pub_5a,
ud_sig=ud_sig,
signaling_bitmap=bitmap,
)

View File

@@ -0,0 +1,93 @@
"""Command-line entrypoint: `aliro-trustgen init --out-dir PATH`.
Produces a complete TEST-ONLY trust set: issuer keypair, reader keypair,
access credential keypair, reader group identifiers, a minimal signed
Access Document, and a C header the reader firmware includes at build
time.
"""
import os
import sys
from pathlib import Path
import click
from aliro_harness.issuer.access_document import build_access_document
from aliro_harness.issuer.device_response import build_device_response
from aliro_harness.issuer.keys import (
generate_p256_keypair,
save_private_key_pem,
)
from aliro_harness.trustgen.header import TrustBundle, render_trust_header
_ARTIFACTS = (
"issuer.pem",
"reader.pem",
"access_credential.pem",
"reader_group_id.bin",
"reader_group_sub_id.bin",
"access_document.bin",
"device_response.bin",
"aliro_trust.h",
)
@click.group()
def main() -> None:
pass
@main.command()
@click.option(
"--out-dir",
required=True,
type=click.Path(file_okay=False),
help="Directory to write artifacts to (will be created if missing).",
)
@click.option(
"--force",
is_flag=True,
default=False,
help="Overwrite existing artifacts in --out-dir.",
)
def init(out_dir: str, force: bool) -> None:
out = Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
existing = [name for name in _ARTIFACTS if (out / name).exists()]
if existing and not force:
click.echo(
f"Refusing to overwrite existing artifacts in {out}: "
f"{', '.join(existing)}. Pass --force to overwrite.",
err=True,
)
sys.exit(2)
issuer_key = generate_p256_keypair()
reader_key = generate_p256_keypair()
access_credential_key = generate_p256_keypair()
reader_group_id = os.urandom(16)
reader_group_sub_id = os.urandom(16)
save_private_key_pem(out / "issuer.pem", issuer_key)
save_private_key_pem(out / "reader.pem", reader_key)
save_private_key_pem(out / "access_credential.pem", access_credential_key)
(out / "reader_group_id.bin").write_bytes(reader_group_id)
(out / "reader_group_sub_id.bin").write_bytes(reader_group_sub_id)
access_doc = build_access_document(
issuer_private_key=issuer_key,
access_credential_public_key=access_credential_key.public_key(),
)
(out / "access_document.bin").write_bytes(access_doc)
(out / "device_response.bin").write_bytes(build_device_response(access_doc))
bundle = TrustBundle(
issuer_public_key=issuer_key.public_key(),
reader_private_key=reader_key,
reader_group_id=reader_group_id,
reader_group_sub_id=reader_group_sub_id,
)
(out / "aliro_trust.h").write_text(render_trust_header(bundle))
click.echo(f"Wrote {len(_ARTIFACTS)} artifacts to {out}")

View File

@@ -0,0 +1,102 @@
"""Render a C header embedding the v1 Aliro trust anchors.
Consumed at build time by the reader firmware. Format intentionally matches
the byte-array conventions used by X-CUBE-ALIRO's provisioning.c: 32-byte
raw private scalar, 64-byte x||y public point (no 0x04 prefix).
"""
from dataclasses import dataclass
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.issuer.kid import compute_issuer_kid
_P256_COORD_LEN = 32
@dataclass
class TrustBundle:
issuer_public_key: ec.EllipticCurvePublicKey
reader_private_key: ec.EllipticCurvePrivateKey
reader_group_id: bytes
reader_group_sub_id: bytes
def _c_byte_array(data: bytes) -> str:
return "{ " + ", ".join(f"0x{b:02x}" for b in data) + " }"
def _priv_scalar(key: ec.EllipticCurvePrivateKey) -> bytes:
return key.private_numbers().private_value.to_bytes(_P256_COORD_LEN, "big")
def _pub_xy(key: ec.EllipticCurvePublicKey) -> tuple[bytes, bytes]:
n = key.public_numbers()
return (
n.x.to_bytes(_P256_COORD_LEN, "big"),
n.y.to_bytes(_P256_COORD_LEN, "big"),
)
def render_trust_header(bundle: TrustBundle) -> str:
reader_priv = _priv_scalar(bundle.reader_private_key)
reader_x, reader_y = _pub_xy(bundle.reader_private_key.public_key())
issuer_x, issuer_y = _pub_xy(bundle.issuer_public_key)
kid = compute_issuer_kid(bundle.issuer_public_key)
lines = [
"/* aliro_trust.h - AUTO-GENERATED by aliro-trustgen. DO NOT EDIT. */",
"/* Regenerate from the canonical keys under harness/trustgen/out/. */",
"",
"#ifndef ALIRO_TRUST_H_",
"#define ALIRO_TRUST_H_",
"",
"/* Reader long-term ECC P-256 key pair. */",
"/* Private: 32-byte raw scalar. */",
"/* Public: 64-byte x||y (no 0x04 prefix). */",
f"#define ALIRO_READER_PRIVATE_KEY {_c_byte_array(reader_priv)}",
f"#define ALIRO_READER_PUBLIC_KEY {_c_byte_array(reader_x + reader_y)}",
"",
"/* Reader group identifiers (spec \u00a76.2). */",
f"#define ALIRO_READER_GROUP_ID {_c_byte_array(bundle.reader_group_id)}",
f"#define ALIRO_READER_GROUP_SUB_ID {_c_byte_array(bundle.reader_group_sub_id)}",
"",
"/* Credential Issuer trust anchor (raw ECC P-256 public key). */",
f"#define ALIRO_ISSUER_PUB_X {_c_byte_array(issuer_x)}",
f"#define ALIRO_ISSUER_PUB_Y {_c_byte_array(issuer_y)}",
f"#define ALIRO_ISSUER_PUB {_c_byte_array(issuer_x + issuer_y)}",
"",
"/* kid for IssuerAuth header (spec \u00a77.2.1, 8 bytes). */",
f"#define ALIRO_ISSUER_KID {_c_byte_array(kid)}",
"",
"/* ----------------------------------------------------------------- */",
"/* X-CUBE-ALIRO vendor-compat overrides for User_provisioning.h. */",
"/* Gated by ALIRO_TRUST_OVERRIDE so firmware projects that don't */",
"/* opt in keep the vendor defaults. */",
"#ifdef ALIRO_TRUST_OVERRIDE",
"",
"/* READER_ID_* = group_id || group_sub_id (32B concatenated). */",
"#undef READER_ID_2",
"#undef READER_ID",
f"#define READER_ID_2 {_c_byte_array(bundle.reader_group_id + bundle.reader_group_sub_id)}",
f"#define READER_ID {_c_byte_array(bundle.reader_group_id + bundle.reader_group_sub_id)}",
"",
"#undef READER_PUB_KEY_2",
"#undef READER_PUB_KEY",
f"#define READER_PUB_KEY_2 {_c_byte_array(reader_x + reader_y)}",
f"#define READER_PUB_KEY {_c_byte_array(reader_x + reader_y)}",
"",
"#undef READER_PRIVATE_KEY_2",
"#undef READER_PRIVATE_KEY",
f"#define READER_PRIVATE_KEY_2 {_c_byte_array(reader_priv)}",
f"#define READER_PRIVATE_KEY {_c_byte_array(reader_priv)}",
"",
"#undef CREDENTIAL_ISSUER_PUB_KEY_2",
f"#define CREDENTIAL_ISSUER_PUB_KEY_2 {_c_byte_array(issuer_x + issuer_y)}",
"",
"#endif /* ALIRO_TRUST_OVERRIDE */",
"",
"#endif /* ALIRO_TRUST_H_ */",
"",
]
return "\n".join(lines)