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. # Multi-applet CAPs: load the package once, then create each instance.
# --create needs the instance AID, the applet class AID (same as instance # --create needs the instance AID, the applet class AID (same as instance
# here), and the package AID (printed by --load). # 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 --load applet/target/aliro-applet-j3r180.cap
java -jar gp.jar --create A000000909ACCE5501 --applet A000000909ACCE5501 --package $PKG java -jar gp.jar --create A000000909ACCE5501 --applet A000000909ACCE5501 --package $PKG
java -jar gp.jar --create A000000909ACCE5502 --applet A000000909ACCE5502 --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] `--applet`/`--package` fails with `"Need --[package, pkg] and --[applet]
or --[cap]"`.) 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 Default GP test keys (`404142434445464748494A4B4C4D4E4F`) are tried
automatically. For non-default keys, pass `--key <hex>` to every command. 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 02` | `StepUpApplet` | Aliro STEP_UP phase (spec; scaffold only ATM)|
| `A0000009 09 ACCE 55 99 01` | `PersonalizationApplet`| DT-internal provisioning channel | | `A0000009 09 ACCE 55 99 01` | `PersonalizationApplet`| DT-internal provisioning channel |
All three share the package AID `A0000009 09 4454414C49524F` (CSA RID + All three share the package AID `A0000009 09 4454414C49524F 02` (CSA RID
ASCII "DTALIRO"). JavaCard requires applets in one CAP to share the + ASCII "DTALIRO" + version byte). JavaCard requires applets in one CAP
package's RID, hence the proprietary AID for personalization is also to share the package's RID, hence the proprietary AID for personalization
under the CSA RID with a non-spec PIX (`99 01`). is also under the CSA RID with a non-spec PIX (`99 01`).
## Verify ## Verify
@@ -184,7 +199,7 @@ then the package:
java -jar gp.jar --delete A000000909ACCE5501 java -jar gp.jar --delete A000000909ACCE5501
java -jar gp.jar --delete A000000909ACCE5502 java -jar gp.jar --delete A000000909ACCE5502
java -jar gp.jar --delete A000000909ACCE559901 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 Then `--load` + the three `--create` commands above to reinstall the

View File

@@ -12,6 +12,7 @@ from pathlib import Path
import click import click
from aliro_harness.reader.step_up import verify_step_up_m1
from aliro_harness.reader.transaction import TrustBundle, run_aliro_transaction 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, is_flag=True,
help="List PC/SC readers visible to the host and exit.", 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 """Run a single Aliro AUTH0+AUTH1 transaction against a personalized
card via PC/SC. Decrypts the response and verifies the UD signature. 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.""" 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 return bytes(data), (sw1 << 8) | sw2
result = run_aliro_transaction(transmit=transmit, bundle=bundle) 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: finally:
connection.disconnect() connection.disconnect()
@@ -115,6 +128,14 @@ def main(trust_dir: Path | None, reader_index: int, list_readers: bool) -> None:
_echo_tlv_breakdown(result) _echo_tlv_breakdown(result)
_echo_latencies(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: def _echo_tlv_breakdown(result) -> None:
cred_pub_len = len(result.credential_pub) if result.credential_pub else 0 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(b"\x00\x00\x00\x01") # counter = 1 (4 B BE)
h.update(transaction_id) # SharedInfo (16 B) h.update(transaction_id) # SharedInfo (16 B)
return h.digest() # 32 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.crypto import derive_kdh
from aliro_harness.reader.key_derivation import ( from aliro_harness.reader.key_derivation import (
OFF_EXPEDITED_SK_DEVICE, OFF_EXPEDITED_SK_DEVICE,
OFF_STEP_UP_SK,
build_salt_volatile, build_salt_volatile,
derive_expedited_session_keys, derive_expedited_session_keys,
) )
@@ -123,6 +124,7 @@ class TransactionResult:
credential_pub: bytes | None = None # 0x5A value (65B uncompressed) when present credential_pub: bytes | None = None # 0x5A value (65B uncompressed) when present
ud_sig: bytes | None = None # 0x9E value (64B raw r||s) ud_sig: bytes | None = None # 0x9E value (64B raw r||s)
signaling_bitmap: bytes | None = None # 0x5E value (2B) 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 error: str | None = None # populated on failure
# Wall-clock per-APDU latencies in milliseconds. Populated as each step # Wall-clock per-APDU latencies in milliseconds. Populated as each step
# runs, so a partial result on failure still surfaces what we measured # 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 kdh, salt_volatile, cred_ephem_pub_x
) )
sk_device = derived_keys[OFF_EXPEDITED_SK_DEVICE : OFF_EXPEDITED_SK_DEVICE + 32] 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 # AUTH1
reader_id_32 = bundle.reader_group_id + bundle.reader_group_sub_id 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, credential_pub=cred_pub_5a,
ud_sig=ud_sig, ud_sig=ud_sig,
signaling_bitmap=bitmap, signaling_bitmap=bitmap,
step_up_sk=step_up_sk,
latencies_ms=latencies, 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)