diff --git a/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java b/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java index 39d533d..87412df 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java +++ b/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java @@ -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) * + * + *
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 diff --git a/applet/src/main/java/com/dangerousthings/aliro/AliroCrypto.java b/applet/src/main/java/com/dangerousthings/aliro/AliroCrypto.java index 234d8bb..c9c18fa 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/AliroCrypto.java +++ b/applet/src/main/java/com/dangerousthings/aliro/AliroCrypto.java @@ -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: X9.63 KDF 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: + *
+ * Kdh = SHA-256(ZAB || 0x00000001 || transaction_identifier) + *+ * + *
History: 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. + * + *
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; } diff --git a/applet/src/test/java/com/dangerousthings/aliro/AliroAppletAuth1Test.java b/applet/src/test/java/com/dangerousthings/aliro/AliroAppletAuth1Test.java index 6a88627..56d7a0a 100644 --- a/applet/src/test/java/com/dangerousthings/aliro/AliroAppletAuth1Test.java +++ b/applet/src/test/java/com/dangerousthings/aliro/AliroAppletAuth1Test.java @@ -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); diff --git a/applet/src/test/java/com/dangerousthings/aliro/AliroCryptoTest.java b/applet/src/test/java/com/dangerousthings/aliro/AliroCryptoTest.java index 8bb8b07..5bedbfc 100644 --- a/applet/src/test/java/com/dangerousthings/aliro/AliroCryptoTest.java +++ b/applet/src/test/java/com/dangerousthings/aliro/AliroCryptoTest.java @@ -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)"); } /** diff --git a/applet/src/test/java/com/dangerousthings/aliro/ReaderSide.java b/applet/src/test/java/com/dangerousthings/aliro/ReaderSide.java index bf662b2..0e8ac4a 100644 --- a/applet/src/test/java/com/dangerousthings/aliro/ReaderSide.java +++ b/applet/src/test/java/com/dangerousthings/aliro/ReaderSide.java @@ -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) { diff --git a/harness/src/aliro_harness/reader/crypto.py b/harness/src/aliro_harness/reader/crypto.py index f67bd0a..e66a70c 100644 --- a/harness/src/aliro_harness/reader/crypto.py +++ b/harness/src/aliro_harness/reader/crypto.py @@ -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 diff --git a/harness/src/aliro_harness/reader/key_derivation.py b/harness/src/aliro_harness/reader/key_derivation.py index 61d4912..cd99edd 100644 --- a/harness/src/aliro_harness/reader/key_derivation.py +++ b/harness/src/aliro_harness/reader/key_derivation.py @@ -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 diff --git a/harness/src/aliro_harness/reader/transaction.py b/harness/src/aliro_harness/reader/transaction.py index 7be1d0e..853465b 100644 --- a/harness/src/aliro_harness/reader/transaction.py +++ b/harness/src/aliro_harness/reader/transaction.py @@ -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 diff --git a/harness/tests/fake_card.py b/harness/tests/fake_card.py index 12e434e..3415fb2 100644 --- a/harness/tests/fake_card.py +++ b/harness/tests/fake_card.py @@ -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] diff --git a/harness/tests/test_reader_key_derivation.py b/harness/tests/test_reader_key_derivation.py index f36e55a..fe23b21 100644 --- a/harness/tests/test_reader_key_derivation.py +++ b/harness/tests/test_reader_key_derivation.py @@ -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)