Files
aliro-project/harness/tests/fake_card.py
michael f94e416c99 fix: AUTH1 crypto interop with stock X-CUBE-ALIRO + EXCHANGE compat stub
Three independent spec-misreads found via Path X investigation against
the X-CUBE-ALIRO vendor library, all causing
ACWG_Error_Crypto_EncryptDecrypt on the vendor's processAUTH1ResponsePayload.
Each was symmetric between this applet and our PC/SC reader, so
aliro-bench-test passed against our own host-side reader but failed
against any spec-compliant third-party reader. Path X also surfaced
an X-CUBE-ALIRO-specific compat shim (bitmap + EXCHANGE stub) which is
documented to be retired by the Step-Up Milestone 1 work.

1. salt_volatile dropped x(credential_long_term_pub) at the end.
   §8.3.1.13 salt_volatile ends at the 0xA5 proprietary information TLV;
   the credential key belongs in `info` (and even there it's the
   EPHEMERAL one, which buildInfo already does correctly).

2. Kdh now uses X9.63 KDF per §8.3.1.4 instead of HKDF.
   The §8.3.1.4 closing note ("actual key derivation is performed using
   §8.3.1.5") refers to subsequent session-key derivation from Kdh
   (§8.3.1.13 -> §8.3.1.5 HKDF), NOT a substitution for Kdh itself.
   For 32-byte output X9.63 KDF reduces to:
     Kdh = SHA-256(ZAB || 0x00000001 || transaction_identifier)
   Added a native SHA-256 instance to AliroCrypto for this one-shot.

3. salt_volatile flag uses AUTH1's command_parameters, not AUTH0's.
   §8.3.1.13 says "command_parameters || authentication_policy from the
   command data field". When §8.3.1.13 runs (after AUTH1), the active
   request is AUTH1; authentication_policy only exists in AUTH0 so it's
   still pulled from saved AUTH0 state, but command_parameters is the
   AUTH1 value (typically 0x01 = "request credential_PubK in response").

4. signaling_bitmap kept at 0x0005 when AD provisioned + INS_EXCHANGE
   stub on AliroApplet returns 9000 with empty payload. Empirically the
   X-CUBE-ALIRO vendor library errors on bitmap=0x0000 even though the
   spec allows it (separate vendor quirk worth filing); EXCHANGE stub
   exists because the firmware unconditionally sends 0xC9 post-AUTH1
   for the Reader Status sub-event report. Both shims are documented to
   be retired in Step-Up Milestone 1 -- StepUpApplet will handle 0xC9
   on its own AID (ACCE5502) per §10.2.1 after the spec-mandated
   step-up AID SELECT.

PC/SC bench-test still passes: AUTH1=9000, ~3.2 s, bitmap=0x0005.
Nucleo X-CUBE-ALIRO firmware now reports retval=ACWG_OK on
processAUTH1ResponsePayload (confirmed against j3r452 UID
04565E4A0B2190 in /tmp/nucleo-three-fixes.log). The remaining
"DOOR OPERATION FAILED" on Nucleo is downstream Step-Up not being
implemented yet -- StepUpApplet is still the scaffold and returns
6D00/6E00 to ENVELOPE / EXCHANGE. That's Milestone 1 work.

80/80 Java tests + 126/126 Python tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 10:18:42 -07:00

429 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
# §8.3.1.13 flag uses the AUTH1 command_parameters (not AUTH0's).
# Overwrite the AUTH0 value stored on the session so _derive_sk_device
# builds salt_volatile with the right byte.
self.session.command_parameters = cmd_params
# 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-coord for the reader long-term key
reader_long_term_x = self._pub_x(self.reader_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,
)
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