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:
michael
2026-06-11 10:55:09 -07:00
parent 96816bc232
commit e213c65d26
2 changed files with 233 additions and 13 deletions

View File

@@ -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 {