fix: AUTH1 crypto interop with stock X-CUBE-ALIRO + EXCHANGE compat stub
Three independent spec-misreads found via Path X investigation against
the X-CUBE-ALIRO vendor library, all causing
ACWG_Error_Crypto_EncryptDecrypt on the vendor's processAUTH1ResponsePayload.
Each was symmetric between this applet and our PC/SC reader, so
aliro-bench-test passed against our own host-side reader but failed
against any spec-compliant third-party reader. Path X also surfaced
an X-CUBE-ALIRO-specific compat shim (bitmap + EXCHANGE stub) which is
documented to be retired by the Step-Up Milestone 1 work.
1. salt_volatile dropped x(credential_long_term_pub) at the end.
§8.3.1.13 salt_volatile ends at the 0xA5 proprietary information TLV;
the credential key belongs in `info` (and even there it's the
EPHEMERAL one, which buildInfo already does correctly).
2. Kdh now uses X9.63 KDF per §8.3.1.4 instead of HKDF.
The §8.3.1.4 closing note ("actual key derivation is performed using
§8.3.1.5") refers to subsequent session-key derivation from Kdh
(§8.3.1.13 -> §8.3.1.5 HKDF), NOT a substitution for Kdh itself.
For 32-byte output X9.63 KDF reduces to:
Kdh = SHA-256(ZAB || 0x00000001 || transaction_identifier)
Added a native SHA-256 instance to AliroCrypto for this one-shot.
3. salt_volatile flag uses AUTH1's command_parameters, not AUTH0's.
§8.3.1.13 says "command_parameters || authentication_policy from the
command data field". When §8.3.1.13 runs (after AUTH1), the active
request is AUTH1; authentication_policy only exists in AUTH0 so it's
still pulled from saved AUTH0 state, but command_parameters is the
AUTH1 value (typically 0x01 = "request credential_PubK in response").
4. signaling_bitmap kept at 0x0005 when AD provisioned + INS_EXCHANGE
stub on AliroApplet returns 9000 with empty payload. Empirically the
X-CUBE-ALIRO vendor library errors on bitmap=0x0000 even though the
spec allows it (separate vendor quirk worth filing); EXCHANGE stub
exists because the firmware unconditionally sends 0xC9 post-AUTH1
for the Reader Status sub-event report. Both shims are documented to
be retired in Step-Up Milestone 1 -- StepUpApplet will handle 0xC9
on its own AID (ACCE5502) per §10.2.1 after the spec-mandated
step-up AID SELECT.
PC/SC bench-test still passes: AUTH1=9000, ~3.2 s, bitmap=0x0005.
Nucleo X-CUBE-ALIRO firmware now reports retval=ACWG_OK on
processAUTH1ResponsePayload (confirmed against j3r452 UID
04565E4A0B2190 in /tmp/nucleo-three-fixes.log). The remaining
"DOOR OPERATION FAILED" on Nucleo is downstream Step-Up not being
implemented yet -- StepUpApplet is still the scaffold and returns
6D00/6E00 to ENVELOPE / EXCHANGE. That's Milestone 1 work.
80/80 Java tests + 126/126 Python tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -36,7 +36,26 @@ def derive_kdh(
|
||||
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)."""
|
||||
"""§8.3.1.4: X9.63 KDF (BSI TR-03111) with H=SHA-256, ZAB = ECDH
|
||||
shared-secret x-coord, SharedInfo = transaction_identifier, K = 256 bits.
|
||||
For 32-byte output reduces to a single SHA-256:
|
||||
|
||||
Kdh = SHA-256(ZAB || 0x00000001 || transaction_identifier)
|
||||
|
||||
History: previously we used HKDF here, misreading the §8.3.1.4 note
|
||||
("actual key derivation is performed using §8.3.1.5") as authorizing
|
||||
HKDF substitution for Kdh itself. The note is about subsequent
|
||||
session-key derivation (§8.3.1.13 -> §8.3.1.5 HKDF), not Kdh. The
|
||||
misread was symmetric with the applet so AUTH1 succeeded against our
|
||||
own card but failed against ST's X-CUBE-ALIRO library (which follows
|
||||
§8.3.1.4 correctly) with ACWG_Error_Crypto_EncryptDecrypt. Fixed
|
||||
2026-06-11.
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
z_ab = ecdh_shared_x(reader_ephem_priv, credential_ephem_pub_uncompressed)
|
||||
return hkdf_sha256(z_ab, transaction_id, b"", 32)
|
||||
h = hashlib.sha256()
|
||||
h.update(z_ab) # ZAB (32 B)
|
||||
h.update(b"\x00\x00\x00\x01") # counter = 1 (4 B BE)
|
||||
h.update(transaction_id) # SharedInfo (16 B)
|
||||
return h.digest() # 32 B
|
||||
|
||||
@@ -29,9 +29,17 @@ def build_salt_volatile(
|
||||
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)."""
|
||||
"""Per spec §8.3.1.13. Mirrors AliroApplet.buildSaltVolatile.
|
||||
|
||||
Spec §8.3.1.13 salt_volatile ends at the 0xA5 proprietary TLV. We
|
||||
previously appended x(credential_long_term_pub_key) here; that was a
|
||||
misread of the spec (the credential pubkey belongs in `info`, and even
|
||||
there it's the *ephemeral* pubkey, not the long-term one). The misread
|
||||
was symmetric between this reader and the applet, so AUTH1 succeeded
|
||||
against our own card but failed against ST's X-CUBE-ALIRO with
|
||||
ACWG_Error_Crypto_EncryptDecrypt. Fixed 2026-06-11.
|
||||
"""
|
||||
if not (0 <= command_parameters <= 0xFF and 0 <= authentication_policy <= 0xFF):
|
||||
raise ValueError(
|
||||
"command_parameters and authentication_policy must each fit in one byte"
|
||||
@@ -48,11 +56,10 @@ def build_salt_volatile(
|
||||
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:
|
||||
if len(salt) != 141:
|
||||
raise ValueError(
|
||||
f"salt_volatile must be 173 bytes (caller passed wrong-length x-coord or ID); "
|
||||
f"salt_volatile must be 141 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
|
||||
|
||||
@@ -205,15 +205,19 @@ def run_aliro_transaction(
|
||||
|
||||
# Key derivation
|
||||
kdh = derive_kdh(reader_ephem, cred_ephem_pub_uncompressed, txn_id)
|
||||
# flag = command_parameters || authentication_policy per §8.3.1.13.
|
||||
# command_parameters here is the AUTH1 command's value (0x01 for
|
||||
# "request credential_PubK in response"), not AUTH0's. authentication_policy
|
||||
# only appears in AUTH0; v1 hardcodes 0x00 (no policy enforced).
|
||||
# See the AUTH1 build_auth1_data call below -- same value must round-trip.
|
||||
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,
|
||||
command_parameters=0x01,
|
||||
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
|
||||
|
||||
@@ -280,6 +280,11 @@ class FakeAliroCard:
|
||||
if not self._verify_reader_sig(table_812, reader_raw_sig):
|
||||
return b"", 0x6A80
|
||||
|
||||
# §8.3.1.13 flag uses the AUTH1 command_parameters (not AUTH0's).
|
||||
# Overwrite the AUTH0 value stored on the session so _derive_sk_device
|
||||
# builds salt_volatile with the right byte.
|
||||
self.session.command_parameters = cmd_params
|
||||
|
||||
# Derive session keys
|
||||
sk_device = self._derive_sk_device()
|
||||
|
||||
@@ -362,9 +367,8 @@ class FakeAliroCard:
|
||||
txn_id,
|
||||
)
|
||||
|
||||
# x-coords
|
||||
# x-coord for the reader long-term key
|
||||
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,
|
||||
@@ -374,7 +378,6 @@ class FakeAliroCard:
|
||||
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]
|
||||
|
||||
@@ -13,7 +13,6 @@ def test_salt_volatile_layout_per_spec_8_3_1_13():
|
||||
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)
|
||||
@@ -38,9 +37,10 @@ def test_salt_volatile_layout_per_spec_8_3_1_13():
|
||||
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
|
||||
# salt_volatile ends at the 0xA5 TLV per spec §8.3.1.13. The
|
||||
# credential_long_term_pub_x previously appended here was a misread of
|
||||
# the spec (caused vendor library ACWG_Error_Crypto_EncryptDecrypt).
|
||||
assert len(salt) == p == 141
|
||||
|
||||
|
||||
def test_salt_volatile_threads_flag_bytes_in_correct_order():
|
||||
@@ -53,7 +53,6 @@ def test_salt_volatile_threads_flag_bytes_in_correct_order():
|
||||
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
|
||||
@@ -62,7 +61,7 @@ def test_salt_volatile_threads_flag_bytes_in_correct_order():
|
||||
|
||||
def test_derive_expedited_session_keys_returns_160_bytes_deterministic():
|
||||
kdh = bytes.fromhex("11" * 32)
|
||||
salt = bytes.fromhex("22" * 173)
|
||||
salt = bytes.fromhex("22" * 141)
|
||||
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)
|
||||
@@ -72,7 +71,7 @@ def test_derive_expedited_session_keys_returns_160_bytes_deterministic():
|
||||
|
||||
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)
|
||||
salt_a = bytes.fromhex("22" * 141)
|
||||
salt_b = bytes.fromhex("23" + "22" * 140)
|
||||
info = bytes.fromhex("33" * 32)
|
||||
assert derive_expedited_session_keys(kdh, salt_a, info) != derive_expedited_session_keys(kdh, salt_b, info)
|
||||
|
||||
Reference in New Issue
Block a user