feat(harness): aliro-bench-test --step-up + INSTALL.md install-recovery notes

Adds a PC/SC verification path for Step-Up Milestone 1 so M1 can be
validated against a personalized card without depending on Nucleo /
X-CUBE-ALIRO bring-up. Verdict run on J3R452 04555A4A0B2190 with M1
CAP installed: AUTH1 OK (3.4s), EXCHANGE 0xC9 OK, ENVELOPE 0xC3 OK
with response decrypting under StepUpSKDevice to the spec 0xA0 ack.

- crypto.py: derive_step_up_session_keys (HKDF parity with
  AliroCrypto.deriveStepUpSessionKeys)
- transaction.py: expose step_up_sk on TransactionResult
- step_up.py: verify_step_up_m1 -- SELECT 5502 + GCM-encrypted
  C9/C3 round-trip, IVs per StepUpApplet (0x00*8 || counter
  reader-side; 0x00*7 || 0x01 || counter device-side)
- cli.py: --step-up flag on aliro-bench-test
- tests: stdlib-RFC-5869 cross-check on the new KDF (122/122 green)
- INSTALL.md: fix multi-place PKG AID typo (missing 02 version byte),
  document partial-install recovery, document package-static
  credential store (aliro-personalize success !=> 5501/5502 installed)
This commit is contained in:
michael
2026-06-17 12:55:16 -07:00
parent 9189b41e7f
commit 9081990b0e
6 changed files with 173 additions and 7 deletions

View File

@@ -38,7 +38,7 @@ java -jar gp.jar --info # confirm reader sees the card
# Multi-applet CAPs: load the package once, then create each instance.
# --create needs the instance AID, the applet class AID (same as instance
# here), and the package AID (printed by --load).
PKG=A0000009094454414C49524F
PKG=A0000009094454414C49524F02
java -jar gp.jar --load applet/target/aliro-applet-j3r180.cap
java -jar gp.jar --create A000000909ACCE5501 --applet A000000909ACCE5501 --package $PKG
java -jar gp.jar --create A000000909ACCE5502 --applet A000000909ACCE5502 --package $PKG
@@ -50,6 +50,21 @@ specify the right one with --[applet]"`, and `--create AID` without
`--applet`/`--package` fails with `"Need --[package, pkg] and --[applet]
or --[cap]"`.)
**Partial install / recovery**: if a `--create` call fails mid-sequence
(card got reset, USB hiccup, RF dropout), the package stays loaded. Just
re-run the failed `--create` — no need to redo `--load` or the earlier
`--create` calls. The package only needs `--delete` + `--load` again if
the package itself is in a bad state.
**Credential store is package-static**: keys and the Access Document
written via `aliro-personalize` live on a static class shared by all
three applets. Personalization only requires `ACCE559901` to be
SELECTABLE; `5501` and `5502` can be `--create`d after personalize ran
and they'll see the same credentials. (Note: `aliro-personalize`
returning "Personalization complete" only confirms `559901` accepted the
writes — always cross-check `gp --list` to confirm `5501` and `5502`
are also SELECTABLE.)
Default GP test keys (`404142434445464748494A4B4C4D4E4F`) are tried
automatically. For non-default keys, pass `--key <hex>` to every command.
@@ -61,10 +76,10 @@ Three applets get registered, one CAP:
| `A0000009 09 ACCE 55 02` | `StepUpApplet` | Aliro STEP_UP phase (spec; scaffold only ATM)|
| `A0000009 09 ACCE 55 99 01` | `PersonalizationApplet`| DT-internal provisioning channel |
All three share the package AID `A0000009 09 4454414C49524F` (CSA RID +
ASCII "DTALIRO"). JavaCard requires applets in one CAP to share the
package's RID, hence the proprietary AID for personalization is also
under the CSA RID with a non-spec PIX (`99 01`).
All three share the package AID `A0000009 09 4454414C49524F 02` (CSA RID
+ ASCII "DTALIRO" + version byte). JavaCard requires applets in one CAP
to share the package's RID, hence the proprietary AID for personalization
is also under the CSA RID with a non-spec PIX (`99 01`).
## Verify
@@ -184,7 +199,7 @@ then the package:
java -jar gp.jar --delete A000000909ACCE5501
java -jar gp.jar --delete A000000909ACCE5502
java -jar gp.jar --delete A000000909ACCE559901
java -jar gp.jar --delete A0000009094454414C49524F
java -jar gp.jar --delete A0000009094454414C49524F02
```
Then `--load` + the three `--create` commands above to reinstall the

View File

@@ -12,6 +12,7 @@ from pathlib import Path
import click
from aliro_harness.reader.step_up import verify_step_up_m1
from aliro_harness.reader.transaction import TrustBundle, run_aliro_transaction
@@ -38,7 +39,16 @@ from aliro_harness.reader.transaction import TrustBundle, run_aliro_transaction
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:
@click.option(
"--step-up",
is_flag=True,
help="After AUTH1, also exercise Step-Up Milestone 1: SELECT ACCE5502, "
"INS=0xC9 EXCHANGE, INS=0xC3 ENVELOPE. Verifies the StepUpApplet decrypts "
"with StepUpSKReader and encrypts the empty-CBOR-map ack with StepUpSKDevice.",
)
def main(
trust_dir: Path | None, reader_index: int, list_readers: bool, step_up: 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."""
@@ -82,6 +92,9 @@ def main(trust_dir: Path | None, reader_index: int, list_readers: bool) -> None:
return bytes(data), (sw1 << 8) | sw2
result = run_aliro_transaction(transmit=transmit, bundle=bundle)
step_up_verdict: tuple[bool, str] | None = None
if step_up and result.ok and result.step_up_sk is not None:
step_up_verdict = verify_step_up_m1(transmit, result.step_up_sk)
finally:
connection.disconnect()
@@ -115,6 +128,14 @@ def main(trust_dir: Path | None, reader_index: int, list_readers: bool) -> None:
_echo_tlv_breakdown(result)
_echo_latencies(result)
if step_up_verdict is not None:
ok, msg = step_up_verdict
if ok:
click.echo(f"STEP-UP M1: OK \u2014 {msg}")
else:
click.echo(f"STEP-UP M1: FAIL \u2014 {msg}", err=True)
raise click.exceptions.Exit(1)
def _echo_tlv_breakdown(result) -> None:
cred_pub_len = len(result.credential_pub) if result.credential_pub else 0

View File

@@ -59,3 +59,16 @@ def derive_kdh(
h.update(b"\x00\x00\x00\x01") # counter = 1 (4 B BE)
h.update(transaction_id) # SharedInfo (16 B)
return h.digest() # 32 B
def derive_step_up_session_keys(step_up_sk: bytes) -> tuple[bytes, bytes]:
"""§8.4.3 (mdoc 9.1.1.5 with Aliro changes): HKDF-SHA-256 with IKM =
StepUpSK, empty salt, info = ``b"SKDevice"`` / ``b"SKReader"``, L = 32.
Returns ``(sk_device, sk_reader)``. Mirrors
``AliroCrypto.deriveStepUpSessionKeys`` byte-for-byte.
"""
if len(step_up_sk) != 32:
raise ValueError(f"StepUpSK must be 32B, got {len(step_up_sk)}")
sk_device = hkdf_sha256(step_up_sk, b"", b"SKDevice", 32)
sk_reader = hkdf_sha256(step_up_sk, b"", b"SKReader", 32)
return sk_device, sk_reader

View File

@@ -0,0 +1,74 @@
"""Step-Up Milestone 1 PC/SC verification.
Verifies the M1 applet path post-AUTH1:
- SELECT ACCE5502 (StepUpApplet)
- INS=0xC9 EXCHANGE encrypted with StepUpSKReader -> expect SW=9000, empty body
- INS=0xC3 ENVELOPE encrypted with StepUpSKReader -> expect SW=9000,
17B body that decrypts under StepUpSKDevice to single byte 0xA0.
IV layout per applet (StepUpApplet.processExchange / processEnvelope):
reader -> device : 0x00*8 || counter(4B BE)
device -> reader : 0x00*7 || 0x01 || counter(4B BE)
Both counters init to 1; each advances by 1 after use.
"""
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from aliro_harness.reader.crypto import derive_step_up_session_keys
from aliro_harness.reader.transaction import Transmit
STEP_UP_AID = bytes.fromhex("A000000909ACCE5502")
SW_OK = 0x9000
CLA_ISO = 0x00
CLA_PROPRIETARY = 0x80
INS_EXCHANGE = 0xC9
INS_ENVELOPE = 0xC3
def _iv_reader(counter: int) -> bytes:
return b"\x00" * 8 + counter.to_bytes(4, "big")
def _iv_device(counter: int) -> bytes:
return b"\x00" * 7 + b"\x01" + counter.to_bytes(4, "big")
def verify_step_up_m1(transmit: Transmit, step_up_sk: bytes) -> tuple[bool, str]:
sk_device, sk_reader = derive_step_up_session_keys(step_up_sk)
reader_counter = 1
device_counter = 1
# SELECT ACCE5502 -- M1A.2 derives StepUpSKReader/Device on this select().
apdu = bytes([CLA_ISO, 0xA4, 0x04, 0x00, len(STEP_UP_AID)]) + STEP_UP_AID
_, sw = transmit(apdu)
if sw != SW_OK:
return False, f"SELECT ACCE5502 failed: SW=0x{sw:04X}"
# M1B.1 -- EXCHANGE: any plaintext, expect 9000+empty.
pt = b"\x00" # one byte plaintext to exercise the decrypt path
ct = AESGCM(sk_reader).encrypt(_iv_reader(reader_counter), pt, None)
apdu = bytes([CLA_PROPRIETARY, INS_EXCHANGE, 0x00, 0x00, len(ct)]) + ct + b"\x00"
data, sw = transmit(apdu)
if sw != SW_OK:
return False, f"M1B.1 EXCHANGE failed: SW=0x{sw:04X}"
if len(data) != 0:
return False, f"M1B.1 EXCHANGE expected empty body, got {len(data)}B: {data.hex()}"
reader_counter += 1
# M1C.1 -- ENVELOPE: any plaintext, expect 17B response decrypting to 0xA0.
ct = AESGCM(sk_reader).encrypt(_iv_reader(reader_counter), pt, None)
apdu = bytes([CLA_ISO, INS_ENVELOPE, 0x00, 0x00, len(ct)]) + ct + b"\x00"
data, sw = transmit(apdu)
if sw != SW_OK:
return False, f"M1C.1 ENVELOPE failed: SW=0x{sw:04X}"
if len(data) != 17:
return False, f"M1C.1 ENVELOPE expected 17B response, got {len(data)}B"
try:
plaintext = AESGCM(sk_device).decrypt(_iv_device(device_counter), data, None)
except Exception as e:
return False, f"M1C.1 response decrypt failed (tag/key mismatch): {e}"
if plaintext != b"\xA0":
return False, f"M1C.1 response plaintext expected 0xA0, got {plaintext.hex()}"
return True, "M1 step-up verified (EXCHANGE+ENVELOPE round-trip)"

View File

@@ -31,6 +31,7 @@ from aliro_harness.reader.auth1_response import (
from aliro_harness.reader.crypto import derive_kdh
from aliro_harness.reader.key_derivation import (
OFF_EXPEDITED_SK_DEVICE,
OFF_STEP_UP_SK,
build_salt_volatile,
derive_expedited_session_keys,
)
@@ -123,6 +124,7 @@ class TransactionResult:
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)
step_up_sk: bytes | None = None # 32B StepUpSK from derived_keys_volatile[64..96)
error: str | None = None # populated on failure
# Wall-clock per-APDU latencies in milliseconds. Populated as each step
# runs, so a partial result on failure still surfaces what we measured
@@ -223,6 +225,7 @@ def run_aliro_transaction(
kdh, salt_volatile, cred_ephem_pub_x
)
sk_device = derived_keys[OFF_EXPEDITED_SK_DEVICE : OFF_EXPEDITED_SK_DEVICE + 32]
step_up_sk = derived_keys[OFF_STEP_UP_SK : OFF_STEP_UP_SK + 32]
# AUTH1
reader_id_32 = bundle.reader_group_id + bundle.reader_group_sub_id
@@ -301,5 +304,6 @@ def run_aliro_transaction(
credential_pub=cred_pub_5a,
ud_sig=ud_sig,
signaling_bitmap=bitmap,
step_up_sk=step_up_sk,
latencies_ms=latencies,
)

View File

@@ -0,0 +1,39 @@
"""Smoke test for step-up key derivation parity with the applet."""
import hashlib
import hmac
from aliro_harness.reader.crypto import derive_step_up_session_keys
def _hkdf_manual(ikm: bytes, salt: bytes, info: bytes, length: int) -> bytes:
"""Bare-stdlib RFC 5869 reference. Independent of `cryptography` so the
cross-check actually catches a wrong-info / wrong-salt regression in
derive_step_up_session_keys."""
use_salt = salt if salt else b"\x00" * 32
prk = hmac.new(use_salt, ikm, hashlib.sha256).digest()
out = b""
t = b""
counter = 1
while len(out) < length:
t = hmac.new(prk, t + info + bytes([counter]), hashlib.sha256).digest()
out += t
counter += 1
return out[:length]
def test_derive_step_up_session_keys_matches_rfc5869():
# Same IKM as AliroCryptoTest.deriveStepUpSessionKeysMatchesManualHkdf:
# bytes 0xC0..0xDF -- so this Python test pins the same vector the
# applet test pins.
step_up_sk = bytes(range(0xC0, 0xE0))
sk_device, sk_reader = derive_step_up_session_keys(step_up_sk)
assert len(sk_device) == 32
assert len(sk_reader) == 32
assert sk_device != sk_reader
assert sk_device != step_up_sk
assert sk_device == _hkdf_manual(step_up_sk, b"", b"SKDevice", 32)
assert sk_reader == _hkdf_manual(step_up_sk, b"", b"SKReader", 32)