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

@@ -61,11 +61,20 @@ import javacard.framework.Util;
*/ */
public class StepUpApplet extends Applet { 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; private static final byte CLA_PROPRIETARY = (byte) 0x80;
/** EXCHANGE command per spec §8.3.3.5 / Table 8-14. The reader sends a /** 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 * Reader Status sub-event under this INS once the Step-Up AID is the
* active applet. */ * active applet. */
private static final byte INS_EXCHANGE = (byte) 0xC9; 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). */ /** Length of each derived Step-Up session key (spec §8.4.3). */
private static final short STEP_UP_SK_LEN = 32; 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, * first time the reader→UD direction is used in this Step-Up session,
* incremented after each successful decrypt. CLEAR_ON_DESELECT so each * incremented after each successful decrypt. CLEAR_ON_DESELECT so each
* Step-Up phase entry starts fresh -- the matching SELECT re-initialises * 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; 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 /** 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. */ * per §8.3.1.8. Rebuilt per EXCHANGE; CLEAR_ON_DESELECT. */
private final byte[] ivScratch; private final byte[] ivScratch;
@@ -136,6 +154,7 @@ public class StepUpApplet extends Applet {
stepUpSKReader = JCSystem.makeTransientByteArray(STEP_UP_SK_LEN, JCSystem.CLEAR_ON_DESELECT); stepUpSKReader = JCSystem.makeTransientByteArray(STEP_UP_SK_LEN, JCSystem.CLEAR_ON_DESELECT);
stepUpSKScratch = 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); 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); ivScratch = JCSystem.makeTransientByteArray(GCM_IV_LEN, JCSystem.CLEAR_ON_DESELECT);
scratchPlaintext = JCSystem.makeTransientByteArray(SCRATCH_PLAINTEXT_LEN, JCSystem.CLEAR_ON_DESELECT); scratchPlaintext = JCSystem.makeTransientByteArray(SCRATCH_PLAINTEXT_LEN, JCSystem.CLEAR_ON_DESELECT);
sessionFlags = JCSystem.makeTransientByteArray(FLAGS_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 // Wipe the staged StepUpSK — the derived keys are sufficient
// from here on and we don't want the IKM lingering in transient. // from here on and we don't want the IKM lingering in transient.
Util.arrayFillNonAtomic(stepUpSKScratch, (short) 0, STEP_UP_SK_LEN, (byte) 0); 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 // Spec §8.4.3 -> mdoc [6] §9.1.1.5: session-bound counters
// initialized to 0x00000001 on session entry. CLEAR_ON_DESELECT // initialized to 0x00000001 on session entry, one per direction.
// already zeroes it on each fresh select; rewrite explicitly so a // CLEAR_ON_DESELECT already zeroes them on each fresh select;
// Step-Up SELECT mid-session (without a deselect in between) also // rewrite explicitly so a Step-Up SELECT mid-session (without a
// starts the counter at 1. // deselect in between) also starts both counters at 1.
Util.arrayFillNonAtomic(stepUpReaderCounter, (short) 0, COUNTER_LEN, (byte) 0); Util.arrayFillNonAtomic(stepUpReaderCounter, (short) 0, COUNTER_LEN, (byte) 0);
stepUpReaderCounter[3] = (byte) 0x01; stepUpReaderCounter[3] = (byte) 0x01;
Util.arrayFillNonAtomic(stepUpDeviceCounter, (short) 0, COUNTER_LEN, (byte) 0);
stepUpDeviceCounter[3] = (byte) 0x01;
sessionFlags[FLAG_KEYS_READY] = (byte) 1; sessionFlags[FLAG_KEYS_READY] = (byte) 1;
} else { } else {
sessionFlags[FLAG_KEYS_READY] = (byte) 0; sessionFlags[FLAG_KEYS_READY] = (byte) 0;
@@ -184,17 +205,24 @@ public class StepUpApplet extends Applet {
} }
byte[] buf = apdu.getBuffer(); byte[] buf = apdu.getBuffer();
if (buf[ISO7816.OFFSET_CLA] != CLA_PROPRIETARY) { byte cla = buf[ISO7816.OFFSET_CLA];
ISOException.throwIt(ISO7816.SW_CLA_NOT_SUPPORTED);
}
byte ins = buf[ISO7816.OFFSET_INS]; 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); processExchange(apdu);
return; return;
} }
if (cla != CLA_ISO && cla != CLA_PROPRIETARY) {
// ENVELOPE + GET RESPONSE handlers plug in here in follow-up milestones. ISOException.throwIt(ISO7816.SW_CLA_NOT_SUPPORTED);
}
// GET RESPONSE handler plugs in here in follow-up milestones.
ISOException.throwIt(ISO7816.SW_INS_NOT_SUPPORTED); ISOException.throwIt(ISO7816.SW_INS_NOT_SUPPORTED);
} }
@@ -270,6 +298,101 @@ public class StepUpApplet extends Applet {
apdu.setOutgoingAndSend((short) 0, (short) 0); 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).
*
* <p>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.
*
* <p>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.
*
* <p>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. /** 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 * 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 * 0xFFFF before increment (note: spec uses 0xFFFF where 0xFFFFFFFF is

View File

@@ -219,6 +219,103 @@ class StepUpAppletTest {
"M1 EXCHANGE handler returns empty payload (decrypt-and-discard)"); "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 /** HKDF-SHA-256 with empty salt and single-block Expand (L=32). Mirrors
* AliroCrypto.deriveStepUpSessionKeys's spec-pinned HKDF computation. */ * AliroCrypto.deriveStepUpSessionKeys's spec-pinned HKDF computation. */
private static byte[] hkdfStepUp(byte[] ikm, String info) throws Exception { private static byte[] hkdfStepUp(byte[] ikm, String info) throws Exception {