diff --git a/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java b/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java index 87412df..9904401 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java +++ b/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java @@ -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, diff --git a/applet/src/main/java/com/dangerousthings/aliro/CryptoSingletons.java b/applet/src/main/java/com/dangerousthings/aliro/CryptoSingletons.java new file mode 100644 index 0000000..859d56f --- /dev/null +++ b/applet/src/main/java/com/dangerousthings/aliro/CryptoSingletons.java @@ -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. + * + *

Java Card forbids {@code new} in {@code }, 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; + } +} diff --git a/applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java b/applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java index 7559182..f1e415a 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java +++ b/applet/src/main/java/com/dangerousthings/aliro/StepUpApplet.java @@ -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); + } } diff --git a/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java b/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java index 9ddd23c..4f33059 100644 --- a/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java +++ b/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java @@ -1,12 +1,17 @@ package com.dangerousthings.aliro; import com.licel.jcardsim.smartcardio.CardSimulator; +import java.security.KeyPair; import javacard.framework.AID; +import javacard.framework.Applet; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; import javax.smartcardio.CommandAPDU; import javax.smartcardio.ResponseAPDU; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; /** @@ -66,6 +71,105 @@ class StepUpAppletTest { "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. */ private static byte[] unwrap6F(byte[] fci) { if (fci.length < 2 || fci[0] != 0x6F) {