From e213c65d269edea306ea7923ed0414486630e67f Mon Sep 17 00:00:00 2001 From: michael Date: Thu, 11 Jun 2026 10:55:09 -0700 Subject: [PATCH] feat(applet): StepUpApplet INS_ENVELOPE (CLA=0x00) decrypt + encrypted ack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../dangerousthings/aliro/StepUpApplet.java | 149 ++++++++++++++++-- .../aliro/StepUpAppletTest.java | 97 ++++++++++++ 2 files changed, 233 insertions(+), 13 deletions(-) diff --git a/applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java b/applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java index 5d279e2..2f993ac 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java +++ b/applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java @@ -61,11 +61,20 @@ import javacard.framework.Util; */ public class StepUpApplet extends Applet { + /** ISO 7816 CLA byte (0x00) for ISO-standardized commands. ENVELOPE + * (INS=0xC3) per ISO 7816 / Table 8-14 in the Aliro spec uses this CLA, + * not the Aliro-proprietary 0x80, because ENVELOPE is the standard + * ISO command for carrying chained data. */ + private static final byte CLA_ISO = (byte) 0x00; private static final byte CLA_PROPRIETARY = (byte) 0x80; /** EXCHANGE command per spec §8.3.3.5 / Table 8-14. The reader sends a * Reader Status sub-event under this INS once the Step-Up AID is the * active applet. */ private static final byte INS_EXCHANGE = (byte) 0xC9; + /** ENVELOPE command per ISO 7816 + spec §8.4 (Step-Up entry point). The + * X-CUBE-ALIRO firmware sends the encrypted mdoc DeviceRequest in the + * ENVELOPE body once the Step-Up AID is the active applet. */ + private static final byte INS_ENVELOPE = (byte) 0xC3; /** Length of each derived Step-Up session key (spec §8.4.3). */ private static final short STEP_UP_SK_LEN = 32; @@ -95,9 +104,18 @@ public class StepUpApplet extends Applet { * first time the reader→UD direction is used in this Step-Up session, * incremented after each successful decrypt. CLEAR_ON_DESELECT so each * Step-Up phase entry starts fresh -- the matching SELECT re-initialises - * the counter alongside the SK derivation. */ + * the counter alongside the SK derivation. Shared across ENVELOPE and + * EXCHANGE: both commands are reader→device so both consume from the + * same counter. */ private final byte[] stepUpReaderCounter; + /** Session-bound {@code StepUp_device_counter} per §8.4.3 + mdoc [6] + * §9.1.1.5: 32-bit big-endian counter for the device→reader direction, + * starting at {@code 0x00000001} on Step-Up session entry, incremented + * after each successful encrypt. In M1 only ENVELOPE returns ciphertext + * (EXCHANGE returns empty), so this advances once per ENVELOPE. */ + private final byte[] stepUpDeviceCounter; + /** 12-byte scratch for the GCM IV: 8 zero bytes + 4-byte reader counter * per §8.3.1.8. Rebuilt per EXCHANGE; CLEAR_ON_DESELECT. */ private final byte[] ivScratch; @@ -136,6 +154,7 @@ public class StepUpApplet extends Applet { stepUpSKReader = JCSystem.makeTransientByteArray(STEP_UP_SK_LEN, JCSystem.CLEAR_ON_DESELECT); stepUpSKScratch = JCSystem.makeTransientByteArray(STEP_UP_SK_LEN, JCSystem.CLEAR_ON_DESELECT); stepUpReaderCounter = JCSystem.makeTransientByteArray(COUNTER_LEN, JCSystem.CLEAR_ON_DESELECT); + stepUpDeviceCounter = JCSystem.makeTransientByteArray(COUNTER_LEN, JCSystem.CLEAR_ON_DESELECT); ivScratch = JCSystem.makeTransientByteArray(GCM_IV_LEN, JCSystem.CLEAR_ON_DESELECT); scratchPlaintext = JCSystem.makeTransientByteArray(SCRATCH_PLAINTEXT_LEN, JCSystem.CLEAR_ON_DESELECT); sessionFlags = JCSystem.makeTransientByteArray(FLAGS_LEN, JCSystem.CLEAR_ON_DESELECT); @@ -162,13 +181,15 @@ public class StepUpApplet extends Applet { // Wipe the staged StepUpSK — the derived keys are sufficient // from here on and we don't want the IKM lingering in transient. Util.arrayFillNonAtomic(stepUpSKScratch, (short) 0, STEP_UP_SK_LEN, (byte) 0); - // Spec §8.4.3 -> mdoc [6] §9.1.1.5: session-bound reader counter - // initialized to 0x00000001 on session entry. CLEAR_ON_DESELECT - // already zeroes it on each fresh select; rewrite explicitly so a - // Step-Up SELECT mid-session (without a deselect in between) also - // starts the counter at 1. + // Spec §8.4.3 -> mdoc [6] §9.1.1.5: session-bound counters + // initialized to 0x00000001 on session entry, one per direction. + // CLEAR_ON_DESELECT already zeroes them on each fresh select; + // rewrite explicitly so a Step-Up SELECT mid-session (without a + // deselect in between) also starts both counters at 1. Util.arrayFillNonAtomic(stepUpReaderCounter, (short) 0, COUNTER_LEN, (byte) 0); stepUpReaderCounter[3] = (byte) 0x01; + Util.arrayFillNonAtomic(stepUpDeviceCounter, (short) 0, COUNTER_LEN, (byte) 0); + stepUpDeviceCounter[3] = (byte) 0x01; sessionFlags[FLAG_KEYS_READY] = (byte) 1; } else { sessionFlags[FLAG_KEYS_READY] = (byte) 0; @@ -184,17 +205,24 @@ public class StepUpApplet extends Applet { } byte[] buf = apdu.getBuffer(); - if (buf[ISO7816.OFFSET_CLA] != CLA_PROPRIETARY) { - ISOException.throwIt(ISO7816.SW_CLA_NOT_SUPPORTED); - } - + byte cla = buf[ISO7816.OFFSET_CLA]; byte ins = buf[ISO7816.OFFSET_INS]; - if (ins == INS_EXCHANGE) { + + // INS-first dispatch: ENVELOPE is ISO-class (0x00) per ISO 7816 + spec + // Table 8-14, EXCHANGE is Aliro-proprietary (0x80). Don't blanket + // reject CLA=0x00 -- it's a legitimate ENVELOPE entry point. + if (cla == CLA_ISO && ins == INS_ENVELOPE) { + processEnvelope(apdu); + return; + } + if (cla == CLA_PROPRIETARY && ins == INS_EXCHANGE) { processExchange(apdu); return; } - - // ENVELOPE + GET RESPONSE handlers plug in here in follow-up milestones. + if (cla != CLA_ISO && cla != CLA_PROPRIETARY) { + ISOException.throwIt(ISO7816.SW_CLA_NOT_SUPPORTED); + } + // GET RESPONSE handler plugs in here in follow-up milestones. ISOException.throwIt(ISO7816.SW_INS_NOT_SUPPORTED); } @@ -270,6 +298,101 @@ public class StepUpApplet extends Applet { apdu.setOutgoingAndSend((short) 0, (short) 0); } + /** + * ENVELOPE (CLA=0x00, INS=0xC3) handler — Milestone 1 decrypt-and-discard + * the mdoc DeviceRequest, then return a spec-shape encrypted empty CBOR + * map (canonical RFC 8949: single byte 0xA0). + * + *

Spec §8.3.1.9: inbound payload (reader→device) is decrypted with + * {@code StepUpSKReader}, IV {@code 0x0000000000000000 || stepup_reader_counter} + * (8-byte zero prefix + 4-byte BE counter), empty AAD. + * + *

Spec §8.3.1.6: outbound payload (device→reader) is encrypted with + * {@code StepUpSKDevice}, IV {@code 0x0000000000000001 || stepup_device_counter} + * (8-byte prefix ending in 0x01 + 4-byte BE counter), empty AAD. + * + *

The DeviceRequest body is discarded in M1: we don't build a real + * mdoc DeviceResponse yet -- that's M2's job. The 17-byte ciphertext + * (1 ct + 16 tag) is enough for X-CUBE-ALIRO to see a spec-shape + * encrypted response and move on. Counter sequencing: both + * stepup_reader_counter (shared with EXCHANGE) and stepup_device_counter + * advance independently after each successful use. + */ + private void processEnvelope(APDU apdu) { + if (sessionFlags[FLAG_KEYS_READY] == 0) { + // SELECT-Step-Up landed without an armed SessionContext (i.e. no + // successful AUTH1 on AliroApplet first). Spec §8.4 keeps Step-Up + // strictly post-AUTH1; reject cleanly. + ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED); + } + + short lc = apdu.setIncomingAndReceive(); + byte[] buf = apdu.getBuffer(); + short dataOff = apdu.getOffsetCdata(); + + // Need at least the 16-byte tag (zero-byte plaintext is degenerate + // but spec-legal); reject anything that can't possibly carry a tag. + if (lc < GCM_TAG_LEN) { + ISOException.throwIt(ISO7816.SW_WRONG_LENGTH); + } + short ptLen = (short) (lc - GCM_TAG_LEN); + if (ptLen > SCRATCH_PLAINTEXT_LEN) { + ISOException.throwIt(ISO7816.SW_WRONG_LENGTH); + } + + // Build the reader-side IV: 8 zero bytes (§8.3.1.9) + reader counter. + Util.arrayFillNonAtomic(ivScratch, (short) 0, (short) 8, (byte) 0); + Util.arrayCopyNonAtomic(stepUpReaderCounter, (short) 0, + ivScratch, (short) 8, COUNTER_LEN); + + try { + CryptoSingletons.getAliroGcm().decrypt( + stepUpSKReader, (short) 0, + ivScratch, (short) 0, + buf, dataOff, lc, + scratchPlaintext, (short) 0); + } catch (ISOException e) { + throw e; + } catch (Throwable t) { + ISOException.throwIt(ISO7816.SW_SECURITY_STATUS_NOT_SATISFIED); + } + // Spec §8.3.1.9: reader_counter <- reader_counter + 1 after use. + incrementCounter(stepUpReaderCounter, (short) 0); + + // Wipe the decrypted DeviceRequest -- M1 has no use for it. M2 will + // replace this with real mdoc parsing and a real DeviceResponse. + Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0); + + // Build the canonical-CBOR empty map plaintext (single byte 0xA0, + // RFC 8949 major type 5 (map) with length 0). One byte total. + scratchPlaintext[0] = (byte) 0xA0; + + // Build the device-side IV: 0x00*7 || 0x01 || device_counter + // (§8.3.1.6 -- 8-byte prefix ending in 0x01, then 4B BE counter). + Util.arrayFillNonAtomic(ivScratch, (short) 0, (short) 7, (byte) 0); + ivScratch[7] = (byte) 0x01; + Util.arrayCopyNonAtomic(stepUpDeviceCounter, (short) 0, + ivScratch, (short) 8, COUNTER_LEN); + + // Encrypt into the APDU buffer at offset 0. Safe to overwrite the + // inbound command bytes here because we've finished reading them. + // Output length = 1 (ct) + 16 (tag) = 17 bytes; well under the 252-byte + // APDU buffer ceiling. + short ctLen = CryptoSingletons.getAliroGcm().encrypt( + stepUpSKDevice, (short) 0, + ivScratch, (short) 0, + scratchPlaintext, (short) 0, (short) 1, + buf, (short) 0); + // Spec §8.3.1.6: device_counter <- device_counter + 1 after use. + incrementCounter(stepUpDeviceCounter, (short) 0); + + // Wipe the single plaintext byte (CLEAR_ON_DESELECT alone would + // leave 0xA0 sitting in transient until reader walks away). + scratchPlaintext[0] = 0; + + apdu.setOutgoingAndSend((short) 0, ctLen); + } + /** 32-bit big-endian counter increment with carry across all 4 bytes. * Wraps mod 2^32; spec §8.3.3.5.4 says the counter SHALL never reach * 0xFFFF before increment (note: spec uses 0xFFFF where 0xFFFFFFFF is diff --git a/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java b/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java index 09a78ca..502586c 100644 --- a/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java +++ b/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java @@ -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). + * + *

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. + * + *

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 {