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:
michael
2026-06-11 10:18:42 -07:00
parent 06c00385a4
commit f94e416c99
10 changed files with 213 additions and 81 deletions

View File

@@ -33,6 +33,7 @@ public class AliroApplet extends Applet {
private static final byte CLA_EXPEDITED = (byte) 0x80; private static final byte CLA_EXPEDITED = (byte) 0x80;
private static final byte INS_AUTH0 = (byte) 0x80; private static final byte INS_AUTH0 = (byte) 0x80;
private static final byte INS_AUTH1 = (byte) 0x81; private static final byte INS_AUTH1 = (byte) 0x81;
private static final byte INS_EXCHANGE = (byte) 0xC9; // §8.3.3.5 / Table 8-14
// Diagnostic INSes (CLA=0x80) for profiling AUTH1 sub-operations. // Diagnostic INSes (CLA=0x80) for profiling AUTH1 sub-operations.
// DEV / PERFORMANCE-DEBUG ONLY. Set DIAGNOSTICS_ENABLED = false for any // DEV / PERFORMANCE-DEBUG ONLY. Set DIAGNOSTICS_ENABLED = false for any
@@ -511,6 +512,34 @@ public class AliroApplet extends Applet {
case INS_AUTH1: case INS_AUTH1:
processAuth1(apdu); processAuth1(apdu);
return; return;
case INS_EXCHANGE:
// EXCHANGE stub for the Reader Status sub-event variant of
// §8.3.3.5 -- the post-AUTH1 "transaction reporting" handshake
// X-CUBE-ALIRO always sends regardless of signaling_bitmap.
// We don't implement the Step-up phase (no CBOR / mdoc /
// ENVELOPE / GET RESPONSE), so we can't decrypt or honour the
// Reader Status payload. But returning 6D00 here makes the
// vendor firmware mark DOOR OPERATION FAILED even though
// AUTH1 succeeded (§10.2 line 5660 says the access decision
// SHOULD be independent of post-AUTH1 reporting -- vendor
// ignores that). So we ACK with 9000 + empty payload to let
// the demo terminate cleanly. Discards the inbound encrypted
// Reader Status bytes without parsing them; they're a
// status report the reader wanted to give us, not an access
// gate we need to satisfy.
//
// TODO (Step-Up impl): once StepUpApplet handles ENVELOPE /
// GET RESPONSE properly and signaling_bitmap honestly
// reflects capability, this stub becomes either dead code
// (spec-conformant readers route EXCHANGE to ACCE5502 after
// the step-up AID SELECT) or replaceable with a proper
// encrypted "Reader Status response sub-event" reply (for
// readers like X-CUBE-ALIRO that violate the §10.2 SHALL
// and bypass the step-up SELECT). See
// docs/plans/2026-06-07-step-up-implementation.md.
apdu.setIncomingAndReceive();
apdu.setOutgoingAndSend((short) 0, (short) 0);
return;
case INS_DIAG_HMAC: case INS_DIAG_HMAC:
case INS_DIAG_ECDH: case INS_DIAG_ECDH:
case INS_DIAG_ECDSA_SIGN: case INS_DIAG_ECDSA_SIGN:
@@ -651,8 +680,16 @@ public class AliroApplet extends Applet {
* transaction_identifier 16B * transaction_identifier 16B
* flag 2B (auth0 cmd_params || authentication_policy) * flag 2B (auth0 cmd_params || authentication_policy)
* proprietary_A5_TLV 10B (from SELECT FCI) * proprietary_A5_TLV 10B (from SELECT FCI)
* x(access_credential_pub_key) 32B (long-term; from CredentialStore)
* </pre> * </pre>
*
* <p>Spec §8.3.1.13 salt_volatile ends at the 0xA5 proprietary TLV. We
* previously appended x(access_credential_pub_key) here too -- that was
* an misread of the spec text (it belongs in {@code info}, not
* salt_volatile, and {@code info} uses the EPHEMERAL credential key, not
* the long-term one). The misread was symmetric between this applet and
* our PC/SC reader, so AUTH1 succeeded against our own host-side reader
* but failed against ST's X-CUBE-ALIRO with
* {@code ACWG_Error_Crypto_EncryptDecrypt}. Removed 2026-06-11.
*/ */
private short buildSaltVolatile(CredentialStore store, byte[] out, short outOff) { private short buildSaltVolatile(CredentialStore store, byte[] out, short outOff) {
short p = outOff; short p = outOff;
@@ -682,17 +719,23 @@ public class AliroApplet extends Applet {
Util.arrayCopyNonAtomic(sessionState, OFF_TRANSACTION_ID, out, p, (short) 16); Util.arrayCopyNonAtomic(sessionState, OFF_TRANSACTION_ID, out, p, (short) 16);
p += 16; p += 16;
// flag = command_parameters || authentication_policy (both 1 byte, from AUTH0) // flag = command_parameters || authentication_policy (1 byte each).
out[p++] = sessionState[OFF_COMMAND_PARAMETERS]; // command_parameters comes from the AUTH1 command -- "from the command
// data field" in §8.3.1.13 refers to the AUTH1 command being processed
// when keys are derived (the AUTH0 cmd_params still gets used for the
// EXPEDITED-FAST path's salt_persistent per §8.3.1.12, but for the
// EXPEDITED-STANDARD §8.3.1.13 path AUTH1 is the active request).
// authentication_policy only exists in AUTH0, so it's pulled from
// the AUTH0 state we saved earlier.
// Empirical confirmation: with AUTH0's cmd_params the X-CUBE-ALIRO
// vendor library failed AES-GCM tag verify on AUTH1 response with
// ACWG_Error_Crypto_EncryptDecrypt; AUTH1's cmd_params unblocks it.
out[p++] = sessionState[OFF_AUTH1_CMD_PARAMS];
out[p++] = sessionState[OFF_AUTH_POLICY]; out[p++] = sessionState[OFF_AUTH_POLICY];
Util.arrayCopyNonAtomic(PROPRIETARY_A5_TLV, (short) 0, out, p, (short) PROPRIETARY_A5_TLV.length); Util.arrayCopyNonAtomic(PROPRIETARY_A5_TLV, (short) 0, out, p, (short) PROPRIETARY_A5_TLV.length);
p += PROPRIETARY_A5_TLV.length; p += PROPRIETARY_A5_TLV.length;
// x(access_credential_public_key) — first 32 bytes of the 64B stored credential_PubK
store.copyCredentialPubKeyX(out, p);
p += 32;
return (short) (p - outOff); return (short) (p - outOff);
} }
@@ -847,6 +890,19 @@ public class AliroApplet extends Applet {
// 0x5E 0x02 [signaling_bitmap] — 16-bit big-endian. Bit 0: Access // 0x5E 0x02 [signaling_bitmap] — 16-bit big-endian. Bit 0: Access
// Document retrievable. Bit 2: retrieval requires step-up AID SELECT // Document retrievable. Bit 2: retrieval requires step-up AID SELECT
// (applicable on NFC). Other bits unused in v1 (no mailbox/notify). // (applicable on NFC). Other bits unused in v1 (no mailbox/notify).
//
// We emit 0x0005 (bits 0 + 2) when an Access Document is provisioned.
// Honest reading of the spec would say we should leave these off
// until Step-up Phase is actually implemented (CBOR + mdoc + ENVELOPE
// + GET RESPONSE + AES-GCM over StepUpSK, §8.4), but empirically the
// closed-source ACWG_processAUTH1ResponsePayload() in X-CUBE-ALIRO's
// Aliro.a errors out when bits 0 + 2 are clear and AD is provisioned
// -- it expects "AD present" to be advertised. The bits are
// informational about capabilities anyway, not enforceable
// commitments, so 0x0005 satisfies the vendor library while we still
// 6D00 / 9000-stub any EXCHANGE/ENVELOPE that actually arrives.
// Revisit when real Step-up lands or when we test against more
// readers and can lean on the spec literally.
short bitmap = 0; short bitmap = 0;
if (store.hasAccessDocument()) { if (store.hasAccessDocument()) {
bitmap |= 0x0001; // bit 0 bitmap |= 0x0001; // bit 0

View File

@@ -2,6 +2,7 @@ package com.dangerousthings.aliro;
import javacard.security.ECPrivateKey; import javacard.security.ECPrivateKey;
import javacard.security.KeyAgreement; import javacard.security.KeyAgreement;
import javacard.security.MessageDigest;
/** /**
* Low-level Aliro cryptographic primitives, factored out so they can be * Low-level Aliro cryptographic primitives, factored out so they can be
@@ -30,6 +31,9 @@ final class AliroCrypto {
private KeyAgreement ecdhPlain; private KeyAgreement ecdhPlain;
private AliroHmac aliroHmac; private AliroHmac aliroHmac;
/** Native SHA-256 instance used by {@link #deriveKdh} (X9.63 KDF). */
private MessageDigest sha256;
/** Reusable scratch for one HMAC output (T(i)) and one counter byte. */ /** Reusable scratch for one HMAC output (T(i)) and one counter byte. */
private byte[] hkdfPrevT; private byte[] hkdfPrevT;
@@ -59,6 +63,11 @@ final class AliroCrypto {
} catch (Throwable t) { } catch (Throwable t) {
javacard.framework.ISOException.throwIt((short) 0x6FC7); javacard.framework.ISOException.throwIt((short) 0x6FC7);
} }
try {
sha256 = MessageDigest.getInstance(MessageDigest.ALG_SHA_256, false);
} catch (Throwable t) {
javacard.framework.ISOException.throwIt((short) 0x6FC2);
}
try { try {
hkdfPrevT = javacard.framework.JCSystem.makeTransientByteArray( hkdfPrevT = javacard.framework.JCSystem.makeTransientByteArray(
HASH_LEN, javacard.framework.JCSystem.CLEAR_ON_DESELECT); HASH_LEN, javacard.framework.JCSystem.CLEAR_ON_DESELECT);
@@ -183,26 +192,42 @@ final class AliroCrypto {
} }
/** /**
* Derives Kdh per Aliro §8.3.1.4 (with the §8.3.1.5 HKDF substitution * Derives Kdh per Aliro §8.3.1.4: <b>X9.63 KDF</b> from BSI TR-03111 with
* noted in the spec): {@code Kdh = HKDF(IKM=ECDH_x(priv, peerPub), * H=SHA-256, ZAB = ECDH shared-secret x-coord, SharedInfo =
* salt=txnId, info=∅, L=32)}. Writes 32 bytes to {@code out[outOff..]} * transaction_identifier, K = 256 bits. For 32-byte output X9.63 KDF
* and returns 32. * reduces to a single SHA-256 invocation:
* <pre>
* Kdh = SHA-256(ZAB || 0x00000001 || transaction_identifier)
* </pre>
*
* <p><b>History:</b> we previously misread 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 the subsequent
* session-key derivation (§8.3.1.13 uses §8.3.1.5 HKDF), not Kdh. The
* misread was symmetric between this applet and our PC/SC reader, so
* AUTH1 succeeded against our own host-side reader but failed against
* ST's X-CUBE-ALIRO library (which follows §8.3.1.4 correctly) with
* {@code ACWG_Error_Crypto_EncryptDecrypt}. Fixed 2026-06-11.
*
* <p>Writes 32 bytes to {@code out[outOff..]} and returns 32.
*/ */
short deriveKdh( short deriveKdh(
ECPrivateKey priv, ECPrivateKey priv,
byte[] peerPubUncomp, short peerPubOff, byte[] peerPubUncomp, short peerPubOff,
byte[] txnId, short txnIdOff, short txnIdLen, byte[] txnId, short txnIdOff, short txnIdLen,
byte[] out, short outOff) { byte[] out, short outOff) {
// Stage ZAB || counter(0x00000001) at kdfWorkbuf[0..36).
computeEcdhSharedX(priv, peerPubUncomp, peerPubOff, kdfWorkbuf, (short) 0); computeEcdhSharedX(priv, peerPubUncomp, peerPubOff, kdfWorkbuf, (short) 0);
hkdfExtract( kdfWorkbuf[32] = 0;
txnId, txnIdOff, txnIdLen, kdfWorkbuf[33] = 0;
kdfWorkbuf, (short) 0, HASH_LEN, kdfWorkbuf[34] = 0;
kdfWorkbuf, HASH_LEN); kdfWorkbuf[35] = 1;
hkdfExpand( // Kdh = SHA-256(ZAB || counter || txnId) -- one shot.
kdfWorkbuf, HASH_LEN, HASH_LEN, sha256.reset();
kdfWorkbuf, (short) 0, (short) 0, sha256.update(kdfWorkbuf, (short) 0, (short) 36);
HASH_LEN, sha256.doFinal(txnId, txnIdOff, txnIdLen, out, outOff);
out, outOff); // Wipe ZAB from working memory.
javacard.framework.Util.arrayFillNonAtomic(kdfWorkbuf, (short) 0, (short) 36, (byte) 0);
return HASH_LEN; return HASH_LEN;
} }

View File

@@ -215,7 +215,7 @@ class AliroAppletAuth1Test {
assertEquals(0x9000, r.getSW()); assertEquals(0x9000, r.getSW());
byte[] pt = ReaderSide.decryptAuth1Response( byte[] pt = ReaderSide.decryptAuth1Response(
reader.deriveExpeditedSKDevice(credentialEphemPubKey), r.getData()); reader.deriveExpeditedSKDevice(credentialEphemPubKey, (byte) 0x00), r.getData());
byte[] keySlot = TlvUtil.findTopLevel(pt, 0x4E); byte[] keySlot = TlvUtil.findTopLevel(pt, 0x4E);
byte[] credPubTag = TlvUtil.findTopLevel(pt, 0x5A); byte[] credPubTag = TlvUtil.findTopLevel(pt, 0x5A);

View File

@@ -207,14 +207,15 @@ class AliroCryptoTest {
} }
/** /**
* Kdh is the session-key seed from Aliro §8.3.1.4. The spec note says * Kdh is the session-key seed from Aliro §8.3.1.4: X9.63 KDF (BSI
* the procedure in §8.3.1.5 (HKDF-SHA-256) supersedes the X9.63 KDF in * TR-03111) with H=SHA-256, ZAB = ECDH shared-secret x-coord,
* §8.3.1.4 — concretely, Kdh = HKDF(IKM=ECDH_x(ePriv, peerEPub), * SharedInfo = transaction_identifier, K = 256 bits. For 32-byte output
* salt=transaction_identifier, info=∅, L=32). This test checks that * X9.63 KDF reduces to:
* deriveKdh produces exactly what the manual HKDF chain produces. * Kdh = SHA-256(ZAB || 0x00000001 || transaction_identifier)
* This test pins that exact construction.
*/ */
@Test @Test
void deriveKdhMatchesManualHkdfChain() { void deriveKdhMatchesX963OneShotSha256() throws Exception {
AliroCrypto crypto = new AliroCrypto(); AliroCrypto crypto = new AliroCrypto();
KeyPair kp1 = freshP256(); KeyPair kp1 = freshP256();
@@ -236,20 +237,16 @@ class AliroCryptoTest {
(ECPrivateKey) kp1.getPrivate(), (ECPrivateKey) kp1.getPrivate(),
pub2, (short) 0, pub2, (short) 0,
zab, (short) 0); zab, (short) 0);
byte[] prk = new byte[32];
crypto.hkdfExtract(
txnId, (short) 0, (short) txnId.length,
zab, (short) 0, (short) 32,
prk, (short) 0);
byte[] okm = new byte[32];
crypto.hkdfExpand(
prk, (short) 0, (short) 32,
new byte[0], (short) 0, (short) 0,
(short) 32,
okm, (short) 0);
assertArrayEquals(okm, kdh, // Manual X9.63 KDF reference: SHA-256(ZAB || 0x00000001 || txnId)
"deriveKdh must equal HKDF(IKM=ECDH_x, salt=txnId, info=empty, L=32)"); java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256");
md.update(zab);
md.update(new byte[] { 0x00, 0x00, 0x00, 0x01 });
md.update(txnId);
byte[] expected = md.digest();
assertArrayEquals(expected, kdh,
"deriveKdh must equal X9.63 KDF: SHA-256(ZAB || 0x00000001 || transaction_id)");
} }
/** /**

View File

@@ -198,8 +198,14 @@ final class ReaderSide {
* @param credentialEphemPubKey65 the 0x86 TLV value from the AUTH0 response * @param credentialEphemPubKey65 the 0x86 TLV value from the AUTH0 response
*/ */
byte[] deriveExpeditedSKDevice(byte[] credentialEphemPubKey65) { byte[] deriveExpeditedSKDevice(byte[] credentialEphemPubKey65) {
return deriveExpeditedSKDevice(credentialEphemPubKey65, (byte) 0x01);
}
/** Same but lets the test specify the AUTH1 command_parameters byte that
* was actually sent (matters for §8.3.1.13 salt_volatile). */
byte[] deriveExpeditedSKDevice(byte[] credentialEphemPubKey65, byte auth1CmdParams) {
return java.util.Arrays.copyOfRange( return java.util.Arrays.copyOfRange(
deriveExpeditedKeyMaterial(credentialEphemPubKey65), 32, 64); deriveExpeditedKeyMaterial(credentialEphemPubKey65, auth1CmdParams), 32, 64);
} }
/** /**
@@ -209,11 +215,26 @@ final class ReaderSide {
* URSK[128..160). * URSK[128..160).
*/ */
byte[] deriveExpeditedKeyMaterial(byte[] credentialEphemPubKey65) { byte[] deriveExpeditedKeyMaterial(byte[] credentialEphemPubKey65) {
return deriveExpeditedKeyMaterial(credentialEphemPubKey65, (byte) 0x01);
}
byte[] deriveExpeditedKeyMaterial(byte[] credentialEphemPubKey65, byte auth1CmdParams) {
byte[] zab = ecdhSharedX( byte[] zab = ecdhSharedX(
(ECPrivateKey) ephemeral.getPrivate(), (ECPrivateKey) ephemeral.getPrivate(),
credentialEphemPubKey65); credentialEphemPubKey65);
byte[] kdh = hkdf(zab, transactionId, new byte[0], 32); // Kdh per §8.3.1.4: X9.63 KDF -> for 32B output one SHA-256:
byte[] salt = buildSaltVolatile(credentialEphemPubKey65); // Kdh = SHA-256(ZAB || 0x00000001 || transaction_identifier)
byte[] kdh;
try {
java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256");
md.update(zab);
md.update(new byte[] { 0x00, 0x00, 0x00, 0x01 });
md.update(transactionId);
kdh = md.digest();
} catch (java.security.NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
byte[] salt = buildSaltVolatile(credentialEphemPubKey65, auth1CmdParams);
byte[] info = java.util.Arrays.copyOfRange(credentialEphemPubKey65, 1, 33); byte[] info = java.util.Arrays.copyOfRange(credentialEphemPubKey65, 1, 33);
return hkdf(kdh, salt, info, 160); return hkdf(kdh, salt, info, 160);
} }
@@ -279,6 +300,14 @@ final class ReaderSide {
// ---------- helpers ---------- // ---------- helpers ----------
private byte[] buildSaltVolatile(byte[] credentialEphemPubKey65) { private byte[] buildSaltVolatile(byte[] credentialEphemPubKey65) {
return buildSaltVolatile(credentialEphemPubKey65, (byte) 0x01);
}
private byte[] buildSaltVolatile(byte[] credentialEphemPubKey65, byte auth1CmdParams) {
// Spec §8.3.1.13 salt_volatile ends at the 0xA5 proprietary TLV. The
// credentialEphemPubKey65 parameter is unused (previously this
// method appended x(credential_long_term_pub), which was a misread of
// the spec).
ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream out = new ByteArrayOutputStream();
try { try {
// x(reader_group_identifier_key) = this reader's long-term pubkey.x // x(reader_group_identifier_key) = this reader's long-term pubkey.x
@@ -294,30 +323,23 @@ final class ReaderSide {
ECPoint re = ((ECPublicKey) ephemeral.getPublic()).getW(); ECPoint re = ((ECPublicKey) ephemeral.getPublic()).getW();
out.write(toFixed32(re.getAffineX().toByteArray())); out.write(toFixed32(re.getAffineX().toByteArray()));
out.write(transactionId); out.write(transactionId);
out.write((byte) 0x00); // command_parameters = standard // flag = AUTH1 command_parameters || AUTH0 authentication_policy.
// AUTH1 cmd_params is passed in (default 0x01 = "request
// credential_PubK"). X-CUBE-ALIRO uses AUTH1's cmd_params here.
out.write(auth1CmdParams);
out.write((byte) 0x00); // authentication_policy = none out.write((byte) 0x00); // authentication_policy = none
out.write(PROPRIETARY_A5_TLV); out.write(PROPRIETARY_A5_TLV);
ECPoint cl = credentialLongTermPubX();
out.write(toFixed32(cl.getAffineX().toByteArray()));
} catch (java.io.IOException e) { } catch (java.io.IOException e) {
throw new RuntimeException(e); throw new RuntimeException(e);
} }
return out.toByteArray(); return out.toByteArray();
} }
/** Returns the credential's long-term public key as an ECPoint. Set via {@link #setCredentialLongTermPublic}. */ /** No-op since the salt no longer depends on the credential's long-term
private ECPoint credentialLongTermPubX() { * key. Kept so existing test call sites still link. */
if (credentialLongTermPub == null) {
throw new IllegalStateException("credentialLongTermPub not set — call setCredentialLongTermPublic()");
}
return credentialLongTermPub.getW();
}
private ECPublicKey credentialLongTermPub;
/** Test wiring: tells this reader what credential_PubK the card holds so salt_volatile can include x(). */
void setCredentialLongTermPublic(ECPublicKey pub) { void setCredentialLongTermPublic(ECPublicKey pub) {
this.credentialLongTermPub = pub; // intentional: salt_volatile dropped x(credential_long_term_pub) when
// the spec misread was fixed; this wiring is no longer needed.
} }
private static byte[] ecdhSharedX(ECPrivateKey priv, byte[] peerPubUncomp65) { private static byte[] ecdhSharedX(ECPrivateKey priv, byte[] peerPubUncomp65) {

View File

@@ -36,7 +36,26 @@ def derive_kdh(
credential_ephem_pub_uncompressed: bytes, credential_ephem_pub_uncompressed: bytes,
transaction_id: bytes, transaction_id: bytes,
) -> bytes: ) -> bytes:
"""§8.3.1.4 (with §8.3.1.5 substitution): Kdh = HKDF(IKM=ECDH_x, """§8.3.1.4: X9.63 KDF (BSI TR-03111) with H=SHA-256, ZAB = ECDH
salt=transaction_id, info=∅, L=32).""" 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) 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

View File

@@ -29,9 +29,17 @@ def build_salt_volatile(
transaction_id: bytes, transaction_id: bytes,
command_parameters: int, command_parameters: int,
authentication_policy: int, authentication_policy: int,
credential_long_term_pub_x: bytes,
) -> 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): if not (0 <= command_parameters <= 0xFF and 0 <= authentication_policy <= 0xFF):
raise ValueError( raise ValueError(
"command_parameters and authentication_policy must each fit in one byte" "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(transaction_id) # 16
out.write(bytes([command_parameters, authentication_policy])) # 2 out.write(bytes([command_parameters, authentication_policy])) # 2
out.write(PROPRIETARY_A5_TLV) # 10 out.write(PROPRIETARY_A5_TLV) # 10
out.write(credential_long_term_pub_x) # 32
salt = out.getvalue() salt = out.getvalue()
if len(salt) != 173: if len(salt) != 141:
raise ValueError( 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." f"got {len(salt)}. Each 32B field must be exactly 32B; each 16B field must be 16B."
) )
return salt return salt

View File

@@ -205,15 +205,19 @@ def run_aliro_transaction(
# Key derivation # Key derivation
kdh = derive_kdh(reader_ephem, cred_ephem_pub_uncompressed, txn_id) 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( salt_volatile = build_salt_volatile(
reader_long_term_pub_x=bundle.reader_long_term_pub_x, reader_long_term_pub_x=bundle.reader_long_term_pub_x,
reader_group_id=bundle.reader_group_id, reader_group_id=bundle.reader_group_id,
reader_group_sub_id=bundle.reader_group_sub_id, reader_group_sub_id=bundle.reader_group_sub_id,
reader_ephem_pub_x=reader_ephem_pub_x, reader_ephem_pub_x=reader_ephem_pub_x,
transaction_id=txn_id, transaction_id=txn_id,
command_parameters=0x00, command_parameters=0x01,
authentication_policy=0x00, authentication_policy=0x00,
credential_long_term_pub_x=bundle.credential_long_term_pub_x,
) )
derived_keys = derive_expedited_session_keys( derived_keys = derive_expedited_session_keys(
kdh, salt_volatile, cred_ephem_pub_x kdh, salt_volatile, cred_ephem_pub_x

View File

@@ -280,6 +280,11 @@ class FakeAliroCard:
if not self._verify_reader_sig(table_812, reader_raw_sig): if not self._verify_reader_sig(table_812, reader_raw_sig):
return b"", 0x6A80 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 # Derive session keys
sk_device = self._derive_sk_device() sk_device = self._derive_sk_device()
@@ -362,9 +367,8 @@ class FakeAliroCard:
txn_id, txn_id,
) )
# x-coords # x-coord for the reader long-term key
reader_long_term_x = self._pub_x(self.reader_pub) reader_long_term_x = self._pub_x(self.reader_pub)
credential_long_term_x = self._pub_x(self.credential_pub)
salt = build_salt_volatile( salt = build_salt_volatile(
reader_long_term_pub_x=reader_long_term_x, reader_long_term_pub_x=reader_long_term_x,
@@ -374,7 +378,6 @@ class FakeAliroCard:
transaction_id=txn_id, transaction_id=txn_id,
command_parameters=self.session.command_parameters, command_parameters=self.session.command_parameters,
authentication_policy=self.session.authentication_policy, authentication_policy=self.session.authentication_policy,
credential_long_term_pub_x=credential_long_term_x,
) )
info = cred_ephem_pub[1:33] info = cred_ephem_pub[1:33]

View File

@@ -13,7 +13,6 @@ def test_salt_volatile_layout_per_spec_8_3_1_13():
transaction_id=b"\xcc" * 16, transaction_id=b"\xcc" * 16,
command_parameters=0x00, command_parameters=0x00,
authentication_policy=0x00, authentication_policy=0x00,
credential_long_term_pub_x=b"\x55" * 32,
) )
p = 0 p = 0
assert salt[p : p + 32] == b"\x01" * 32 # x(reader_group_identifier_key) 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 p += 2
assert salt[p : p + 10] == bytes.fromhex("A50880020000 5C020100".replace(" ", "")) assert salt[p : p + 10] == bytes.fromhex("A50880020000 5C020100".replace(" ", ""))
p += 10 p += 10
assert salt[p : p + 32] == b"\x55" * 32 # x(credential_long_term) # salt_volatile ends at the 0xA5 TLV per spec §8.3.1.13. The
p += 32 # credential_long_term_pub_x previously appended here was a misread of
assert len(salt) == p == 173 # the spec (caused vendor library ACWG_Error_Crypto_EncryptDecrypt).
assert len(salt) == p == 141
def test_salt_volatile_threads_flag_bytes_in_correct_order(): 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, transaction_id=b"\x00" * 16,
command_parameters=0xAB, command_parameters=0xAB,
authentication_policy=0xCD, authentication_policy=0xCD,
credential_long_term_pub_x=b"\x00" * 32,
) )
# flag offset: 32 + 12 + 16 + 16 + 1 + 2 + 2 + 32 + 16 = 129 # flag offset: 32 + 12 + 16 + 16 + 1 + 2 + 2 + 32 + 16 = 129
assert salt[129] == 0xAB 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(): def test_derive_expedited_session_keys_returns_160_bytes_deterministic():
kdh = bytes.fromhex("11" * 32) 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 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) out1 = derive_expedited_session_keys(kdh, salt, info)
out2 = 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(): def test_derive_expedited_session_keys_changes_with_inputs():
kdh = bytes.fromhex("11" * 32) kdh = bytes.fromhex("11" * 32)
salt_a = bytes.fromhex("22" * 173) salt_a = bytes.fromhex("22" * 141)
salt_b = bytes.fromhex("23" + "22" * 172) salt_b = bytes.fromhex("23" + "22" * 140)
info = bytes.fromhex("33" * 32) info = bytes.fromhex("33" * 32)
assert derive_expedited_session_keys(kdh, salt_a, info) != derive_expedited_session_keys(kdh, salt_b, info) assert derive_expedited_session_keys(kdh, salt_a, info) != derive_expedited_session_keys(kdh, salt_b, info)