diff --git a/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java b/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java index 9904401..6935c45 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java +++ b/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java @@ -134,13 +134,12 @@ public class AliroApplet extends Applet { /** SHA-1 for key_slot = first 8 bytes of SHA-1(uncompressed credential_PubK). */ private MessageDigest sha1; - /** Userland AES-256-GCM (built on AES-ECB-NOPAD + AES-256 key). The - * target card (NXP J3R180) does not expose {@code AEADCipher.ALG_AES_GCM} - * despite advertising JC 3.0.5, so we implement GCM in userland. - * {@link AliroGcm} owns its own persistent {@link AESKey} and - * {@link javacardx.crypto.Cipher} internally — this class no longer - * needs a separate {@code expeditedSKDeviceKey} field. */ - private AliroGcm gcm; + // Userland AES-256-GCM (built on AES-ECB-NOPAD + AES-256 key) is pulled + // from CryptoSingletons.getAliroGcm(). The target card (NXP J3R180) does + // not expose AEADCipher.ALG_AES_GCM despite advertising JC 3.0.5, so we + // implement GCM in userland. Sharing with StepUpApplet via the singleton + // saves ~370 B of transient/EEPROM footprint that a per-applet duplicate + // would otherwise pay. See encryptResponseGcm(). // Low-level crypto primitives (ECDH, HKDF, Kdh, expedited key derivation) // are pulled from CryptoSingletons.getAliroCrypto() so both AliroApplet @@ -291,7 +290,10 @@ public class AliroApplet extends Applet { ecdsaSigner = Signature.getInstance(Signature.ALG_ECDSA_SHA_256, false); sha1 = MessageDigest.getInstance(MessageDigest.ALG_SHA, false); try { - gcm = new AliroGcm(); + // Force lazy alloc of the shared AliroGcm so any install-time + // failure (Cipher.getInstance / KeyBuilder.buildKey rejection) + // surfaces with the same greppable diagnostic SW as before. + CryptoSingletons.getAliroGcm(); } catch (ISOException e) { throw e; // preserve inner diagnostic SW } catch (Throwable t) { ISOException.throwIt((short) 0x6FA8); } try { @@ -457,6 +459,7 @@ public class AliroApplet extends Applet { case INS_DIAG_GCM: { // Plaintext: 137 bytes anywhere in buf past the output region. // Contents don't affect timing. + AliroGcm gcm = CryptoSingletons.getAliroGcm(); for (short i = 0; i < n; i++) { gcm.encrypt( DIAG_KEY_32, (short) 0, @@ -964,7 +967,7 @@ public class AliroApplet extends Applet { // device_counter big-endian in the last 4 bytes; first AUTH1 = 1 scratch[(short) (ivOff + 11)] = (byte) 0x01; - return gcm.encrypt( + return CryptoSingletons.getAliroGcm().encrypt( derivedKeys, OFF_EXPEDITED_SK_DEVICE, scratch, ivOff, plaintext, ptOff, ptLen, diff --git a/applet/src/main/java/com/dangerousthings/aliro/AliroGcm.java b/applet/src/main/java/com/dangerousthings/aliro/AliroGcm.java index b83546f..a2be448 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/AliroGcm.java +++ b/applet/src/main/java/com/dangerousthings/aliro/AliroGcm.java @@ -261,6 +261,157 @@ final class AliroGcm { return (short) (ptLen + TAG_LEN); } + /** + * AES-256-GCM decrypt with the Aliro parameters: 32-byte key, 12-byte IV, + * empty AAD, 16-byte tag appended. Input is + * {@code in[inOff..inOff+inLen)} laid out as + * {@code ciphertext || tag} where the trailing 16 bytes are the tag and + * the preceding {@code inLen - 16} bytes are the ciphertext. The tag is + * verified before any plaintext is emitted; if verification fails the + * method throws a {@link CryptoException} ({@code ILLEGAL_VALUE}) and does + * NOT write to {@code out}. On success {@code inLen - 16} plaintext bytes + * are written to {@code out[outOff..]} and the same value is returned. + * + *
The buffer-aliasing rules mirror {@link #encrypt}: {@code in} and + * {@code out} may be the same buffer at the same offset (we GHASH the + * ciphertext BEFORE we touch the output region, then GCTR overwrites it + * left-to-right). + * + *
Used by the Step-Up phase EXCHANGE / ENVELOPE handlers for the
+ * reader→device direction per spec §8.3.1.9. The expedited-phase
+ * decrypt path on the reader side is not exercised by the applet; this
+ * exists to verify the GCM tag on inbound traffic so the applet knows
+ * the reader holds the matching session keys.
+ *
+ * @param key 32-byte AES-256 key
+ * @param iv 12-byte IV
+ * @param in input buffer, layout {@code ciphertext || tag}
+ * @param inOff, inLen input region; {@code inLen >= 16} required
+ * @param out output buffer, must have at least {@code inLen - 16} bytes
+ * available at {@code outOff}
+ * @return plaintext length = {@code inLen - 16}
+ * @throws CryptoException with reason {@code ILLEGAL_VALUE} on bad input
+ * length or tag mismatch
+ */
+ short decrypt(
+ byte[] key, short keyOff,
+ byte[] iv, short ivOff,
+ byte[] in, short inOff, short inLen,
+ byte[] out, short outOff) {
+
+ if (inLen < TAG_LEN) {
+ CryptoException.throwIt(CryptoException.ILLEGAL_VALUE);
+ }
+ short ctLen = (short) (inLen - TAG_LEN);
+ short tagOff = (short) (inOff + ctLen);
+
+ aesKey.setKey(key, keyOff);
+ aesEcb.init(aesKey, Cipher.MODE_ENCRYPT);
+
+ // H = AES_K(0^128). Same as encrypt() -- GCM is one-direction at the
+ // primitive level: encrypt and decrypt both run GCTR + GHASH and
+ // differ only in whether GHASH consumes provided ciphertext or
+ // freshly-emitted ciphertext, plus the tag compare/emit step.
+ Util.arrayFillNonAtomic(scratch, OFF_H, BLOCK_LEN, (byte) 0);
+ aesEcb.doFinal(scratch, OFF_H, BLOCK_LEN, scratch, OFF_H);
+ buildMTable();
+
+ // J0 = IV || 0x00000001 (96-bit IV canonical case).
+ Util.arrayCopyNonAtomic(iv, ivOff, scratch, OFF_J0, IV_LEN);
+ scratch[(short) (OFF_J0 + 12)] = 0x00;
+ scratch[(short) (OFF_J0 + 13)] = 0x00;
+ scratch[(short) (OFF_J0 + 14)] = 0x00;
+ scratch[(short) (OFF_J0 + 15)] = 0x01;
+
+ // GHASH over the PROVIDED ciphertext first (so tag verify doesn't
+ // depend on a successful decrypt). AAD is empty.
+ Util.arrayFillNonAtomic(scratch, OFF_GHASH, BLOCK_LEN, (byte) 0);
+ short consumed = 0;
+ while (consumed < ctLen) {
+ short chunk = (short) (ctLen - consumed);
+ if (chunk >= BLOCK_LEN) {
+ for (short i = 0; i < BLOCK_LEN; i++) {
+ scratch[(short) (OFF_GHASH + i)] ^= in[(short) (inOff + consumed + i)];
+ }
+ consumed += BLOCK_LEN;
+ } else {
+ for (short i = 0; i < chunk; i++) {
+ scratch[(short) (OFF_GHASH + i)] ^= in[(short) (inOff + consumed + i)];
+ }
+ consumed += chunk;
+ }
+ gfMul4Bit(scratch, OFF_GHASH);
+ Util.arrayCopyNonAtomic(scratch, OFF_ECB_OUT,
+ scratch, OFF_GHASH, BLOCK_LEN);
+ }
+
+ // len_block = 0^64 || (8*ctLen)^64. AAD bits = 0; same byte-shift
+ // dance as encrypt() to dodge JC's int-promotion conversion failure.
+ Util.arrayFillNonAtomic(scratch, OFF_LEN_BLK, BLOCK_LEN, (byte) 0);
+ short ctBitsLo = (short) (ctLen << 3);
+ short ctBitsHi = (short) (((short)(ctLen >>> 13)) & 0x07);
+ scratch[(short) (OFF_LEN_BLK + 13)] = (byte) (ctBitsHi & 0xFF);
+ scratch[(short) (OFF_LEN_BLK + 14)] = (byte) ((ctBitsLo >>> 8) & 0xFF);
+ scratch[(short) (OFF_LEN_BLK + 15)] = (byte) (ctBitsLo & 0xFF);
+
+ for (short i = 0; i < BLOCK_LEN; i++) {
+ scratch[(short) (OFF_GHASH + i)] ^= scratch[(short) (OFF_LEN_BLK + i)];
+ }
+ gfMul(scratch, OFF_GHASH, scratch, OFF_H);
+
+ // T_expected = AES_K(J0) XOR GHASH. Compare against received tag in
+ // constant-ish time (XOR-then-OR; no early exit). On JC this isn't
+ // truly constant-time at the bytecode level, but the smartcard SE
+ // doesn't expose timing channels at the resolution that would matter
+ // for a 128-bit tag forgery anyway.
+ aesEcb.doFinal(scratch, OFF_J0, BLOCK_LEN, scratch, OFF_ECB_OUT);
+ byte diff = 0;
+ for (short i = 0; i < TAG_LEN; i++) {
+ byte expected = (byte) (scratch[(short) (OFF_ECB_OUT + i)]
+ ^ scratch[(short) (OFF_GHASH + i)]);
+ diff |= (byte) (expected ^ in[(short) (tagOff + i)]);
+ }
+ if (diff != 0) {
+ // Wipe scratch before throwing so a tag-mismatch doesn't leave H,
+ // GHASH state, or AES_K(J0) sitting in transient.
+ Util.arrayFillNonAtomic(scratch, (short) 0, SCRATCH_LEN, (byte) 0);
+ CryptoException.throwIt(CryptoException.ILLEGAL_VALUE);
+ }
+
+ // Tag verified -- now GCTR-decrypt the ciphertext into out[]. cb is
+ // INC32(J0) just like encrypt(). Reusable OFF_CB slot has been free
+ // since J0 was last referenced for the tag XOR.
+ Util.arrayCopyNonAtomic(scratch, OFF_J0, scratch, OFF_CB, BLOCK_LEN);
+ inc32(scratch, OFF_CB);
+
+ if (usesNativeCtr != 0) {
+ aesCtr.init(aesKey, Cipher.MODE_ENCRYPT, scratch, OFF_CB, BLOCK_LEN);
+ // CTR is symmetric: encrypting the ciphertext with the same
+ // keystream produces the plaintext.
+ aesCtr.doFinal(in, inOff, ctLen, out, outOff);
+ } else {
+ short produced = 0;
+ while (produced < ctLen) {
+ short blockLen = (short) (ctLen - produced);
+ if (blockLen > BLOCK_LEN) blockLen = BLOCK_LEN;
+
+ aesEcb.doFinal(scratch, OFF_CB, BLOCK_LEN, scratch, OFF_ECB_OUT);
+
+ for (short i = 0; i < blockLen; i++) {
+ out[(short) (outOff + produced + i)] =
+ (byte) (in[(short) (inOff + produced + i)]
+ ^ scratch[(short) (OFF_ECB_OUT + i)]);
+ }
+
+ inc32(scratch, OFF_CB);
+ produced += blockLen;
+ }
+ }
+
+ Util.arrayFillNonAtomic(scratch, (short) 0, SCRATCH_LEN, (byte) 0);
+ return ctLen;
+ }
+
/**
* INC32 per NIST SP 800-38D §6.2: increments the last 4 bytes of the
* 16-byte block, big-endian, modulo 2^32.
diff --git a/applet/src/main/java/com/dangerousthings/aliro/CryptoSingletons.java b/applet/src/main/java/com/dangerousthings/aliro/CryptoSingletons.java
index 859d56f..b283406 100644
--- a/applet/src/main/java/com/dangerousthings/aliro/CryptoSingletons.java
+++ b/applet/src/main/java/com/dangerousthings/aliro/CryptoSingletons.java
@@ -15,6 +15,7 @@ package com.dangerousthings.aliro;
final class CryptoSingletons {
private static AliroCrypto aliroCrypto;
+ private static AliroGcm aliroGcm;
private CryptoSingletons() { }
@@ -26,4 +27,19 @@ final class CryptoSingletons {
}
return aliroCrypto;
}
+
+ /** Returns the process-wide {@link AliroGcm} instance. Same Java-Card
+ * {@code 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
diff --git a/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java b/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java
index 4f33059..09a78ca 100644
--- a/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java
+++ b/applet/src/test/java/com/dangerousthings/aliro/StepUpAppletTest.java
@@ -4,7 +4,9 @@ import com.licel.jcardsim.smartcardio.CardSimulator;
import java.security.KeyPair;
import javacard.framework.AID;
import javacard.framework.Applet;
+import javax.crypto.Cipher;
import javax.crypto.Mac;
+import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import javax.smartcardio.CommandAPDU;
import javax.smartcardio.ResponseAPDU;
@@ -142,6 +144,81 @@ class StepUpAppletTest {
"StepUpSKReader = HKDF-Expand(HKDF-Extract(empty, StepUpSK), \"SKReader\", 32)");
}
+ /**
+ * After SELECT-Step-Up has armed {@code StepUpSKReader}, the X-CUBE-ALIRO
+ * firmware sends a "Reader Status sub-event" via the EXCHANGE command
+ * (CLA=0x80, INS=0xC9) per spec §8.3.3.5 / Table 8-14. The payload is
+ * AES-256-GCM encrypted with {@code StepUpSKReader}; IV layout from
+ * §8.3.1.8 is {@code 0x0000000000000000 || stepup_reader_counter (4B BE)},
+ * with the counter session-bound and initialized to 1 per §8.4.3 (mdoc
+ * [6] §9.1.1.5 derivation).
+ *
+ * For Milestone 1 the applet only needs to decrypt-and-discard: tag
+ * verification proves the session keys match, then we return SW=9000 with
+ * empty payload. The real Step-Up "Reader Status response sub-event"
+ * (encrypted with StepUpSKDevice) lands in M2.
+ */
+ @Test
+ void exchangeAfterStepUpSelectDecryptsAndAcksWithEmptyPayload() throws Exception {
+ 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 the existing
+ // selectAfterArmedAuth1DerivesStepUpSessionKeys test.
+ 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 StepUpSKReader the card now holds.
+ byte[] stepUpSK = java.util.Arrays.copyOfRange(
+ reader.deriveExpeditedKeyMaterial(credentialEphemPubKey), 64, 96);
+ byte[] stepUpSKReader = hkdfStepUp(stepUpSK, "SKReader");
+
+ // SELECT the Step-Up AID -- arms StepUpApplet's StepUpSKReader and
+ // initializes its stepup_reader_counter session-bound to 0x00000001.
+ assertEquals(0x9000, sim.transmitCommand(
+ new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256)).getSW(),
+ "SELECT step-up must succeed");
+
+ // Reader-side encrypt of a 4-byte fake Reader Status payload under
+ // IV = 00 00 00 00 00 00 00 00 00 00 00 01 (8B zero prefix + counter=1).
+ byte[] iv = new byte[12];
+ iv[11] = 0x01;
+ byte[] plaintext = new byte[] { 0x42, 0x42, 0x42, 0x42 };
+ Cipher gcm = Cipher.getInstance("AES/GCM/NoPadding");
+ gcm.init(Cipher.ENCRYPT_MODE,
+ new SecretKeySpec(stepUpSKReader, "AES"),
+ new GCMParameterSpec(128, iv));
+ byte[] ctAndTag = gcm.doFinal(plaintext); // 4 + 16 = 20 bytes
+ assertEquals(20, ctAndTag.length);
+
+ ResponseAPDU exchangeResp = sim.transmitCommand(
+ new CommandAPDU(0x80, 0xC9, 0x00, 0x00, ctAndTag, 256));
+ assertEquals(0x9000, exchangeResp.getSW(),
+ "EXCHANGE with valid GCM tag must return SW=9000");
+ assertEquals(0, exchangeResp.getData().length,
+ "M1 EXCHANGE handler returns empty payload (decrypt-and-discard)");
+ }
+
/** 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 {