feat(applet): StepUpApplet derives session keys on SELECT when armed
After AUTH1 parks StepUpSK in SessionContext, the reader issues SELECT to the Step-Up AID per spec §10.2. StepUpApplet.select() now picks up the parked SK, derives StepUpSKDevice / StepUpSKReader via the §8.4.3 HKDF (already implemented in AliroCrypto.deriveStepUpSessionKeys), and stages both in transient CLEAR_ON_DESELECT fields for the ENVELOPE (M1C.1) and EXCHANGE (M1B.1) handlers. Introduces CryptoSingletons -- a lazy package-private holder for the single AliroCrypto instance shared between AliroApplet and StepUpApplet. Saves ~352 B of transient (kdfWorkbuf + hkdfPrevT + expandScratch) versus a per-applet duplicate. Java Card forbids new in <clinit> so the singleton uses lazy null-check init. Opt 1 prelude per docs/plans/2026-06-11-step-up-implementation-v2.md. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -142,8 +142,10 @@ public class AliroApplet extends Applet {
|
|||||||
* needs a separate {@code expeditedSKDeviceKey} field. */
|
* needs a separate {@code expeditedSKDeviceKey} field. */
|
||||||
private AliroGcm gcm;
|
private AliroGcm gcm;
|
||||||
|
|
||||||
/** Low-level crypto primitives (ECDH, HKDF, Kdh, expedited key derivation). */
|
// Low-level crypto primitives (ECDH, HKDF, Kdh, expedited key derivation)
|
||||||
private AliroCrypto crypto;
|
// are pulled from CryptoSingletons.getAliroCrypto() so both AliroApplet
|
||||||
|
// and StepUpApplet share one instance — saves ~352 B transient versus a
|
||||||
|
// per-applet duplicate. Local refs in deriveSessionKeys / processDiag.
|
||||||
|
|
||||||
/** Persistent (1B EEPROM) — set after {@link #ensureCryptoInitialized()} runs. */
|
/** Persistent (1B EEPROM) — set after {@link #ensureCryptoInitialized()} runs. */
|
||||||
private byte cryptoInitialized;
|
private byte cryptoInitialized;
|
||||||
@@ -293,7 +295,9 @@ public class AliroApplet extends Applet {
|
|||||||
} catch (ISOException e) { throw e; // preserve inner diagnostic SW
|
} catch (ISOException e) { throw e; // preserve inner diagnostic SW
|
||||||
} catch (Throwable t) { ISOException.throwIt((short) 0x6FA8); }
|
} catch (Throwable t) { ISOException.throwIt((short) 0x6FA8); }
|
||||||
try {
|
try {
|
||||||
crypto = new AliroCrypto();
|
// Force lazy alloc of the shared AliroCrypto so the same install-
|
||||||
|
// time failure ladder applies if the constructor throws.
|
||||||
|
CryptoSingletons.getAliroCrypto();
|
||||||
} catch (ISOException e) { throw e; // preserve inner diagnostic SW (0x6FC1-C5)
|
} catch (ISOException e) { throw e; // preserve inner diagnostic SW (0x6FC1-C5)
|
||||||
} catch (Throwable t) { ISOException.throwIt((short) 0x6FA6); }
|
} catch (Throwable t) { ISOException.throwIt((short) 0x6FA6); }
|
||||||
// Seed P-256 curve params on every keypair before any genKeyPair / setS /
|
// Seed P-256 curve params on every keypair before any genKeyPair / setS /
|
||||||
@@ -416,8 +420,9 @@ public class AliroApplet extends Applet {
|
|||||||
|
|
||||||
switch (ins) {
|
switch (ins) {
|
||||||
case INS_DIAG_HMAC: {
|
case INS_DIAG_HMAC: {
|
||||||
|
AliroCrypto cryptoHmac = CryptoSingletons.getAliroCrypto();
|
||||||
for (short i = 0; i < n; i++) {
|
for (short i = 0; i < n; i++) {
|
||||||
crypto.diagHmac(
|
cryptoHmac.diagHmac(
|
||||||
DIAG_KEY_32, (short) 0, (short) DIAG_KEY_32.length,
|
DIAG_KEY_32, (short) 0, (short) DIAG_KEY_32.length,
|
||||||
DIAG_MSG_64, (short) 0, (short) DIAG_MSG_64.length,
|
DIAG_MSG_64, (short) 0, (short) DIAG_MSG_64.length,
|
||||||
buf, DIAG_OUT_OFF);
|
buf, DIAG_OUT_OFF);
|
||||||
@@ -427,8 +432,9 @@ public class AliroApplet extends Applet {
|
|||||||
case INS_DIAG_ECDH: {
|
case INS_DIAG_ECDH: {
|
||||||
javacard.security.ECPrivateKey priv =
|
javacard.security.ECPrivateKey priv =
|
||||||
(javacard.security.ECPrivateKey) diagKeyPair.getPrivate();
|
(javacard.security.ECPrivateKey) diagKeyPair.getPrivate();
|
||||||
|
AliroCrypto cryptoEcdh = CryptoSingletons.getAliroCrypto();
|
||||||
for (short i = 0; i < n; i++) {
|
for (short i = 0; i < n; i++) {
|
||||||
crypto.computeEcdhSharedX(priv,
|
cryptoEcdh.computeEcdhSharedX(priv,
|
||||||
SECP256R1_G, (short) 0,
|
SECP256R1_G, (short) 0,
|
||||||
buf, DIAG_OUT_OFF);
|
buf, DIAG_OUT_OFF);
|
||||||
}
|
}
|
||||||
@@ -652,6 +658,7 @@ public class AliroApplet extends Applet {
|
|||||||
short saltLen = buildSaltVolatile(store, saltVolatile, (short) 0);
|
short saltLen = buildSaltVolatile(store, saltVolatile, (short) 0);
|
||||||
short infoLen = buildInfo(scratch, SCRATCH_READER_PUB_UNCOMP_OFF);
|
short infoLen = buildInfo(scratch, SCRATCH_READER_PUB_UNCOMP_OFF);
|
||||||
|
|
||||||
|
AliroCrypto crypto = CryptoSingletons.getAliroCrypto();
|
||||||
crypto.deriveKdh(
|
crypto.deriveKdh(
|
||||||
(ECPrivateKey) credentialEphemeralKeyPair.getPrivate(),
|
(ECPrivateKey) credentialEphemeralKeyPair.getPrivate(),
|
||||||
sessionState, OFF_READER_EPUBK,
|
sessionState, OFF_READER_EPUBK,
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.dangerousthings.aliro;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy holders for crypto objects shared between {@link AliroApplet} and
|
||||||
|
* {@link StepUpApplet}. Both applets live in the same package and only one
|
||||||
|
* is selected at a time, so the shared instances are safe — Java Card runs
|
||||||
|
* one applet at a time and the JCRE serializes APDU dispatch.
|
||||||
|
*
|
||||||
|
* <p>Java Card forbids {@code new} in {@code <clinit>}, so the getter lazy-
|
||||||
|
* creates on first call. Subsequent calls return the cached instance. The
|
||||||
|
* combined transient footprint of one shared {@link AliroCrypto} is roughly
|
||||||
|
* 352 B (kdfWorkbuf 64 + hkdfPrevT 32 + expandScratch 256); without this
|
||||||
|
* sharing each applet would allocate its own.
|
||||||
|
*/
|
||||||
|
final class CryptoSingletons {
|
||||||
|
|
||||||
|
private static AliroCrypto aliroCrypto;
|
||||||
|
|
||||||
|
private CryptoSingletons() { }
|
||||||
|
|
||||||
|
/** Returns the process-wide {@link AliroCrypto} instance. Allocates on
|
||||||
|
* first call; cheap thereafter. */
|
||||||
|
static AliroCrypto getAliroCrypto() {
|
||||||
|
if (aliroCrypto == null) {
|
||||||
|
aliroCrypto = new AliroCrypto();
|
||||||
|
}
|
||||||
|
return aliroCrypto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import javacard.framework.APDU;
|
|||||||
import javacard.framework.Applet;
|
import javacard.framework.Applet;
|
||||||
import javacard.framework.ISO7816;
|
import javacard.framework.ISO7816;
|
||||||
import javacard.framework.ISOException;
|
import javacard.framework.ISOException;
|
||||||
|
import javacard.framework.JCSystem;
|
||||||
import javacard.framework.Util;
|
import javacard.framework.Util;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,6 +63,24 @@ public class StepUpApplet extends Applet {
|
|||||||
|
|
||||||
private static final byte CLA_PROPRIETARY = (byte) 0x80;
|
private static final byte CLA_PROPRIETARY = (byte) 0x80;
|
||||||
|
|
||||||
|
/** Length of each derived Step-Up session key (spec §8.4.3). */
|
||||||
|
private static final short STEP_UP_SK_LEN = 32;
|
||||||
|
|
||||||
|
/** {@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. */
|
||||||
|
private final byte[] stepUpSKDevice;
|
||||||
|
|
||||||
|
/** {@code StepUpSKReader} — reader→UD leg of the Step-Up GCM session.
|
||||||
|
* Same derivation context as {@link #stepUpSKDevice}. */
|
||||||
|
private final byte[] stepUpSKReader;
|
||||||
|
|
||||||
|
/** 32-byte scratch used only during {@link #select()} to stage the
|
||||||
|
* StepUpSK copied out of {@link SessionContext} before HKDF derives the
|
||||||
|
* two session keys. Wiped after derivation so the parked StepUpSK never
|
||||||
|
* outlives the call. */
|
||||||
|
private final byte[] stepUpSKScratch;
|
||||||
|
|
||||||
public static void install(byte[] bArray, short bOffset, byte bLength) {
|
public static void install(byte[] bArray, short bOffset, byte bLength) {
|
||||||
StepUpApplet applet = new StepUpApplet();
|
StepUpApplet applet = new StepUpApplet();
|
||||||
if (bArray == null || bLength == 0) {
|
if (bArray == null || bLength == 0) {
|
||||||
@@ -71,8 +90,38 @@ public class StepUpApplet extends Applet {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private StepUpApplet() {
|
||||||
|
// CLEAR_ON_DESELECT: the next deselect (e.g. reader moves back to
|
||||||
|
// EXPEDITED or pulls the field) zeroes the session keys, matching the
|
||||||
|
// "fresh keys per Step-Up phase" invariant we'll need when ENVELOPE /
|
||||||
|
// EXCHANGE handlers run AES-GCM.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SELECT entry point. If {@link SessionContext} is armed (i.e. AUTH1 on
|
||||||
|
* {@link AliroApplet} just succeeded and parked {@code StepUpSK}), copy
|
||||||
|
* it out and derive {@code StepUpSKDevice}/{@code StepUpSKReader} via
|
||||||
|
* the §8.4.3 HKDF so the ENVELOPE / EXCHANGE handlers in M1B / M1C can
|
||||||
|
* AES-256-GCM with them. Always returns true — an un-armed SELECT (e.g.
|
||||||
|
* a reader that touches the Step-Up AID before AUTH1) still gets a
|
||||||
|
* successful FCI; downstream handlers will reject commands that need a
|
||||||
|
* live session.
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public boolean select() {
|
public boolean select() {
|
||||||
|
if (SessionContext.isStepUpArmed()) {
|
||||||
|
SessionContext.copyStepUpSK(stepUpSKScratch, (short) 0);
|
||||||
|
CryptoSingletons.getAliroCrypto().deriveStepUpSessionKeys(
|
||||||
|
stepUpSKScratch, (short) 0,
|
||||||
|
stepUpSKDevice, (short) 0,
|
||||||
|
stepUpSKReader, (short) 0);
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,4 +166,17 @@ public class StepUpApplet extends Applet {
|
|||||||
buf[outerLenPos] = (byte) (off - 2);
|
buf[outerLenPos] = (byte) (off - 2);
|
||||||
apdu.setOutgoingAndSend((short) 0, off);
|
apdu.setOutgoingAndSend((short) 0, off);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Testing hooks ------------------------------------------------------
|
||||||
|
// Package-private accessors that let tests verify SELECT-time key
|
||||||
|
// derivation without exposing the session keys to any production caller.
|
||||||
|
// NOT for use outside the applet's own test module.
|
||||||
|
|
||||||
|
void copyStepUpSKDeviceForTesting(byte[] out, short outOff) {
|
||||||
|
Util.arrayCopyNonAtomic(stepUpSKDevice, (short) 0, out, outOff, STEP_UP_SK_LEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
void copyStepUpSKReaderForTesting(byte[] out, short outOff) {
|
||||||
|
Util.arrayCopyNonAtomic(stepUpSKReader, (short) 0, out, outOff, STEP_UP_SK_LEN);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
package com.dangerousthings.aliro;
|
package com.dangerousthings.aliro;
|
||||||
|
|
||||||
import com.licel.jcardsim.smartcardio.CardSimulator;
|
import com.licel.jcardsim.smartcardio.CardSimulator;
|
||||||
|
import java.security.KeyPair;
|
||||||
import javacard.framework.AID;
|
import javacard.framework.AID;
|
||||||
|
import javacard.framework.Applet;
|
||||||
|
import javax.crypto.Mac;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
import javax.smartcardio.CommandAPDU;
|
import javax.smartcardio.CommandAPDU;
|
||||||
import javax.smartcardio.ResponseAPDU;
|
import javax.smartcardio.ResponseAPDU;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,6 +71,105 @@ class StepUpAppletTest {
|
|||||||
"wrong CLA must return SW_CLA_NOT_SUPPORTED");
|
"wrong CLA must return SW_CLA_NOT_SUPPORTED");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After AUTH1 succeeds on {@link AliroApplet}, {@code SessionContext} is
|
||||||
|
* armed with the 32-byte {@code StepUpSK}. When the reader subsequently
|
||||||
|
* SELECTs the Step-Up AID (per spec §10.2.x and the empirical X-CUBE-ALIRO
|
||||||
|
* flow), {@link StepUpApplet#select()} must derive
|
||||||
|
* {@code StepUpSKDevice}/{@code StepUpSKReader} via
|
||||||
|
* {@link AliroCrypto#deriveStepUpSessionKeys} and stage both in
|
||||||
|
* {@code CLEAR_ON_DESELECT} transient fields. This pins the wiring end-to-end.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void selectAfterArmedAuth1DerivesStepUpSessionKeys() throws Exception {
|
||||||
|
// Full applet stack: PersonalizationApplet (via ReaderSide.provision),
|
||||||
|
// AliroApplet under EXPEDITED, StepUpApplet under STEP_UP. The bare
|
||||||
|
// StepUpApplet install done by setUp() is overwritten by re-installing
|
||||||
|
// here; jcardsim treats the AID install as idempotent for our purposes.
|
||||||
|
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 (mirrors
|
||||||
|
// AliroAppletAuth1Test.auth1SuccessArmsStepUpContextWithExpectedStepUpSK).
|
||||||
|
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 the StepUpSK the test knows the card holds: it's bytes
|
||||||
|
// 64..96 of the expedited-standard derived_keys_volatile material.
|
||||||
|
byte[] stepUpSK = java.util.Arrays.copyOfRange(
|
||||||
|
reader.deriveExpeditedKeyMaterial(credentialEphemPubKey), 64, 96);
|
||||||
|
|
||||||
|
// SELECT the Step-Up AID — this should trigger StepUpApplet.select(),
|
||||||
|
// which sees SessionContext armed, copies out StepUpSK, derives the
|
||||||
|
// two session keys, and stashes them.
|
||||||
|
assertEquals(0x9000, sim.transmitCommand(
|
||||||
|
new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256)).getSW(),
|
||||||
|
"SELECT step-up must succeed regardless of armed state");
|
||||||
|
|
||||||
|
// Reference HKDF-SHA-256 with empty salt — matches AliroCrypto.deriveStepUpSessionKeys.
|
||||||
|
byte[] expectedDevice = hkdfStepUp(stepUpSK, "SKDevice");
|
||||||
|
byte[] expectedReader = hkdfStepUp(stepUpSK, "SKReader");
|
||||||
|
|
||||||
|
byte[] gotDevice = new byte[32];
|
||||||
|
byte[] gotReader = new byte[32];
|
||||||
|
StepUpApplet appletInstance = stepUpAppletInstance(sim, stepUpAid);
|
||||||
|
appletInstance.copyStepUpSKDeviceForTesting(gotDevice, (short) 0);
|
||||||
|
appletInstance.copyStepUpSKReaderForTesting(gotReader, (short) 0);
|
||||||
|
|
||||||
|
assertArrayEquals(expectedDevice, gotDevice,
|
||||||
|
"StepUpSKDevice = HKDF-Expand(HKDF-Extract(empty, StepUpSK), \"SKDevice\", 32)");
|
||||||
|
assertArrayEquals(expectedReader, gotReader,
|
||||||
|
"StepUpSKReader = HKDF-Expand(HKDF-Extract(empty, StepUpSK), \"SKReader\", 32)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
||||||
|
Mac extract = Mac.getInstance("HmacSHA256");
|
||||||
|
extract.init(new SecretKeySpec(new byte[32], "HmacSHA256"));
|
||||||
|
byte[] prk = extract.doFinal(ikm);
|
||||||
|
Mac expand = Mac.getInstance("HmacSHA256");
|
||||||
|
expand.init(new SecretKeySpec(prk, "HmacSHA256"));
|
||||||
|
expand.update(info.getBytes("US-ASCII"));
|
||||||
|
expand.update((byte) 0x01);
|
||||||
|
return expand.doFinal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull the live StepUpApplet instance out of jcardsim so the test can
|
||||||
|
* reach the package-private testing accessors. Mirrors the reflection
|
||||||
|
* pattern in {@code PersonalizationAppletTest#getInstalledApplet}. */
|
||||||
|
private static StepUpApplet stepUpAppletInstance(CardSimulator sim, AID aid) throws Exception {
|
||||||
|
java.lang.reflect.Field runtimeField = null;
|
||||||
|
for (Class<?> c = sim.getClass(); c != null && runtimeField == null; c = c.getSuperclass()) {
|
||||||
|
try { runtimeField = c.getDeclaredField("runtime"); } catch (NoSuchFieldException ignore) {}
|
||||||
|
}
|
||||||
|
runtimeField.setAccessible(true);
|
||||||
|
Object runtime = runtimeField.get(sim);
|
||||||
|
java.lang.reflect.Method getApplet = runtime.getClass().getDeclaredMethod("getApplet", AID.class);
|
||||||
|
getApplet.setAccessible(true);
|
||||||
|
return (StepUpApplet) (Applet) getApplet.invoke(runtime, aid);
|
||||||
|
}
|
||||||
|
|
||||||
/** Unwraps the outer 6F FCI template to expose its nested TLVs. */
|
/** Unwraps the outer 6F FCI template to expose its nested TLVs. */
|
||||||
private static byte[] unwrap6F(byte[] fci) {
|
private static byte[] unwrap6F(byte[] fci) {
|
||||||
if (fci.length < 2 || fci[0] != 0x6F) {
|
if (fci.length < 2 || fci[0] != 0x6F) {
|
||||||
|
|||||||
Reference in New Issue
Block a user