feat(applet): StepUpApplet INS_ENVELOPE (CLA=0x00) decrypt + encrypted ack
X-CUBE-ALIRO firmware sends ENVELOPE (CLA=0x00 INS=0xC3) carrying the encrypted mdoc DeviceRequest after the step-up AID SELECT. Spec §8.3.1.9 defines the inbound IV (reader-side: 0x0000000000000000 || stepup_reader_counter). For Milestone 1 we decrypt-and-discard the DeviceRequest, then return a spec-shape encrypted empty CBOR map (1 plaintext byte 0xA0, GCM-encrypted with StepUpSKDevice using device-side IV per §8.3.1.6: 0x0000000000000001 || stepup_device_counter). Total response: 17 bytes (1 ct + 16 tag). Adds stepUpDeviceCounter[4] CLEAR_ON_DESELECT alongside the existing stepUpReaderCounter; both init to [0,0,0,1] when StepUpApplet.select() derives session keys (i.e. each Step-Up phase entry). Restructures the StepUpApplet dispatch so CLA=0x00 ENVELOPE coexists with CLA=0x80 EXCHANGE -- ENVELOPE uses the ISO-standard CLA per spec/ISO 7816 convention, EXCHANGE remains Aliro-proprietary. M2 will replace the empty CBOR map with a real mdoc DeviceResponse carrying the cached-verified Access Document bytes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -219,6 +219,103 @@ class StepUpAppletTest {
|
||||
"M1 EXCHANGE handler returns empty payload (decrypt-and-discard)");
|
||||
}
|
||||
|
||||
/**
|
||||
* After SELECT-Step-Up the X-CUBE-ALIRO firmware also sends an ENVELOPE
|
||||
* command (CLA=0x00 INS=0xC3) carrying the encrypted mdoc DeviceRequest
|
||||
* (spec §8.4 + ISO 7816 ENVELOPE). Inbound encryption per §8.3.1.9
|
||||
* (reader-side IV {@code 0x0000000000000000 || stepup_reader_counter},
|
||||
* empty AAD); response encryption per §8.3.1.6 (device-side IV
|
||||
* {@code 0x0000000000000001 || stepup_device_counter}, empty AAD).
|
||||
*
|
||||
* <p>For Milestone 1 we decrypt-and-discard the DeviceRequest, then
|
||||
* return an encrypted single-byte CBOR empty map ({@code 0xA0}) so the
|
||||
* X-CUBE-ALIRO firmware sees a spec-shape encrypted response. Total
|
||||
* response bytes: 1 ct + 16 tag = 17.
|
||||
*
|
||||
* <p>This test also pins counter sequencing: ENVELOPE consumes
|
||||
* stepup_reader_counter=1 (then increments) and emits with
|
||||
* stepup_device_counter=1 (then increments). The reader-counter is
|
||||
* shared with EXCHANGE, but EXCHANGE isn't sent in this test so we
|
||||
* only see counter=1 on each side.
|
||||
*/
|
||||
@Test
|
||||
void envelopeAfterStepUpSelectDecryptsAndAcksWithEncryptedEmptyCborMap() 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);
|
||||
|
||||
// SELECT expedited + run AUTH0 + AUTH1.
|
||||
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");
|
||||
|
||||
// Compute both StepUpSK leg keys 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 session keys and
|
||||
// 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 plaintext under the reader IV
|
||||
// (00 00 00 00 00 00 00 00 || 00 00 00 01). The applet decrypts and
|
||||
// discards; the actual plaintext is irrelevant for M1.
|
||||
byte[] readerIv = new byte[12];
|
||||
readerIv[11] = 0x01;
|
||||
byte[] plaintext = new byte[] { 0x42, 0x42, 0x42, 0x42 };
|
||||
Cipher gcmEnc = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
gcmEnc.init(Cipher.ENCRYPT_MODE,
|
||||
new SecretKeySpec(stepUpSKReader, "AES"),
|
||||
new GCMParameterSpec(128, readerIv));
|
||||
byte[] envelopeBody = gcmEnc.doFinal(plaintext); // 4 + 16 = 20 bytes
|
||||
assertEquals(20, envelopeBody.length);
|
||||
|
||||
// Send ENVELOPE: CLA=0x00 INS=0xC3 (ISO-class command per Table 8-14).
|
||||
ResponseAPDU envelopeResp = sim.transmitCommand(
|
||||
new CommandAPDU(0x00, 0xC3, 0x00, 0x00, envelopeBody, 256));
|
||||
assertEquals(0x9000, envelopeResp.getSW(),
|
||||
"ENVELOPE with valid GCM tag must return SW=9000");
|
||||
|
||||
byte[] respBody = envelopeResp.getData();
|
||||
assertEquals(17, respBody.length,
|
||||
"M1 ENVELOPE response = 1 ciphertext byte + 16 GCM tag");
|
||||
|
||||
// Device-side decrypt under IV = 00 00 00 00 00 00 00 01 || 00 00 00 01
|
||||
// (device-prefix per §8.3.1.6 + device_counter=1).
|
||||
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[] decoded = gcmDec.doFinal(respBody);
|
||||
assertArrayEquals(new byte[] { (byte) 0xA0 }, decoded,
|
||||
"M1 ENVELOPE response plaintext = canonical CBOR empty map (0xA0)");
|
||||
}
|
||||
|
||||
/** HKDF-SHA-256 with empty salt and single-block Expand (L=32). Mirrors
|
||||
* AliroCrypto.deriveStepUpSessionKeys's spec-pinned HKDF computation. */
|
||||
private static byte[] hkdfStepUp(byte[] ikm, String info) throws Exception {
|
||||
|
||||
Reference in New Issue
Block a user