feat(applet): StepUpApplet INS_EXCHANGE decrypt-and-discard with 9000 ack
X-CUBE-ALIRO firmware sends a Reader Status sub-event via EXCHANGE (CLA=0x80, INS=0xC9) after the step-up AID SELECT, per Aliro §8.3.3.5 Table 8-14. The payload is encrypted with StepUpSKReader using AES-256-GCM with IV = 0x0000000000000000 || stepup_reader_counter (4B BE) per §8.3.1.8. For Milestone 1 we decrypt-and-discard: tag verification proves we have matching session keys, then we return SW=9000 with empty payload. M2 will add a proper encrypted Reader Status response sub-event. Extends CryptoSingletons to hold the shared AliroGcm instance too -- opt 1 sibling of the AliroCrypto sharing from M1A.2. Adds AliroGcm.decrypt since the prior pipeline only encrypted (AUTH1 response path). Counter starts at 1 per session-bound init (spec §8.4.3 -> mdoc [6] §9.1.1.5), incremented after each successful decrypt. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -62,10 +62,19 @@ import javacard.framework.Util;
|
||||
public class StepUpApplet extends Applet {
|
||||
|
||||
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;
|
||||
|
||||
/** Length of each derived Step-Up session key (spec §8.4.3). */
|
||||
private static final short STEP_UP_SK_LEN = 32;
|
||||
|
||||
/** 12-byte AES-256-GCM IV layout (§8.3.1.8/9): 8B prefix + 4B counter. */
|
||||
private static final short GCM_IV_LEN = 12;
|
||||
private static final short GCM_TAG_LEN = 16;
|
||||
private static final short COUNTER_LEN = 4;
|
||||
|
||||
/** {@code StepUpSKDevice} — UD→reader leg of the Step-Up AES-256-GCM
|
||||
* session, derived from {@code StepUpSK} via HKDF (§8.4.3) when SELECT
|
||||
* finds an armed {@link SessionContext}. Transient, cleared on deselect. */
|
||||
@@ -81,6 +90,34 @@ public class StepUpApplet extends Applet {
|
||||
* outlives the call. */
|
||||
private final byte[] stepUpSKScratch;
|
||||
|
||||
/** Session-bound {@code StepUp_reader_counter} per §8.4.3 + mdoc [6]
|
||||
* §9.1.1.5: 32-bit big-endian counter starting at {@code 0x00000001} the
|
||||
* 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. */
|
||||
private final byte[] stepUpReaderCounter;
|
||||
|
||||
/** 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;
|
||||
|
||||
/** Persistent EXCHANGE plaintext sink for the decrypt-and-discard path.
|
||||
* Sized to the largest reasonable Reader Status sub-event we'd see
|
||||
* during M1 (X-CUBE-ALIRO observed values are well under 64 B); we'll
|
||||
* resize when the real Reader Status payload size lands. CLEAR_ON_DESELECT
|
||||
* so post-deselect there's no plaintext residue. */
|
||||
private final byte[] scratchPlaintext;
|
||||
private static final short SCRATCH_PLAINTEXT_LEN = 256;
|
||||
|
||||
/** Transient single-slot flag: 1 once {@link #select()} successfully
|
||||
* derived the Step-Up session keys (i.e. the SELECT found
|
||||
* {@link SessionContext} armed). EXCHANGE / ENVELOPE handlers refuse to
|
||||
* run if this is 0. CLEAR_ON_DESELECT, alongside the keys themselves. */
|
||||
private final byte[] sessionFlags;
|
||||
private static final short FLAG_KEYS_READY = 0;
|
||||
private static final short FLAGS_LEN = 1;
|
||||
|
||||
public static void install(byte[] bArray, short bOffset, byte bLength) {
|
||||
StepUpApplet applet = new StepUpApplet();
|
||||
if (bArray == null || bLength == 0) {
|
||||
@@ -98,6 +135,10 @@ public class StepUpApplet extends Applet {
|
||||
stepUpSKDevice = 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);
|
||||
stepUpReaderCounter = 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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,6 +162,16 @@ 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.
|
||||
Util.arrayFillNonAtomic(stepUpReaderCounter, (short) 0, COUNTER_LEN, (byte) 0);
|
||||
stepUpReaderCounter[3] = (byte) 0x01;
|
||||
sessionFlags[FLAG_KEYS_READY] = (byte) 1;
|
||||
} else {
|
||||
sessionFlags[FLAG_KEYS_READY] = (byte) 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -137,11 +188,100 @@ public class StepUpApplet extends Applet {
|
||||
ISOException.throwIt(ISO7816.SW_CLA_NOT_SUPPORTED);
|
||||
}
|
||||
|
||||
// Until the mdoc DeviceRequest pipeline lands, every proprietary INS
|
||||
// is unrecognised. ENVELOPE + GET RESPONSE handlers plug in here.
|
||||
byte ins = buf[ISO7816.OFFSET_INS];
|
||||
if (ins == INS_EXCHANGE) {
|
||||
processExchange(apdu);
|
||||
return;
|
||||
}
|
||||
|
||||
// ENVELOPE + GET RESPONSE handlers plug in here in follow-up milestones.
|
||||
ISOException.throwIt(ISO7816.SW_INS_NOT_SUPPORTED);
|
||||
}
|
||||
|
||||
/**
|
||||
* EXCHANGE (CLA=0x80, INS=0xC9) handler — Milestone 1 decrypt-and-discard.
|
||||
*
|
||||
* <p>Spec §8.3.3.5 / Table 8-14: the reader sends
|
||||
* {@code encrypted_payload || authentication_tag} encrypted with
|
||||
* {@code StepUpSKReader} per §8.3.1.8, IV layout
|
||||
* {@code 0x0000000000000000 || stepup_reader_counter (4B BE)} and empty
|
||||
* AAD. M1 only needs to verify the tag (proves matching session keys)
|
||||
* then ACK with 9000 + empty payload so the X-CUBE-ALIRO firmware marks
|
||||
* "DOOR OPERATION SUCCEEDED" and moves on. The real Reader Status
|
||||
* response sub-event (encrypted with StepUpSKDevice) lands in M2.
|
||||
*/
|
||||
private void processExchange(APDU apdu) {
|
||||
if (sessionFlags[FLAG_KEYS_READY] == 0) {
|
||||
// SELECT hit StepUpApplet without an armed SessionContext (i.e.
|
||||
// no successful AUTH1 ran on AliroApplet first). Spec §8.4 says
|
||||
// the Step-Up phase is only entered 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.
|
||||
if (lc < GCM_TAG_LEN) {
|
||||
ISOException.throwIt(ISO7816.SW_WRONG_LENGTH);
|
||||
}
|
||||
// M1 sink is fixed-size; reject payloads that wouldn't fit. The real
|
||||
// EXCHANGE payload during M1 ack flow is tiny (X-CUBE-ALIRO sends a
|
||||
// few bytes of CBOR), so this bound is comfortable.
|
||||
short ptLen = (short) (lc - GCM_TAG_LEN);
|
||||
if (ptLen > SCRATCH_PLAINTEXT_LEN) {
|
||||
ISOException.throwIt(ISO7816.SW_WRONG_LENGTH);
|
||||
}
|
||||
|
||||
// Build the IV: 8 zero bytes (reader→device prefix per §8.3.1.8) +
|
||||
// stepup_reader_counter, big-endian, in the trailing 4 bytes.
|
||||
Util.arrayFillNonAtomic(ivScratch, (short) 0, (short) 8, (byte) 0);
|
||||
Util.arrayCopyNonAtomic(stepUpReaderCounter, (short) 0,
|
||||
ivScratch, (short) 8, COUNTER_LEN);
|
||||
|
||||
// Decrypt-and-discard. AliroGcm.decrypt throws CryptoException on
|
||||
// tag mismatch; remap to a security SW so an attacker can't tell
|
||||
// tag-mismatch from any other failure mode.
|
||||
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);
|
||||
}
|
||||
|
||||
// Wipe the discarded plaintext immediately -- M1 has no use for it,
|
||||
// and CLEAR_ON_DESELECT alone would leave it sitting around until the
|
||||
// reader walks away.
|
||||
Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0);
|
||||
|
||||
// Spec §8.3.1.8: reader_counter <- reader_counter + 1 after use.
|
||||
incrementCounter(stepUpReaderCounter, (short) 0);
|
||||
|
||||
// Ack with SW=9000 and empty payload. If field testing on real
|
||||
// X-CUBE-ALIRO firmware shows the reader rejects an empty payload,
|
||||
// M1E iteration escalates this to "9000 + encrypted-empty-CBOR-map"
|
||||
// per the implementation plan.
|
||||
apdu.setOutgoingAndSend((short) 0, (short) 0);
|
||||
}
|
||||
|
||||
/** 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
|
||||
* clearly meant -- 4-byte BE counter), so wrap is unreachable in
|
||||
* practice during normal protocol flow. */
|
||||
private static void incrementCounter(byte[] buf, short off) {
|
||||
for (short i = (short) (off + 3); i >= off; i--) {
|
||||
buf[i]++;
|
||||
if (buf[i] != 0) return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal FCI for step-up SELECT. The spec (§10.2.1.2) allows the UD to
|
||||
* advertise supported APDU command/response sizes here; those get added
|
||||
|
||||
Reference in New Issue
Block a user