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

8
harness/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.venv/
__pycache__/
*.egg-info/
build/
dist/
.pytest_cache/
.coverage
out/

29
harness/README.md Normal file
View File

@@ -0,0 +1,29 @@
# Aliro test harness
PC-side tools for the Aliro Java Card applet project.
## Layout
```
src/aliro_harness/
├── issuer/ Test CA: keypairs, X.509 certs, signed Access Documents
├── personalize/ pyscard-based loader driving the PersonalizationApplet
├── reader_log/ pyserial VCP capture, tag parser, trace differ
└── trustgen/ CLI emitting reader/aliro_trust.h from CA + reader keys
tests/ pytest suite (unit + end-to-end)
```
## Setup
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -e '.[dev]'
pytest
```
## Not for production
All keys generated or accepted by this harness are TEST-ONLY. Do not reuse in a
production Aliro deployment.

42
harness/pyproject.toml Normal file
View File

@@ -0,0 +1,42 @@
[project]
name = "aliro-harness"
version = "0.1.0"
description = "PC-side test harness for the Aliro Java Card applet: test CA, personalizer, reader log capture"
requires-python = ">=3.11"
dependencies = [
"cryptography>=42.0",
"cbor2>=5.6",
"pyscard>=2.0",
"pyserial>=3.5",
"click>=8.1",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-cov>=5.0",
"ruff>=0.4",
]
[project.scripts]
aliro-trustgen = "aliro_harness.trustgen.cli:main"
aliro-personalize = "aliro_harness.personalizer.cli:main"
aliro-bench-test = "aliro_harness.reader.cli:main"
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "SIM"]

View File

View File

@@ -0,0 +1,82 @@
"""Minimal Aliro Access Document builder.
v1 scope: an Access Document with no Access Data Elements. The MSO carries
the Access Credential public key (the User Device's long-term key) signed
by the Credential Issuer. A Reader provisioned with the Credential Issuer's
public key can verify the Access Document and trust the contained device key.
Aliro spec \u00a77.2 mandates integer-replaced map keys encoded as text strings
(Table 7-1). The COSE_Key encoding (RFC 8152) inside deviceKeyInfo uses its
own integer keys unchanged.
"""
from datetime import UTC, datetime, timedelta
import cbor2
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.issuer.cose import sign_cose_sign1
from aliro_harness.issuer.kid import compute_issuer_kid
MSO_KEY_VERSION = "1"
MSO_KEY_DIGEST_ALG = "2"
MSO_KEY_VALUE_DIGESTS = "3"
MSO_KEY_DEVICE_KEY_INFO = "4"
MSO_KEY_DOC_TYPE = "5"
MSO_KEY_VALIDITY_INFO = "6"
MSO_KEY_TIME_VERIFICATION_REQUIRED = "7"
DEVICE_KEY_INFO_KEY_DEVICE_KEY = "1"
VALIDITY_KEY_SIGNED = "1"
VALIDITY_KEY_VALID_FROM = "2"
VALIDITY_KEY_VALID_UNTIL = "3"
ALIRO_DOCTYPE_ACCESS = "aliro-a"
ALIRO_NAMESPACE_ACCESS = "aliro-a"
_COSE_KEY_KTY = 1
_COSE_KEY_KTY_EC2 = 2
_COSE_KEY_CRV = -1
_COSE_KEY_CRV_P256 = 1
_COSE_KEY_X = -2
_COSE_KEY_Y = -3
_P256_COORD_LEN = 32
def _cose_key_p256(public_key: ec.EllipticCurvePublicKey) -> dict:
numbers = public_key.public_numbers()
return {
_COSE_KEY_KTY: _COSE_KEY_KTY_EC2,
_COSE_KEY_CRV: _COSE_KEY_CRV_P256,
_COSE_KEY_X: numbers.x.to_bytes(_P256_COORD_LEN, "big"),
_COSE_KEY_Y: numbers.y.to_bytes(_P256_COORD_LEN, "big"),
}
def build_access_document(
*,
issuer_private_key: ec.EllipticCurvePrivateKey,
access_credential_public_key: ec.EllipticCurvePublicKey,
validity_days: int = 365,
) -> bytes:
now = datetime.now(UTC).replace(microsecond=0)
mso = {
MSO_KEY_VERSION: "1.0",
MSO_KEY_DIGEST_ALG: "SHA-256",
MSO_KEY_VALUE_DIGESTS: {},
MSO_KEY_DEVICE_KEY_INFO: {
DEVICE_KEY_INFO_KEY_DEVICE_KEY: _cose_key_p256(access_credential_public_key),
},
MSO_KEY_DOC_TYPE: ALIRO_DOCTYPE_ACCESS,
MSO_KEY_VALIDITY_INFO: {
VALIDITY_KEY_SIGNED: now,
VALIDITY_KEY_VALID_FROM: now,
VALIDITY_KEY_VALID_UNTIL: now + timedelta(days=validity_days),
},
MSO_KEY_TIME_VERIFICATION_REQUIRED: False,
}
payload = cbor2.dumps(mso, canonical=True)
kid = compute_issuer_kid(issuer_private_key.public_key())
return sign_cose_sign1(private_key=issuer_private_key, payload=payload, kid=kid)

View File

@@ -0,0 +1,45 @@
from datetime import UTC, datetime, timedelta
from cryptography import x509
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.issuer.keys import generate_p256_keypair
def create_self_signed_ca(
*,
common_name: str,
validity_days: int = 3650,
) -> tuple[x509.Certificate, ec.EllipticCurvePrivateKey]:
key = generate_p256_keypair()
subject = issuer = x509.Name(
[x509.NameAttribute(x509.NameOID.COMMON_NAME, common_name)]
)
now = datetime.now(UTC)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(minutes=1))
.not_valid_after(now + timedelta(days=validity_days))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.add_extension(
x509.KeyUsage(
digital_signature=True,
content_commitment=False,
key_encipherment=False,
data_encipherment=False,
key_agreement=False,
key_cert_sign=True,
crl_sign=True,
encipher_only=False,
decipher_only=False,
),
critical=True,
)
.sign(private_key=key, algorithm=hashes.SHA256())
)
return cert, key

View File

@@ -0,0 +1,90 @@
"""Minimal COSE_Sign1 implementation sufficient for Aliro IssuerAuth.
Reference: RFC 8152 (COSE), \u00a74.4 for the Sig_structure.
COSE_Sign1 wire format:
[ protected_bstr, unprotected_map, payload_bstr, signature_bstr ]
Sig_structure (what gets signed):
[ "Signature1", body_protected, external_aad, payload ]
serialised with deterministic CBOR, then ECDSA-SHA-256.
COSE ECDSA signatures are raw r||s (64 bytes for P-256), not DER.
"""
import cbor2
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 (
decode_dss_signature,
encode_dss_signature,
)
COSE_ALG_ES256 = -7
COSE_HDR_ALG = 1
COSE_HDR_KID = 4
_P256_COORD_LEN = 32
class CoseVerificationError(Exception):
pass
def _sig_structure(protected_bstr: bytes, payload: bytes) -> bytes:
return cbor2.dumps(["Signature1", protected_bstr, b"", payload], canonical=True)
def _der_to_raw(der_sig: bytes) -> bytes:
r, s = decode_dss_signature(der_sig)
return r.to_bytes(_P256_COORD_LEN, "big") + s.to_bytes(_P256_COORD_LEN, "big")
def _raw_to_der(raw_sig: bytes) -> bytes:
if len(raw_sig) != 2 * _P256_COORD_LEN:
raise CoseVerificationError(f"expected 64-byte raw signature, got {len(raw_sig)}")
r = int.from_bytes(raw_sig[:_P256_COORD_LEN], "big")
s = int.from_bytes(raw_sig[_P256_COORD_LEN:], "big")
return encode_dss_signature(r, s)
def sign_cose_sign1(
*,
private_key: ec.EllipticCurvePrivateKey,
payload: bytes,
kid: bytes,
) -> bytes:
protected_bstr = cbor2.dumps({COSE_HDR_ALG: COSE_ALG_ES256}, canonical=True)
unprotected = {COSE_HDR_KID: kid}
to_be_signed = _sig_structure(protected_bstr, payload)
der_sig = private_key.sign(to_be_signed, ec.ECDSA(hashes.SHA256()))
raw_sig = _der_to_raw(der_sig)
return cbor2.dumps([protected_bstr, unprotected, payload, raw_sig], canonical=True)
def verify_cose_sign1(
signed: bytes,
public_key: ec.EllipticCurvePublicKey,
) -> bytes:
try:
decoded = cbor2.loads(signed)
except Exception as e:
raise CoseVerificationError(f"not valid CBOR: {e}") from e
if not isinstance(decoded, list) or len(decoded) != 4:
raise CoseVerificationError("COSE_Sign1 must be a 4-element array")
protected_bstr, _unprotected, payload, raw_sig = decoded
if not isinstance(protected_bstr, bytes):
raise CoseVerificationError("protected header must be bstr")
if not isinstance(payload, bytes):
raise CoseVerificationError("payload must be bstr")
if not isinstance(raw_sig, bytes):
raise CoseVerificationError("signature must be bstr")
to_be_signed = _sig_structure(protected_bstr, payload)
der_sig = _raw_to_der(raw_sig)
try:
public_key.verify(der_sig, to_be_signed, ec.ECDSA(hashes.SHA256()))
except InvalidSignature as e:
raise CoseVerificationError("signature verification failed") from e
return payload

View File

@@ -0,0 +1,61 @@
"""Aliro-flavored mdoc DeviceResponse for the step-up phase.
Per Aliro §8.4.2, the User Device returns an ISO 18013-5 DeviceResponse
with two changes: readerAuth / documentErrors / errors / deviceSigned MUST
NOT be present, and map keys are replaced by the integer aliases in
Table 8-22 (still encoded as text strings).
For v1 (no Access Data Elements; Access Document carries only the
credential long-term public key inside IssuerAuth), the DeviceResponse
is effectively static once the AD is generated:
{
"1": "1.0", # version
"2": [ # documents
{
"1": { # issuerSigned
"1": {}, # nameSpaces (empty — no ADEs)
"2": <AD> # IssuerAuth — raw COSE_Sign1 bytes
},
"5": "aliro-a" # docType
}
],
"3": 0 # status (0 = OK)
}
The Access Document is already a serialized COSE_Sign1, which is itself
valid CBOR (a 4-element array). Splicing its bytes into the DeviceResponse
at the IssuerAuth position yields valid CBOR — no decode/re-encode needed.
"""
import cbor2
def build_device_response(access_document_bytes: bytes) -> bytes:
"""Returns a deterministic-CBOR DeviceResponse wrapping the given
Access Document as IssuerAuth.
``access_document_bytes`` must be the serialized COSE_Sign1 produced
by :func:`build_access_document` — it is embedded verbatim.
"""
# cbor2.dumps with canonical=True produces deterministic CBOR. The AD is
# loaded first so it lives in the structure as real Python objects that
# cbor2 re-encodes — this gives us canonical ordering guarantees even if
# the source AD was encoded differently.
ad = cbor2.loads(access_document_bytes)
return cbor2.dumps(
{
"1": "1.0",
"2": [
{
"1": {
"1": {},
"2": ad,
},
"5": "aliro-a",
}
],
"3": 0,
},
canonical=True,
)

View File

@@ -0,0 +1,24 @@
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
def generate_p256_keypair() -> ec.EllipticCurvePrivateKey:
return ec.generate_private_key(ec.SECP256R1())
def save_private_key_pem(path: Path, key: ec.EllipticCurvePrivateKey) -> None:
pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
path.write_bytes(pem)
def load_private_key_pem(path: Path) -> ec.EllipticCurvePrivateKey:
key = serialization.load_pem_private_key(path.read_bytes(), password=None)
if not isinstance(key, ec.EllipticCurvePrivateKey):
raise ValueError(f"expected an EC private key at {path}, got {type(key).__name__}")
return key

View File

@@ -0,0 +1,16 @@
"""Aliro IssuerAuth `kid` computation (spec \u00a77.2.1).
kid = SHA256("key-identifier" || 0x04 || IssuerKey_PubK.x || IssuerKey_PubK.y)[:8]
"""
import hashlib
from cryptography.hazmat.primitives.asymmetric import ec
def compute_issuer_kid(public_key: ec.EllipticCurvePublicKey) -> bytes:
numbers = public_key.public_numbers()
x_bytes = numbers.x.to_bytes(32, "big")
y_bytes = numbers.y.to_bytes(32, "big")
payload = b"key-identifier" + b"\x04" + x_bytes + y_bytes
return hashlib.sha256(payload).digest()[:8]

View File

@@ -0,0 +1,21 @@
"""Produce the APDU command sequences that drive PersonalizationApplet.
This module intentionally only builds byte-level APDU commands; actually
sending them is the caller's job (pyscard for real cards, or the Java test
harness via CardSimulator). Keeping transport out means these functions are
trivially unit-testable without any card or simulator available.
"""
from aliro_harness.personalizer.access_document import access_document_apdus
from aliro_harness.personalizer.orchestrator import (
PersonalizationError,
TrustArtifacts,
personalize_card,
)
__all__ = [
"access_document_apdus",
"personalize_card",
"PersonalizationError",
"TrustArtifacts",
]

View File

@@ -0,0 +1,51 @@
"""APDU sequence for provisioning an Access Document via PersonalizationApplet.
The applet's PersonalizationApplet (DT-proprietary AID) accepts the AD via
two INSes:
INS 0x23 WRITE_ACCESS_DOC — P1|P2 = destination offset, data = chunk
INS 0x24 FINALIZE_ACCESS_DOC — P1|P2 = total length, Lc = 0
The AD must fit within CredentialStore.ACCESS_DOC_MAX_LEN (1024 bytes).
"""
CLA_PROPRIETARY = 0x80
INS_WRITE_ACCESS_DOC = 0x23
INS_FINALIZE_ACCESS_DOC = 0x24
# Max command data field in a short-form APDU (ISO 7816-4).
APDU_CHUNK_MAX = 255
# Must match CredentialStore.ACCESS_DOC_MAX_LEN on the applet side.
ACCESS_DOC_MAX_LEN = 1024
def access_document_apdus(ad_bytes: bytes, chunk_size: int = APDU_CHUNK_MAX) -> list[bytes]:
"""Returns the ordered list of APDU commands that load ``ad_bytes`` onto
a provisioning-channel-selected applet.
The caller should have already SELECTed the PersonalizationApplet AID
and not yet called COMMIT.
"""
if len(ad_bytes) > ACCESS_DOC_MAX_LEN:
raise ValueError(
f"Access Document is {len(ad_bytes)} bytes; exceeds {ACCESS_DOC_MAX_LEN}-byte applet budget"
)
if not (1 <= chunk_size <= APDU_CHUNK_MAX):
raise ValueError(f"chunk_size must be in [1, {APDU_CHUNK_MAX}]")
apdus: list[bytes] = []
for offset in range(0, len(ad_bytes), chunk_size):
chunk = ad_bytes[offset : offset + chunk_size]
apdus.append(_apdu(INS_WRITE_ACCESS_DOC, offset >> 8, offset & 0xFF, chunk))
total = len(ad_bytes)
apdus.append(_apdu(INS_FINALIZE_ACCESS_DOC, total >> 8, total & 0xFF, b""))
return apdus
def _apdu(ins: int, p1: int, p2: int, data: bytes) -> bytes:
# Short-form APDU: CLA INS P1 P2 Lc data
header = bytes([CLA_PROPRIETARY, ins, p1, p2])
if data:
return header + bytes([len(data)]) + data
return header

View File

@@ -0,0 +1,59 @@
"""APDU constructors for PersonalizationApplet.
Mirrors the INS table in applet/INSTALL.md. See AliroAids.PROVISIONING for
the AID and PersonalizationApplet.java for the INS handlers.
"""
CLA_PROPRIETARY = 0x80
INS_SET_CRED_PRIV = 0x20
INS_SET_CRED_PUBK = 0x21
INS_SET_READER_PUBK = 0x22
INS_WRITE_ACCESS_DOC = 0x23
INS_FINALIZE_ACCESS_DOC = 0x24
INS_COMMIT = 0x2C
# Provisioning AID — must match AliroAids.PROVISIONING in the applet.
# (CSA RID + ACCE 55 99 01 — DT-internal, namespaced under the CSA RID so
# all three applets fit in one CAP.)
PROVISIONING_AID = bytes.fromhex("A000000909ACCE559901")
CRED_PRIV_LEN = 32
CRED_PUBK_LEN = 64
READER_PUBK_LEN = 64
def select_provisioning_apdu() -> bytes:
"""ISO 7816-4 SELECT by name: 00 A4 04 00 Lc <AID>."""
return bytes([0x00, 0xA4, 0x04, 0x00, len(PROVISIONING_AID)]) + PROVISIONING_AID
def set_credential_priv_apdu(priv: bytes) -> bytes:
if len(priv) != CRED_PRIV_LEN:
raise ValueError(f"credential_PrivK must be {CRED_PRIV_LEN}B, got {len(priv)}")
return _short_apdu(INS_SET_CRED_PRIV, 0, 0, priv)
def set_credential_pubk_apdu(pubk_xy: bytes) -> bytes:
if len(pubk_xy) != CRED_PUBK_LEN:
raise ValueError(f"credential_PubK must be {CRED_PUBK_LEN}B (x||y), got {len(pubk_xy)}")
return _short_apdu(INS_SET_CRED_PUBK, 0, 0, pubk_xy)
def set_reader_pubk_apdu(pubk_xy: bytes) -> bytes:
if len(pubk_xy) != READER_PUBK_LEN:
raise ValueError(f"reader_PubK must be {READER_PUBK_LEN}B (x||y), got {len(pubk_xy)}")
return _short_apdu(INS_SET_READER_PUBK, 0, 0, pubk_xy)
def commit_apdu() -> bytes:
return bytes([CLA_PROPRIETARY, INS_COMMIT, 0x00, 0x00])
def _short_apdu(ins: int, p1: int, p2: int, data: bytes) -> bytes:
"""Short-form APDU: CLA INS P1 P2 Lc data."""
if not data:
return bytes([CLA_PROPRIETARY, ins, p1, p2])
if len(data) > 255:
raise ValueError(f"data too long for short-form APDU: {len(data)}B")
return bytes([CLA_PROPRIETARY, ins, p1, p2, len(data)]) + data

View File

@@ -0,0 +1,101 @@
"""``aliro-personalize`` — load credential keys + Access Document onto a
freshly-installed Aliro applet via a PC/SC reader.
The card must already have the CAP installed and all three applets created
(see ``applet/INSTALL.md``). This tool drives the PersonalizationApplet
(AID ``A0000009 09 ACCE 55 99 01``) through its INS sequence:
SELECT → SET_CRED_PRIV → SET_CRED_PUBK → SET_READER_PUBK
→ WRITE_ACCESS_DOC × N → FINALIZE_ACCESS_DOC → COMMIT
After COMMIT the applet refuses every further write, so this is a one-shot
per card. Uncommit isn't supported.
"""
from pathlib import Path
import click
from aliro_harness.personalizer.orchestrator import (
PersonalizationError,
TrustArtifacts,
personalize_card,
)
@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 "
"access_credential.pem, reader.pem, access_document.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:
"""Personalize an Aliro card with the trust artifacts in TRUST_DIR."""
# 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 >= 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}")
artifacts = TrustArtifacts.from_trust_dir(trust_dir)
click.echo(
f"Loaded artifacts: credential_PrivK={len(artifacts.credential_priv)}B, "
f"credential_PubK={len(artifacts.credential_pubk_xy)}B, "
f"reader_PubK={len(artifacts.reader_pubk_xy)}B, "
f"Access Document={len(artifacts.access_document)}B"
)
connection = reader.createConnection()
connection.connect()
click.echo(f"Connected. ATR: {bytes(connection.getATR()).hex()}")
def transmit(apdu: bytes) -> tuple[bytes, int]:
data, sw1, sw2 = connection.transmit(list(apdu))
return bytes(data), (sw1 << 8) | sw2
try:
personalize_card(transmit, artifacts)
except PersonalizationError as e:
raise click.ClickException(str(e)) from e
finally:
connection.disconnect()
click.echo("Personalization complete. Card is locked (no further writes accepted).")

View File

@@ -0,0 +1,31 @@
"""Extract the byte representations PersonalizationApplet expects from
PEM-encoded P-256 keys produced by ``aliro-trustgen init``.
The applet stores raw scalars / raw uncompressed-point coordinates with
fixed lengths (32 / 64 bytes), so we strip PEM/DER framing and BigInteger
sign-padding here.
"""
from cryptography.hazmat.primitives.asymmetric import ec
P256_COORD_LEN = 32
def extract_priv_scalar(private_key: ec.EllipticCurvePrivateKey) -> bytes:
"""Return the 32-byte big-endian P-256 private scalar."""
if not isinstance(private_key.curve, ec.SECP256R1):
raise ValueError(f"expected P-256 (secp256r1), got {private_key.curve.name}")
return _to_fixed_be(private_key.private_numbers().private_value, P256_COORD_LEN)
def extract_pub_xy(public_key: ec.EllipticCurvePublicKey) -> bytes:
"""Return the 64-byte uncompressed point (x || y) without the 0x04 tag."""
if not isinstance(public_key.curve, ec.SECP256R1):
raise ValueError(f"expected P-256 (secp256r1), got {public_key.curve.name}")
nums = public_key.public_numbers()
return _to_fixed_be(nums.x, P256_COORD_LEN) + _to_fixed_be(nums.y, P256_COORD_LEN)
def _to_fixed_be(value: int, length: int) -> bytes:
"""Big-endian unsigned, zero-padded to exactly ``length`` bytes."""
return value.to_bytes(length, "big")

View File

@@ -0,0 +1,87 @@
"""Orchestrate the full personalization sequence over an abstract APDU
transport. Decoupled from pyscard so unit tests can drive a mock transport
and verify the exact byte sequence produced.
"""
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from aliro_harness.personalizer.access_document import access_document_apdus
from aliro_harness.personalizer.apdus import (
commit_apdu,
select_provisioning_apdu,
set_credential_priv_apdu,
set_credential_pubk_apdu,
set_reader_pubk_apdu,
)
from aliro_harness.personalizer.credentials import (
extract_priv_scalar,
extract_pub_xy,
)
SW_OK = 0x9000
# A transmit callable takes one APDU (bytes) and returns (response_data, sw).
Transmit = Callable[[bytes], tuple[bytes, int]]
class PersonalizationError(RuntimeError):
"""Raised when an APDU returns a non-9000 status word."""
def __init__(self, step: str, sw: int):
super().__init__(f"{step} failed: SW=0x{sw:04X}")
self.step = step
self.sw = sw
@dataclass(frozen=True)
class TrustArtifacts:
"""Bytes the applet expects, extracted from a ``trustgen init`` output dir."""
credential_priv: bytes # 32B
credential_pubk_xy: bytes # 64B (x || y, no 0x04 prefix)
reader_pubk_xy: bytes # 64B
access_document: bytes # COSE_Sign1 serialized
@classmethod
def from_trust_dir(cls, trust_dir: Path) -> "TrustArtifacts":
cred_pem = (trust_dir / "access_credential.pem").read_bytes()
reader_pem = (trust_dir / "reader.pem").read_bytes()
ad = (trust_dir / "access_document.bin").read_bytes()
cred_key = serialization.load_pem_private_key(cred_pem, password=None)
reader_key = serialization.load_pem_private_key(reader_pem, password=None)
return cls(
credential_priv=extract_priv_scalar(cred_key),
credential_pubk_xy=extract_pub_xy(cred_key.public_key()),
reader_pubk_xy=extract_pub_xy(reader_key.public_key()),
access_document=ad,
)
def personalize_card(transmit: Transmit, artifacts: TrustArtifacts) -> None:
"""Drive the full provisioning sequence on a card already powered up
behind ``transmit``. Raises :class:`PersonalizationError` on the first
APDU that doesn't return 0x9000.
"""
_send(transmit, "SELECT provisioning AID", select_provisioning_apdu())
_send(transmit, "SET credential_PrivK", set_credential_priv_apdu(artifacts.credential_priv))
_send(transmit, "SET credential_PubK", set_credential_pubk_apdu(artifacts.credential_pubk_xy))
_send(transmit, "SET reader_PubK", set_reader_pubk_apdu(artifacts.reader_pubk_xy))
for i, apdu in enumerate(access_document_apdus(artifacts.access_document)):
_send(transmit, f"Access Document APDU {i}", apdu)
_send(transmit, "COMMIT", commit_apdu())
def _send(transmit: Transmit, step: str, apdu: bytes) -> bytes:
data, sw = transmit(apdu)
if sw != SW_OK:
raise PersonalizationError(step, sw)
return data

View 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

View 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

View 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

View 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}")

View 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)

View 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)

View 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()

View 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

View 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,
)

View File

@@ -0,0 +1,93 @@
"""Command-line entrypoint: `aliro-trustgen init --out-dir PATH`.
Produces a complete TEST-ONLY trust set: issuer keypair, reader keypair,
access credential keypair, reader group identifiers, a minimal signed
Access Document, and a C header the reader firmware includes at build
time.
"""
import os
import sys
from pathlib import Path
import click
from aliro_harness.issuer.access_document import build_access_document
from aliro_harness.issuer.device_response import build_device_response
from aliro_harness.issuer.keys import (
generate_p256_keypair,
save_private_key_pem,
)
from aliro_harness.trustgen.header import TrustBundle, render_trust_header
_ARTIFACTS = (
"issuer.pem",
"reader.pem",
"access_credential.pem",
"reader_group_id.bin",
"reader_group_sub_id.bin",
"access_document.bin",
"device_response.bin",
"aliro_trust.h",
)
@click.group()
def main() -> None:
pass
@main.command()
@click.option(
"--out-dir",
required=True,
type=click.Path(file_okay=False),
help="Directory to write artifacts to (will be created if missing).",
)
@click.option(
"--force",
is_flag=True,
default=False,
help="Overwrite existing artifacts in --out-dir.",
)
def init(out_dir: str, force: bool) -> None:
out = Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
existing = [name for name in _ARTIFACTS if (out / name).exists()]
if existing and not force:
click.echo(
f"Refusing to overwrite existing artifacts in {out}: "
f"{', '.join(existing)}. Pass --force to overwrite.",
err=True,
)
sys.exit(2)
issuer_key = generate_p256_keypair()
reader_key = generate_p256_keypair()
access_credential_key = generate_p256_keypair()
reader_group_id = os.urandom(16)
reader_group_sub_id = os.urandom(16)
save_private_key_pem(out / "issuer.pem", issuer_key)
save_private_key_pem(out / "reader.pem", reader_key)
save_private_key_pem(out / "access_credential.pem", access_credential_key)
(out / "reader_group_id.bin").write_bytes(reader_group_id)
(out / "reader_group_sub_id.bin").write_bytes(reader_group_sub_id)
access_doc = build_access_document(
issuer_private_key=issuer_key,
access_credential_public_key=access_credential_key.public_key(),
)
(out / "access_document.bin").write_bytes(access_doc)
(out / "device_response.bin").write_bytes(build_device_response(access_doc))
bundle = TrustBundle(
issuer_public_key=issuer_key.public_key(),
reader_private_key=reader_key,
reader_group_id=reader_group_id,
reader_group_sub_id=reader_group_sub_id,
)
(out / "aliro_trust.h").write_text(render_trust_header(bundle))
click.echo(f"Wrote {len(_ARTIFACTS)} artifacts to {out}")

View File

@@ -0,0 +1,102 @@
"""Render a C header embedding the v1 Aliro trust anchors.
Consumed at build time by the reader firmware. Format intentionally matches
the byte-array conventions used by X-CUBE-ALIRO's provisioning.c: 32-byte
raw private scalar, 64-byte x||y public point (no 0x04 prefix).
"""
from dataclasses import dataclass
from cryptography.hazmat.primitives.asymmetric import ec
from aliro_harness.issuer.kid import compute_issuer_kid
_P256_COORD_LEN = 32
@dataclass
class TrustBundle:
issuer_public_key: ec.EllipticCurvePublicKey
reader_private_key: ec.EllipticCurvePrivateKey
reader_group_id: bytes
reader_group_sub_id: bytes
def _c_byte_array(data: bytes) -> str:
return "{ " + ", ".join(f"0x{b:02x}" for b in data) + " }"
def _priv_scalar(key: ec.EllipticCurvePrivateKey) -> bytes:
return key.private_numbers().private_value.to_bytes(_P256_COORD_LEN, "big")
def _pub_xy(key: ec.EllipticCurvePublicKey) -> tuple[bytes, bytes]:
n = key.public_numbers()
return (
n.x.to_bytes(_P256_COORD_LEN, "big"),
n.y.to_bytes(_P256_COORD_LEN, "big"),
)
def render_trust_header(bundle: TrustBundle) -> str:
reader_priv = _priv_scalar(bundle.reader_private_key)
reader_x, reader_y = _pub_xy(bundle.reader_private_key.public_key())
issuer_x, issuer_y = _pub_xy(bundle.issuer_public_key)
kid = compute_issuer_kid(bundle.issuer_public_key)
lines = [
"/* aliro_trust.h - AUTO-GENERATED by aliro-trustgen. DO NOT EDIT. */",
"/* Regenerate from the canonical keys under harness/trustgen/out/. */",
"",
"#ifndef ALIRO_TRUST_H_",
"#define ALIRO_TRUST_H_",
"",
"/* Reader long-term ECC P-256 key pair. */",
"/* Private: 32-byte raw scalar. */",
"/* Public: 64-byte x||y (no 0x04 prefix). */",
f"#define ALIRO_READER_PRIVATE_KEY {_c_byte_array(reader_priv)}",
f"#define ALIRO_READER_PUBLIC_KEY {_c_byte_array(reader_x + reader_y)}",
"",
"/* Reader group identifiers (spec \u00a76.2). */",
f"#define ALIRO_READER_GROUP_ID {_c_byte_array(bundle.reader_group_id)}",
f"#define ALIRO_READER_GROUP_SUB_ID {_c_byte_array(bundle.reader_group_sub_id)}",
"",
"/* Credential Issuer trust anchor (raw ECC P-256 public key). */",
f"#define ALIRO_ISSUER_PUB_X {_c_byte_array(issuer_x)}",
f"#define ALIRO_ISSUER_PUB_Y {_c_byte_array(issuer_y)}",
f"#define ALIRO_ISSUER_PUB {_c_byte_array(issuer_x + issuer_y)}",
"",
"/* kid for IssuerAuth header (spec \u00a77.2.1, 8 bytes). */",
f"#define ALIRO_ISSUER_KID {_c_byte_array(kid)}",
"",
"/* ----------------------------------------------------------------- */",
"/* X-CUBE-ALIRO vendor-compat overrides for User_provisioning.h. */",
"/* Gated by ALIRO_TRUST_OVERRIDE so firmware projects that don't */",
"/* opt in keep the vendor defaults. */",
"#ifdef ALIRO_TRUST_OVERRIDE",
"",
"/* READER_ID_* = group_id || group_sub_id (32B concatenated). */",
"#undef READER_ID_2",
"#undef READER_ID",
f"#define READER_ID_2 {_c_byte_array(bundle.reader_group_id + bundle.reader_group_sub_id)}",
f"#define READER_ID {_c_byte_array(bundle.reader_group_id + bundle.reader_group_sub_id)}",
"",
"#undef READER_PUB_KEY_2",
"#undef READER_PUB_KEY",
f"#define READER_PUB_KEY_2 {_c_byte_array(reader_x + reader_y)}",
f"#define READER_PUB_KEY {_c_byte_array(reader_x + reader_y)}",
"",
"#undef READER_PRIVATE_KEY_2",
"#undef READER_PRIVATE_KEY",
f"#define READER_PRIVATE_KEY_2 {_c_byte_array(reader_priv)}",
f"#define READER_PRIVATE_KEY {_c_byte_array(reader_priv)}",
"",
"#undef CREDENTIAL_ISSUER_PUB_KEY_2",
f"#define CREDENTIAL_ISSUER_PUB_KEY_2 {_c_byte_array(issuer_x + issuer_y)}",
"",
"#endif /* ALIRO_TRUST_OVERRIDE */",
"",
"#endif /* ALIRO_TRUST_H_ */",
"",
]
return "\n".join(lines)

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]