feat(applet): StepUpApplet INS_EXCHANGE decrypt-and-discard with 9000 ack
X-CUBE-ALIRO firmware sends a Reader Status sub-event via EXCHANGE (CLA=0x80, INS=0xC9) after the step-up AID SELECT, per Aliro §8.3.3.5 Table 8-14. The payload is encrypted with StepUpSKReader using AES-256-GCM with IV = 0x0000000000000000 || stepup_reader_counter (4B BE) per §8.3.1.8. For Milestone 1 we decrypt-and-discard: tag verification proves we have matching session keys, then we return SW=9000 with empty payload. M2 will add a proper encrypted Reader Status response sub-event. Extends CryptoSingletons to hold the shared AliroGcm instance too -- opt 1 sibling of the AliroCrypto sharing from M1A.2. Adds AliroGcm.decrypt since the prior pipeline only encrypted (AUTH1 response path). Counter starts at 1 per session-bound init (spec §8.4.3 -> mdoc [6] §9.1.1.5), incremented after each successful decrypt. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -134,13 +134,12 @@ public class AliroApplet extends Applet {
|
|||||||
/** SHA-1 for key_slot = first 8 bytes of SHA-1(uncompressed credential_PubK). */
|
/** SHA-1 for key_slot = first 8 bytes of SHA-1(uncompressed credential_PubK). */
|
||||||
private MessageDigest sha1;
|
private MessageDigest sha1;
|
||||||
|
|
||||||
/** Userland AES-256-GCM (built on AES-ECB-NOPAD + AES-256 key). The
|
// Userland AES-256-GCM (built on AES-ECB-NOPAD + AES-256 key) is pulled
|
||||||
* target card (NXP J3R180) does not expose {@code AEADCipher.ALG_AES_GCM}
|
// from CryptoSingletons.getAliroGcm(). The target card (NXP J3R180) does
|
||||||
* despite advertising JC 3.0.5, so we implement GCM in userland.
|
// not expose AEADCipher.ALG_AES_GCM despite advertising JC 3.0.5, so we
|
||||||
* {@link AliroGcm} owns its own persistent {@link AESKey} and
|
// implement GCM in userland. Sharing with StepUpApplet via the singleton
|
||||||
* {@link javacardx.crypto.Cipher} internally — this class no longer
|
// saves ~370 B of transient/EEPROM footprint that a per-applet duplicate
|
||||||
* needs a separate {@code expeditedSKDeviceKey} field. */
|
// would otherwise pay. See encryptResponseGcm().
|
||||||
private AliroGcm gcm;
|
|
||||||
|
|
||||||
// Low-level crypto primitives (ECDH, HKDF, Kdh, expedited key derivation)
|
// Low-level crypto primitives (ECDH, HKDF, Kdh, expedited key derivation)
|
||||||
// are pulled from CryptoSingletons.getAliroCrypto() so both AliroApplet
|
// 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);
|
ecdsaSigner = Signature.getInstance(Signature.ALG_ECDSA_SHA_256, false);
|
||||||
sha1 = MessageDigest.getInstance(MessageDigest.ALG_SHA, false);
|
sha1 = MessageDigest.getInstance(MessageDigest.ALG_SHA, false);
|
||||||
try {
|
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 (ISOException e) { throw e; // preserve inner diagnostic SW
|
||||||
} catch (Throwable t) { ISOException.throwIt((short) 0x6FA8); }
|
} catch (Throwable t) { ISOException.throwIt((short) 0x6FA8); }
|
||||||
try {
|
try {
|
||||||
@@ -457,6 +459,7 @@ public class AliroApplet extends Applet {
|
|||||||
case INS_DIAG_GCM: {
|
case INS_DIAG_GCM: {
|
||||||
// Plaintext: 137 bytes anywhere in buf past the output region.
|
// Plaintext: 137 bytes anywhere in buf past the output region.
|
||||||
// Contents don't affect timing.
|
// Contents don't affect timing.
|
||||||
|
AliroGcm gcm = CryptoSingletons.getAliroGcm();
|
||||||
for (short i = 0; i < n; i++) {
|
for (short i = 0; i < n; i++) {
|
||||||
gcm.encrypt(
|
gcm.encrypt(
|
||||||
DIAG_KEY_32, (short) 0,
|
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
|
// device_counter big-endian in the last 4 bytes; first AUTH1 = 1
|
||||||
scratch[(short) (ivOff + 11)] = (byte) 0x01;
|
scratch[(short) (ivOff + 11)] = (byte) 0x01;
|
||||||
|
|
||||||
return gcm.encrypt(
|
return CryptoSingletons.getAliroGcm().encrypt(
|
||||||
derivedKeys, OFF_EXPEDITED_SK_DEVICE,
|
derivedKeys, OFF_EXPEDITED_SK_DEVICE,
|
||||||
scratch, ivOff,
|
scratch, ivOff,
|
||||||
plaintext, ptOff, ptLen,
|
plaintext, ptOff, ptLen,
|
||||||
|
|||||||
@@ -261,6 +261,157 @@ final class AliroGcm {
|
|||||||
return (short) (ptLen + TAG_LEN);
|
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.
|
||||||
|
*
|
||||||
|
* <p>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).
|
||||||
|
*
|
||||||
|
* <p>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
|
* INC32 per NIST SP 800-38D §6.2: increments the last 4 bytes of the
|
||||||
* 16-byte block, big-endian, modulo 2^32.
|
* 16-byte block, big-endian, modulo 2^32.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ package com.dangerousthings.aliro;
|
|||||||
final class CryptoSingletons {
|
final class CryptoSingletons {
|
||||||
|
|
||||||
private static AliroCrypto aliroCrypto;
|
private static AliroCrypto aliroCrypto;
|
||||||
|
private static AliroGcm aliroGcm;
|
||||||
|
|
||||||
private CryptoSingletons() { }
|
private CryptoSingletons() { }
|
||||||
|
|
||||||
@@ -26,4 +27,19 @@ final class CryptoSingletons {
|
|||||||
}
|
}
|
||||||
return aliroCrypto;
|
return aliroCrypto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns the process-wide {@link AliroGcm} instance. Same Java-Card
|
||||||
|
* {@code <clinit>}-cannot-{@code new} rationale as {@link #getAliroCrypto()}:
|
||||||
|
* lazy-allocate on first call so both {@link AliroApplet} and
|
||||||
|
* {@link StepUpApplet} share one userland-GCM machine. Each shared
|
||||||
|
* instance saves ~370 B of transient/EEPROM footprint that a per-applet
|
||||||
|
* duplicate would otherwise pay. The two applets are never selected
|
||||||
|
* simultaneously and the JCRE serializes APDU dispatch, so the shared
|
||||||
|
* scratch and {@code AESKey} slot don't race. */
|
||||||
|
static AliroGcm getAliroGcm() {
|
||||||
|
if (aliroGcm == null) {
|
||||||
|
aliroGcm = new AliroGcm();
|
||||||
|
}
|
||||||
|
return aliroGcm;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,10 +62,19 @@ import javacard.framework.Util;
|
|||||||
public class StepUpApplet extends Applet {
|
public class StepUpApplet extends Applet {
|
||||||
|
|
||||||
private static final byte CLA_PROPRIETARY = (byte) 0x80;
|
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;
|
||||||
|
|
||||||
/** Length of each derived Step-Up session key (spec §8.4.3). */
|
/** Length of each derived Step-Up session key (spec §8.4.3). */
|
||||||
private static final short STEP_UP_SK_LEN = 32;
|
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
|
/** {@code StepUpSKDevice} — UD→reader leg of the Step-Up AES-256-GCM
|
||||||
* session, derived from {@code StepUpSK} via HKDF (§8.4.3) when SELECT
|
* session, derived from {@code StepUpSK} via HKDF (§8.4.3) when SELECT
|
||||||
* finds an armed {@link SessionContext}. Transient, cleared on deselect. */
|
* finds an armed {@link SessionContext}. Transient, cleared on deselect. */
|
||||||
@@ -81,6 +90,34 @@ public class StepUpApplet extends Applet {
|
|||||||
* outlives the call. */
|
* outlives the call. */
|
||||||
private final byte[] stepUpSKScratch;
|
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. */
|
||||||
|
private final byte[] stepUpReaderCounter;
|
||||||
|
|
||||||
|
/** 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) {
|
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) {
|
||||||
@@ -98,6 +135,10 @@ public class StepUpApplet extends Applet {
|
|||||||
stepUpSKDevice = JCSystem.makeTransientByteArray(STEP_UP_SK_LEN, JCSystem.CLEAR_ON_DESELECT);
|
stepUpSKDevice = JCSystem.makeTransientByteArray(STEP_UP_SK_LEN, JCSystem.CLEAR_ON_DESELECT);
|
||||||
stepUpSKReader = 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);
|
stepUpSKScratch = JCSystem.makeTransientByteArray(STEP_UP_SK_LEN, JCSystem.CLEAR_ON_DESELECT);
|
||||||
|
stepUpReaderCounter = 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -121,6 +162,16 @@ public class StepUpApplet extends Applet {
|
|||||||
// Wipe the staged StepUpSK — the derived keys are sufficient
|
// Wipe the staged StepUpSK — the derived keys are sufficient
|
||||||
// from here on and we don't want the IKM lingering in transient.
|
// from here on and we don't want the IKM lingering in transient.
|
||||||
Util.arrayFillNonAtomic(stepUpSKScratch, (short) 0, STEP_UP_SK_LEN, (byte) 0);
|
Util.arrayFillNonAtomic(stepUpSKScratch, (short) 0, STEP_UP_SK_LEN, (byte) 0);
|
||||||
|
// Spec §8.4.3 -> mdoc [6] §9.1.1.5: session-bound reader counter
|
||||||
|
// initialized to 0x00000001 on session entry. CLEAR_ON_DESELECT
|
||||||
|
// already zeroes it on each fresh select; rewrite explicitly so a
|
||||||
|
// Step-Up SELECT mid-session (without a deselect in between) also
|
||||||
|
// starts the counter at 1.
|
||||||
|
Util.arrayFillNonAtomic(stepUpReaderCounter, (short) 0, COUNTER_LEN, (byte) 0);
|
||||||
|
stepUpReaderCounter[3] = (byte) 0x01;
|
||||||
|
sessionFlags[FLAG_KEYS_READY] = (byte) 1;
|
||||||
|
} else {
|
||||||
|
sessionFlags[FLAG_KEYS_READY] = (byte) 0;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -137,11 +188,100 @@ public class StepUpApplet extends Applet {
|
|||||||
ISOException.throwIt(ISO7816.SW_CLA_NOT_SUPPORTED);
|
ISOException.throwIt(ISO7816.SW_CLA_NOT_SUPPORTED);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Until the mdoc DeviceRequest pipeline lands, every proprietary INS
|
byte ins = buf[ISO7816.OFFSET_INS];
|
||||||
// is unrecognised. ENVELOPE + GET RESPONSE handlers plug in here.
|
if (ins == INS_EXCHANGE) {
|
||||||
|
processExchange(apdu);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ENVELOPE + GET RESPONSE handlers plug in here in follow-up milestones.
|
||||||
ISOException.throwIt(ISO7816.SW_INS_NOT_SUPPORTED);
|
ISOException.throwIt(ISO7816.SW_INS_NOT_SUPPORTED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EXCHANGE (CLA=0x80, INS=0xC9) handler — Milestone 1 decrypt-and-discard.
|
||||||
|
*
|
||||||
|
* <p>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
|
* 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
|
* advertise supported APDU command/response sizes here; those get added
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import com.licel.jcardsim.smartcardio.CardSimulator;
|
|||||||
import java.security.KeyPair;
|
import java.security.KeyPair;
|
||||||
import javacard.framework.AID;
|
import javacard.framework.AID;
|
||||||
import javacard.framework.Applet;
|
import javacard.framework.Applet;
|
||||||
|
import javax.crypto.Cipher;
|
||||||
import javax.crypto.Mac;
|
import javax.crypto.Mac;
|
||||||
|
import javax.crypto.spec.GCMParameterSpec;
|
||||||
import javax.crypto.spec.SecretKeySpec;
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
import javax.smartcardio.CommandAPDU;
|
import javax.smartcardio.CommandAPDU;
|
||||||
import javax.smartcardio.ResponseAPDU;
|
import javax.smartcardio.ResponseAPDU;
|
||||||
@@ -142,6 +144,81 @@ class StepUpAppletTest {
|
|||||||
"StepUpSKReader = HKDF-Expand(HKDF-Extract(empty, StepUpSK), \"SKReader\", 32)");
|
"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).
|
||||||
|
*
|
||||||
|
* <p>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
|
/** HKDF-SHA-256 with empty salt and single-block Expand (L=32). Mirrors
|
||||||
* AliroCrypto.deriveStepUpSessionKeys's spec-pinned HKDF computation. */
|
* AliroCrypto.deriveStepUpSessionKeys's spec-pinned HKDF computation. */
|
||||||
private static byte[] hkdfStepUp(byte[] ikm, String info) throws Exception {
|
private static byte[] hkdfStepUp(byte[] ikm, String info) throws Exception {
|
||||||
|
|||||||
Reference in New Issue
Block a user