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:
michael
2026-06-17 16:50:47 -07:00
parent f0765eb329
commit 0c7d4c642e
4 changed files with 236 additions and 77 deletions

View File

@@ -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 * <p>Spec §8.3.3.5 / Tables 8-19 + 8-20. The reader sends
* {@code encrypted_payload || authentication_tag} encrypted with * {@code encrypted_payload || authentication_tag} under
* {@code StepUpSKReader} per §8.3.1.8, IV layout * {@code StepUpSKReader} (IV {@code 0x00*8 || stepup_reader_counter},
* {@code 0x0000000000000000 || stepup_reader_counter (4B BE)} and empty * §8.3.1.8), empty AAD. The decrypted plaintext is the Reader Status
* AAD. M1 only needs to verify the tag (proves matching session keys) * sub-event REQUEST:
* then ACK with 9000 + empty payload so the X-CUBE-ALIRO firmware marks * <pre>
* "DOOR OPERATION SUCCEEDED" and moves on. The real Reader Status * sub_event_id : 1B ; 0x01 = ReaderStatusRequest (M2 only supports this)
* response sub-event (encrypted with StepUpSKDevice) lands in M2. * 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) { private void processExchange(APDU apdu) {
if (sessionFlags[FLAG_KEYS_READY] == 0) { if (sessionFlags[FLAG_KEYS_READY] == 0) {
@@ -264,9 +278,6 @@ public class StepUpApplet extends Applet {
if (lc < GCM_TAG_LEN) { if (lc < GCM_TAG_LEN) {
ISOException.throwIt(ISO7816.SW_WRONG_LENGTH); 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); short ptLen = (short) (lc - GCM_TAG_LEN);
if (ptLen > SCRATCH_PLAINTEXT_LEN) { if (ptLen > SCRATCH_PLAINTEXT_LEN) {
ISOException.throwIt(ISO7816.SW_WRONG_LENGTH); 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. // stepup_reader_counter, big-endian, in the trailing 4 bytes.
session.readerIv(ivScratch, (short) 0); session.readerIv(ivScratch, (short) 0);
// Decrypt-and-discard. AliroGcm.decrypt throws CryptoException on // Decrypt. AliroGcm.decrypt throws CryptoException on tag mismatch;
// tag mismatch; remap to a security SW so an attacker can't tell // remap to a security SW so an attacker can't tell tag-mismatch from
// tag-mismatch from any other failure mode. // any other failure mode.
try { try {
CryptoSingletons.getAliroGcm().decrypt( CryptoSingletons.getAliroGcm().decrypt(
session.skReader, (short) 0, session.skReader, (short) 0,
@@ -291,19 +302,55 @@ public class StepUpApplet extends Applet {
ISOException.throwIt(ISO7816.SW_SECURITY_STATUS_NOT_SATISFIED); 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. // 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(); session.advanceReaderCounter();
// Ack with SW=9000 and empty payload. If field testing on real // M2E.1: parse the request shape: [sub_event_id, payload_len, payload].
// X-CUBE-ALIRO firmware shows the reader rejects an empty payload, // Need at least sub_event_id + payload_len = 2 bytes.
// M1E iteration escalates this to "9000 + encrypted-empty-CBOR-map" if (ptLen < 2) {
// per the implementation plan. Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0);
apdu.setOutgoingAndSend((short) 0, (short) 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);
} }
/** /**

View File

@@ -145,21 +145,24 @@ class StepUpAppletTest {
} }
/** /**
* After SELECT-Step-Up has armed {@code StepUpSKReader}, the X-CUBE-ALIRO * After SELECT-Step-Up has armed {@code StepUpSKReader}/{@code StepUpSKDevice},
* firmware sends a "Reader Status sub-event" via the EXCHANGE command * the X-CUBE-ALIRO firmware sends a "Reader Status sub-event request" via
* (CLA=0x80, INS=0xC9) per spec §8.3.3.5 / Table 8-14. The payload is * the EXCHANGE command (CLA=0x80, INS=0xC9) per spec §8.3.3.5 / Table 8-19.
* 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).
* *
* <p>For Milestone 1 the applet only needs to decrypt-and-discard: tag * <p>M2 wire shape (plaintext under GCM):
* verification proves the session keys match, then we return SW=9000 with * <pre>
* empty payload. The real Step-Up "Reader Status response sub-event" * sub_event_id : 1B ; 0x01 = ReaderStatusRequest
* (encrypted with StepUpSKDevice) lands in M2. * 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 @Test
void exchangeAfterStepUpSelectDecryptsAndAcksWithEmptyPayload() throws Exception { void exchangeReturnsEncryptedReaderStatusResponse() throws Exception {
sim = new CardSimulator(); sim = new CardSimulator();
AID expeditedAid = new AID(AliroAids.EXPEDITED, (short) 0, (byte) AliroAids.EXPEDITED.length); AID expeditedAid = new AID(AliroAids.EXPEDITED, (short) 0, (byte) AliroAids.EXPEDITED.length);
sim.installApplet(expeditedAid, AliroApplet.class); sim.installApplet(expeditedAid, AliroApplet.class);
@@ -170,8 +173,6 @@ class StepUpAppletTest {
ReaderSide reader = new ReaderSide(); ReaderSide reader = new ReaderSide();
reader.provision(sim, credentialKeyPair); reader.provision(sim, credentialKeyPair);
// SELECT expedited + run AUTH0 + AUTH1 -- mirrors the existing
// selectAfterArmedAuth1DerivesStepUpSessionKeys test.
assertEquals(0x9000, sim.transmitCommand( assertEquals(0x9000, sim.transmitCommand(
new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256)).getSW(), new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256)).getSW(),
"SELECT expedited must succeed"); "SELECT expedited must succeed");
@@ -188,35 +189,106 @@ class StepUpAppletTest {
reader.buildAuth1Data(credentialEphemPubKey), 256)); reader.buildAuth1Data(credentialEphemPubKey), 256));
assertEquals(0x9000, auth1Resp.getSW(), "AUTH1 must succeed"); assertEquals(0x9000, auth1Resp.getSW(), "AUTH1 must succeed");
// Compute the StepUpSKReader the card now holds.
byte[] stepUpSK = java.util.Arrays.copyOfRange( byte[] stepUpSK = java.util.Arrays.copyOfRange(
reader.deriveExpeditedKeyMaterial(credentialEphemPubKey), 64, 96); reader.deriveExpeditedKeyMaterial(credentialEphemPubKey), 64, 96);
byte[] stepUpSKReader = hkdfStepUp(stepUpSK, "SKReader"); byte[] stepUpSKReader = hkdfStepUp(stepUpSK, "SKReader");
byte[] stepUpSKDevice = hkdfStepUp(stepUpSK, "SKDevice");
// SELECT the Step-Up AID -- arms StepUpApplet's StepUpSKReader and // SELECT the Step-Up AID -- arms session keys + initialises both
// initializes its stepup_reader_counter session-bound to 0x00000001. // counters to 0x00000001.
assertEquals(0x9000, sim.transmitCommand( assertEquals(0x9000, sim.transmitCommand(
new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256)).getSW(), new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256)).getSW(),
"SELECT step-up must succeed"); "SELECT step-up must succeed");
// Reader-side encrypt of a 4-byte fake Reader Status payload under // Reader-side encrypt of the spec Reader Status request plaintext:
// IV = 00 00 00 00 00 00 00 00 00 00 00 01 (8B zero prefix + counter=1). // sub_event_id=0x01, payload_len=0x00 (no payload bytes follow).
byte[] iv = new byte[12]; byte[] readerIv = new byte[12];
iv[11] = 0x01; readerIv[11] = 0x01;
byte[] plaintext = new byte[] { 0x42, 0x42, 0x42, 0x42 }; byte[] requestPt = new byte[] { 0x01, 0x00 };
Cipher gcm = Cipher.getInstance("AES/GCM/NoPadding"); Cipher gcmEnc = Cipher.getInstance("AES/GCM/NoPadding");
gcm.init(Cipher.ENCRYPT_MODE, gcmEnc.init(Cipher.ENCRYPT_MODE,
new SecretKeySpec(stepUpSKReader, "AES"), new SecretKeySpec(stepUpSKReader, "AES"),
new GCMParameterSpec(128, iv)); new GCMParameterSpec(128, readerIv));
byte[] ctAndTag = gcm.doFinal(plaintext); // 4 + 16 = 20 bytes byte[] ctAndTag = gcmEnc.doFinal(requestPt); // 2 + 16 = 18 bytes
assertEquals(20, ctAndTag.length);
ResponseAPDU exchangeResp = sim.transmitCommand( ResponseAPDU exchangeResp = sim.transmitCommand(
new CommandAPDU(0x80, 0xC9, 0x00, 0x00, ctAndTag, 256)); new CommandAPDU(0x80, 0xC9, 0x00, 0x00, ctAndTag, 256));
assertEquals(0x9000, exchangeResp.getSW(), assertEquals(0x9000, exchangeResp.getSW(),
"EXCHANGE with valid GCM tag must return SW=9000"); "EXCHANGE with valid Reader Status request must return SW=9000");
assertEquals(0, exchangeResp.getData().length, byte[] respCt = exchangeResp.getData();
"M1 EXCHANGE handler returns empty payload (decrypt-and-discard)"); 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");
} }
/** /**

View File

@@ -2,13 +2,17 @@
Drives the M2 applet path post-AUTH1: Drives the M2 applet path post-AUTH1:
- SELECT ACCE5502 (StepUpApplet) - SELECT ACCE5502 (StepUpApplet)
- INS=0xC9 EXCHANGE encrypted with StepUpSKReader -> expect SW=9000, empty body. - INS=0xC9 EXCHANGE encrypted with StepUpSKReader carrying a Reader Status
(M1 behavior, unchanged in this commit; M2E.x will rework it.) 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 - INS=0xC3 ENVELOPE encrypted with StepUpSKReader carrying a canonical CBOR
mdoc DeviceRequest -> applet returns the first chunk of an encrypted mdoc DeviceRequest -> applet returns the first chunk of an encrypted
DeviceResponse with SW=61xx. DeviceResponse with SW=61xx.
- INS=0xC0 GET RESPONSE repeated until SW=9000 — concatenated body decrypts - 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 - Plaintext is a canonical CBOR DeviceResponse; we walk
``documents[0].issuerSigned.issuerAuth`` and assert it round-trips the ``documents[0].issuerSigned.issuerAuth`` and assert it round-trips the
Access Document the reader was provisioned with. 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): IV layout per applet (StepUpApplet.processExchange / processEnvelope):
reader -> device : 0x00*8 || counter(4B BE) reader -> device : 0x00*8 || counter(4B BE)
device -> reader : 0x00*7 || 0x01 || 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 Both counters init to 1; each advances by 1 after use. EXCHANGE now consumes
uses deviceCounter=1 (it's the first device-side message). deviceCounter=1 (encrypted Reader Status response), so the ENVELOPE response
ciphertext decrypts under deviceCounter=2.
""" """
import cbor2 import cbor2
@@ -105,21 +110,32 @@ def verify_step_up_m2(
if sw != SW_OK: if sw != SW_OK:
return False, f"SELECT ACCE5502 failed: SW=0x{sw:04X}" return False, f"SELECT ACCE5502 failed: SW=0x{sw:04X}"
# M1B.1 -- EXCHANGE: M1 behavior (any plaintext, expect 9000+empty). M2E.x # M2E.1 + M2E.2 -- EXCHANGE: ship a Reader Status sub-event REQUEST
# will rework this leg; until then we keep the M1 shape so the round-trip # ([sub_event_id=0x01, payload_len=0x00]), expect a 19 B encrypted Reader
# exercises both crypto contexts (EXCHANGE + ENVELOPE) under fresh keys. # Status sub-event RESPONSE back ([sub_event_id=0x01, status=0x00,
pt = b"\x00" # payload_len=0x00] under SKDevice + deviceIv(1)).
ct = AESGCM(sk_reader).encrypt(_iv_reader(reader_counter), pt, None) 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" apdu = bytes([CLA_PROPRIETARY, INS_EXCHANGE, 0x00, 0x00, len(ct)]) + ct + b"\x00"
data, sw = transmit(apdu) data, sw = transmit(apdu)
if sw != SW_OK: if sw != SW_OK:
return False, f"M2 EXCHANGE failed: SW=0x{sw:04X}" return False, f"M2 EXCHANGE failed: SW=0x{sw:04X}"
if len(data) != 0: if len(data) != 19:
return False, f"M2 EXCHANGE expected empty body, got {len(data)}B: {data.hex()}" 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 reader_counter += 1
device_counter += 1
# M2 -- ENVELOPE: ship a valid CBOR DeviceRequest, drain GET RESPONSE # 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) 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" apdu = bytes([CLA_ISO, INS_ENVELOPE, 0x00, 0x00, len(ct)]) + ct + b"\x00"
first_body, first_sw = transmit(apdu) first_body, first_sw = transmit(apdu)

View File

@@ -114,10 +114,18 @@ def _iv_device(counter: int) -> bytes:
class _MockTransmit: class _MockTransmit:
"""Records APDUs and replies from a scripted SELECT/EXCHANGE/ENVELOPE/ """Records APDUs and replies from a scripted SELECT/EXCHANGE/ENVELOPE/
GET RESPONSE sequence. ENVELOPE replies with the first 252 B of a GET RESPONSE sequence.
pre-encrypted DeviceResponse and SW=61xx; GET RESPONSE drains the rest."""
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 CHUNK_LEN = 252
EXCHANGE_RESPONSE_PT = b"\x01\x00\x00"
def __init__( def __init__(
self, self,
@@ -127,13 +135,22 @@ class _MockTransmit:
): ):
self.sk_device = sk_device self.sk_device = sk_device
self.sk_reader = sk_reader 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( 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.ct_off = 0
self.apdus: list[bytes] = [] self.apdus: list[bytes] = []
self.reader_counter = 1 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. # The DeviceRequest the verifier is expected to send.
self.expected_request_pt = cbor2.dumps( self.expected_request_pt = cbor2.dumps(
{ {
@@ -161,16 +178,23 @@ class _MockTransmit:
if cla == 0x00 and ins == 0xA4: if cla == 0x00 and ins == 0xA4:
return b"", 0x9000 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: if cla == 0x80 and ins == 0xC9:
# Sanity-check: payload should decrypt under reader-counter=1.
lc = apdu[4] lc = apdu[4]
body = apdu[5 : 5 + lc] 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 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: if cla == 0x00 and ins == 0xC3:
lc = apdu[4] lc = apdu[4]
body = apdu[5 : 5 + lc] body = apdu[5 : 5 + lc]