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:
michael
2026-06-11 10:33:46 -07:00
parent cedefef70e
commit 8e999b770d
4 changed files with 207 additions and 5 deletions

View File

@@ -142,8 +142,10 @@ public class AliroApplet extends Applet {
* needs a separate {@code expeditedSKDeviceKey} field. */
private AliroGcm gcm;
/** Low-level crypto primitives (ECDH, HKDF, Kdh, expedited key derivation). */
private AliroCrypto crypto;
// Low-level crypto primitives (ECDH, HKDF, Kdh, expedited key derivation)
// 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. */
private byte cryptoInitialized;
@@ -293,7 +295,9 @@ public class AliroApplet extends Applet {
} catch (ISOException e) { throw e; // preserve inner diagnostic SW
} catch (Throwable t) { ISOException.throwIt((short) 0x6FA8); }
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 (Throwable t) { ISOException.throwIt((short) 0x6FA6); }
// Seed P-256 curve params on every keypair before any genKeyPair / setS /
@@ -416,8 +420,9 @@ public class AliroApplet extends Applet {
switch (ins) {
case INS_DIAG_HMAC: {
AliroCrypto cryptoHmac = CryptoSingletons.getAliroCrypto();
for (short i = 0; i < n; i++) {
crypto.diagHmac(
cryptoHmac.diagHmac(
DIAG_KEY_32, (short) 0, (short) DIAG_KEY_32.length,
DIAG_MSG_64, (short) 0, (short) DIAG_MSG_64.length,
buf, DIAG_OUT_OFF);
@@ -427,8 +432,9 @@ public class AliroApplet extends Applet {
case INS_DIAG_ECDH: {
javacard.security.ECPrivateKey priv =
(javacard.security.ECPrivateKey) diagKeyPair.getPrivate();
AliroCrypto cryptoEcdh = CryptoSingletons.getAliroCrypto();
for (short i = 0; i < n; i++) {
crypto.computeEcdhSharedX(priv,
cryptoEcdh.computeEcdhSharedX(priv,
SECP256R1_G, (short) 0,
buf, DIAG_OUT_OFF);
}
@@ -652,6 +658,7 @@ public class AliroApplet extends Applet {
short saltLen = buildSaltVolatile(store, saltVolatile, (short) 0);
short infoLen = buildInfo(scratch, SCRATCH_READER_PUB_UNCOMP_OFF);
AliroCrypto crypto = CryptoSingletons.getAliroCrypto();
crypto.deriveKdh(
(ECPrivateKey) credentialEphemeralKeyPair.getPrivate(),
sessionState, OFF_READER_EPUBK,

View File

@@ -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;
}
}

View File

@@ -4,6 +4,7 @@ import javacard.framework.APDU;
import javacard.framework.Applet;
import javacard.framework.ISO7816;
import javacard.framework.ISOException;
import javacard.framework.JCSystem;
import javacard.framework.Util;
/**
@@ -62,6 +63,24 @@ public class StepUpApplet extends Applet {
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) {
StepUpApplet applet = new StepUpApplet();
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
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;
}
@@ -117,4 +166,17 @@ public class StepUpApplet extends Applet {
buf[outerLenPos] = (byte) (off - 2);
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);
}
}