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:
8
harness/.gitignore
vendored
Normal file
8
harness/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
out/
|
||||
29
harness/README.md
Normal file
29
harness/README.md
Normal 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
42
harness/pyproject.toml
Normal 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"]
|
||||
0
harness/src/aliro_harness/__init__.py
Normal file
0
harness/src/aliro_harness/__init__.py
Normal file
0
harness/src/aliro_harness/issuer/__init__.py
Normal file
0
harness/src/aliro_harness/issuer/__init__.py
Normal file
82
harness/src/aliro_harness/issuer/access_document.py
Normal file
82
harness/src/aliro_harness/issuer/access_document.py
Normal 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)
|
||||
45
harness/src/aliro_harness/issuer/ca.py
Normal file
45
harness/src/aliro_harness/issuer/ca.py
Normal 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
|
||||
90
harness/src/aliro_harness/issuer/cose.py
Normal file
90
harness/src/aliro_harness/issuer/cose.py
Normal 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
|
||||
61
harness/src/aliro_harness/issuer/device_response.py
Normal file
61
harness/src/aliro_harness/issuer/device_response.py
Normal 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,
|
||||
)
|
||||
24
harness/src/aliro_harness/issuer/keys.py
Normal file
24
harness/src/aliro_harness/issuer/keys.py
Normal 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
|
||||
16
harness/src/aliro_harness/issuer/kid.py
Normal file
16
harness/src/aliro_harness/issuer/kid.py
Normal 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]
|
||||
21
harness/src/aliro_harness/personalizer/__init__.py
Normal file
21
harness/src/aliro_harness/personalizer/__init__.py
Normal 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",
|
||||
]
|
||||
51
harness/src/aliro_harness/personalizer/access_document.py
Normal file
51
harness/src/aliro_harness/personalizer/access_document.py
Normal 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
|
||||
59
harness/src/aliro_harness/personalizer/apdus.py
Normal file
59
harness/src/aliro_harness/personalizer/apdus.py
Normal 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
|
||||
101
harness/src/aliro_harness/personalizer/cli.py
Normal file
101
harness/src/aliro_harness/personalizer/cli.py
Normal 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).")
|
||||
31
harness/src/aliro_harness/personalizer/credentials.py
Normal file
31
harness/src/aliro_harness/personalizer/credentials.py
Normal 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")
|
||||
87
harness/src/aliro_harness/personalizer/orchestrator.py
Normal file
87
harness/src/aliro_harness/personalizer/orchestrator.py
Normal 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
|
||||
0
harness/src/aliro_harness/reader/__init__.py
Normal file
0
harness/src/aliro_harness/reader/__init__.py
Normal file
48
harness/src/aliro_harness/reader/auth0.py
Normal file
48
harness/src/aliro_harness/reader/auth0.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""AUTH0 command-data-field builder (spec §8.3.3.2.1, Table 8-4).
|
||||
|
||||
Mirrors applet/src/test/java/com/dangerousthings/aliro/Auth0Command.java
|
||||
buildStandardData(). The applet validates this in
|
||||
AliroApplet.validateAuth0Data -- both sides must agree byte-for-byte.
|
||||
"""
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
CLA = 0x80
|
||||
INS_AUTH0 = 0x80
|
||||
PROTOCOL_VERSION_1_0 = 0x0100
|
||||
|
||||
|
||||
def build_auth0_data(
|
||||
*,
|
||||
reader_ephem_pub_uncompressed: bytes,
|
||||
reader_group_id: bytes,
|
||||
reader_group_sub_id: bytes,
|
||||
transaction_id: bytes,
|
||||
command_parameters: int = 0x00,
|
||||
authentication_policy: int = 0x00,
|
||||
) -> bytes:
|
||||
if len(reader_ephem_pub_uncompressed) != 65 or reader_ephem_pub_uncompressed[0] != 0x04:
|
||||
raise ValueError("reader_ephem_pub must be 65B uncompressed (0x04 || x || y)")
|
||||
if len(reader_group_id) != 16 or len(reader_group_sub_id) != 16:
|
||||
raise ValueError("reader_group_{id,sub_id} must each be 16B")
|
||||
if len(transaction_id) != 16:
|
||||
raise ValueError("transaction_identifier must be 16B")
|
||||
|
||||
out = BytesIO()
|
||||
_write_tlv(out, 0x41, bytes([command_parameters]))
|
||||
_write_tlv(out, 0x42, bytes([authentication_policy]))
|
||||
_write_tlv(out, 0x5C, PROTOCOL_VERSION_1_0.to_bytes(2, "big"))
|
||||
_write_tlv(out, 0x87, reader_ephem_pub_uncompressed)
|
||||
_write_tlv(out, 0x4C, transaction_id)
|
||||
_write_tlv(out, 0x4D, reader_group_id + reader_group_sub_id)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def _write_tlv(out: BytesIO, tag: int, value: bytes) -> None:
|
||||
out.write(bytes([tag, len(value)]))
|
||||
out.write(value)
|
||||
|
||||
|
||||
def build_auth0_apdu(data: bytes) -> bytes:
|
||||
"""Wraps AUTH0 data-field in a short-form APDU: 80 80 00 00 Lc data."""
|
||||
return bytes([CLA, INS_AUTH0, 0x00, 0x00, len(data)]) + data
|
||||
86
harness/src/aliro_harness/reader/auth1.py
Normal file
86
harness/src/aliro_harness/reader/auth1.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""AUTH1 command builder + Table 8-12 reader-signature input.
|
||||
|
||||
Mirrors applet/src/test/.../ReaderSide.java buildTable812 + buildAuth1Data.
|
||||
"""
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
|
||||
|
||||
CLA = 0x80
|
||||
INS_AUTH1 = 0x81
|
||||
|
||||
USAGE_READER_AUTH1 = bytes([0x41, 0x5D, 0x95, 0x69])
|
||||
USAGE_UD_AUTH1 = bytes([0x4E, 0x88, 0x7B, 0x4C])
|
||||
|
||||
|
||||
def build_table_812(
|
||||
*,
|
||||
reader_id_32: bytes,
|
||||
credential_ephem_pub_x: bytes,
|
||||
reader_ephem_pub_x: bytes,
|
||||
transaction_id: bytes,
|
||||
) -> bytes:
|
||||
return _build_table_8_12_or_13(
|
||||
reader_id_32, credential_ephem_pub_x, reader_ephem_pub_x,
|
||||
transaction_id, USAGE_READER_AUTH1,
|
||||
)
|
||||
|
||||
|
||||
def build_table_813(
|
||||
*,
|
||||
reader_id_32: bytes,
|
||||
credential_ephem_pub_x: bytes,
|
||||
reader_ephem_pub_x: bytes,
|
||||
transaction_id: bytes,
|
||||
) -> bytes:
|
||||
"""Same TLV shape as Table 8-12, only the usage tag differs."""
|
||||
return _build_table_8_12_or_13(
|
||||
reader_id_32, credential_ephem_pub_x, reader_ephem_pub_x,
|
||||
transaction_id, USAGE_UD_AUTH1,
|
||||
)
|
||||
|
||||
|
||||
def _build_table_8_12_or_13(
|
||||
rid: bytes, cred_x: bytes, reader_x: bytes, txn: bytes, usage: bytes,
|
||||
) -> bytes:
|
||||
# Match auth0.py / build_auth1_data discipline: validate fixed-length
|
||||
# inputs up front. Java callers get an ArrayIndexOutOfBoundsException
|
||||
# naturally; Python slicing would silently produce a wrong-length blob.
|
||||
if len(rid) != 32:
|
||||
raise ValueError(f"reader_id_32 must be 32B (got {len(rid)})")
|
||||
if len(cred_x) != 32:
|
||||
raise ValueError(f"credential_ephem_pub_x must be 32B (got {len(cred_x)})")
|
||||
if len(reader_x) != 32:
|
||||
raise ValueError(f"reader_ephem_pub_x must be 32B (got {len(reader_x)})")
|
||||
if len(txn) != 16:
|
||||
raise ValueError(f"transaction_id must be 16B (got {len(txn)})")
|
||||
return (
|
||||
bytes([0x4D, 0x20]) + rid
|
||||
+ bytes([0x86, 0x20]) + cred_x
|
||||
+ bytes([0x87, 0x20]) + reader_x
|
||||
+ bytes([0x4C, 0x10]) + txn
|
||||
+ bytes([0x93, 0x04]) + usage
|
||||
)
|
||||
|
||||
|
||||
def sign_table_812_raw(table_812: bytes, reader_priv: ec.EllipticCurvePrivateKey) -> bytes:
|
||||
"""Returns 64B raw r||s ECDSA-SHA-256 signature."""
|
||||
der = reader_priv.sign(table_812, ec.ECDSA(hashes.SHA256()))
|
||||
r, s = decode_dss_signature(der)
|
||||
return r.to_bytes(32, "big") + s.to_bytes(32, "big")
|
||||
|
||||
|
||||
def build_auth1_data(raw_sig_64: bytes, command_parameters: int = 0x01) -> bytes:
|
||||
"""AUTH1 command data field per spec Table 8-10. cmd_params bit 0 = 1
|
||||
means 'return credential_PubK' (simpler than the SHA-1 key_slot path)."""
|
||||
if len(raw_sig_64) != 64:
|
||||
raise ValueError("reader signature must be 64B raw r||s")
|
||||
return (
|
||||
bytes([0x41, 0x01, command_parameters])
|
||||
+ bytes([0x9E, 0x40]) + raw_sig_64
|
||||
)
|
||||
|
||||
|
||||
def build_auth1_apdu(data: bytes) -> bytes:
|
||||
return bytes([CLA, INS_AUTH1, 0x00, 0x00, len(data)]) + data
|
||||
54
harness/src/aliro_harness/reader/auth1_response.py
Normal file
54
harness/src/aliro_harness/reader/auth1_response.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""AUTH1 response: AES-256-GCM decrypt + UD signature verification.
|
||||
|
||||
device_counter == 1 because we are the FIRST step-up-eligible response in
|
||||
the session (spec §8.3.1.13 initializes expedited_device_counter = 1).
|
||||
"""
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
|
||||
def decrypt_auth1_response(sk_device: bytes, ciphertext_with_tag: bytes) -> bytes:
|
||||
"""Decrypt the FIRST AUTH1 response per spec §8.3.1.6.
|
||||
|
||||
IV = 0x00*7 || 0x01 || expedited_device_counter (4B BE).
|
||||
device_counter is HARDCODED to 1: v1 does exactly one AUTH1 per session
|
||||
(the applet clears FLAG_AUTH0_DONE after a successful AUTH1 — see
|
||||
AliroApplet.processAuth1 — so the next AUTH1 needs a fresh AUTH0,
|
||||
fresh sk_device, and the counter resets to 1). If a future revision
|
||||
loops AUTH1 within a session, this must take counter as a parameter.
|
||||
|
||||
Raises ``cryptography.exceptions.InvalidTag`` on auth-tag mismatch.
|
||||
"""
|
||||
iv = b"\x00" * 7 + b"\x01" + (1).to_bytes(4, "big")
|
||||
return AESGCM(sk_device).decrypt(iv, ciphertext_with_tag, associated_data=None)
|
||||
|
||||
|
||||
def verify_ud_signature(
|
||||
credential_long_term_pub_uncompressed: bytes,
|
||||
table_813: bytes,
|
||||
raw_sig_64: bytes,
|
||||
) -> bool:
|
||||
"""Verify the UD's ECDSA-SHA-256 signature over Table 8-13.
|
||||
|
||||
Returns True on valid sig, False on invalid sig.
|
||||
Raises ``ValueError`` on malformed inputs (wrong sig length, malformed
|
||||
pubkey encoding — the latter from ``from_encoded_point`` on a non-65B
|
||||
or off-curve point).
|
||||
"""
|
||||
if len(raw_sig_64) != 64:
|
||||
raise ValueError("UD signature must be 64B raw r||s")
|
||||
pub = ec.EllipticCurvePublicKey.from_encoded_point(
|
||||
ec.SECP256R1(), credential_long_term_pub_uncompressed
|
||||
)
|
||||
r = int.from_bytes(raw_sig_64[:32], "big")
|
||||
s = int.from_bytes(raw_sig_64[32:], "big")
|
||||
der = encode_dss_signature(r, s)
|
||||
try:
|
||||
pub.verify(der, table_813, ec.ECDSA(hashes.SHA256()))
|
||||
return True
|
||||
except InvalidSignature:
|
||||
return False
|
||||
118
harness/src/aliro_harness/reader/cli.py
Normal file
118
harness/src/aliro_harness/reader/cli.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""``aliro-bench-test`` — run a single Aliro AUTH0+AUTH1 transaction against
|
||||
a personalized card via a PC/SC reader.
|
||||
|
||||
Drives the Aliro EXPEDITED phase end-to-end:
|
||||
|
||||
SELECT EXPEDITED -> AUTH0 -> AUTH1 -> decrypt -> verify UD signature
|
||||
|
||||
Exits 0 on success, non-zero with a clear error on any failure.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from aliro_harness.reader.transaction import TrustBundle, run_aliro_transaction
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--trust-dir",
|
||||
required=False,
|
||||
default=None,
|
||||
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
||||
help="Directory written by 'aliro-trustgen init'. Must contain "
|
||||
"reader.pem, access_credential.pem, reader_group_id.bin, "
|
||||
"reader_group_sub_id.bin. Required unless --list-readers is given.",
|
||||
)
|
||||
@click.option(
|
||||
"--reader",
|
||||
"reader_index",
|
||||
type=int,
|
||||
default=0,
|
||||
show_default=True,
|
||||
help="Index into the list of PC/SC readers (use --list-readers to see).",
|
||||
)
|
||||
@click.option(
|
||||
"--list-readers",
|
||||
is_flag=True,
|
||||
help="List PC/SC readers visible to the host and exit.",
|
||||
)
|
||||
def main(trust_dir: Path | None, reader_index: int, list_readers: bool) -> None:
|
||||
"""Run a single Aliro AUTH0+AUTH1 transaction against a personalized
|
||||
card via PC/SC. Decrypts the response and verifies the UD signature.
|
||||
Exits 0 on success, non-zero with a clear error on any failure."""
|
||||
# pyscard is imported lazily so --help and unit tests don't require pcscd.
|
||||
from smartcard.System import readers
|
||||
|
||||
available = readers()
|
||||
if list_readers:
|
||||
if not available:
|
||||
click.echo("No PC/SC readers found.", err=True)
|
||||
raise SystemExit(1)
|
||||
for i, r in enumerate(available):
|
||||
click.echo(f"[{i}] {r}")
|
||||
return
|
||||
|
||||
if trust_dir is None:
|
||||
raise click.UsageError(
|
||||
"--trust-dir is required unless --list-readers is given."
|
||||
)
|
||||
|
||||
if not available:
|
||||
raise click.ClickException("No PC/SC readers found. Is pcscd running?")
|
||||
if reader_index < 0 or reader_index >= len(available):
|
||||
raise click.ClickException(
|
||||
f"--reader {reader_index} out of range; only {len(available)} reader(s) available."
|
||||
)
|
||||
|
||||
reader = available[reader_index]
|
||||
click.echo(f"Using reader: {reader}")
|
||||
|
||||
connection = reader.createConnection()
|
||||
connection.connect()
|
||||
try:
|
||||
click.echo(f"Connected. ATR: {bytes(connection.getATR()).hex()}")
|
||||
|
||||
bundle = TrustBundle.from_trust_dir(trust_dir)
|
||||
click.echo(f"Loaded trust artifacts from {trust_dir}")
|
||||
|
||||
def transmit(apdu: bytes) -> tuple[bytes, int]:
|
||||
data, sw1, sw2 = connection.transmit(list(apdu))
|
||||
return bytes(data), (sw1 << 8) | sw2
|
||||
|
||||
result = run_aliro_transaction(transmit=transmit, bundle=bundle)
|
||||
finally:
|
||||
connection.disconnect()
|
||||
|
||||
if not result.ok:
|
||||
# If decrypt succeeded but verify (or another late step) failed,
|
||||
# the orchestrator populates the TLV fields anyway — print them so
|
||||
# the user at the bench can see WHERE in the pipeline the failure
|
||||
# happened rather than just the last error message.
|
||||
if result.plaintext is not None:
|
||||
click.echo(
|
||||
f"RESULT: FAIL \u2014 {result.error}", err=True
|
||||
)
|
||||
_echo_tlv_breakdown(result)
|
||||
click.echo(
|
||||
"Hint: the AUTH1 response decrypted, so session keys are "
|
||||
"correct. The signature verification failed — check that the "
|
||||
"credential public key in the trust dir matches the one "
|
||||
"provisioned on the card.",
|
||||
err=True,
|
||||
)
|
||||
raise click.exceptions.Exit(1)
|
||||
raise click.ClickException(result.error or "Aliro transaction failed.")
|
||||
|
||||
click.echo("RESULT: OK \u2014 applet round-trip on real hardware.")
|
||||
_echo_tlv_breakdown(result)
|
||||
|
||||
|
||||
def _echo_tlv_breakdown(result) -> None:
|
||||
cred_pub_len = len(result.credential_pub) if result.credential_pub else 0
|
||||
ud_sig_len = len(result.ud_sig) if result.ud_sig else 0
|
||||
bitmap_hex = result.signaling_bitmap.hex() if result.signaling_bitmap else ""
|
||||
click.echo(f" 0x5A credential_PubK: {cred_pub_len}B")
|
||||
click.echo(f" 0x9E UD signature: {ud_sig_len}B")
|
||||
click.echo(f" 0x5E signaling_bitmap: 0x{bitmap_hex}")
|
||||
42
harness/src/aliro_harness/reader/crypto.py
Normal file
42
harness/src/aliro_harness/reader/crypto.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Reader-side crypto primitives for the Aliro NFC transaction.
|
||||
|
||||
Wraps cryptography's HKDF, ECDH, ECDSA, AES-GCM into Aliro-shaped helpers.
|
||||
Intentionally NOT a re-export — these wrappers fix the algorithm choices
|
||||
specified by Aliro §8.3.1 so callers can't pick the wrong hash, padding,
|
||||
etc. by mistake.
|
||||
"""
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
|
||||
|
||||
def hkdf_sha256(ikm: bytes, salt: bytes, info: bytes, length: int) -> bytes:
|
||||
"""HKDF-SHA-256 per RFC 5869. An empty (or None) salt is substituted
|
||||
with HashLen (32) zero bytes, per RFC 5869 §2.2."""
|
||||
return HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=length,
|
||||
salt=salt or b"\x00" * 32,
|
||||
info=info,
|
||||
).derive(ikm)
|
||||
|
||||
|
||||
def ecdh_shared_x(priv: ec.EllipticCurvePrivateKey, peer_pub_uncompressed_65: bytes) -> bytes:
|
||||
"""ECDH P-256: returns the 32-byte x-coordinate of the shared point.
|
||||
Point-on-curve validation is enforced by ``from_encoded_point``."""
|
||||
if len(peer_pub_uncompressed_65) != 65 or peer_pub_uncompressed_65[0] != 0x04:
|
||||
raise ValueError("peer pubkey must be 65B uncompressed")
|
||||
peer = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), peer_pub_uncompressed_65)
|
||||
return priv.exchange(ec.ECDH(), peer) # cryptography returns the 32B x-coord
|
||||
|
||||
|
||||
def derive_kdh(
|
||||
reader_ephem_priv: ec.EllipticCurvePrivateKey,
|
||||
credential_ephem_pub_uncompressed: bytes,
|
||||
transaction_id: bytes,
|
||||
) -> bytes:
|
||||
"""§8.3.1.4 (with §8.3.1.5 substitution): Kdh = HKDF(IKM=ECDH_x,
|
||||
salt=transaction_id, info=∅, L=32)."""
|
||||
z_ab = ecdh_shared_x(reader_ephem_priv, credential_ephem_pub_uncompressed)
|
||||
return hkdf_sha256(z_ab, transaction_id, b"", 32)
|
||||
68
harness/src/aliro_harness/reader/key_derivation.py
Normal file
68
harness/src/aliro_harness/reader/key_derivation.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Reader-side mirror of AliroApplet.buildSaltVolatile / deriveSessionKeys.
|
||||
|
||||
Keep this BYTE-IDENTICAL to the applet implementation in
|
||||
applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java
|
||||
(buildSaltVolatile). Drift here = decryption fails on real card.
|
||||
"""
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from aliro_harness.reader.crypto import hkdf_sha256
|
||||
|
||||
SALT_VOLATILE_TAG = b"Volatile****"
|
||||
INTERFACE_BYTE_NFC = 0x5E
|
||||
PROPRIETARY_A5_TLV = bytes.fromhex("A50880020000" "5C020100")
|
||||
PROTOCOL_VERSION_1_0 = bytes([0x01, 0x00])
|
||||
|
||||
# Offsets into the 160B derived_keys_volatile (§8.3.1.13).
|
||||
OFF_EXPEDITED_SK_READER = 0
|
||||
OFF_EXPEDITED_SK_DEVICE = 32
|
||||
OFF_STEP_UP_SK = 64
|
||||
|
||||
|
||||
def build_salt_volatile(
|
||||
*,
|
||||
reader_long_term_pub_x: bytes,
|
||||
reader_group_id: bytes,
|
||||
reader_group_sub_id: bytes,
|
||||
reader_ephem_pub_x: bytes,
|
||||
transaction_id: bytes,
|
||||
command_parameters: int,
|
||||
authentication_policy: int,
|
||||
credential_long_term_pub_x: bytes,
|
||||
) -> bytes:
|
||||
"""Per spec §8.3.1.13. Mirrors AliroApplet.buildSaltVolatile (lines 406-446)."""
|
||||
if not (0 <= command_parameters <= 0xFF and 0 <= authentication_policy <= 0xFF):
|
||||
raise ValueError(
|
||||
"command_parameters and authentication_policy must each fit in one byte"
|
||||
)
|
||||
out = BytesIO()
|
||||
out.write(reader_long_term_pub_x) # 32
|
||||
out.write(SALT_VOLATILE_TAG) # 12
|
||||
out.write(reader_group_id) # 16
|
||||
out.write(reader_group_sub_id) # 16
|
||||
out.write(bytes([INTERFACE_BYTE_NFC])) # 1
|
||||
out.write(bytes([0x5C, 0x02])) # 2
|
||||
out.write(PROTOCOL_VERSION_1_0) # 2
|
||||
out.write(reader_ephem_pub_x) # 32
|
||||
out.write(transaction_id) # 16
|
||||
out.write(bytes([command_parameters, authentication_policy])) # 2
|
||||
out.write(PROPRIETARY_A5_TLV) # 10
|
||||
out.write(credential_long_term_pub_x) # 32
|
||||
salt = out.getvalue()
|
||||
if len(salt) != 173:
|
||||
raise ValueError(
|
||||
f"salt_volatile must be 173 bytes (caller passed wrong-length x-coord or ID); "
|
||||
f"got {len(salt)}. Each 32B field must be exactly 32B; each 16B field must be 16B."
|
||||
)
|
||||
return salt
|
||||
|
||||
|
||||
def derive_expedited_session_keys(
|
||||
kdh: bytes,
|
||||
salt_volatile: bytes,
|
||||
info_credential_ephem_pub_x: bytes,
|
||||
) -> bytes:
|
||||
"""§8.3.1.13: derived_keys_volatile (160B). info = x(credential_ephem_pub) +
|
||||
AUTH0 vendor extensions (none in v1)."""
|
||||
return hkdf_sha256(kdh, salt_volatile, info_credential_ephem_pub_x, 160)
|
||||
20
harness/src/aliro_harness/reader/session.py
Normal file
20
harness/src/aliro_harness/reader/session.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Per-transaction reader-side state for an Aliro NFC transaction."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReaderSession:
|
||||
"""Mutable per-transaction state. Mirrors fields kept in the Java
|
||||
ReaderSide test fixture (applet/src/test/.../ReaderSide.java)."""
|
||||
|
||||
reader_ephem_priv: Optional[ec.EllipticCurvePrivateKey] = None
|
||||
transaction_id: Optional[bytes] = None
|
||||
credential_ephem_pubk: Optional[bytes] = None # 65B uncompressed, captured from AUTH0 response
|
||||
|
||||
@classmethod
|
||||
def fresh(cls) -> "ReaderSession":
|
||||
return cls()
|
||||
38
harness/src/aliro_harness/reader/tlv.py
Normal file
38
harness/src/aliro_harness/reader/tlv.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Minimal TLV reader for AUTH0/AUTH1 response parsing.
|
||||
|
||||
Mirrors applet/src/test/.../TlvUtil.java. Single-byte tags only
|
||||
(Aliro uses BER-TLV but every tag in AUTH0/AUTH1 responses — 0x86, 0x9E,
|
||||
0x5A, 0x5E, 0x4D, 0x4B, 0x4C, 0x91, 0x92 — is single-byte). The Java
|
||||
reference additionally supports BER multi-byte tag continuation
|
||||
((tag & 0x1F) == 0x1F); revisit if a future Aliro spec revision starts
|
||||
emitting extended tags.
|
||||
|
||||
Short-form lengths plus 0x81 / 0x82 long-form for sizes >= 128. Returned
|
||||
slice is a fresh ``bytes`` object (not aliased into ``data``).
|
||||
|
||||
Inputs are assumed well-formed BER-TLV from a trusted card response;
|
||||
truncated input raises ``IndexError`` (left unwrapped because the
|
||||
upstream transaction orchestrator treats any parse failure as a protocol
|
||||
error and surfaces a clear message there).
|
||||
"""
|
||||
|
||||
|
||||
def find_top_level(data: bytes, tag: int) -> bytes | None:
|
||||
"""Returns the value bytes of the first TLV whose tag matches, or None.
|
||||
Does not recurse into constructed (0xA*) values."""
|
||||
i = 0
|
||||
while i < len(data):
|
||||
cur_tag = data[i]
|
||||
i += 1
|
||||
length = data[i]
|
||||
i += 1
|
||||
if length == 0x81:
|
||||
length = data[i]
|
||||
i += 1
|
||||
elif length == 0x82:
|
||||
length = (data[i] << 8) | data[i + 1]
|
||||
i += 2
|
||||
if cur_tag == tag:
|
||||
return data[i : i + length]
|
||||
i += length
|
||||
return None
|
||||
269
harness/src/aliro_harness/reader/transaction.py
Normal file
269
harness/src/aliro_harness/reader/transaction.py
Normal file
@@ -0,0 +1,269 @@
|
||||
"""Top-level Aliro reader transaction orchestrator.
|
||||
|
||||
Runs SELECT EXPEDITED -> AUTH0 -> AUTH1 against an abstract APDU transport,
|
||||
decrypts the AUTH1 response, and verifies the UD signature against the
|
||||
configured credential_PubK. Decoupled from pyscard so unit tests can drive
|
||||
a mocked/in-process card.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
|
||||
from aliro_harness.personalizer.credentials import extract_pub_xy
|
||||
from aliro_harness.reader.auth0 import build_auth0_apdu, build_auth0_data
|
||||
from aliro_harness.reader.auth1 import (
|
||||
build_auth1_apdu,
|
||||
build_auth1_data,
|
||||
build_table_812,
|
||||
build_table_813,
|
||||
sign_table_812_raw,
|
||||
)
|
||||
from aliro_harness.reader.auth1_response import (
|
||||
decrypt_auth1_response,
|
||||
verify_ud_signature,
|
||||
)
|
||||
from aliro_harness.reader.crypto import derive_kdh
|
||||
from aliro_harness.reader.key_derivation import (
|
||||
OFF_EXPEDITED_SK_DEVICE,
|
||||
build_salt_volatile,
|
||||
derive_expedited_session_keys,
|
||||
)
|
||||
from aliro_harness.reader.tlv import find_top_level
|
||||
|
||||
# Aliro EXPEDITED phase AID -- must match AliroAids.EXPEDITED in the applet.
|
||||
EXPEDITED_AID = bytes.fromhex("A000000909ACCE5501")
|
||||
|
||||
Transmit = Callable[[bytes], tuple[bytes, int]]
|
||||
SW_OK = 0x9000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrustBundle:
|
||||
"""Trust artifacts the reader needs for a transaction. Mirrors what
|
||||
personalizer.TrustArtifacts loads but adds reader_priv (which the
|
||||
personalizer doesn't need, since it never signs)."""
|
||||
|
||||
reader_priv: ec.EllipticCurvePrivateKey
|
||||
reader_long_term_pub_x: bytes # 32B (used in salt_volatile)
|
||||
reader_group_id: bytes # 16B
|
||||
reader_group_sub_id: bytes # 16B
|
||||
credential_long_term_pub_uncompressed: bytes # 65B (for verify_ud_signature)
|
||||
credential_long_term_pub_x: bytes # 32B (for salt_volatile)
|
||||
|
||||
@classmethod
|
||||
def synthesize(
|
||||
cls,
|
||||
*,
|
||||
reader_priv: ec.EllipticCurvePrivateKey,
|
||||
credential_pub: ec.EllipticCurvePublicKey,
|
||||
reader_group_id: bytes | None = None,
|
||||
reader_group_sub_id: bytes | None = None,
|
||||
) -> "TrustBundle":
|
||||
"""Convenience for tests: synthesize a TrustBundle from in-process keys."""
|
||||
reader_pub_xy = extract_pub_xy(reader_priv.public_key()) # 64B (x||y)
|
||||
reader_pub_x = reader_pub_xy[:32]
|
||||
|
||||
credential_pub_xy = extract_pub_xy(credential_pub) # 64B
|
||||
credential_pub_x = credential_pub_xy[:32]
|
||||
credential_pub_uncompressed = b"\x04" + credential_pub_xy
|
||||
|
||||
if reader_group_id is None:
|
||||
reader_group_id = secrets.token_bytes(16)
|
||||
if reader_group_sub_id is None:
|
||||
reader_group_sub_id = secrets.token_bytes(16)
|
||||
|
||||
if len(reader_group_id) != 16:
|
||||
raise ValueError("reader_group_id must be 16B")
|
||||
if len(reader_group_sub_id) != 16:
|
||||
raise ValueError("reader_group_sub_id must be 16B")
|
||||
|
||||
return cls(
|
||||
reader_priv=reader_priv,
|
||||
reader_long_term_pub_x=reader_pub_x,
|
||||
reader_group_id=reader_group_id,
|
||||
reader_group_sub_id=reader_group_sub_id,
|
||||
credential_long_term_pub_uncompressed=credential_pub_uncompressed,
|
||||
credential_long_term_pub_x=credential_pub_x,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_trust_dir(cls, trust_dir: Path) -> "TrustBundle":
|
||||
"""Loads from ``aliro-trustgen init`` output:
|
||||
- reader.pem -> reader_priv
|
||||
- access_credential.pem -> credential public key
|
||||
- reader_group_id.bin -> reader_group_id
|
||||
- reader_group_sub_id.bin -> reader_group_sub_id
|
||||
"""
|
||||
reader_pem = (trust_dir / "reader.pem").read_bytes()
|
||||
cred_pem = (trust_dir / "access_credential.pem").read_bytes()
|
||||
reader_group_id = (trust_dir / "reader_group_id.bin").read_bytes()
|
||||
reader_group_sub_id = (trust_dir / "reader_group_sub_id.bin").read_bytes()
|
||||
|
||||
reader_priv = serialization.load_pem_private_key(reader_pem, password=None)
|
||||
cred_priv = serialization.load_pem_private_key(cred_pem, password=None)
|
||||
|
||||
return cls.synthesize(
|
||||
reader_priv=reader_priv,
|
||||
credential_pub=cred_priv.public_key(),
|
||||
reader_group_id=reader_group_id,
|
||||
reader_group_sub_id=reader_group_sub_id,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TransactionResult:
|
||||
ok: bool
|
||||
plaintext: bytes | None = None # decrypted Table 8-11 bytes
|
||||
credential_pub: bytes | None = None # 0x5A value (65B uncompressed) when present
|
||||
ud_sig: bytes | None = None # 0x9E value (64B raw r||s)
|
||||
signaling_bitmap: bytes | None = None # 0x5E value (2B)
|
||||
error: str | None = None # populated on failure
|
||||
|
||||
|
||||
def run_aliro_transaction(
|
||||
*, transmit: Transmit, bundle: TrustBundle
|
||||
) -> TransactionResult:
|
||||
"""Orchestrate one Aliro AUTH0+AUTH1 transaction.
|
||||
|
||||
Steps:
|
||||
1. SELECT EXPEDITED
|
||||
2. AUTH0 -- generate ephemeral, send, parse 0x86 (credential_ePubK)
|
||||
3. Derive Kdh + ExpeditedSKDevice
|
||||
4. Build + sign Table 8-12 -> AUTH1 command
|
||||
5. Decrypt response, extract 0x5A/0x9E/0x5E TLVs
|
||||
6. Verify UD signature over Table 8-13 against credential_PubK
|
||||
|
||||
Any non-9000 SW or signature mismatch returns ok=False with ``error`` set.
|
||||
"""
|
||||
# SELECT EXPEDITED
|
||||
select_apdu = (
|
||||
bytes([0x00, 0xA4, 0x04, 0x00, len(EXPEDITED_AID)]) + EXPEDITED_AID
|
||||
)
|
||||
_, sw = transmit(select_apdu)
|
||||
if sw != SW_OK:
|
||||
return TransactionResult(
|
||||
ok=False, error=f"SELECT EXPEDITED failed: SW=0x{sw:04X}"
|
||||
)
|
||||
|
||||
# Generate reader ephemeral + transaction state
|
||||
reader_ephem = ec.generate_private_key(ec.SECP256R1())
|
||||
reader_ephem_pub_uncompressed = reader_ephem.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.X962,
|
||||
format=serialization.PublicFormat.UncompressedPoint,
|
||||
)
|
||||
reader_ephem_pub_x = reader_ephem_pub_uncompressed[1:33]
|
||||
txn_id = secrets.token_bytes(16)
|
||||
|
||||
# AUTH0
|
||||
auth0_data = build_auth0_data(
|
||||
reader_ephem_pub_uncompressed=reader_ephem_pub_uncompressed,
|
||||
reader_group_id=bundle.reader_group_id,
|
||||
reader_group_sub_id=bundle.reader_group_sub_id,
|
||||
transaction_id=txn_id,
|
||||
command_parameters=0x00,
|
||||
authentication_policy=0x00,
|
||||
)
|
||||
auth0_resp_data, sw = transmit(build_auth0_apdu(auth0_data))
|
||||
if sw != SW_OK:
|
||||
return TransactionResult(ok=False, error=f"AUTH0 failed: SW=0x{sw:04X}")
|
||||
|
||||
cred_ephem_pub_uncompressed = find_top_level(auth0_resp_data, 0x86)
|
||||
if (
|
||||
cred_ephem_pub_uncompressed is None
|
||||
or len(cred_ephem_pub_uncompressed) != 65
|
||||
):
|
||||
return TransactionResult(
|
||||
ok=False,
|
||||
error="AUTH0 response missing or malformed 0x86 credential_ePubK",
|
||||
)
|
||||
cred_ephem_pub_x = cred_ephem_pub_uncompressed[1:33]
|
||||
|
||||
# Key derivation
|
||||
kdh = derive_kdh(reader_ephem, cred_ephem_pub_uncompressed, txn_id)
|
||||
salt_volatile = build_salt_volatile(
|
||||
reader_long_term_pub_x=bundle.reader_long_term_pub_x,
|
||||
reader_group_id=bundle.reader_group_id,
|
||||
reader_group_sub_id=bundle.reader_group_sub_id,
|
||||
reader_ephem_pub_x=reader_ephem_pub_x,
|
||||
transaction_id=txn_id,
|
||||
command_parameters=0x00,
|
||||
authentication_policy=0x00,
|
||||
credential_long_term_pub_x=bundle.credential_long_term_pub_x,
|
||||
)
|
||||
derived_keys = derive_expedited_session_keys(
|
||||
kdh, salt_volatile, cred_ephem_pub_x
|
||||
)
|
||||
sk_device = derived_keys[OFF_EXPEDITED_SK_DEVICE : OFF_EXPEDITED_SK_DEVICE + 32]
|
||||
|
||||
# AUTH1
|
||||
reader_id_32 = bundle.reader_group_id + bundle.reader_group_sub_id
|
||||
table_812 = build_table_812(
|
||||
reader_id_32=reader_id_32,
|
||||
credential_ephem_pub_x=cred_ephem_pub_x,
|
||||
reader_ephem_pub_x=reader_ephem_pub_x,
|
||||
transaction_id=txn_id,
|
||||
)
|
||||
raw_sig = sign_table_812_raw(table_812, bundle.reader_priv)
|
||||
auth1_data = build_auth1_data(raw_sig, command_parameters=0x01)
|
||||
auth1_resp_data, sw = transmit(build_auth1_apdu(auth1_data))
|
||||
if sw != SW_OK:
|
||||
return TransactionResult(ok=False, error=f"AUTH1 failed: SW=0x{sw:04X}")
|
||||
|
||||
# Decrypt + verify
|
||||
try:
|
||||
plaintext = decrypt_auth1_response(sk_device, auth1_resp_data)
|
||||
except Exception as e:
|
||||
return TransactionResult(
|
||||
ok=False, error=f"AUTH1 response decrypt failed: {e}"
|
||||
)
|
||||
|
||||
cred_pub_5a = find_top_level(plaintext, 0x5A)
|
||||
ud_sig = find_top_level(plaintext, 0x9E)
|
||||
bitmap = find_top_level(plaintext, 0x5E)
|
||||
|
||||
if ud_sig is None or len(ud_sig) != 64:
|
||||
return TransactionResult(
|
||||
ok=False,
|
||||
error="Table 8-11 missing 0x9E UD signature",
|
||||
plaintext=plaintext,
|
||||
)
|
||||
if bitmap is None:
|
||||
return TransactionResult(
|
||||
ok=False,
|
||||
error="Table 8-11 missing 0x5E signaling_bitmap",
|
||||
plaintext=plaintext,
|
||||
)
|
||||
|
||||
table_813 = build_table_813(
|
||||
reader_id_32=reader_id_32,
|
||||
credential_ephem_pub_x=cred_ephem_pub_x,
|
||||
reader_ephem_pub_x=reader_ephem_pub_x,
|
||||
transaction_id=txn_id,
|
||||
)
|
||||
ud_pub = (
|
||||
cred_pub_5a
|
||||
if cred_pub_5a
|
||||
else bundle.credential_long_term_pub_uncompressed
|
||||
)
|
||||
if not verify_ud_signature(ud_pub, table_813, ud_sig):
|
||||
return TransactionResult(
|
||||
ok=False,
|
||||
error="UD signature verification failed",
|
||||
plaintext=plaintext,
|
||||
credential_pub=cred_pub_5a,
|
||||
ud_sig=ud_sig,
|
||||
signaling_bitmap=bitmap,
|
||||
)
|
||||
|
||||
return TransactionResult(
|
||||
ok=True,
|
||||
plaintext=plaintext,
|
||||
credential_pub=cred_pub_5a,
|
||||
ud_sig=ud_sig,
|
||||
signaling_bitmap=bitmap,
|
||||
)
|
||||
0
harness/src/aliro_harness/trustgen/__init__.py
Normal file
0
harness/src/aliro_harness/trustgen/__init__.py
Normal file
93
harness/src/aliro_harness/trustgen/cli.py
Normal file
93
harness/src/aliro_harness/trustgen/cli.py
Normal 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}")
|
||||
102
harness/src/aliro_harness/trustgen/header.py
Normal file
102
harness/src/aliro_harness/trustgen/header.py
Normal 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)
|
||||
0
harness/tests/__init__.py
Normal file
0
harness/tests/__init__.py
Normal file
425
harness/tests/fake_card.py
Normal file
425
harness/tests/fake_card.py
Normal 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
|
||||
161
harness/tests/test_access_document.py
Normal file
161
harness/tests/test_access_document.py
Normal 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
39
harness/tests/test_ca.py
Normal 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),
|
||||
)
|
||||
74
harness/tests/test_cose.py
Normal file
74
harness/tests/test_cose.py
Normal 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
|
||||
97
harness/tests/test_device_response.py
Normal file
97
harness/tests/test_device_response.py
Normal 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"
|
||||
25
harness/tests/test_keys.py
Normal file
25
harness/tests/test_keys.py
Normal 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
45
harness/tests/test_kid.py
Normal 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())
|
||||
72
harness/tests/test_personalizer_access_document.py
Normal file
72
harness/tests/test_personalizer_access_document.py
Normal 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)
|
||||
61
harness/tests/test_personalizer_apdus.py
Normal file
61
harness/tests/test_personalizer_apdus.py
Normal 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
|
||||
70
harness/tests/test_personalizer_cli.py
Normal file
70
harness/tests/test_personalizer_cli.py
Normal 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
|
||||
55
harness/tests/test_personalizer_credentials.py
Normal file
55
harness/tests/test_personalizer_credentials.py
Normal 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())
|
||||
111
harness/tests/test_personalizer_orchestrator.py
Normal file
111
harness/tests/test_personalizer_orchestrator.py
Normal 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)
|
||||
72
harness/tests/test_reader_auth0.py
Normal file
72
harness/tests/test_reader_auth0.py
Normal 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])
|
||||
100
harness/tests/test_reader_auth1.py
Normal file
100
harness/tests/test_reader_auth1.py
Normal 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)
|
||||
92
harness/tests/test_reader_auth1_response.py
Normal file
92
harness/tests/test_reader_auth1_response.py
Normal 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)
|
||||
71
harness/tests/test_reader_cli.py
Normal file
71
harness/tests/test_reader_cli.py
Normal 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
|
||||
39
harness/tests/test_reader_crypto.py
Normal file
39
harness/tests/test_reader_crypto.py
Normal 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)
|
||||
78
harness/tests/test_reader_key_derivation.py
Normal file
78
harness/tests/test_reader_key_derivation.py
Normal 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)
|
||||
8
harness/tests/test_reader_session.py
Normal file
8
harness/tests/test_reader_session.py
Normal 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
|
||||
62
harness/tests/test_reader_tlv.py
Normal file
62
harness/tests/test_reader_tlv.py
Normal 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])
|
||||
101
harness/tests/test_reader_transaction_offline.py
Normal file
101
harness/tests/test_reader_transaction_offline.py
Normal 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
|
||||
)
|
||||
87
harness/tests/test_trust_header.py
Normal file
87
harness/tests/test_trust_header.py
Normal 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
|
||||
79
harness/tests/test_trustgen_cli.py
Normal file
79
harness/tests/test_trustgen_cli.py
Normal 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]
|
||||
Reference in New Issue
Block a user