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

425
harness/tests/fake_card.py Normal file
View File

@@ -0,0 +1,425 @@
"""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

View File

@@ -0,0 +1,161 @@
"""Tests for building minimal Aliro Access Documents.
v1 scope: MSO contains only the fields needed for a Reader to verify the
Access Credential public key. No Access Data Elements, no schedules, no
access rules. Those arrive later.
Reference: Aliro spec \u00a77.2 and ISO 18013-5 \u00a79.1.2.
"""
import cbor2
import pytest
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.issuer.access_document import (
ALIRO_DOCTYPE_ACCESS,
MSO_KEY_DEVICE_KEY_INFO,
MSO_KEY_DIGEST_ALG,
MSO_KEY_DOC_TYPE,
MSO_KEY_TIME_VERIFICATION_REQUIRED,
MSO_KEY_VALIDITY_INFO,
MSO_KEY_VALUE_DIGESTS,
MSO_KEY_VERSION,
build_access_document,
)
from aliro_harness.issuer.cose import verify_cose_sign1
from aliro_harness.issuer.kid import compute_issuer_kid
@pytest.fixture
def issuer_key():
return ec.generate_private_key(ec.SECP256R1())
@pytest.fixture
def access_credential_pub():
return ec.generate_private_key(ec.SECP256R1()).public_key()
def test_access_document_verifies_with_issuer_public_key(issuer_key, access_credential_pub):
doc = build_access_document(
issuer_private_key=issuer_key,
access_credential_public_key=access_credential_pub,
)
payload = verify_cose_sign1(doc, issuer_key.public_key())
assert isinstance(payload, bytes)
assert len(payload) > 0
def test_access_document_payload_is_canonical_cbor_map(issuer_key, access_credential_pub):
doc = build_access_document(
issuer_private_key=issuer_key,
access_credential_public_key=access_credential_pub,
)
payload = verify_cose_sign1(doc, issuer_key.public_key())
mso = cbor2.loads(payload)
assert isinstance(mso, dict)
def test_access_document_mso_has_required_aliro_keys(issuer_key, access_credential_pub):
doc = build_access_document(
issuer_private_key=issuer_key,
access_credential_public_key=access_credential_pub,
)
mso = cbor2.loads(verify_cose_sign1(doc, issuer_key.public_key()))
required = {
MSO_KEY_VERSION,
MSO_KEY_DIGEST_ALG,
MSO_KEY_VALUE_DIGESTS,
MSO_KEY_DEVICE_KEY_INFO,
MSO_KEY_DOC_TYPE,
MSO_KEY_VALIDITY_INFO,
MSO_KEY_TIME_VERIFICATION_REQUIRED,
}
assert required.issubset(mso.keys()), f"missing: {required - mso.keys()}"
def test_access_document_keys_are_text_strings_not_ints(issuer_key, access_credential_pub):
"""Aliro \u00a77.2.2: keys replaced by integers 'still encoded as text strings'."""
doc = build_access_document(
issuer_private_key=issuer_key,
access_credential_public_key=access_credential_pub,
)
mso = cbor2.loads(verify_cose_sign1(doc, issuer_key.public_key()))
for key in mso:
assert isinstance(key, str), f"MSO key {key!r} must be tstr, got {type(key).__name__}"
def test_access_document_docType_is_aliro_a(issuer_key, access_credential_pub):
doc = build_access_document(
issuer_private_key=issuer_key,
access_credential_public_key=access_credential_pub,
)
mso = cbor2.loads(verify_cose_sign1(doc, issuer_key.public_key()))
assert mso[MSO_KEY_DOC_TYPE] == ALIRO_DOCTYPE_ACCESS == "aliro-a"
def test_access_document_digest_alg_is_sha256(issuer_key, access_credential_pub):
doc = build_access_document(
issuer_private_key=issuer_key,
access_credential_public_key=access_credential_pub,
)
mso = cbor2.loads(verify_cose_sign1(doc, issuer_key.public_key()))
assert mso[MSO_KEY_DIGEST_ALG] == "SHA-256"
def test_access_document_time_verification_required_is_false_for_v1(
issuer_key, access_credential_pub
):
"""v1 test CA emits permissive documents; Reader may ignore time checks."""
doc = build_access_document(
issuer_private_key=issuer_key,
access_credential_public_key=access_credential_pub,
)
mso = cbor2.loads(verify_cose_sign1(doc, issuer_key.public_key()))
assert mso[MSO_KEY_TIME_VERIFICATION_REQUIRED] is False
def test_access_document_device_key_is_access_credential_pubkey(
issuer_key, access_credential_pub
):
doc = build_access_document(
issuer_private_key=issuer_key,
access_credential_public_key=access_credential_pub,
)
mso = cbor2.loads(verify_cose_sign1(doc, issuer_key.public_key()))
device_key_info = mso[MSO_KEY_DEVICE_KEY_INFO]
cose_key = device_key_info["1"]
assert cose_key[1] == 2 # kty = EC2
assert cose_key[-1] == 1 # crv = P-256
expected = access_credential_pub.public_numbers()
assert cose_key[-2] == expected.x.to_bytes(32, "big")
assert cose_key[-3] == expected.y.to_bytes(32, "big")
def test_access_document_fits_within_applet_storage_budget(issuer_key, access_credential_pub):
"""The applet's CredentialStore reserves ACCESS_DOC_MAX_LEN = 1024 bytes
for the Access Document. If this test ever fails, either the AD has
grown (add fields? new namespaces?) or the card-side budget needs a
bump — sync with CredentialStore.ACCESS_DOC_MAX_LEN on the applet side.
"""
doc = build_access_document(
issuer_private_key=issuer_key,
access_credential_public_key=access_credential_pub,
)
assert len(doc) <= 1024, f"Access Document is {len(doc)} bytes, exceeds 1024-byte applet budget"
def test_access_document_kid_in_unprotected_header_matches_issuer(
issuer_key, access_credential_pub
):
doc = build_access_document(
issuer_private_key=issuer_key,
access_credential_public_key=access_credential_pub,
)
decoded = cbor2.loads(doc)
_protected, unprotected, _payload, _sig = decoded
assert unprotected[4] == compute_issuer_kid(issuer_key.public_key())

39
harness/tests/test_ca.py Normal file
View File

@@ -0,0 +1,39 @@
from cryptography import x509
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.issuer.ca import create_self_signed_ca
def test_create_self_signed_ca_returns_p256_cert_and_key():
cert, key = create_self_signed_ca(common_name="Test Credential Issuer CA")
assert isinstance(cert, x509.Certificate)
assert isinstance(key, ec.EllipticCurvePrivateKey)
assert key.curve.name == "secp256r1"
def test_self_signed_ca_subject_and_issuer_match():
cert, _ = create_self_signed_ca(common_name="Test Credential Issuer CA")
assert cert.subject == cert.issuer
cn = cert.subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME)[0].value
assert cn == "Test Credential Issuer CA"
def test_self_signed_ca_has_ca_basic_constraints():
cert, _ = create_self_signed_ca(common_name="Test CA")
bc = cert.extensions.get_extension_for_class(x509.BasicConstraints)
assert bc.value.ca is True
assert bc.critical is True
def test_self_signed_ca_signature_verifies_with_own_key():
cert, key = create_self_signed_ca(common_name="Test CA")
public_key = key.public_key()
public_key.verify(
cert.signature,
cert.tbs_certificate_bytes,
ec.ECDSA(cert.signature_hash_algorithm),
)

View File

@@ -0,0 +1,74 @@
"""Tests for the hand-rolled COSE_Sign1 sign/verify primitive."""
import cbor2
import pytest
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.issuer.cose import (
CoseVerificationError,
sign_cose_sign1,
verify_cose_sign1,
)
def test_sign_then_verify_returns_original_payload():
key = ec.generate_private_key(ec.SECP256R1())
payload = b"hello, aliro"
signed = sign_cose_sign1(
private_key=key,
payload=payload,
kid=b"\x01\x02\x03\x04\x05\x06\x07\x08",
)
recovered = verify_cose_sign1(signed, key.public_key())
assert recovered == payload
def test_verify_fails_with_wrong_key():
signing_key = ec.generate_private_key(ec.SECP256R1())
wrong_key = ec.generate_private_key(ec.SECP256R1())
signed = sign_cose_sign1(
private_key=signing_key,
payload=b"aliro-a",
kid=b"\x00" * 8,
)
with pytest.raises(CoseVerificationError):
verify_cose_sign1(signed, wrong_key.public_key())
def test_signed_structure_is_well_formed_cose_sign1():
"""COSE_Sign1 wire format: 4-element CBOR array, alg in protected, ES256 = -7."""
key = ec.generate_private_key(ec.SECP256R1())
kid = b"\xaa\xbb\xcc\xdd\xee\xff\x11\x22"
signed = sign_cose_sign1(private_key=key, payload=b"x", kid=kid)
decoded = cbor2.loads(signed)
assert isinstance(decoded, list)
assert len(decoded) == 4
protected_bstr, unprotected, payload_bstr, signature = decoded
assert isinstance(protected_bstr, bytes)
assert isinstance(unprotected, dict)
assert isinstance(payload_bstr, bytes)
assert isinstance(signature, bytes)
protected = cbor2.loads(protected_bstr)
assert protected == {1: -7}
assert unprotected.get(4) == kid
assert payload_bstr == b"x"
assert len(signature) == 64
def test_signature_uses_raw_r_s_not_der():
"""COSE ECDSA signatures are raw r||s (64 bytes for P-256), not DER-encoded."""
key = ec.generate_private_key(ec.SECP256R1())
signed = sign_cose_sign1(private_key=key, payload=b"x", kid=b"\x00" * 8)
signature = cbor2.loads(signed)[3]
assert len(signature) == 64
assert signature[0] != 0x30

View File

@@ -0,0 +1,97 @@
"""Tests for the DeviceResponse builder used in the step-up phase."""
import cbor2
import pytest
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.issuer.access_document import build_access_document
from aliro_harness.issuer.cose import verify_cose_sign1
from aliro_harness.issuer.device_response import build_device_response
@pytest.fixture
def issuer_key():
return ec.generate_private_key(ec.SECP256R1())
@pytest.fixture
def access_doc(issuer_key):
return build_access_document(
issuer_private_key=issuer_key,
access_credential_public_key=ec.generate_private_key(ec.SECP256R1()).public_key(),
)
def test_device_response_has_aliro_remapped_top_level_keys(access_doc):
decoded = cbor2.loads(build_device_response(access_doc))
assert set(decoded.keys()) == {"1", "2", "3"}, (
"top-level keys must be Aliro Table 8-22 aliases (version, documents, status)"
)
def test_device_response_version_is_1_0(access_doc):
decoded = cbor2.loads(build_device_response(access_doc))
assert decoded["1"] == "1.0"
def test_device_response_status_is_ok(access_doc):
decoded = cbor2.loads(build_device_response(access_doc))
assert decoded["3"] == 0
def test_device_response_has_exactly_one_document(access_doc):
decoded = cbor2.loads(build_device_response(access_doc))
assert isinstance(decoded["2"], list)
assert len(decoded["2"]) == 1
def test_document_doctype_is_aliro_a(access_doc):
decoded = cbor2.loads(build_device_response(access_doc))
assert decoded["2"][0]["5"] == "aliro-a"
def test_issuer_signed_has_empty_namespaces(access_doc):
decoded = cbor2.loads(build_device_response(access_doc))
issuer_signed = decoded["2"][0]["1"]
assert issuer_signed["1"] == {}, "v1 returns no Access Data Elements"
def test_issuer_auth_is_the_original_cose_sign1(access_doc, issuer_key):
"""IssuerAuth embedded in DeviceResponse must still verify as a valid
COSE_Sign1 under the issuer's public key — i.e. the AD bytes round-trip."""
decoded = cbor2.loads(build_device_response(access_doc))
issuer_auth = decoded["2"][0]["1"]["2"]
assert isinstance(issuer_auth, list) and len(issuer_auth) == 4, (
"IssuerAuth is a 4-element COSE_Sign1 array"
)
# Re-serialize and verify.
reserialized = cbor2.dumps(issuer_auth, canonical=True)
payload = verify_cose_sign1(reserialized, issuer_key.public_key())
assert isinstance(payload, bytes) and len(payload) > 0
def test_forbidden_fields_absent(access_doc):
"""Per Aliro §8.4.2, documentErrors / errors / deviceSigned / readerAuth
SHALL NOT be present."""
decoded = cbor2.loads(build_device_response(access_doc))
doc = decoded["2"][0]
assert "documentErrors" not in decoded
assert "errors" not in decoded
assert "documentErrors" not in doc
assert "errors" not in doc
assert "deviceSigned" not in doc
def test_device_response_is_deterministic(access_doc):
"""Same inputs must produce byte-identical CBOR."""
a = build_device_response(access_doc)
b = build_device_response(access_doc)
assert a == b, "DeviceResponse must be deterministic CBOR for a given AD"
def test_device_response_fits_within_realistic_apdu_budget(access_doc):
"""Sanity-check that encrypted response size stays under the step-up
APDU chain budget (ENVELOPE/GET RESPONSE supports >2000 bytes with
extended-length support, but we want to stay comfortably small)."""
encoded = build_device_response(access_doc)
assert len(encoded) < 2000, f"DeviceResponse is {len(encoded)} bytes"

View File

@@ -0,0 +1,25 @@
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.issuer.keys import (
generate_p256_keypair,
load_private_key_pem,
save_private_key_pem,
)
def test_generate_p256_keypair_returns_p256_private_key():
priv = generate_p256_keypair()
assert isinstance(priv, ec.EllipticCurvePrivateKey)
assert priv.curve.name == "secp256r1"
def test_pem_roundtrip_preserves_private_key(tmp_path):
original = generate_p256_keypair()
path = tmp_path / "key.pem"
save_private_key_pem(path, original)
loaded = load_private_key_pem(path)
original_bytes = original.private_numbers().private_value.to_bytes(32, "big")
loaded_bytes = loaded.private_numbers().private_value.to_bytes(32, "big")
assert original_bytes == loaded_bytes

45
harness/tests/test_kid.py Normal file
View File

@@ -0,0 +1,45 @@
"""Tests for the Aliro `kid` header derivation per spec \u00a77.2.1.
The spec defines:
kid = SHA256("key-identifier" || 0x04 || IssuerKey_PubK.x || IssuerKey_PubK.y)[:8]
where "key-identifier" is the literal ASCII string.
"""
import hashlib
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.issuer.kid import compute_issuer_kid
def test_kid_is_8_bytes():
key = ec.generate_private_key(ec.SECP256R1())
kid = compute_issuer_kid(key.public_key())
assert isinstance(kid, bytes)
assert len(kid) == 8
def test_kid_matches_spec_formula():
"""Reference check: re-derive the kid from scratch and compare."""
key = ec.generate_private_key(ec.SECP256R1())
pub = key.public_key()
numbers = pub.public_numbers()
x_bytes = numbers.x.to_bytes(32, "big")
y_bytes = numbers.y.to_bytes(32, "big")
expected_input = b"key-identifier" + b"\x04" + x_bytes + y_bytes
expected_kid = hashlib.sha256(expected_input).digest()[:8]
assert compute_issuer_kid(pub) == expected_kid
def test_kid_is_deterministic():
key = ec.generate_private_key(ec.SECP256R1())
pub = key.public_key()
assert compute_issuer_kid(pub) == compute_issuer_kid(pub)
def test_kid_differs_for_different_keys():
key_a = ec.generate_private_key(ec.SECP256R1())
key_b = ec.generate_private_key(ec.SECP256R1())
assert compute_issuer_kid(key_a.public_key()) != compute_issuer_kid(key_b.public_key())

View File

@@ -0,0 +1,72 @@
"""Tests for the Access Document APDU sequence builder."""
import pytest
from aliro_harness.personalizer.access_document import (
ACCESS_DOC_MAX_LEN,
APDU_CHUNK_MAX,
CLA_PROPRIETARY,
INS_FINALIZE_ACCESS_DOC,
INS_WRITE_ACCESS_DOC,
access_document_apdus,
)
def test_small_ad_emits_one_write_then_finalize():
ad = bytes(range(50))
apdus = access_document_apdus(ad)
assert len(apdus) == 2, "small AD => 1 WRITE + 1 FINALIZE"
write = apdus[0]
assert write[0] == CLA_PROPRIETARY
assert write[1] == INS_WRITE_ACCESS_DOC
assert write[2] == 0 and write[3] == 0, "first chunk at offset 0"
assert write[4] == len(ad)
assert write[5 : 5 + len(ad)] == ad
finalize = apdus[1]
assert finalize == bytes([CLA_PROPRIETARY, INS_FINALIZE_ACCESS_DOC, 0x00, len(ad)])
def test_ad_over_chunk_size_splits_into_multiple_writes():
ad = bytes(i & 0xFF for i in range(600))
apdus = access_document_apdus(ad, chunk_size=255)
# 600 bytes @ 255/chunk = 3 writes (255 + 255 + 90), + 1 finalize
assert len(apdus) == 4
# Check offsets and chunk boundaries.
assert apdus[0][2:4] == bytes([0, 0])
assert apdus[0][4] == 255
assert apdus[1][2:4] == bytes([255 >> 8, 255 & 0xFF])
assert apdus[1][4] == 255
assert apdus[2][2:4] == bytes([510 >> 8, 510 & 0xFF])
assert apdus[2][4] == 90
assert apdus[3][0:2] == bytes([CLA_PROPRIETARY, INS_FINALIZE_ACCESS_DOC])
assert apdus[3][2:4] == bytes([600 >> 8, 600 & 0xFF])
def test_concatenated_write_payloads_reconstruct_the_original_ad():
ad = bytes(i & 0xFF for i in range(777))
apdus = access_document_apdus(ad)
reconstructed = b"".join(a[5:] for a in apdus if a[1] == INS_WRITE_ACCESS_DOC)
assert reconstructed == ad
def test_ad_exactly_at_budget_is_accepted():
ad = bytes(ACCESS_DOC_MAX_LEN)
apdus = access_document_apdus(ad)
assert apdus[-1][2] == (ACCESS_DOC_MAX_LEN >> 8)
assert apdus[-1][3] == (ACCESS_DOC_MAX_LEN & 0xFF)
def test_ad_over_budget_is_rejected():
ad = bytes(ACCESS_DOC_MAX_LEN + 1)
with pytest.raises(ValueError, match="exceeds"):
access_document_apdus(ad)
def test_chunk_size_out_of_range_is_rejected():
with pytest.raises(ValueError, match="chunk_size"):
access_document_apdus(b"x", chunk_size=0)
with pytest.raises(ValueError, match="chunk_size"):
access_document_apdus(b"x", chunk_size=APDU_CHUNK_MAX + 1)

View File

@@ -0,0 +1,61 @@
"""APDU constructor tests — exact byte-shape locks."""
import pytest
from aliro_harness.personalizer.apdus import (
CLA_PROPRIETARY,
INS_COMMIT,
INS_SET_CRED_PRIV,
INS_SET_CRED_PUBK,
INS_SET_READER_PUBK,
PROVISIONING_AID,
commit_apdu,
select_provisioning_apdu,
set_credential_priv_apdu,
set_credential_pubk_apdu,
set_reader_pubk_apdu,
)
def test_provisioning_aid_matches_applet_constant():
"""If this changes, AliroAids.PROVISIONING in the applet has drifted."""
assert PROVISIONING_AID.hex().upper() == "A000000909ACCE559901"
def test_select_provisioning_apdu_shape():
apdu = select_provisioning_apdu()
assert apdu == bytes([0x00, 0xA4, 0x04, 0x00, 0x0A]) + PROVISIONING_AID
def test_set_credential_priv_apdu():
priv = bytes(range(32))
apdu = set_credential_priv_apdu(priv)
assert apdu == bytes([CLA_PROPRIETARY, INS_SET_CRED_PRIV, 0x00, 0x00, 0x20]) + priv
def test_set_credential_priv_rejects_wrong_length():
with pytest.raises(ValueError, match="32B"):
set_credential_priv_apdu(bytes(31))
def test_set_credential_pubk_apdu():
pub = bytes(range(64))
apdu = set_credential_pubk_apdu(pub)
assert apdu == bytes([CLA_PROPRIETARY, INS_SET_CRED_PUBK, 0x00, 0x00, 0x40]) + pub
def test_set_credential_pubk_rejects_wrong_length():
with pytest.raises(ValueError, match="64B"):
set_credential_pubk_apdu(bytes(65))
def test_set_reader_pubk_apdu():
pub = bytes(range(64))
apdu = set_reader_pubk_apdu(pub)
assert apdu == bytes([CLA_PROPRIETARY, INS_SET_READER_PUBK, 0x00, 0x00, 0x40]) + pub
def test_commit_apdu_has_no_data_field():
apdu = commit_apdu()
assert apdu == bytes([CLA_PROPRIETARY, INS_COMMIT, 0x00, 0x00])
assert len(apdu) == 4

View File

@@ -0,0 +1,70 @@
"""CLI-level tests for ``aliro-personalize``.
The pyscard import is lazy inside ``main()`` so most of these tests don't
need pcscd at all. Tests that exercise --list-readers monkeypatch
``smartcard.System.readers`` to keep them hermetic.
"""
from click.testing import CliRunner
from aliro_harness.personalizer.cli import main
def test_help_lists_both_modes():
result = CliRunner().invoke(main, ["--help"])
assert result.exit_code == 0, result.output
assert "--trust-dir" in result.output
assert "--list-readers" in result.output
def test_bare_invocation_errors_with_clear_message_about_trust_dir():
result = CliRunner().invoke(main, [])
assert result.exit_code != 0
assert "--trust-dir" in result.output
# Click 8 prints "Error: ..." for UsageError; either wording is fine
# so long as trust-dir is mentioned.
def test_list_readers_with_no_readers_exits_nonzero(monkeypatch):
"""If pcscd reports no readers, --list-readers prints a message + exits 1."""
import smartcard.System
monkeypatch.setattr(smartcard.System, "readers", lambda: [])
result = CliRunner().invoke(main, ["--list-readers"])
assert result.exit_code == 1
# The message is printed to stderr; click.testing captures both into output.
assert "No PC/SC readers found" in result.output
def test_list_readers_enumerates_each_with_index(monkeypatch):
"""Two fake readers should be printed with [0] / [1] prefixes."""
import smartcard.System
class FakeReader:
def __init__(self, name: str) -> None:
self._name = name
def __str__(self) -> str:
return self._name
monkeypatch.setattr(
smartcard.System,
"readers",
lambda: [FakeReader("ACS Reader 0"), FakeReader("ACS Reader 1")],
)
result = CliRunner().invoke(main, ["--list-readers"])
assert result.exit_code == 0, result.output
assert "[0] ACS Reader 0" in result.output
assert "[1] ACS Reader 1" in result.output
def test_list_readers_does_not_require_trust_dir(monkeypatch):
"""The bug we're fixing: --list-readers should work standalone."""
import smartcard.System
monkeypatch.setattr(smartcard.System, "readers", lambda: [])
result = CliRunner().invoke(main, ["--list-readers"])
# Whatever the reader-discovery outcome, the failure mode must NOT be
# Click's "Missing option '--trust-dir'" usage error.
assert "Missing option" not in result.output
assert "--trust-dir is required" not in result.output

View File

@@ -0,0 +1,55 @@
"""Tests for PEM → fixed-width byte extraction."""
import pytest
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.personalizer.credentials import (
P256_COORD_LEN,
extract_priv_scalar,
extract_pub_xy,
)
def test_extract_priv_scalar_is_32_bytes():
key = ec.generate_private_key(ec.SECP256R1())
s = extract_priv_scalar(key)
assert len(s) == 32
def test_extract_priv_scalar_matches_private_value_be():
key = ec.generate_private_key(ec.SECP256R1())
expected = key.private_numbers().private_value.to_bytes(32, "big")
assert extract_priv_scalar(key) == expected
def test_extract_priv_scalar_pads_short_value():
"""A scalar small enough to need leading zeros must be left-padded."""
# Private value of 1 is the smallest possible; should pad with 31 zeros.
pem = ec.derive_private_key(1, ec.SECP256R1()).private_numbers()
key = ec.derive_private_key(1, ec.SECP256R1())
s = extract_priv_scalar(key)
assert s == bytes(31) + b"\x01"
assert len(s) == P256_COORD_LEN
def test_extract_pub_xy_is_64_bytes_no_prefix():
key = ec.generate_private_key(ec.SECP256R1())
xy = extract_pub_xy(key.public_key())
assert len(xy) == 64
# The 0x04 uncompressed-point tag must NOT appear; the applet's
# set_reader_pubk_apdu prepends nothing.
nums = key.public_key().public_numbers()
assert xy[:32] == nums.x.to_bytes(32, "big")
assert xy[32:] == nums.y.to_bytes(32, "big")
def test_non_p256_priv_rejected():
key = ec.generate_private_key(ec.SECP384R1())
with pytest.raises(ValueError, match="P-256"):
extract_priv_scalar(key)
def test_non_p256_pub_rejected():
key = ec.generate_private_key(ec.SECP384R1())
with pytest.raises(ValueError, match="P-256"):
extract_pub_xy(key.public_key())

View File

@@ -0,0 +1,111 @@
"""Orchestrator tests — verifies the full APDU sequence is sent in the
right order, with the right shapes, and that a non-9000 response halts."""
import pytest
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.issuer.access_document import build_access_document
from aliro_harness.personalizer.apdus import (
INS_COMMIT,
INS_FINALIZE_ACCESS_DOC,
INS_SET_CRED_PRIV,
INS_SET_CRED_PUBK,
INS_SET_READER_PUBK,
INS_WRITE_ACCESS_DOC,
)
from aliro_harness.personalizer.credentials import (
extract_priv_scalar,
extract_pub_xy,
)
from aliro_harness.personalizer.orchestrator import (
PersonalizationError,
TrustArtifacts,
personalize_card,
)
class FakeTransport:
"""Records each APDU sent and returns 0x9000 unless preconfigured."""
def __init__(self, sw_overrides: dict[int, int] | None = None) -> None:
self.sent: list[bytes] = []
self._sw_overrides = sw_overrides or {}
def __call__(self, apdu: bytes) -> tuple[bytes, int]:
idx = len(self.sent)
self.sent.append(apdu)
return b"", self._sw_overrides.get(idx, 0x9000)
@pytest.fixture
def artifacts() -> TrustArtifacts:
cred = ec.generate_private_key(ec.SECP256R1())
reader = ec.generate_private_key(ec.SECP256R1())
issuer = ec.generate_private_key(ec.SECP256R1())
return TrustArtifacts(
credential_priv=extract_priv_scalar(cred),
credential_pubk_xy=extract_pub_xy(cred.public_key()),
reader_pubk_xy=extract_pub_xy(reader.public_key()),
access_document=build_access_document(
issuer_private_key=issuer,
access_credential_public_key=cred.public_key(),
),
)
def test_full_sequence_in_expected_order(artifacts):
transport = FakeTransport()
personalize_card(transport, artifacts)
insns = [apdu[1] for apdu in transport.sent]
# SELECT (0xA4) → SET_PRIV → SET_PUBK → SET_READER_PUBK → WRITE_AD+ → FINALIZE_AD → COMMIT
assert insns[0] == 0xA4
assert insns[1] == INS_SET_CRED_PRIV
assert insns[2] == INS_SET_CRED_PUBK
assert insns[3] == INS_SET_READER_PUBK
# AD writes (variable count) then a single finalize, then commit.
assert all(i == INS_WRITE_ACCESS_DOC for i in insns[4:-2])
assert insns[-2] == INS_FINALIZE_ACCESS_DOC
assert insns[-1] == INS_COMMIT
def test_select_apdu_carries_provisioning_aid(artifacts):
transport = FakeTransport()
personalize_card(transport, artifacts)
select = transport.sent[0]
assert select.hex().upper().endswith("A000000909ACCE559901")
def test_credential_priv_apdu_carries_exact_bytes(artifacts):
transport = FakeTransport()
personalize_card(transport, artifacts)
apdu = transport.sent[1] # SET_CRED_PRIV right after SELECT
assert apdu[5:] == artifacts.credential_priv
def test_failure_at_set_credential_priv_aborts_sequence(artifacts):
# SELECT (idx 0) returns 9000, SET_CRED_PRIV (idx 1) returns 6985.
transport = FakeTransport(sw_overrides={1: 0x6985})
with pytest.raises(PersonalizationError) as exc_info:
personalize_card(transport, artifacts)
assert exc_info.value.sw == 0x6985
assert "SET credential_PrivK" in str(exc_info.value)
# No further APDUs should have been sent past the failure.
assert len(transport.sent) == 2
def test_access_document_chunks_concatenate_to_original(artifacts):
transport = FakeTransport()
personalize_card(transport, artifacts)
write_apdus = [a for a in transport.sent if a[1] == INS_WRITE_ACCESS_DOC]
# Each WRITE APDU: CLA INS P1 P2 Lc <chunk>
reconstructed = b"".join(a[5:] for a in write_apdus)
assert reconstructed == artifacts.access_document
def test_finalize_carries_total_ad_length_in_p1p2(artifacts):
transport = FakeTransport()
personalize_card(transport, artifacts)
finalize = next(a for a in transport.sent if a[1] == INS_FINALIZE_ACCESS_DOC)
total = (finalize[2] << 8) | finalize[3]
assert total == len(artifacts.access_document)

View File

@@ -0,0 +1,72 @@
import pytest
from aliro_harness.reader.auth0 import build_auth0_apdu, build_auth0_data
def test_auth0_data_field_carries_six_mandatory_tlvs_in_spec_order():
reader_ephem_uncomp = bytes([0x04]) + b"\x11" * 32 + b"\x22" * 32
data = build_auth0_data(
reader_ephem_pub_uncompressed=reader_ephem_uncomp,
reader_group_id=b"\xaa" * 16,
reader_group_sub_id=b"\xbb" * 16,
transaction_id=b"\xcc" * 16,
command_parameters=0x00,
authentication_policy=0x00,
)
assert data[0:3] == bytes([0x41, 0x01, 0x00]) # command_parameters
assert data[3:6] == bytes([0x42, 0x01, 0x00]) # authentication_policy
assert data[6:10] == bytes([0x5C, 0x02, 0x01, 0x00]) # protocol_version
assert data[10:12] == bytes([0x87, 0x41]) # reader_ePubK header (65B)
assert data[12:77] == reader_ephem_uncomp
assert data[77:79] == bytes([0x4C, 0x10]) # transaction_id header
assert data[79:95] == b"\xcc" * 16
assert data[95:97] == bytes([0x4D, 0x20]) # reader_identifier (32B = group||sub)
assert data[97:113] == b"\xaa" * 16
assert data[113:129] == b"\xbb" * 16
assert len(data) == 129
def test_build_auth0_apdu_wraps_data_in_short_form():
apdu = build_auth0_apdu(b"\x41\x01\x00")
assert apdu == bytes([0x80, 0x80, 0x00, 0x00, 0x03, 0x41, 0x01, 0x00])
def test_reader_ephem_must_be_uncompressed():
with pytest.raises(ValueError, match="65B uncompressed"):
build_auth0_data(
reader_ephem_pub_uncompressed=b"\x02" + b"\x11" * 32, # compressed
reader_group_id=b"\xaa" * 16,
reader_group_sub_id=b"\xbb" * 16,
transaction_id=b"\xcc" * 16,
)
def test_wrong_group_id_length_rejected():
with pytest.raises(ValueError, match="must each be 16B"):
build_auth0_data(
reader_ephem_pub_uncompressed=bytes([0x04]) + b"\x11" * 32 + b"\x22" * 32,
reader_group_id=b"\xaa" * 15,
reader_group_sub_id=b"\xbb" * 16,
transaction_id=b"\xcc" * 16,
)
def test_wrong_transaction_id_length_rejected():
with pytest.raises(ValueError, match="must be 16B"):
build_auth0_data(
reader_ephem_pub_uncompressed=bytes([0x04]) + b"\x11" * 32 + b"\x22" * 32,
reader_group_id=b"\xaa" * 16,
reader_group_sub_id=b"\xbb" * 16,
transaction_id=b"\xcc" * 17,
)
def test_command_parameters_bit_zero_set_emits_0x01_byte():
data = build_auth0_data(
reader_ephem_pub_uncompressed=bytes([0x04]) + b"\x11" * 32 + b"\x22" * 32,
reader_group_id=b"\xaa" * 16,
reader_group_sub_id=b"\xbb" * 16,
transaction_id=b"\xcc" * 16,
command_parameters=0x01,
)
assert data[0:3] == bytes([0x41, 0x01, 0x01])

View File

@@ -0,0 +1,100 @@
import pytest
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
from aliro_harness.reader.auth1 import (
build_auth1_apdu,
build_auth1_data,
build_table_812,
build_table_813,
sign_table_812_raw,
)
def test_table_812_layout_per_spec_table_8_12():
t = build_table_812(
reader_id_32=b"\x4d" * 32,
credential_ephem_pub_x=b"\x86" * 32,
reader_ephem_pub_x=b"\x87" * 32,
transaction_id=b"\x4c" * 16,
)
assert t[0:2] == bytes([0x4D, 0x20])
assert t[2:34] == b"\x4d" * 32
assert t[34:36] == bytes([0x86, 0x20])
assert t[36:68] == b"\x86" * 32
assert t[68:70] == bytes([0x87, 0x20])
assert t[70:102] == b"\x87" * 32
assert t[102:104] == bytes([0x4C, 0x10])
assert t[104:120] == b"\x4c" * 16
assert t[120:122] == bytes([0x93, 0x04])
assert t[122:126] == bytes.fromhex("415D9569") # reader sig usage
# 5 TLVs: 34+34+34+18+6 = 126 bytes (plan's "128" was off-by-2; byte
# offsets above are self-consistent and match ReaderSide.buildTable812).
assert len(t) == 126
def test_table_813_uses_ud_usage_tag():
"""Same TLV positions as 8-12, but the 0x93 value = UD usage tag."""
kwargs = dict(
reader_id_32=b"\x4d" * 32,
credential_ephem_pub_x=b"\x86" * 32,
reader_ephem_pub_x=b"\x87" * 32,
transaction_id=b"\x4c" * 16,
)
t = build_table_813(**kwargs)
t12 = build_table_812(**kwargs)
# Everything up through the 0x93 0x04 header is identical...
assert t[0:122] == t12[0:122]
assert t[120:122] == bytes([0x93, 0x04])
# ...but the usage tag differs.
assert t[122:126] == bytes.fromhex("4E887B4C")
assert t12[122:126] == bytes.fromhex("415D9569")
assert len(t) == 126
def test_sign_then_verify_round_trip():
"""Sign Table 8-12 with a fresh P-256 key, verify with public half.
Raw 64B r||s must re-assemble into a DER signature that verifies."""
priv = ec.generate_private_key(ec.SECP256R1())
table_812 = build_table_812(
reader_id_32=b"\x01" * 32,
credential_ephem_pub_x=b"\x02" * 32,
reader_ephem_pub_x=b"\x03" * 32,
transaction_id=b"\x04" * 16,
)
raw_sig = sign_table_812_raw(table_812, priv)
assert len(raw_sig) == 64
r = int.from_bytes(raw_sig[:32], "big")
s = int.from_bytes(raw_sig[32:], "big")
der = encode_dss_signature(r, s)
# Should verify -- raises InvalidSignature if not.
priv.public_key().verify(der, table_812, ec.ECDSA(hashes.SHA256()))
def test_build_auth1_data_with_cmd_params_zero():
"""cmd_params=0 ('key_slot' path) still emits the 0x41/0x9E TLV shell."""
sig = b"\xaa" * 64
data = build_auth1_data(sig, command_parameters=0x00)
assert data[:5] == bytes([0x41, 0x01, 0x00, 0x9E, 0x40])
assert data[5:69] == sig
assert len(data) == 69
def test_build_auth1_data_rejects_wrong_sig_length():
with pytest.raises(ValueError, match="64B raw"):
build_auth1_data(b"\x00" * 63)
def test_build_auth1_apdu_short_form_wrap():
"""CLA=0x80, INS=0x81, P1=P2=0, Lc=len(data), then data."""
data = bytes([0x41, 0x01, 0x01]) + bytes([0x9E, 0x40]) + b"\xcc" * 64
apdu = build_auth1_apdu(data)
assert apdu[0] == 0x80 # CLA
assert apdu[1] == 0x81 # INS_AUTH1
assert apdu[2] == 0x00 # P1
assert apdu[3] == 0x00 # P2
assert apdu[4] == len(data) # Lc (short form)
assert apdu[5:] == data
assert len(apdu) == 5 + len(data)

View File

@@ -0,0 +1,92 @@
import pytest
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from aliro_harness.reader.auth1_response import (
decrypt_auth1_response,
verify_ud_signature,
)
def _gen_ud_keypair_and_pub_uncompressed():
priv = ec.generate_private_key(ec.SECP256R1())
pub_uncompressed = priv.public_key().public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
return priv, pub_uncompressed
def _sign_raw(priv, message: bytes) -> bytes:
der = priv.sign(message, ec.ECDSA(hashes.SHA256()))
r, s = decode_dss_signature(der)
return r.to_bytes(32, "big") + s.to_bytes(32, "big")
def test_decrypt_auth1_response_roundtrips_with_device_counter_1():
sk = b"\x42" * 32
iv = b"\x00" * 7 + b"\x01" + b"\x00\x00\x00\x01"
plaintext = b"\xAB" * 17
ct_and_tag = AESGCM(sk).encrypt(iv, plaintext, associated_data=None)
assert decrypt_auth1_response(sk, ct_and_tag) == plaintext
def test_decrypt_auth1_response_fails_on_tampered_ciphertext():
sk = b"\x42" * 32
iv = b"\x00" * 7 + b"\x01" + b"\x00\x00\x00\x01"
plaintext = b"\xAB" * 17
ct_and_tag = bytearray(AESGCM(sk).encrypt(iv, plaintext, associated_data=None))
ct_and_tag[0] ^= 0x01
with pytest.raises(InvalidTag):
decrypt_auth1_response(sk, bytes(ct_and_tag))
def test_verify_ud_signature_round_trip_returns_true():
priv, pub_uncompressed = _gen_ud_keypair_and_pub_uncompressed()
table_813 = b"\x00\x01\x02\x03" + b"table-8-13-test-bytes" * 3
raw_sig = _sign_raw(priv, table_813)
assert len(pub_uncompressed) == 65
assert verify_ud_signature(pub_uncompressed, table_813, raw_sig) is True
def test_verify_ud_signature_returns_false_on_tampered_table():
priv, pub_uncompressed = _gen_ud_keypair_and_pub_uncompressed()
table_813 = b"table-8-13-original-bytes-here!!"
raw_sig = _sign_raw(priv, table_813)
tampered = bytearray(table_813)
tampered[0] ^= 0x01
assert verify_ud_signature(pub_uncompressed, bytes(tampered), raw_sig) is False
def test_verify_ud_signature_returns_false_on_tampered_sig():
priv, pub_uncompressed = _gen_ud_keypair_and_pub_uncompressed()
table_813 = b"table-8-13-original-bytes-here!!"
raw_sig = bytearray(_sign_raw(priv, table_813))
raw_sig[0] ^= 0x01
assert verify_ud_signature(pub_uncompressed, table_813, bytes(raw_sig)) is False
def test_verify_ud_signature_rejects_wrong_sig_length():
_, pub_uncompressed = _gen_ud_keypair_and_pub_uncompressed()
with pytest.raises(ValueError):
verify_ud_signature(pub_uncompressed, b"data", b"\x00" * 63)
def test_decrypt_auth1_response_fails_with_wrong_counter():
"""Pins the hardcoded counter=1 behavior. If someone "fixes" the counter
to 0 or 2 without thinking, this test fails loudly."""
sk = b"\x42" * 32
wrong_iv = b"\x00" * 7 + b"\x01" + b"\x00\x00\x00\x02" # counter=2
ct = AESGCM(sk).encrypt(wrong_iv, b"hello", associated_data=None)
with pytest.raises(InvalidTag):
decrypt_auth1_response(sk, ct)
def test_verify_ud_signature_rejects_malformed_pubkey():
"""A short or off-curve pubkey blob must raise (not return False).
Guards against a silent ``cryptography`` behavior change."""
with pytest.raises(ValueError):
verify_ud_signature(b"\x04" + b"\x00" * 10, b"data", b"\x00" * 64)

View File

@@ -0,0 +1,71 @@
"""CLI-level tests for ``aliro-bench-test``.
Mirrors ``test_personalizer_cli.py``. The pyscard import is lazy inside
``main()`` so most of these tests don't need pcscd at all. Tests that
exercise --list-readers monkeypatch ``smartcard.System.readers`` to keep
them hermetic.
"""
from click.testing import CliRunner
from aliro_harness.reader.cli import main
def test_help_lists_both_modes():
result = CliRunner().invoke(main, ["--help"])
assert result.exit_code == 0, result.output
assert "--trust-dir" in result.output
assert "--list-readers" in result.output
def test_bare_invocation_errors_with_clear_message_about_trust_dir():
result = CliRunner().invoke(main, [])
assert result.exit_code != 0
assert "--trust-dir" in result.output
# Click 8 prints "Error: ..." for UsageError; either wording is fine
# so long as trust-dir is mentioned.
def test_list_readers_with_no_readers_exits_nonzero(monkeypatch):
"""If pcscd reports no readers, --list-readers prints a message + exits 1."""
import smartcard.System
monkeypatch.setattr(smartcard.System, "readers", lambda: [])
result = CliRunner().invoke(main, ["--list-readers"])
assert result.exit_code == 1
# The message is printed to stderr; click.testing captures both into output.
assert "No PC/SC readers found" in result.output
def test_list_readers_enumerates_each_with_index(monkeypatch):
"""Two fake readers should be printed with [0] / [1] prefixes."""
import smartcard.System
class FakeReader:
def __init__(self, name: str) -> None:
self._name = name
def __str__(self) -> str:
return self._name
monkeypatch.setattr(
smartcard.System,
"readers",
lambda: [FakeReader("ACS Reader 0"), FakeReader("ACS Reader 1")],
)
result = CliRunner().invoke(main, ["--list-readers"])
assert result.exit_code == 0, result.output
assert "[0] ACS Reader 0" in result.output
assert "[1] ACS Reader 1" in result.output
def test_list_readers_does_not_require_trust_dir(monkeypatch):
"""The bug we're fixing: --list-readers should work standalone."""
import smartcard.System
monkeypatch.setattr(smartcard.System, "readers", lambda: [])
result = CliRunner().invoke(main, ["--list-readers"])
# Whatever the reader-discovery outcome, the failure mode must NOT be
# Click's "Missing option '--trust-dir'" usage error.
assert "Missing option" not in result.output
assert "--trust-dir is required" not in result.output

View File

@@ -0,0 +1,39 @@
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.reader.crypto import (
derive_kdh,
ecdh_shared_x,
hkdf_sha256,
)
# RFC 5869 Test Case 1
RFC5869_T1_IKM = bytes.fromhex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b")
RFC5869_T1_SALT = bytes.fromhex("000102030405060708090a0b0c")
RFC5869_T1_INFO = bytes.fromhex("f0f1f2f3f4f5f6f7f8f9")
RFC5869_T1_OKM_42 = bytes.fromhex(
"3cb25f25faacd57a90434f64d0362f2a"
"2d2d0a90cf1a5a4c5db02d56ecc4c5bf"
"34007208d5b887185865"
)
def test_hkdf_sha256_matches_rfc5869_test_case_1():
assert hkdf_sha256(RFC5869_T1_IKM, RFC5869_T1_SALT, RFC5869_T1_INFO, 42) == RFC5869_T1_OKM_42
def test_ecdh_commutative():
a = ec.generate_private_key(ec.SECP256R1())
b = ec.generate_private_key(ec.SECP256R1())
a_pub_uncomp = bytes([0x04]) + a.public_key().public_numbers().x.to_bytes(32, "big") + a.public_key().public_numbers().y.to_bytes(32, "big")
b_pub_uncomp = bytes([0x04]) + b.public_key().public_numbers().x.to_bytes(32, "big") + b.public_key().public_numbers().y.to_bytes(32, "big")
assert ecdh_shared_x(a, b_pub_uncomp) == ecdh_shared_x(b, a_pub_uncomp)
def test_derive_kdh_agrees_on_both_sides():
a = ec.generate_private_key(ec.SECP256R1())
b = ec.generate_private_key(ec.SECP256R1())
a_pub = bytes([0x04]) + a.public_key().public_numbers().x.to_bytes(32, "big") + a.public_key().public_numbers().y.to_bytes(32, "big")
b_pub = bytes([0x04]) + b.public_key().public_numbers().x.to_bytes(32, "big") + b.public_key().public_numbers().y.to_bytes(32, "big")
txn = b"\x01" * 16
assert derive_kdh(a, b_pub, txn) == derive_kdh(b, a_pub, txn)

View File

@@ -0,0 +1,78 @@
from aliro_harness.reader.key_derivation import (
build_salt_volatile,
derive_expedited_session_keys,
)
def test_salt_volatile_layout_per_spec_8_3_1_13():
salt = build_salt_volatile(
reader_long_term_pub_x=b"\x01" * 32,
reader_group_id=b"\xaa" * 16,
reader_group_sub_id=b"\xbb" * 16,
reader_ephem_pub_x=b"\x33" * 32,
transaction_id=b"\xcc" * 16,
command_parameters=0x00,
authentication_policy=0x00,
credential_long_term_pub_x=b"\x55" * 32,
)
p = 0
assert salt[p : p + 32] == b"\x01" * 32 # x(reader_group_identifier_key)
p += 32
assert salt[p : p + 12] == b"Volatile****"
p += 12
assert salt[p : p + 16] == b"\xaa" * 16 # group_id
p += 16
assert salt[p : p + 16] == b"\xbb" * 16 # sub_id
p += 16
assert salt[p] == 0x5E # interface_byte = NFC
p += 1
assert salt[p : p + 2] == bytes([0x5C, 0x02]) # literal
p += 2
assert salt[p : p + 2] == bytes([0x01, 0x00]) # protocol_version
p += 2
assert salt[p : p + 32] == b"\x33" * 32 # x(reader_ephem)
p += 32
assert salt[p : p + 16] == b"\xcc" * 16 # txn_id
p += 16
assert salt[p : p + 2] == bytes([0x00, 0x00]) # flag = cmd_params || auth_policy
p += 2
assert salt[p : p + 10] == bytes.fromhex("A50880020000 5C020100".replace(" ", ""))
p += 10
assert salt[p : p + 32] == b"\x55" * 32 # x(credential_long_term)
p += 32
assert len(salt) == p == 173
def test_salt_volatile_threads_flag_bytes_in_correct_order():
"""Guards against cmd_params/auth_policy swap."""
salt = build_salt_volatile(
reader_long_term_pub_x=b"\x00" * 32,
reader_group_id=b"\x00" * 16,
reader_group_sub_id=b"\x00" * 16,
reader_ephem_pub_x=b"\x00" * 32,
transaction_id=b"\x00" * 16,
command_parameters=0xAB,
authentication_policy=0xCD,
credential_long_term_pub_x=b"\x00" * 32,
)
# flag offset: 32 + 12 + 16 + 16 + 1 + 2 + 2 + 32 + 16 = 129
assert salt[129] == 0xAB
assert salt[130] == 0xCD
def test_derive_expedited_session_keys_returns_160_bytes_deterministic():
kdh = bytes.fromhex("11" * 32)
salt = bytes.fromhex("22" * 173)
info = bytes.fromhex("33" * 65) # x(credential_ephem_pub) is 32B in practice; any bytes OK here
out1 = derive_expedited_session_keys(kdh, salt, info)
out2 = derive_expedited_session_keys(kdh, salt, info)
assert len(out1) == 160
assert out1 == out2
def test_derive_expedited_session_keys_changes_with_inputs():
kdh = bytes.fromhex("11" * 32)
salt_a = bytes.fromhex("22" * 173)
salt_b = bytes.fromhex("23" + "22" * 172)
info = bytes.fromhex("33" * 32)
assert derive_expedited_session_keys(kdh, salt_a, info) != derive_expedited_session_keys(kdh, salt_b, info)

View File

@@ -0,0 +1,8 @@
from aliro_harness.reader.session import ReaderSession
def test_session_starts_with_no_transaction_state():
s = ReaderSession.fresh()
assert s.reader_ephem_priv is None
assert s.transaction_id is None
assert s.credential_ephem_pubk is None

View File

@@ -0,0 +1,62 @@
from aliro_harness.reader.tlv import find_top_level
def test_find_top_level_returns_value_bytes():
# 86 41 [65B value] 9D 02 [2B value]
blob = bytes([0x86, 0x41]) + b"\xaa" * 65 + bytes([0x9D, 0x02, 0xff, 0xee])
assert find_top_level(blob, 0x86) == b"\xaa" * 65
assert find_top_level(blob, 0x9D) == bytes([0xff, 0xee])
assert find_top_level(blob, 0x42) is None
def test_short_form_length_under_128():
value = b"\xbb" * 100
blob = bytes([0x9E, 100]) + value
assert find_top_level(blob, 0x9E) == value
def test_long_form_0x81_length_128_to_255():
value = b"\xcc" * 200
blob = bytes([0x9E, 0x81, 200]) + value
assert find_top_level(blob, 0x9E) == value
def test_long_form_0x82_length_256_or_more():
value = b"\xdd" * 1000
blob = bytes([0x9E, 0x82, (1000 >> 8) & 0xFF, 1000 & 0xFF]) + value
assert find_top_level(blob, 0x9E) == value
def test_returns_none_when_tag_absent():
blob = bytes([0x86, 0x02, 0x01, 0x02, 0x9D, 0x01, 0x03])
assert find_top_level(blob, 0x42) is None
def test_skips_over_unmatched_tags_to_find_later_match():
# Three TLVs: 0x86, 0x9D, 0x9E. Ask for the third.
tlv1 = bytes([0x86, 0x03, 0x11, 0x22, 0x33])
tlv2 = bytes([0x9D, 0x02, 0x44, 0x55])
tlv3 = bytes([0x9E, 0x04, 0x66, 0x77, 0x88, 0x99])
blob = tlv1 + tlv2 + tlv3
assert find_top_level(blob, 0x9E) == bytes([0x66, 0x77, 0x88, 0x99])
def test_zero_length_value_returns_empty_bytes():
"""Pin the 0-length contract — easy to break with a `if length > 0` micro-opt."""
blob = bytes([0x86, 0x00, 0x9D, 0x02, 0x11, 0x22])
assert find_top_level(blob, 0x86) == b""
assert find_top_level(blob, 0x9D) == bytes([0x11, 0x22])
def test_empty_input_returns_none():
assert find_top_level(b"", 0x9E) is None
def test_skips_long_form_unmatched_to_find_later_match():
"""Make sure the 0x82-length skip path advances correctly when the
matched tag comes AFTER a long-form TLV (the current `skips_over_…`
test only exercises short-form skips)."""
skip_value = b"\x99" * 1000
skip_tlv = bytes([0x86, 0x82, (1000 >> 8) & 0xFF, 1000 & 0xFF]) + skip_value
target = bytes([0x9E, 0x02, 0xAB, 0xCD])
assert find_top_level(skip_tlv + target, 0x9E) == bytes([0xAB, 0xCD])

View File

@@ -0,0 +1,101 @@
"""Offline round-trip: reader transaction orchestrator vs. in-process fake card."""
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.reader.transaction import (
TransactionResult,
TrustBundle,
run_aliro_transaction,
)
from tests.fake_card import FakeAliroCard
def _make_keys():
reader = ec.generate_private_key(ec.SECP256R1())
cred = ec.generate_private_key(ec.SECP256R1())
return reader, cred
def test_transaction_against_in_process_fake_card_succeeds():
reader, cred = _make_keys()
bundle = TrustBundle.synthesize(
reader_priv=reader, credential_pub=cred.public_key()
)
fake = FakeAliroCard.provisioned(
reader_pub=reader.public_key(),
credential_priv=cred,
credential_pub=cred.public_key(),
)
result = run_aliro_transaction(transmit=fake.transmit, bundle=bundle)
assert result.ok, f"expected success, got error={result.error!r}"
assert result.signaling_bitmap == bytes([0x00, 0x00]) # no AD in fake
# cmd_params=0x01 path returns 0x5A credential_PubK
assert result.credential_pub is not None
assert len(result.credential_pub) == 65
assert result.credential_pub[0] == 0x04
assert result.ud_sig is not None
assert len(result.ud_sig) == 64
def test_transaction_signals_ad_present_when_fake_card_has_access_document():
"""Cross-check: bitmap tracks the has_access_document provisioning bit."""
reader, cred = _make_keys()
bundle = TrustBundle.synthesize(
reader_priv=reader, credential_pub=cred.public_key()
)
fake = FakeAliroCard.provisioned(
reader_pub=reader.public_key(),
credential_priv=cred,
credential_pub=cred.public_key(),
has_access_document=True,
)
result = run_aliro_transaction(transmit=fake.transmit, bundle=bundle)
assert result.ok
# Bit 0 (AD retrievable) + bit 2 (step-up-AID-required) set.
assert result.signaling_bitmap == bytes([0x00, 0x05])
def test_transaction_fails_when_reader_priv_mismatches_card_reader_pub():
"""Reader's long-term priv does not match what the card has provisioned
-> card's reader-sig verification fails -> AUTH1 returns 6A80."""
_, cred = _make_keys()
reader_in_bundle = ec.generate_private_key(ec.SECP256R1())
reader_provisioned = ec.generate_private_key(ec.SECP256R1())
bundle = TrustBundle.synthesize(
reader_priv=reader_in_bundle, credential_pub=cred.public_key()
)
fake = FakeAliroCard.provisioned(
reader_pub=reader_provisioned.public_key(), # different key
credential_priv=cred,
credential_pub=cred.public_key(),
)
result = run_aliro_transaction(transmit=fake.transmit, bundle=bundle)
assert not result.ok
assert result.error is not None
assert "AUTH1 failed" in result.error
assert "6A80" in result.error
def test_trust_bundle_synthesize_field_types():
"""Round-trip: TrustBundle.synthesize produces the expected field shapes."""
reader = ec.generate_private_key(ec.SECP256R1())
cred = ec.generate_private_key(ec.SECP256R1())
bundle = TrustBundle.synthesize(
reader_priv=reader, credential_pub=cred.public_key()
)
assert isinstance(bundle.reader_priv, ec.EllipticCurvePrivateKey)
assert len(bundle.reader_long_term_pub_x) == 32
assert len(bundle.reader_group_id) == 16
assert len(bundle.reader_group_sub_id) == 16
assert len(bundle.credential_long_term_pub_uncompressed) == 65
assert bundle.credential_long_term_pub_uncompressed[0] == 0x04
assert len(bundle.credential_long_term_pub_x) == 32
# The x-coord inside the uncompressed encoding matches the separate x-coord field.
assert (
bundle.credential_long_term_pub_uncompressed[1:33]
== bundle.credential_long_term_pub_x
)

View File

@@ -0,0 +1,87 @@
"""Tests for the C header emitted from a TrustBundle."""
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.trustgen.header import TrustBundle, render_trust_header
def _bundle():
return TrustBundle(
issuer_public_key=ec.generate_private_key(ec.SECP256R1()).public_key(),
reader_private_key=ec.generate_private_key(ec.SECP256R1()),
reader_group_id=bytes(range(16)),
reader_group_sub_id=bytes(range(16, 32)),
)
def test_header_has_include_guard():
text = render_trust_header(_bundle())
assert "#ifndef ALIRO_TRUST_H_" in text
assert "#define ALIRO_TRUST_H_" in text
assert "#endif" in text
def test_header_marks_file_as_autogenerated():
text = render_trust_header(_bundle())
assert "AUTO-GENERATED" in text
assert "DO NOT EDIT" in text
def test_header_defines_reader_private_key_as_32_bytes():
text = render_trust_header(_bundle())
assert "#define ALIRO_READER_PRIVATE_KEY" in text
assert text.count("0x", text.index("ALIRO_READER_PRIVATE_KEY")) >= 32
def test_header_defines_reader_public_key_as_x_y_concatenated_64_bytes():
text = render_trust_header(_bundle())
assert "#define ALIRO_READER_PUBLIC_KEY" in text
def test_header_defines_issuer_pub_x_y_and_concatenated():
text = render_trust_header(_bundle())
assert "#define ALIRO_ISSUER_PUB_X" in text
assert "#define ALIRO_ISSUER_PUB_Y" in text
assert "#define ALIRO_ISSUER_PUB" in text
def test_header_defines_issuer_kid():
text = render_trust_header(_bundle())
assert "#define ALIRO_ISSUER_KID" in text
def test_header_defines_reader_group_ids():
text = render_trust_header(_bundle())
assert "#define ALIRO_READER_GROUP_ID" in text
assert "#define ALIRO_READER_GROUP_SUB_ID" in text
def test_reader_private_key_bytes_match_input():
bundle = _bundle()
text = render_trust_header(bundle)
expected = bundle.reader_private_key.private_numbers().private_value.to_bytes(32, "big")
first_byte_hex = f"0x{expected[0]:02x}"
last_byte_hex = f"0x{expected[-1]:02x}"
priv_block_start = text.index("ALIRO_READER_PRIVATE_KEY")
priv_block_end = text.index("#define", priv_block_start + 1)
block = text[priv_block_start:priv_block_end]
assert first_byte_hex in block
assert last_byte_hex in block
def test_issuer_kid_matches_compute_issuer_kid():
from aliro_harness.issuer.kid import compute_issuer_kid
bundle = _bundle()
text = render_trust_header(bundle)
expected_kid = compute_issuer_kid(bundle.issuer_public_key)
kid_start = text.index("ALIRO_ISSUER_KID")
kid_end = text.index("\n", kid_start)
kid_line = text[kid_start:kid_end]
for b in expected_kid:
assert f"0x{b:02x}" in kid_line

View File

@@ -0,0 +1,79 @@
from click.testing import CliRunner
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.issuer.cose import verify_cose_sign1
from aliro_harness.issuer.keys import load_private_key_pem
from aliro_harness.trustgen.cli import main
def test_init_creates_all_artifacts(tmp_path):
runner = CliRunner()
result = runner.invoke(main, ["init", "--out-dir", str(tmp_path)])
assert result.exit_code == 0, result.output
assert (tmp_path / "issuer.pem").is_file()
assert (tmp_path / "reader.pem").is_file()
assert (tmp_path / "access_credential.pem").is_file()
assert (tmp_path / "reader_group_id.bin").is_file()
assert (tmp_path / "reader_group_sub_id.bin").is_file()
assert (tmp_path / "access_document.bin").is_file()
assert (tmp_path / "device_response.bin").is_file()
assert (tmp_path / "aliro_trust.h").is_file()
def test_init_writes_consistent_issuer_and_access_document(tmp_path):
"""The Access Document must verify against the issuer public key on disk."""
runner = CliRunner()
result = runner.invoke(main, ["init", "--out-dir", str(tmp_path)])
assert result.exit_code == 0, result.output
issuer = load_private_key_pem(tmp_path / "issuer.pem")
access_doc = (tmp_path / "access_document.bin").read_bytes()
payload = verify_cose_sign1(access_doc, issuer.public_key())
assert len(payload) > 0
def test_init_refuses_to_overwrite_without_force(tmp_path):
(tmp_path / "issuer.pem").write_bytes(b"dummy")
runner = CliRunner()
result = runner.invoke(main, ["init", "--out-dir", str(tmp_path)])
assert result.exit_code != 0
assert "force" in result.output.lower()
def test_init_force_overwrites(tmp_path):
(tmp_path / "issuer.pem").write_bytes(b"dummy")
runner = CliRunner()
result = runner.invoke(main, ["init", "--out-dir", str(tmp_path), "--force"])
assert result.exit_code == 0, result.output
loaded = load_private_key_pem(tmp_path / "issuer.pem")
assert isinstance(loaded, ec.EllipticCurvePrivateKey)
def test_reader_group_id_is_16_bytes(tmp_path):
runner = CliRunner()
result = runner.invoke(main, ["init", "--out-dir", str(tmp_path)])
assert result.exit_code == 0, result.output
assert (tmp_path / "reader_group_id.bin").stat().st_size == 16
assert (tmp_path / "reader_group_sub_id.bin").stat().st_size == 16
def test_trust_header_references_issuer_on_disk(tmp_path):
"""Header should embed the same issuer key that signed the access_document."""
runner = CliRunner()
result = runner.invoke(main, ["init", "--out-dir", str(tmp_path)])
assert result.exit_code == 0, result.output
issuer = load_private_key_pem(tmp_path / "issuer.pem")
header = (tmp_path / "aliro_trust.h").read_text()
n = issuer.public_key().public_numbers()
first_byte_of_x = f"0x{n.x.to_bytes(32, 'big')[0]:02x}"
pub_x_idx = header.index("ALIRO_ISSUER_PUB_X")
pub_x_end = header.index("\n", pub_x_idx)
assert first_byte_of_x in header[pub_x_idx:pub_x_end]