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>
426 lines
15 KiB
Python
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
|