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,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,
)