feat: EXCHANGE Reader Status request validation + spec-shape response (M2E.1, M2E.2)
M2E.1: parse the EXCHANGE plaintext as Aliro §8.3.3.5 / Table 8-19 [sub_event_id, payload_len, payload], reject SW_WRONG_LENGTH on truncation or length mismatch and SW_DATA_INVALID on unknown sub_event_id (M2 only supports 0x01 = ReaderStatusRequest). M2E.2: emit a Reader Status sub-event RESPONSE (Table 8-20) plaintext [0x01, 0x00, 0x00] in place in the APDU buffer, GCM-encrypted under StepUpSKDevice + deviceIv(stepup_device_counter) per §8.3.1.6. Ciphertext+ tag = 19 B, single APDU, no chaining. Harness verify_step_up_m2 + the test mock now ship the spec request shape, decrypt the 19 B EXCHANGE response under deviceCounter=1, and decrypt the subsequent ENVELOPE response under deviceCounter=2 (EXCHANGE consumed 1). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -237,16 +237,30 @@ public class StepUpApplet extends Applet {
|
||||
}
|
||||
|
||||
/**
|
||||
* EXCHANGE (CLA=0x80, INS=0xC9) handler — Milestone 1 decrypt-and-discard.
|
||||
* EXCHANGE (CLA=0x80, INS=0xC9) handler — M2E.1 (request validation) +
|
||||
* M2E.2 (encrypted Reader Status response).
|
||||
*
|
||||
* <p>Spec §8.3.3.5 / Table 8-14: the reader sends
|
||||
* {@code encrypted_payload || authentication_tag} encrypted with
|
||||
* {@code StepUpSKReader} per §8.3.1.8, IV layout
|
||||
* {@code 0x0000000000000000 || stepup_reader_counter (4B BE)} and empty
|
||||
* AAD. M1 only needs to verify the tag (proves matching session keys)
|
||||
* then ACK with 9000 + empty payload so the X-CUBE-ALIRO firmware marks
|
||||
* "DOOR OPERATION SUCCEEDED" and moves on. The real Reader Status
|
||||
* response sub-event (encrypted with StepUpSKDevice) lands in M2.
|
||||
* <p>Spec §8.3.3.5 / Tables 8-19 + 8-20. The reader sends
|
||||
* {@code encrypted_payload || authentication_tag} under
|
||||
* {@code StepUpSKReader} (IV {@code 0x00*8 || stepup_reader_counter},
|
||||
* §8.3.1.8), empty AAD. The decrypted plaintext is the Reader Status
|
||||
* sub-event REQUEST:
|
||||
* <pre>
|
||||
* sub_event_id : 1B ; 0x01 = ReaderStatusRequest (M2 only supports this)
|
||||
* payload_len : 1B
|
||||
* payload : Lb ; empty for 0x01
|
||||
* </pre>
|
||||
*
|
||||
* <p>The applet validates the structure, then emits a Reader Status
|
||||
* sub-event RESPONSE (Table 8-20) plaintext:
|
||||
* <pre>
|
||||
* sub_event_id : 1B ; echoes 0x01
|
||||
* status : 1B ; 0x00 = OK
|
||||
* payload_len : 1B ; 0 for M2
|
||||
* </pre>
|
||||
* GCM-encrypted under {@code StepUpSKDevice} + device IV
|
||||
* ({@code 0x00*7 || 0x01 || stepup_device_counter}, §8.3.1.6). Ciphertext+
|
||||
* tag is 3 + 16 = 19 B — fits in one APDU, no chaining needed.
|
||||
*/
|
||||
private void processExchange(APDU apdu) {
|
||||
if (sessionFlags[FLAG_KEYS_READY] == 0) {
|
||||
@@ -264,9 +278,6 @@ public class StepUpApplet extends Applet {
|
||||
if (lc < GCM_TAG_LEN) {
|
||||
ISOException.throwIt(ISO7816.SW_WRONG_LENGTH);
|
||||
}
|
||||
// M1 sink is fixed-size; reject payloads that wouldn't fit. The real
|
||||
// EXCHANGE payload during M1 ack flow is tiny (X-CUBE-ALIRO sends a
|
||||
// few bytes of CBOR), so this bound is comfortable.
|
||||
short ptLen = (short) (lc - GCM_TAG_LEN);
|
||||
if (ptLen > SCRATCH_PLAINTEXT_LEN) {
|
||||
ISOException.throwIt(ISO7816.SW_WRONG_LENGTH);
|
||||
@@ -276,9 +287,9 @@ public class StepUpApplet extends Applet {
|
||||
// stepup_reader_counter, big-endian, in the trailing 4 bytes.
|
||||
session.readerIv(ivScratch, (short) 0);
|
||||
|
||||
// Decrypt-and-discard. AliroGcm.decrypt throws CryptoException on
|
||||
// tag mismatch; remap to a security SW so an attacker can't tell
|
||||
// tag-mismatch from any other failure mode.
|
||||
// Decrypt. AliroGcm.decrypt throws CryptoException on tag mismatch;
|
||||
// remap to a security SW so an attacker can't tell tag-mismatch from
|
||||
// any other failure mode.
|
||||
try {
|
||||
CryptoSingletons.getAliroGcm().decrypt(
|
||||
session.skReader, (short) 0,
|
||||
@@ -291,19 +302,55 @@ public class StepUpApplet extends Applet {
|
||||
ISOException.throwIt(ISO7816.SW_SECURITY_STATUS_NOT_SATISFIED);
|
||||
}
|
||||
|
||||
// Wipe the discarded plaintext immediately -- M1 has no use for it,
|
||||
// and CLEAR_ON_DESELECT alone would leave it sitting around until the
|
||||
// reader walks away.
|
||||
Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0);
|
||||
|
||||
// Spec §8.3.1.8: reader_counter <- reader_counter + 1 after use.
|
||||
// Advance now so an early exit from request validation still leaves
|
||||
// the counter at the post-decrypt value (the reader counter advances
|
||||
// on every successful decrypt regardless of whether the request
|
||||
// semantically validates).
|
||||
session.advanceReaderCounter();
|
||||
|
||||
// Ack with SW=9000 and empty payload. If field testing on real
|
||||
// X-CUBE-ALIRO firmware shows the reader rejects an empty payload,
|
||||
// M1E iteration escalates this to "9000 + encrypted-empty-CBOR-map"
|
||||
// per the implementation plan.
|
||||
apdu.setOutgoingAndSend((short) 0, (short) 0);
|
||||
// M2E.1: parse the request shape: [sub_event_id, payload_len, payload].
|
||||
// Need at least sub_event_id + payload_len = 2 bytes.
|
||||
if (ptLen < 2) {
|
||||
Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0);
|
||||
ISOException.throwIt(ISO7816.SW_WRONG_LENGTH);
|
||||
}
|
||||
byte subEventId = scratchPlaintext[0];
|
||||
short payloadLen = (short) (scratchPlaintext[1] & 0xFF);
|
||||
if (payloadLen != (short) (ptLen - 2)) {
|
||||
Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0);
|
||||
ISOException.throwIt(ISO7816.SW_WRONG_LENGTH);
|
||||
}
|
||||
// Only sub_event_id 0x01 (ReaderStatusRequest) is supported in M2.
|
||||
// M2 ignores the payload contents for 0x01 (just length-validated above).
|
||||
if (subEventId != (byte) 0x01) {
|
||||
Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0);
|
||||
ISOException.throwIt(ISO7816.SW_DATA_INVALID);
|
||||
}
|
||||
|
||||
// Wipe the request plaintext — content not needed past validation.
|
||||
Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0);
|
||||
|
||||
// M2E.2: build the Reader Status response plaintext directly into
|
||||
// the APDU buffer at offset 0, then encrypt in place. AliroGcm.encrypt
|
||||
// supports out == pt at the same offset (CTR mode + appended tag).
|
||||
buf[0] = (byte) 0x01; // sub_event_id (echoes request)
|
||||
buf[1] = (byte) 0x00; // status = OK
|
||||
buf[2] = (byte) 0x00; // payload_len = 0
|
||||
short respPtLen = 3;
|
||||
|
||||
// Device-side IV: 0x00*7 || 0x01 || stepup_device_counter (§8.3.1.6).
|
||||
session.deviceIv(ivScratch, (short) 0);
|
||||
|
||||
short ctLen = CryptoSingletons.getAliroGcm().encrypt(
|
||||
session.skDevice, (short) 0,
|
||||
ivScratch, (short) 0,
|
||||
buf, (short) 0, respPtLen,
|
||||
buf, (short) 0);
|
||||
// Spec §8.3.1.6: device_counter <- device_counter + 1 after use.
|
||||
session.advanceDeviceCounter();
|
||||
|
||||
apdu.setOutgoingAndSend((short) 0, ctLen);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -145,21 +145,24 @@ class StepUpAppletTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* After SELECT-Step-Up has armed {@code StepUpSKReader}, the X-CUBE-ALIRO
|
||||
* firmware sends a "Reader Status sub-event" via the EXCHANGE command
|
||||
* (CLA=0x80, INS=0xC9) per spec §8.3.3.5 / Table 8-14. The payload is
|
||||
* AES-256-GCM encrypted with {@code StepUpSKReader}; IV layout from
|
||||
* §8.3.1.8 is {@code 0x0000000000000000 || stepup_reader_counter (4B BE)},
|
||||
* with the counter session-bound and initialized to 1 per §8.4.3 (mdoc
|
||||
* [6] §9.1.1.5 derivation).
|
||||
* After SELECT-Step-Up has armed {@code StepUpSKReader}/{@code StepUpSKDevice},
|
||||
* the X-CUBE-ALIRO firmware sends a "Reader Status sub-event request" via
|
||||
* the EXCHANGE command (CLA=0x80, INS=0xC9) per spec §8.3.3.5 / Table 8-19.
|
||||
*
|
||||
* <p>For Milestone 1 the applet only needs to decrypt-and-discard: tag
|
||||
* verification proves the session keys match, then we return SW=9000 with
|
||||
* empty payload. The real Step-Up "Reader Status response sub-event"
|
||||
* (encrypted with StepUpSKDevice) lands in M2.
|
||||
* <p>M2 wire shape (plaintext under GCM):
|
||||
* <pre>
|
||||
* sub_event_id : 1B ; 0x01 = ReaderStatusRequest
|
||||
* payload_len : 1B
|
||||
* payload : Lb ; empty for ReaderStatusRequest
|
||||
* </pre>
|
||||
*
|
||||
* <p>The applet validates the structure, then emits a Reader Status response
|
||||
* sub-event (Table 8-20) plaintext {@code [sub_event_id=0x01, status=0x00,
|
||||
* payload_len=0x00]}, GCM-encrypted under {@code StepUpSKDevice} +
|
||||
* device IV (counter=1). Ciphertext+tag = 3 + 16 = 19 B; fits in one APDU.
|
||||
*/
|
||||
@Test
|
||||
void exchangeAfterStepUpSelectDecryptsAndAcksWithEmptyPayload() throws Exception {
|
||||
void exchangeReturnsEncryptedReaderStatusResponse() throws Exception {
|
||||
sim = new CardSimulator();
|
||||
AID expeditedAid = new AID(AliroAids.EXPEDITED, (short) 0, (byte) AliroAids.EXPEDITED.length);
|
||||
sim.installApplet(expeditedAid, AliroApplet.class);
|
||||
@@ -170,8 +173,6 @@ class StepUpAppletTest {
|
||||
ReaderSide reader = new ReaderSide();
|
||||
reader.provision(sim, credentialKeyPair);
|
||||
|
||||
// SELECT expedited + run AUTH0 + AUTH1 -- mirrors the existing
|
||||
// selectAfterArmedAuth1DerivesStepUpSessionKeys test.
|
||||
assertEquals(0x9000, sim.transmitCommand(
|
||||
new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256)).getSW(),
|
||||
"SELECT expedited must succeed");
|
||||
@@ -188,35 +189,106 @@ class StepUpAppletTest {
|
||||
reader.buildAuth1Data(credentialEphemPubKey), 256));
|
||||
assertEquals(0x9000, auth1Resp.getSW(), "AUTH1 must succeed");
|
||||
|
||||
// Compute the StepUpSKReader the card now holds.
|
||||
byte[] stepUpSK = java.util.Arrays.copyOfRange(
|
||||
reader.deriveExpeditedKeyMaterial(credentialEphemPubKey), 64, 96);
|
||||
byte[] stepUpSKReader = hkdfStepUp(stepUpSK, "SKReader");
|
||||
byte[] stepUpSKDevice = hkdfStepUp(stepUpSK, "SKDevice");
|
||||
|
||||
// SELECT the Step-Up AID -- arms StepUpApplet's StepUpSKReader and
|
||||
// initializes its stepup_reader_counter session-bound to 0x00000001.
|
||||
// SELECT the Step-Up AID -- arms session keys + initialises both
|
||||
// counters to 0x00000001.
|
||||
assertEquals(0x9000, sim.transmitCommand(
|
||||
new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256)).getSW(),
|
||||
"SELECT step-up must succeed");
|
||||
|
||||
// Reader-side encrypt of a 4-byte fake Reader Status payload under
|
||||
// IV = 00 00 00 00 00 00 00 00 00 00 00 01 (8B zero prefix + counter=1).
|
||||
byte[] iv = new byte[12];
|
||||
iv[11] = 0x01;
|
||||
byte[] plaintext = new byte[] { 0x42, 0x42, 0x42, 0x42 };
|
||||
Cipher gcm = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
gcm.init(Cipher.ENCRYPT_MODE,
|
||||
// Reader-side encrypt of the spec Reader Status request plaintext:
|
||||
// sub_event_id=0x01, payload_len=0x00 (no payload bytes follow).
|
||||
byte[] readerIv = new byte[12];
|
||||
readerIv[11] = 0x01;
|
||||
byte[] requestPt = new byte[] { 0x01, 0x00 };
|
||||
Cipher gcmEnc = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
gcmEnc.init(Cipher.ENCRYPT_MODE,
|
||||
new SecretKeySpec(stepUpSKReader, "AES"),
|
||||
new GCMParameterSpec(128, iv));
|
||||
byte[] ctAndTag = gcm.doFinal(plaintext); // 4 + 16 = 20 bytes
|
||||
assertEquals(20, ctAndTag.length);
|
||||
new GCMParameterSpec(128, readerIv));
|
||||
byte[] ctAndTag = gcmEnc.doFinal(requestPt); // 2 + 16 = 18 bytes
|
||||
|
||||
ResponseAPDU exchangeResp = sim.transmitCommand(
|
||||
new CommandAPDU(0x80, 0xC9, 0x00, 0x00, ctAndTag, 256));
|
||||
assertEquals(0x9000, exchangeResp.getSW(),
|
||||
"EXCHANGE with valid GCM tag must return SW=9000");
|
||||
assertEquals(0, exchangeResp.getData().length,
|
||||
"M1 EXCHANGE handler returns empty payload (decrypt-and-discard)");
|
||||
"EXCHANGE with valid Reader Status request must return SW=9000");
|
||||
byte[] respCt = exchangeResp.getData();
|
||||
assertEquals(19, respCt.length,
|
||||
"Reader Status response plaintext is 3 B + 16 B GCM tag = 19 B");
|
||||
|
||||
// Decrypt under StepUpSKDevice + device IV (counter=1 — this is the
|
||||
// first device-side message in the Step-Up session).
|
||||
byte[] deviceIv = new byte[12];
|
||||
deviceIv[7] = 0x01;
|
||||
deviceIv[11] = 0x01;
|
||||
Cipher gcmDec = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
gcmDec.init(Cipher.DECRYPT_MODE,
|
||||
new SecretKeySpec(stepUpSKDevice, "AES"),
|
||||
new GCMParameterSpec(128, deviceIv));
|
||||
byte[] respPt = gcmDec.doFinal(respCt);
|
||||
assertArrayEquals(new byte[] { 0x01, 0x00, 0x00 }, respPt,
|
||||
"Reader Status response plaintext = [sub_event_id=0x01, status=0x00, payload_len=0x00]");
|
||||
}
|
||||
|
||||
/**
|
||||
* EXCHANGE with an unknown {@code sub_event_id} must return
|
||||
* {@code SW_DATA_INVALID (0x6984)} per M2 spec §8.3.3.5 — M2 only supports
|
||||
* sub_event_id 0x01 (ReaderStatusRequest); 0x02 (TransactionEnd) and
|
||||
* higher are reserved / not implemented.
|
||||
*/
|
||||
@Test
|
||||
void exchangeWithUnknownSubEventIdReturnsDataInvalid() throws Exception {
|
||||
sim = new CardSimulator();
|
||||
AID expeditedAid = new AID(AliroAids.EXPEDITED, (short) 0, (byte) AliroAids.EXPEDITED.length);
|
||||
sim.installApplet(expeditedAid, AliroApplet.class);
|
||||
AID stepUpAid = new AID(AliroAids.STEP_UP, (short) 0, (byte) AliroAids.STEP_UP.length);
|
||||
sim.installApplet(stepUpAid, StepUpApplet.class);
|
||||
|
||||
KeyPair credentialKeyPair = Auth0Command.generateEphemeralKeyPair();
|
||||
ReaderSide reader = new ReaderSide();
|
||||
reader.provision(sim, credentialKeyPair);
|
||||
|
||||
assertEquals(0x9000, sim.transmitCommand(
|
||||
new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256)).getSW(),
|
||||
"SELECT expedited must succeed");
|
||||
|
||||
reader.startTransaction();
|
||||
ResponseAPDU auth0Resp = sim.transmitCommand(new CommandAPDU(
|
||||
Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00,
|
||||
reader.buildAuth0Data(), 256));
|
||||
assertEquals(0x9000, auth0Resp.getSW(), "AUTH0 must succeed");
|
||||
byte[] credentialEphemPubKey = TlvUtil.findTopLevel(auth0Resp.getData(), 0x86);
|
||||
|
||||
ResponseAPDU auth1Resp = sim.transmitCommand(new CommandAPDU(
|
||||
Auth0Command.CLA & 0xFF, 0x81, 0x00, 0x00,
|
||||
reader.buildAuth1Data(credentialEphemPubKey), 256));
|
||||
assertEquals(0x9000, auth1Resp.getSW(), "AUTH1 must succeed");
|
||||
|
||||
byte[] stepUpSK = java.util.Arrays.copyOfRange(
|
||||
reader.deriveExpeditedKeyMaterial(credentialEphemPubKey), 64, 96);
|
||||
byte[] stepUpSKReader = hkdfStepUp(stepUpSK, "SKReader");
|
||||
|
||||
assertEquals(0x9000, sim.transmitCommand(
|
||||
new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256)).getSW(),
|
||||
"SELECT step-up must succeed");
|
||||
|
||||
// Unknown sub_event_id (0x02 = TransactionEnd, reserved for M3+).
|
||||
byte[] readerIv = new byte[12];
|
||||
readerIv[11] = 0x01;
|
||||
byte[] requestPt = new byte[] { 0x02, 0x00 };
|
||||
Cipher gcmEnc = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
gcmEnc.init(Cipher.ENCRYPT_MODE,
|
||||
new SecretKeySpec(stepUpSKReader, "AES"),
|
||||
new GCMParameterSpec(128, readerIv));
|
||||
byte[] ctAndTag = gcmEnc.doFinal(requestPt);
|
||||
|
||||
ResponseAPDU exchangeResp = sim.transmitCommand(
|
||||
new CommandAPDU(0x80, 0xC9, 0x00, 0x00, ctAndTag, 256));
|
||||
assertEquals(0x6984, exchangeResp.getSW(),
|
||||
"unknown sub_event_id must return SW_DATA_INVALID");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
|
||||
Drives the M2 applet path post-AUTH1:
|
||||
- SELECT ACCE5502 (StepUpApplet)
|
||||
- INS=0xC9 EXCHANGE encrypted with StepUpSKReader -> expect SW=9000, empty body.
|
||||
(M1 behavior, unchanged in this commit; M2E.x will rework it.)
|
||||
- INS=0xC9 EXCHANGE encrypted with StepUpSKReader carrying a Reader Status
|
||||
sub-event REQUEST plaintext (spec §8.3.3.5 / Table 8-19):
|
||||
sub_event_id = 0x01 (ReaderStatusRequest), payload_len = 0
|
||||
-> applet returns 19 B = 3 B response plaintext + 16 B GCM tag, decrypts
|
||||
under StepUpSKDevice + deviceIv(1) to:
|
||||
sub_event_id = 0x01, status = 0x00, payload_len = 0 (Table 8-20).
|
||||
- INS=0xC3 ENVELOPE encrypted with StepUpSKReader carrying a canonical CBOR
|
||||
mdoc DeviceRequest -> applet returns the first chunk of an encrypted
|
||||
DeviceResponse with SW=61xx.
|
||||
- INS=0xC0 GET RESPONSE repeated until SW=9000 — concatenated body decrypts
|
||||
under StepUpSKDevice + deviceIv(1).
|
||||
under StepUpSKDevice + deviceIv(2) (EXCHANGE consumed deviceIv(1)).
|
||||
- Plaintext is a canonical CBOR DeviceResponse; we walk
|
||||
``documents[0].issuerSigned.issuerAuth`` and assert it round-trips the
|
||||
Access Document the reader was provisioned with.
|
||||
@@ -16,8 +20,9 @@ Drives the M2 applet path post-AUTH1:
|
||||
IV layout per applet (StepUpApplet.processExchange / processEnvelope):
|
||||
reader -> device : 0x00*8 || counter(4B BE)
|
||||
device -> reader : 0x00*7 || 0x01 || counter(4B BE)
|
||||
Both counters init to 1; each advances by 1 after use. The ENVELOPE response
|
||||
uses deviceCounter=1 (it's the first device-side message).
|
||||
Both counters init to 1; each advances by 1 after use. EXCHANGE now consumes
|
||||
deviceCounter=1 (encrypted Reader Status response), so the ENVELOPE response
|
||||
ciphertext decrypts under deviceCounter=2.
|
||||
"""
|
||||
|
||||
import cbor2
|
||||
@@ -105,21 +110,32 @@ def verify_step_up_m2(
|
||||
if sw != SW_OK:
|
||||
return False, f"SELECT ACCE5502 failed: SW=0x{sw:04X}"
|
||||
|
||||
# M1B.1 -- EXCHANGE: M1 behavior (any plaintext, expect 9000+empty). M2E.x
|
||||
# will rework this leg; until then we keep the M1 shape so the round-trip
|
||||
# exercises both crypto contexts (EXCHANGE + ENVELOPE) under fresh keys.
|
||||
pt = b"\x00"
|
||||
ct = AESGCM(sk_reader).encrypt(_iv_reader(reader_counter), pt, None)
|
||||
# M2E.1 + M2E.2 -- EXCHANGE: ship a Reader Status sub-event REQUEST
|
||||
# ([sub_event_id=0x01, payload_len=0x00]), expect a 19 B encrypted Reader
|
||||
# Status sub-event RESPONSE back ([sub_event_id=0x01, status=0x00,
|
||||
# payload_len=0x00] under SKDevice + deviceIv(1)).
|
||||
request_pt = b"\x01\x00"
|
||||
ct = AESGCM(sk_reader).encrypt(_iv_reader(reader_counter), request_pt, None)
|
||||
apdu = bytes([CLA_PROPRIETARY, INS_EXCHANGE, 0x00, 0x00, len(ct)]) + ct + b"\x00"
|
||||
data, sw = transmit(apdu)
|
||||
if sw != SW_OK:
|
||||
return False, f"M2 EXCHANGE failed: SW=0x{sw:04X}"
|
||||
if len(data) != 0:
|
||||
return False, f"M2 EXCHANGE expected empty body, got {len(data)}B: {data.hex()}"
|
||||
if len(data) != 19:
|
||||
return False, f"M2 EXCHANGE expected 19B response, got {len(data)}B: {data.hex()}"
|
||||
try:
|
||||
exchange_pt = AESGCM(sk_device).decrypt(_iv_device(device_counter), data, None)
|
||||
except Exception as e:
|
||||
return False, f"M2 EXCHANGE response decrypt failed (tag/key mismatch): {e}"
|
||||
if exchange_pt != b"\x01\x00\x00":
|
||||
return False, (
|
||||
"M2 EXCHANGE response plaintext mismatch: expected "
|
||||
f"[0x01, 0x00, 0x00], got {exchange_pt.hex()}"
|
||||
)
|
||||
reader_counter += 1
|
||||
device_counter += 1
|
||||
|
||||
# M2 -- ENVELOPE: ship a valid CBOR DeviceRequest, drain GET RESPONSE
|
||||
# chaining, decrypt, and assert the round-tripped AD.
|
||||
# chaining, decrypt under deviceCounter=2, and assert the round-tripped AD.
|
||||
ct = AESGCM(sk_reader).encrypt(_iv_reader(reader_counter), _DEVICE_REQUEST, None)
|
||||
apdu = bytes([CLA_ISO, INS_ENVELOPE, 0x00, 0x00, len(ct)]) + ct + b"\x00"
|
||||
first_body, first_sw = transmit(apdu)
|
||||
|
||||
@@ -114,10 +114,18 @@ def _iv_device(counter: int) -> bytes:
|
||||
|
||||
class _MockTransmit:
|
||||
"""Records APDUs and replies from a scripted SELECT/EXCHANGE/ENVELOPE/
|
||||
GET RESPONSE sequence. ENVELOPE replies with the first 252 B of a
|
||||
pre-encrypted DeviceResponse and SW=61xx; GET RESPONSE drains the rest."""
|
||||
GET RESPONSE sequence.
|
||||
|
||||
EXCHANGE replies with the spec Reader Status sub-event RESPONSE
|
||||
([0x01, 0x00, 0x00]) encrypted under SKDevice + deviceIv(1). The applet
|
||||
consumes deviceCounter=1 on this encrypt, so the subsequent ENVELOPE
|
||||
response uses deviceCounter=2.
|
||||
|
||||
ENVELOPE replies with the first 252 B of a pre-encrypted DeviceResponse
|
||||
and SW=61xx; GET RESPONSE drains the rest."""
|
||||
|
||||
CHUNK_LEN = 252
|
||||
EXCHANGE_RESPONSE_PT = b"\x01\x00\x00"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -127,13 +135,22 @@ class _MockTransmit:
|
||||
):
|
||||
self.sk_device = sk_device
|
||||
self.sk_reader = sk_reader
|
||||
# Pre-encrypt the DeviceResponse under SKDevice + device-IV(counter=1).
|
||||
# Pre-encrypt the EXCHANGE response under SKDevice + deviceIv(1). The
|
||||
# applet's EXCHANGE handler consumes deviceCounter=1 first.
|
||||
self.exchange_ct = AESGCM(sk_device).encrypt(
|
||||
_iv_device(1), self.EXCHANGE_RESPONSE_PT, None
|
||||
)
|
||||
# Pre-encrypt the DeviceResponse under SKDevice + deviceIv(2) — after
|
||||
# EXCHANGE advanced the device counter from 1 -> 2.
|
||||
self.ct = AESGCM(sk_device).encrypt(
|
||||
_iv_device(1), device_response_plaintext, None
|
||||
_iv_device(2), device_response_plaintext, None
|
||||
)
|
||||
self.ct_off = 0
|
||||
self.apdus: list[bytes] = []
|
||||
self.reader_counter = 1
|
||||
self.device_counter = 1
|
||||
# The Reader Status sub-event REQUEST the verifier is expected to send.
|
||||
self.expected_exchange_pt = b"\x01\x00"
|
||||
# The DeviceRequest the verifier is expected to send.
|
||||
self.expected_request_pt = cbor2.dumps(
|
||||
{
|
||||
@@ -161,16 +178,23 @@ class _MockTransmit:
|
||||
if cla == 0x00 and ins == 0xA4:
|
||||
return b"", 0x9000
|
||||
|
||||
# EXCHANGE — M1 behavior: decrypt one-byte plaintext, ack 9000+empty.
|
||||
# EXCHANGE — decrypt [0x01, 0x00], reply with [0x01, 0x00, 0x00]
|
||||
# encrypted under SKDevice + deviceIv(device_counter=1).
|
||||
if cla == 0x80 and ins == 0xC9:
|
||||
# Sanity-check: payload should decrypt under reader-counter=1.
|
||||
lc = apdu[4]
|
||||
body = apdu[5 : 5 + lc]
|
||||
AESGCM(self.sk_reader).decrypt(_iv_reader(self.reader_counter), body, None)
|
||||
pt = AESGCM(self.sk_reader).decrypt(
|
||||
_iv_reader(self.reader_counter), body, None
|
||||
)
|
||||
assert pt == self.expected_exchange_pt, (
|
||||
f"EXCHANGE pt mismatch:\n got={pt.hex()}\n want={self.expected_exchange_pt.hex()}"
|
||||
)
|
||||
self.reader_counter += 1
|
||||
return b"", 0x9000
|
||||
self.device_counter += 1
|
||||
return self.exchange_ct, 0x9000
|
||||
|
||||
# ENVELOPE — decrypt DeviceRequest under reader-counter=2, ship first chunk.
|
||||
# ENVELOPE — decrypt DeviceRequest under reader-counter=2, ship first
|
||||
# chunk of the response encrypted under deviceCounter=2.
|
||||
if cla == 0x00 and ins == 0xC3:
|
||||
lc = apdu[4]
|
||||
body = apdu[5 : 5 + lc]
|
||||
|
||||
Reference in New Issue
Block a user