package com.dangerousthings.aliro; import javacard.framework.APDU; import javacard.framework.Applet; import javacard.framework.ISO7816; import javacard.framework.ISOException; import javacard.framework.JCSystem; import javacard.framework.Util; /** * Scaffold for the Aliro step-up phase (spec §8.4). Lives under * {@link AliroAids#STEP_UP}. * *

This first pass only answers SELECT with a minimal FCI and rejects * unknown commands cleanly. The actual step-up protocol — mdoc * SessionData framing (DeviceRequest/DeviceResponse CBOR with * Aliro-remapped integer keys per Tables 8-21/8-22), ENVELOPE/GET * RESPONSE command chaining, and a fresh AES-256-GCM session keyed by * StepUpSKDevice/StepUpSKReader derived per spec §8.4.3 — comes in * follow-up iterations. * *

When that work lands, this applet will need access to the * {@code StepUpSK} (a 32-byte session-bound value produced by * {@link AliroApplet}'s derivation in §8.3.1.13). Since Java Card * installs distinct instances per AID, that state will be exchanged via * a shared package-private holder (not yet implemented). * *

Implementation notes for the Step-Up milestone (see * {@code docs/plans/2026-06-07-step-up-implementation.md}): *

    *
  1. Opt 1 — Reuse AliroGcm. Don't allocate a second * {@link AliroGcm} for Step-Up; reuse {@link AliroApplet}'s instance * via {@code aesKey.setKey(stepUpSKDevice)} between phases. Saves * ~368 B transient. ExpeditedSK and StepUpSK are distinct so the * key/IV uniqueness invariant holds across rekey.
  2. *
  3. Opt 2 — Structural CBOR only. The Access Document is opaque * transport bytes to us; the reader does field-level parsing. We only * need a structural CBOR codec (major type + length boundaries) for * the SessionData wrapper and to extract {@code IssuerAuth} for * verification. Do not implement field-level mdoc parsing — wastes * bytecode and RAM. CBOR encoding MUST be deterministic per RFC 8949 * §4.2.1 anywhere we emit.
  4. *
  5. Opt 3 — Stream-encrypt during ENVELOPE emit. Don't buffer * the entire encrypted DeviceResponse then chunk it; pipe plaintext * through GCM as we emit ENVELOPE response chunks. Reduces transient * footprint AND minimizes plaintext residence in RAM.
  6. *
  7. Opt 4 — IssuerAuth verify cached at personalization. See * {@link CredentialStore#finalizeAccessDocument(short)} TODO — the * COSE_Sign1 verify happens once at write time, the persistent * {@code accessDocumentVerified} flag is checked here at read time. * Saves ~100 ms per transaction. Trusts that the Credential Issuer * trust anchor is fixed for the card's lifetime — true for DT's * implantable target but document the limitation.
  8. *
* *

The {@code AliroApplet.INS_EXCHANGE} stub was retired once StepUpApplet * landed handling for 0xC9 directly — spec-conformant readers (X-CUBE-ALIRO * included, once the upstream crypto interop is right) route EXCHANGE here * after the step-up AID SELECT per §10.2. */ 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; /** 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. */ 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; /** 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. 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; /** 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) { applet.register(); } else { applet.register(bArray, (short) (bOffset + 1), bArray[bOffset]); } } 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); 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); } /** * 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); // 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; } return true; } @Override public void process(APDU apdu) { if (selectingApplet()) { sendStepUpFci(apdu); return; } byte[] buf = apdu.getBuffer(); byte cla = buf[ISO7816.OFFSET_CLA]; byte ins = buf[ISO7816.OFFSET_INS]; // 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; } 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); } /** * EXCHANGE (CLA=0x80, INS=0xC9) handler — Milestone 1 decrypt-and-discard. * *

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); } /** * 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). * *

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. * *

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. * *

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 * 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 * when we implement ENVELOPE/GET RESPONSE. * *

     * 6F L
     *   84 09 [step-up AID]
     * 
*/ private void sendStepUpFci(APDU apdu) { byte[] buf = apdu.getBuffer(); short off = 0; buf[off++] = 0x6F; short outerLenPos = off++; buf[off++] = (byte) 0x84; buf[off++] = (byte) AliroAids.STEP_UP.length; off = Util.arrayCopyNonAtomic(AliroAids.STEP_UP, (short) 0, buf, off, (short) AliroAids.STEP_UP.length); 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); } }