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

@@ -215,7 +215,7 @@ class AliroAppletAuth1Test {
assertEquals(0x9000, r.getSW());
byte[] pt = ReaderSide.decryptAuth1Response(
reader.deriveExpeditedSKDevice(credentialEphemPubKey), r.getData());
reader.deriveExpeditedSKDevice(credentialEphemPubKey, (byte) 0x00), r.getData());
byte[] keySlot = TlvUtil.findTopLevel(pt, 0x4E);
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
* the procedure in §8.3.1.5 (HKDF-SHA-256) supersedes the X9.63 KDF in
* §8.3.1.4 — concretely, Kdh = HKDF(IKM=ECDH_x(ePriv, peerEPub),
* salt=transaction_identifier, info=∅, L=32). This test checks that
* deriveKdh produces exactly what the manual HKDF chain produces.
* Kdh is the session-key seed from Aliro §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
* X9.63 KDF reduces to:
* Kdh = SHA-256(ZAB || 0x00000001 || transaction_identifier)
* This test pins that exact construction.
*/
@Test
void deriveKdhMatchesManualHkdfChain() {
void deriveKdhMatchesX963OneShotSha256() throws Exception {
AliroCrypto crypto = new AliroCrypto();
KeyPair kp1 = freshP256();
@@ -236,20 +237,16 @@ class AliroCryptoTest {
(ECPrivateKey) kp1.getPrivate(),
pub2, (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,
"deriveKdh must equal HKDF(IKM=ECDH_x, salt=txnId, info=empty, L=32)");
// Manual X9.63 KDF reference: SHA-256(ZAB || 0x00000001 || txnId)
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
*/
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(
deriveExpeditedKeyMaterial(credentialEphemPubKey65), 32, 64);
deriveExpeditedKeyMaterial(credentialEphemPubKey65, auth1CmdParams), 32, 64);
}
/**
@@ -209,11 +215,26 @@ final class ReaderSide {
* URSK[128..160).
*/
byte[] deriveExpeditedKeyMaterial(byte[] credentialEphemPubKey65) {
return deriveExpeditedKeyMaterial(credentialEphemPubKey65, (byte) 0x01);
}
byte[] deriveExpeditedKeyMaterial(byte[] credentialEphemPubKey65, byte auth1CmdParams) {
byte[] zab = ecdhSharedX(
(ECPrivateKey) ephemeral.getPrivate(),
credentialEphemPubKey65);
byte[] kdh = hkdf(zab, transactionId, new byte[0], 32);
byte[] salt = buildSaltVolatile(credentialEphemPubKey65);
// Kdh per §8.3.1.4: X9.63 KDF -> for 32B output one SHA-256:
// 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);
return hkdf(kdh, salt, info, 160);
}
@@ -279,6 +300,14 @@ final class ReaderSide {
// ---------- helpers ----------
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();
try {
// 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();
out.write(toFixed32(re.getAffineX().toByteArray()));
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(PROPRIETARY_A5_TLV);
ECPoint cl = credentialLongTermPubX();
out.write(toFixed32(cl.getAffineX().toByteArray()));
} catch (java.io.IOException e) {
throw new RuntimeException(e);
}
return out.toByteArray();
}
/** Returns the credential's long-term public key as an ECPoint. Set via {@link #setCredentialLongTermPublic}. */
private ECPoint credentialLongTermPubX() {
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(). */
/** No-op since the salt no longer depends on the credential's long-term
* key. Kept so existing test call sites still link. */
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) {