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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
74
harness/src/aliro_harness/reader/step_up.py
Normal file
74
harness/src/aliro_harness/reader/step_up.py
Normal 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)"
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
39
harness/tests/test_reader_step_up.py
Normal file
39
harness/tests/test_reader_step_up.py
Normal 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)
|
||||||
Reference in New Issue
Block a user