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:
@@ -31,8 +31,9 @@ import javacard.security.Signature;
|
|||||||
public class AliroApplet extends Applet {
|
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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user