Files
aliro-project/harness/tests/fake_card.py
Dangerous Things 782074f6ae 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>
2026-05-02 10:17:46 -07:00

426 lines
15 KiB
Python

"""In-process Python port of AliroApplet's AUTH0+AUTH1 paths.
Used by test_reader_transaction_offline.py to validate the reader without
needing a real card or pyscard. Mirrors AliroApplet processAuth0 +
processAuth1 -- but only enough to satisfy a one-shot reader transaction.
Skips: provisioning, replay protection (we never call AUTH1 twice in tests),
step-up, mailbox, vendor extensions, error paths beyond happy-path returns.
"""
from dataclasses import dataclass
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import (
decode_dss_signature,
encode_dss_signature,
)
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from aliro_harness.reader.auth1 import (
build_table_812,
build_table_813,
)
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,
)
# AIDs and INS values -- match the applet
EXPEDITED_AID = bytes.fromhex("A000000909ACCE5501")
INS_SELECT = 0xA4
INS_AUTH0 = 0x80
INS_AUTH1 = 0x81
# FCI bytes the real applet returns from sendExpeditedFci. Pre-computed so
# the fake card returns a byte-identical FCI.
# 6F 15 -- FCI template, outer len = 21
# 84 09 A0 00 00 09 09 AC CE 55 01 -- DF name = EXPEDITED_AID
# A5 08 -- proprietary, inner len = 8
# 80 02 00 00 -- application type = CSA (0x0000)
# 5C 02 01 00 -- protocol version = 0x0100
EXPEDITED_FCI = bytes.fromhex(
"6F15"
"8409" "A000000909ACCE5501"
"A508" "80020000" "5C020100"
)
# AUTH0 TLV tags (AliroApplet)
TAG_COMMAND_PARAMETERS = 0x41
TAG_AUTHENTICATION_POLICY = 0x42
TAG_PROTOCOL_VERSION = 0x5C
TAG_READER_EPUBK = 0x87
TAG_TRANSACTION_ID = 0x4C
TAG_READER_IDENTIFIER = 0x4D
TAG_CREDENTIAL_EPUBK = 0x86
# AUTH1 TLV tags
TAG_READER_SIGNATURE = 0x9E
TAG_READER_CERT = 0x90
PROTOCOL_VERSION_1_0 = 0x0100
@dataclass
class _FakeSession:
"""Per-transaction state captured during AUTH0, consumed by AUTH1."""
reader_ephem_pub_uncompressed: bytes
reader_group_id: bytes
reader_group_sub_id: bytes
transaction_id: bytes
command_parameters: int
authentication_policy: int
credential_ephem_pub_uncompressed: bytes | None = None
@dataclass
class FakeAliroCard:
"""Holds provisioned trust state + per-transaction state.
Mirrors just enough of AliroApplet to drive the reader through
SELECT -> AUTH0 -> AUTH1 successfully.
"""
# Provisioned trust state
reader_pub: ec.EllipticCurvePublicKey
credential_priv: ec.EllipticCurvePrivateKey
credential_pub: ec.EllipticCurvePublicKey
has_access_document: bool = False
# Per-transaction state, set during processAuth0
auth0_done: bool = False
session: _FakeSession | None = None
credential_ephem_priv: ec.EllipticCurvePrivateKey | None = None
@classmethod
def provisioned(
cls,
*,
reader_pub: ec.EllipticCurvePublicKey,
credential_priv: ec.EllipticCurvePrivateKey,
credential_pub: ec.EllipticCurvePublicKey,
has_access_document: bool = False,
) -> "FakeAliroCard":
return cls(
reader_pub=reader_pub,
credential_priv=credential_priv,
credential_pub=credential_pub,
has_access_document=has_access_document,
)
# ---------------- Transport ----------------
def transmit(self, apdu: bytes) -> tuple[bytes, int]:
"""Implements ISO 7816 SELECT + AUTH0 + AUTH1 INSes."""
if len(apdu) < 4:
return b"", 0x6700
cla, ins, p1, _p2 = apdu[0], apdu[1], apdu[2], apdu[3]
# SELECT is CLA=0x00
if cla == 0x00 and ins == INS_SELECT and p1 == 0x04:
if len(apdu) < 5:
return b"", 0x6700
lc = apdu[4]
aid = apdu[5 : 5 + lc]
if aid == EXPEDITED_AID:
# Fresh SELECT clears any prior session state.
self.auth0_done = False
self.session = None
self.credential_ephem_priv = None
return EXPEDITED_FCI, 0x9000
return b"", 0x6A82 # file not found
if cla == 0x80 and ins == INS_AUTH0:
return self._process_auth0(apdu)
if cla == 0x80 and ins == INS_AUTH1:
return self._process_auth1(apdu)
if cla != 0x80:
return b"", 0x6E00 # CLA not supported
return b"", 0x6D00 # INS not supported
# ---------------- AUTH0 ----------------
def _process_auth0(self, apdu: bytes) -> tuple[bytes, int]:
"""Validate AUTH0 TLVs, capture session state, generate ephemeral,
return 0x86 TLV (uncompressed 65B credential_ePubK)."""
if len(apdu) < 5:
return b"", 0x6700
lc = apdu[4]
data = apdu[5 : 5 + lc]
if len(data) != lc:
return b"", 0x6700
try:
parsed = self._parse_auth0(data)
except _WrongData:
return b"", 0x6A80
# Capture session state
self.session = _FakeSession(
reader_ephem_pub_uncompressed=parsed["reader_ephem_pub"],
reader_group_id=parsed["reader_group_id"],
reader_group_sub_id=parsed["reader_group_sub_id"],
transaction_id=parsed["transaction_id"],
command_parameters=parsed["command_parameters"],
authentication_policy=parsed["authentication_policy"],
)
# Generate fresh credential ephemeral keypair
self.credential_ephem_priv = ec.generate_private_key(ec.SECP256R1())
ephem_pub_uncompressed = self.credential_ephem_priv.public_key().public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
self.session.credential_ephem_pub_uncompressed = ephem_pub_uncompressed
# Build response: 0x86 0x41 [65B]
resp = bytes([TAG_CREDENTIAL_EPUBK, len(ephem_pub_uncompressed)]) + ephem_pub_uncompressed
self.auth0_done = True
return resp, 0x9000
def _parse_auth0(self, data: bytes) -> dict:
if len(data) == 0:
raise _WrongData()
seen = 0
out = {}
i = 0
while i < len(data):
if i + 2 > len(data):
raise _WrongData()
tag = data[i]
length = data[i + 1]
value_off = i + 2
if value_off + length > len(data):
raise _WrongData()
value = data[value_off : value_off + length]
if tag == TAG_COMMAND_PARAMETERS:
if length != 1:
raise _WrongData()
out["command_parameters"] = value[0]
seen |= 0x01
elif tag == TAG_AUTHENTICATION_POLICY:
if length != 1:
raise _WrongData()
out["authentication_policy"] = value[0]
seen |= 0x02
elif tag == TAG_PROTOCOL_VERSION:
if length != 2:
raise _WrongData()
version = (value[0] << 8) | value[1]
if version != PROTOCOL_VERSION_1_0:
raise _WrongData()
seen |= 0x04
elif tag == TAG_READER_EPUBK:
if length != 65 or value[0] != 0x04:
raise _WrongData()
out["reader_ephem_pub"] = value
seen |= 0x08
elif tag == TAG_TRANSACTION_ID:
if length != 16:
raise _WrongData()
out["transaction_id"] = value
seen |= 0x10
elif tag == TAG_READER_IDENTIFIER:
if length != 32:
raise _WrongData()
out["reader_group_id"] = value[:16]
out["reader_group_sub_id"] = value[16:]
seen |= 0x20
# else: unknown tag, silently accept (spec 1.4.3)
i = value_off + length
if seen != 0x3F:
raise _WrongData()
return out
# ---------------- AUTH1 ----------------
def _process_auth1(self, apdu: bytes) -> tuple[bytes, int]:
"""Verify reader sig, derive keys, sign Table 8-13 with credential
priv, build Table 8-11 plaintext, GCM-encrypt, return."""
if not self.auth0_done:
return b"", 0x6985 # CONDITIONS_NOT_SATISFIED
# Mirror applet: clear AUTH0_DONE before crypto, to prevent replay.
self.auth0_done = False
if len(apdu) < 5:
return b"", 0x6700
lc = apdu[4]
data = apdu[5 : 5 + lc]
if len(data) != lc:
return b"", 0x6700
try:
auth1 = self._parse_auth1(data)
except _WrongData:
return b"", 0x6A80
cmd_params = auth1["command_parameters"]
reader_raw_sig = auth1["reader_signature"]
# Reconstruct Table 8-12 and verify against captured reader pub
reader_ephem_pub = self.session.reader_ephem_pub_uncompressed
cred_ephem_pub = self.session.credential_ephem_pub_uncompressed
txn_id = self.session.transaction_id
reader_id_32 = (
self.session.reader_group_id + self.session.reader_group_sub_id
)
table_812 = build_table_812(
reader_id_32=reader_id_32,
credential_ephem_pub_x=cred_ephem_pub[1:33],
reader_ephem_pub_x=reader_ephem_pub[1:33],
transaction_id=txn_id,
)
if not self._verify_reader_sig(table_812, reader_raw_sig):
return b"", 0x6A80
# Derive session keys
sk_device = self._derive_sk_device()
# Sign Table 8-13 with credential_priv
table_813 = build_table_813(
reader_id_32=reader_id_32,
credential_ephem_pub_x=cred_ephem_pub[1:33],
reader_ephem_pub_x=reader_ephem_pub[1:33],
transaction_id=txn_id,
)
der_sig = self.credential_priv.sign(table_813, ec.ECDSA(hashes.SHA256()))
r, s = decode_dss_signature(der_sig)
ud_raw_sig = r.to_bytes(32, "big") + s.to_bytes(32, "big")
# Build Table 8-11 plaintext
plaintext = self._build_table_811_plaintext(cmd_params, ud_raw_sig)
# Encrypt with sk_device, IV = 7x00 || 01 || 0x00000001
iv = b"\x00" * 7 + b"\x01" + (1).to_bytes(4, "big")
ct_and_tag = AESGCM(sk_device).encrypt(iv, plaintext, associated_data=None)
return ct_and_tag, 0x9000
def _parse_auth1(self, data: bytes) -> dict:
if len(data) == 0:
raise _WrongData()
seen = 0
out = {}
i = 0
while i < len(data):
if i + 2 > len(data):
raise _WrongData()
tag = data[i]
length = data[i + 1]
value_off = i + 2
if value_off + length > len(data):
raise _WrongData()
value = data[value_off : value_off + length]
if tag == TAG_COMMAND_PARAMETERS:
if length != 1:
raise _WrongData()
out["command_parameters"] = value[0]
seen |= 0x01
elif tag == TAG_READER_SIGNATURE:
if length != 64:
raise _WrongData()
out["reader_signature"] = value
seen |= 0x02
elif tag == TAG_READER_CERT:
# v1: rejected explicitly
raise _WrongData()
# else: unknown tag, silently accept
i = value_off + length
if seen != 0x03:
raise _WrongData()
return out
def _verify_reader_sig(self, table_812: bytes, raw_sig_64: bytes) -> bool:
if len(raw_sig_64) != 64:
return False
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:
self.reader_pub.verify(der, table_812, ec.ECDSA(hashes.SHA256()))
return True
except InvalidSignature:
return False
def _derive_sk_device(self) -> bytes:
"""Mirrors AliroApplet.deriveSessionKeys."""
reader_ephem_pub = self.session.reader_ephem_pub_uncompressed
cred_ephem_pub = self.session.credential_ephem_pub_uncompressed
txn_id = self.session.transaction_id
# Kdh: ECDH(credential_ephem_priv, reader_ephem_pub) -> HKDF with salt=txn_id
kdh = derive_kdh(
self.credential_ephem_priv,
reader_ephem_pub,
txn_id,
)
# x-coords
reader_long_term_x = self._pub_x(self.reader_pub)
credential_long_term_x = self._pub_x(self.credential_pub)
salt = build_salt_volatile(
reader_long_term_pub_x=reader_long_term_x,
reader_group_id=self.session.reader_group_id,
reader_group_sub_id=self.session.reader_group_sub_id,
reader_ephem_pub_x=reader_ephem_pub[1:33],
transaction_id=txn_id,
command_parameters=self.session.command_parameters,
authentication_policy=self.session.authentication_policy,
credential_long_term_pub_x=credential_long_term_x,
)
info = cred_ephem_pub[1:33]
derived = derive_expedited_session_keys(kdh, salt, info)
return derived[OFF_EXPEDITED_SK_DEVICE : OFF_EXPEDITED_SK_DEVICE + 32]
def _build_table_811_plaintext(self, cmd_params: int, ud_raw_sig: bytes) -> bytes:
"""Table 8-11 plaintext per AliroApplet.buildTable811Plaintext."""
out = bytearray()
if (cmd_params & 0x01) == 0:
# 0x4E 0x08 [key_slot] = first 8B of SHA-1(uncompressed credential_PubK)
cred_pub_uncomp = self.credential_pub.public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
sha1 = hashes.Hash(hashes.SHA1())
sha1.update(cred_pub_uncomp)
digest = sha1.finalize()
out += bytes([0x4E, 0x08]) + digest[:8]
else:
# 0x5A 0x41 [0x04 || x || y]
cred_pub_uncomp = self.credential_pub.public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
out += bytes([0x5A, 0x41]) + cred_pub_uncomp
# 0x9E 0x40 [raw r||s UD signature]
out += bytes([0x9E, 0x40]) + ud_raw_sig
# 0x5E 0x02 [signaling_bitmap] (big-endian 16-bit)
bitmap = 0
if self.has_access_document:
bitmap |= 0x0001 # bit 0: AD retrievable
bitmap |= 0x0004 # bit 2: step-up AID required on NFC
out += bytes([0x5E, 0x02, (bitmap >> 8) & 0xFF, bitmap & 0xFF])
return bytes(out)
@staticmethod
def _pub_x(pub: ec.EllipticCurvePublicKey) -> bytes:
nums = pub.public_numbers()
return nums.x.to_bytes(32, "big")
class _WrongData(Exception):
"""Raised internally when TLV parsing fails; mapped to SW=6A80."""
pass