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 {
|
||||
|
||||
private static final byte CLA_EXPEDITED = (byte) 0x80;
|
||||
private static final byte INS_AUTH0 = (byte) 0x80;
|
||||
private static final byte INS_AUTH1 = (byte) 0x81;
|
||||
private static final byte INS_AUTH0 = (byte) 0x80;
|
||||
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.
|
||||
// DEV / PERFORMANCE-DEBUG ONLY. Set DIAGNOSTICS_ENABLED = false for any
|
||||
@@ -511,6 +512,34 @@ public class AliroApplet extends Applet {
|
||||
case INS_AUTH1:
|
||||
processAuth1(apdu);
|
||||
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_ECDH:
|
||||
case INS_DIAG_ECDSA_SIGN:
|
||||
@@ -651,8 +680,16 @@ public class AliroApplet extends Applet {
|
||||
* transaction_identifier 16B
|
||||
* flag 2B (auth0 cmd_params || authentication_policy)
|
||||
* proprietary_A5_TLV 10B (from SELECT FCI)
|
||||
* x(access_credential_pub_key) 32B (long-term; from CredentialStore)
|
||||
* </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) {
|
||||
short p = outOff;
|
||||
@@ -682,17 +719,23 @@ public class AliroApplet extends Applet {
|
||||
Util.arrayCopyNonAtomic(sessionState, OFF_TRANSACTION_ID, out, p, (short) 16);
|
||||
p += 16;
|
||||
|
||||
// flag = command_parameters || authentication_policy (both 1 byte, from AUTH0)
|
||||
out[p++] = sessionState[OFF_COMMAND_PARAMETERS];
|
||||
// flag = command_parameters || authentication_policy (1 byte each).
|
||||
// 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];
|
||||
|
||||
Util.arrayCopyNonAtomic(PROPRIETARY_A5_TLV, (short) 0, out, p, (short) 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);
|
||||
}
|
||||
|
||||
@@ -847,6 +890,19 @@ public class AliroApplet extends Applet {
|
||||
// 0x5E 0x02 [signaling_bitmap] — 16-bit big-endian. Bit 0: Access
|
||||
// Document retrievable. Bit 2: retrieval requires step-up AID SELECT
|
||||
// (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;
|
||||
if (store.hasAccessDocument()) {
|
||||
bitmap |= 0x0001; // bit 0
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.dangerousthings.aliro;
|
||||
|
||||
import javacard.security.ECPrivateKey;
|
||||
import javacard.security.KeyAgreement;
|
||||
import javacard.security.MessageDigest;
|
||||
|
||||
/**
|
||||
* Low-level Aliro cryptographic primitives, factored out so they can be
|
||||
@@ -30,6 +31,9 @@ final class AliroCrypto {
|
||||
private KeyAgreement ecdhPlain;
|
||||
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. */
|
||||
private byte[] hkdfPrevT;
|
||||
|
||||
@@ -59,6 +63,11 @@ final class AliroCrypto {
|
||||
} catch (Throwable t) {
|
||||
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 {
|
||||
hkdfPrevT = javacard.framework.JCSystem.makeTransientByteArray(
|
||||
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
|
||||
* noted in the spec): {@code Kdh = HKDF(IKM=ECDH_x(priv, peerPub),
|
||||
* salt=txnId, info=∅, L=32)}. Writes 32 bytes to {@code out[outOff..]}
|
||||
* and returns 32.
|
||||
* Derives Kdh per Aliro §8.3.1.4: <b>X9.63 KDF</b> from 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 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(
|
||||
ECPrivateKey priv,
|
||||
byte[] peerPubUncomp, short peerPubOff,
|
||||
byte[] txnId, short txnIdOff, short txnIdLen,
|
||||
byte[] out, short outOff) {
|
||||
// Stage ZAB || counter(0x00000001) at kdfWorkbuf[0..36).
|
||||
computeEcdhSharedX(priv, peerPubUncomp, peerPubOff, kdfWorkbuf, (short) 0);
|
||||
hkdfExtract(
|
||||
txnId, txnIdOff, txnIdLen,
|
||||
kdfWorkbuf, (short) 0, HASH_LEN,
|
||||
kdfWorkbuf, HASH_LEN);
|
||||
hkdfExpand(
|
||||
kdfWorkbuf, HASH_LEN, HASH_LEN,
|
||||
kdfWorkbuf, (short) 0, (short) 0,
|
||||
HASH_LEN,
|
||||
out, outOff);
|
||||
kdfWorkbuf[32] = 0;
|
||||
kdfWorkbuf[33] = 0;
|
||||
kdfWorkbuf[34] = 0;
|
||||
kdfWorkbuf[35] = 1;
|
||||
// Kdh = SHA-256(ZAB || counter || txnId) -- one shot.
|
||||
sha256.reset();
|
||||
sha256.update(kdfWorkbuf, (short) 0, (short) 36);
|
||||
sha256.doFinal(txnId, txnIdOff, txnIdLen, out, outOff);
|
||||
// Wipe ZAB from working memory.
|
||||
javacard.framework.Util.arrayFillNonAtomic(kdfWorkbuf, (short) 0, (short) 36, (byte) 0);
|
||||
return HASH_LEN;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user