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:
0
harness/src/aliro_harness/reader/__init__.py
Normal file
0
harness/src/aliro_harness/reader/__init__.py
Normal file
48
harness/src/aliro_harness/reader/auth0.py
Normal file
48
harness/src/aliro_harness/reader/auth0.py
Normal 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
|
||||
86
harness/src/aliro_harness/reader/auth1.py
Normal file
86
harness/src/aliro_harness/reader/auth1.py
Normal 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
|
||||
54
harness/src/aliro_harness/reader/auth1_response.py
Normal file
54
harness/src/aliro_harness/reader/auth1_response.py
Normal 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
|
||||
118
harness/src/aliro_harness/reader/cli.py
Normal file
118
harness/src/aliro_harness/reader/cli.py
Normal 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}")
|
||||
42
harness/src/aliro_harness/reader/crypto.py
Normal file
42
harness/src/aliro_harness/reader/crypto.py
Normal 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)
|
||||
68
harness/src/aliro_harness/reader/key_derivation.py
Normal file
68
harness/src/aliro_harness/reader/key_derivation.py
Normal 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)
|
||||
20
harness/src/aliro_harness/reader/session.py
Normal file
20
harness/src/aliro_harness/reader/session.py
Normal 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()
|
||||
38
harness/src/aliro_harness/reader/tlv.py
Normal file
38
harness/src/aliro_harness/reader/tlv.py
Normal 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
|
||||
269
harness/src/aliro_harness/reader/transaction.py
Normal file
269
harness/src/aliro_harness/reader/transaction.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user