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
|
* <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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user