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:
@@ -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).
|
||||
*
|
||||
* <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.
|
||||
* 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
|
||||
|
||||
@@ -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