Merge feat/step-up-m1: Step-Up M1 applet implementation
Lands the M1 StepUpApplet end-to-end on main, where9081990already landed the matching harness verifier. M1 was verified end-to-end via aliro-bench-test --step-up on J3R452 04555A4A0B2190 before this merge. Commits: -cedefeftest(applet): pin deriveStepUpSessionKeys to spec ASCII info strings (M1A.1) -8e999b7feat(applet): StepUpApplet derives session keys on SELECT when armed (M1A.2) -96816bcfeat(applet): StepUpApplet INS_EXCHANGE decrypt-and-discard with 9000 ack (M1B.1) -e213c65feat(applet): StepUpApplet INS_ENVELOPE (CLA=0x00) decrypt + encrypted ack (M1C.1) -ebefc4erefactor(applet): retire AliroApplet INS_EXCHANGE stub (M1D.1) -87065b0docs(applet): refresh stale comments referring to deleted INS_EXCHANGE stub
This commit is contained in:
@@ -33,7 +33,6 @@ public class AliroApplet extends Applet {
|
||||
private static final byte CLA_EXPEDITED = (byte) 0x80;
|
||||
private static final byte INS_AUTH0 = (byte) 0x80;
|
||||
private static final byte INS_AUTH1 = (byte) 0x81;
|
||||
private static final byte INS_EXCHANGE = (byte) 0xC9; // §8.3.3.5 / Table 8-14
|
||||
|
||||
// Diagnostic INSes (CLA=0x80) for profiling AUTH1 sub-operations.
|
||||
// DEV / PERFORMANCE-DEBUG ONLY. Set DIAGNOSTICS_ENABLED = false for any
|
||||
@@ -134,16 +133,17 @@ 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). */
|
||||
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;
|
||||
@@ -289,11 +289,16 @@ 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 {
|
||||
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 +421,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 +433,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);
|
||||
}
|
||||
@@ -451,6 +458,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,
|
||||
@@ -512,34 +520,6 @@ public class AliroApplet extends Applet {
|
||||
case INS_AUTH1:
|
||||
processAuth1(apdu);
|
||||
return;
|
||||
case INS_EXCHANGE:
|
||||
// EXCHANGE stub for the Reader Status sub-event variant of
|
||||
// §8.3.3.5 -- the post-AUTH1 "transaction reporting" handshake
|
||||
// X-CUBE-ALIRO always sends regardless of signaling_bitmap.
|
||||
// We don't implement the Step-up phase (no CBOR / mdoc /
|
||||
// ENVELOPE / GET RESPONSE), so we can't decrypt or honour the
|
||||
// Reader Status payload. But returning 6D00 here makes the
|
||||
// vendor firmware mark DOOR OPERATION FAILED even though
|
||||
// AUTH1 succeeded (§10.2 line 5660 says the access decision
|
||||
// SHOULD be independent of post-AUTH1 reporting -- vendor
|
||||
// ignores that). So we ACK with 9000 + empty payload to let
|
||||
// the demo terminate cleanly. Discards the inbound encrypted
|
||||
// Reader Status bytes without parsing them; they're a
|
||||
// status report the reader wanted to give us, not an access
|
||||
// gate we need to satisfy.
|
||||
//
|
||||
// TODO (Step-Up impl): once StepUpApplet handles ENVELOPE /
|
||||
// GET RESPONSE properly and signaling_bitmap honestly
|
||||
// reflects capability, this stub becomes either dead code
|
||||
// (spec-conformant readers route EXCHANGE to ACCE5502 after
|
||||
// the step-up AID SELECT) or replaceable with a proper
|
||||
// encrypted "Reader Status response sub-event" reply (for
|
||||
// readers like X-CUBE-ALIRO that violate the §10.2 SHALL
|
||||
// and bypass the step-up SELECT). See
|
||||
// docs/plans/2026-06-07-step-up-implementation.md.
|
||||
apdu.setIncomingAndReceive();
|
||||
apdu.setOutgoingAndSend((short) 0, (short) 0);
|
||||
return;
|
||||
case INS_DIAG_HMAC:
|
||||
case INS_DIAG_ECDH:
|
||||
case INS_DIAG_ECDSA_SIGN:
|
||||
@@ -652,6 +632,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,
|
||||
@@ -899,10 +880,11 @@ public class AliroApplet extends Applet {
|
||||
// Aliro.a errors out when bits 0 + 2 are clear and AD is provisioned
|
||||
// -- it expects "AD present" to be advertised. The bits are
|
||||
// informational about capabilities anyway, not enforceable
|
||||
// commitments, so 0x0005 satisfies the vendor library while we still
|
||||
// 6D00 / 9000-stub any EXCHANGE/ENVELOPE that actually arrives.
|
||||
// Revisit when real Step-up lands or when we test against more
|
||||
// readers and can lean on the spec literally.
|
||||
// commitments, so 0x0005 satisfies the vendor library. AliroApplet
|
||||
// now returns 6D00 for any post-AUTH1 INS like 0xC9 -- StepUpApplet
|
||||
// at ACCE5502 handles ENVELOPE and EXCHANGE properly per spec §10.2
|
||||
// + §8.4. Revisit when we test against more readers and can lean on
|
||||
// the spec literally.
|
||||
short bitmap = 0;
|
||||
if (store.hasAccessDocument()) {
|
||||
bitmap |= 0x0001; // bit 0
|
||||
@@ -957,7 +939,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,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <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
|
||||
* 16-byte block, big-endian, modulo 2^32.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
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 static AliroGcm aliroGcm;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
@@ -52,15 +53,87 @@ import javacard.framework.Util;
|
||||
* implantable target but document the limitation.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Also when this lands, revisit {@link AliroApplet}'s {@code INS_EXCHANGE}
|
||||
* stub: with real Step-Up at this AID, the stub becomes either (a) unused
|
||||
* if {@code signaling_bitmap} bit 2 stays set and spec-conformant readers
|
||||
* route EXCHANGE here, or (b) replaceable with a proper "Reader Status
|
||||
* sub-event" reply if X-CUBE-ALIRO's vendor bug persists.
|
||||
* <p>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();
|
||||
@@ -71,8 +144,55 @@ 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);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -84,15 +204,206 @@ public class StepUpApplet extends Applet {
|
||||
}
|
||||
|
||||
byte[] buf = apdu.getBuffer();
|
||||
if (buf[ISO7816.OFFSET_CLA] != CLA_PROPRIETARY) {
|
||||
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);
|
||||
}
|
||||
|
||||
// Until the mdoc DeviceRequest pipeline lands, every proprietary INS
|
||||
// is unrecognised. ENVELOPE + GET RESPONSE handlers plug in here.
|
||||
// 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.
|
||||
*
|
||||
* <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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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
|
||||
@@ -117,4 +428,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,4 +92,19 @@ class AliroAppletTest {
|
||||
assertEquals(0x01, versions[0] & 0xFF, "expected first protocol version high byte 0x01");
|
||||
assertEquals(0x00, versions[1] & 0xFF, "expected first protocol version low byte 0x00");
|
||||
}
|
||||
|
||||
@Test
|
||||
void auth1ExchangeInsRejectedNowThatStepUpAppletOwnsIt() {
|
||||
// Once M1B.1 / M1C.1 land StepUpApplet (ACCE5502) as the proper owner
|
||||
// of INS_EXCHANGE (0xC9), AliroApplet must NOT also answer 0xC9 — a
|
||||
// spec-conformant reader routes EXCHANGE to ACCE5502 after the §10.2
|
||||
// step-up AID SELECT. AliroApplet receiving 0xC9 is a reader bug or
|
||||
// a stale flow, and must be rejected with SW_INS_NOT_SUPPORTED so it
|
||||
// can never accidentally interact with expedited session state.
|
||||
selectExpedited();
|
||||
CommandAPDU exchange = new CommandAPDU(0x80, 0xC9, 0x00, 0x00);
|
||||
ResponseAPDU resp = sim.transmitCommand(exchange);
|
||||
assertEquals(0x6D00, resp.getSW(),
|
||||
"AliroApplet must reject INS_EXCHANGE (0xC9) — StepUpApplet (ACCE5502) owns it now");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +167,50 @@ class AliroCryptoTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Independent pin against an inline RFC 5869 reference (one HKDF-Expand
|
||||
* block: T(1) = HMAC(PRK, info || 0x01)). Complements
|
||||
* {@link #deriveStepUpSessionKeysMatchesManualHkdf} by anchoring on the
|
||||
* exact ASCII info strings the spec specifies ("SKDevice" / "SKReader")
|
||||
* with a different IKM byte pattern, so a future spec misread can't be
|
||||
* masked by sharing helper code with the equivalent test above.
|
||||
*/
|
||||
@Test
|
||||
void deriveStepUpSessionKeysMatchesRfc5869WithSpecInfoStrings() throws Exception {
|
||||
AliroCrypto crypto = new AliroCrypto();
|
||||
byte[] stepUpSK = new byte[32];
|
||||
for (byte i = 0; i < 32; i++) stepUpSK[i] = (byte) (0x10 + i);
|
||||
|
||||
byte[] skDevice = new byte[32];
|
||||
byte[] skReader = new byte[32];
|
||||
crypto.deriveStepUpSessionKeys(
|
||||
stepUpSK, (short) 0,
|
||||
skDevice, (short) 0,
|
||||
skReader, (short) 0);
|
||||
|
||||
// Reference HKDF-SHA-256 with empty salt
|
||||
javax.crypto.Mac extractMac = javax.crypto.Mac.getInstance("HmacSHA256");
|
||||
extractMac.init(new javax.crypto.spec.SecretKeySpec(new byte[32], "HmacSHA256"));
|
||||
byte[] prk = extractMac.doFinal(stepUpSK);
|
||||
|
||||
byte[] expectedDevice = expandOnce(prk, "SKDevice".getBytes("US-ASCII"));
|
||||
byte[] expectedReader = expandOnce(prk, "SKReader".getBytes("US-ASCII"));
|
||||
|
||||
assertArrayEquals(expectedDevice, skDevice,
|
||||
"StepUpSKDevice = HKDF-Expand(PRK, info=ASCII(\"SKDevice\"), L=32)");
|
||||
assertArrayEquals(expectedReader, skReader,
|
||||
"StepUpSKReader = HKDF-Expand(PRK, info=ASCII(\"SKReader\"), L=32)");
|
||||
}
|
||||
|
||||
private static byte[] expandOnce(byte[] prk, byte[] info) throws Exception {
|
||||
// T(1) = HMAC-SHA-256(PRK, info || 0x01); first 32 bytes is the output
|
||||
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");
|
||||
mac.init(new javax.crypto.spec.SecretKeySpec(prk, "HmacSHA256"));
|
||||
mac.update(info);
|
||||
mac.update((byte) 0x01);
|
||||
return mac.doFinal();
|
||||
}
|
||||
|
||||
@Test
|
||||
void hkdfExpandRfc5869TestCase1() {
|
||||
AliroCrypto crypto = new AliroCrypto();
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
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.Cipher;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
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 +73,277 @@ 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)");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)");
|
||||
}
|
||||
|
||||
/**
|
||||
* After SELECT-Step-Up the X-CUBE-ALIRO firmware also sends an ENVELOPE
|
||||
* command (CLA=0x00 INS=0xC3) carrying the encrypted mdoc DeviceRequest
|
||||
* (spec §8.4 + ISO 7816 ENVELOPE). Inbound encryption per §8.3.1.9
|
||||
* (reader-side IV {@code 0x0000000000000000 || stepup_reader_counter},
|
||||
* empty AAD); response encryption per §8.3.1.6 (device-side IV
|
||||
* {@code 0x0000000000000001 || stepup_device_counter}, empty AAD).
|
||||
*
|
||||
* <p>For Milestone 1 we decrypt-and-discard the DeviceRequest, then
|
||||
* return an encrypted single-byte CBOR empty map ({@code 0xA0}) so the
|
||||
* X-CUBE-ALIRO firmware sees a spec-shape encrypted response. Total
|
||||
* response bytes: 1 ct + 16 tag = 17.
|
||||
*
|
||||
* <p>This test also pins counter sequencing: ENVELOPE consumes
|
||||
* stepup_reader_counter=1 (then increments) and emits with
|
||||
* stepup_device_counter=1 (then increments). The reader-counter is
|
||||
* shared with EXCHANGE, but EXCHANGE isn't sent in this test so we
|
||||
* only see counter=1 on each side.
|
||||
*/
|
||||
@Test
|
||||
void envelopeAfterStepUpSelectDecryptsAndAcksWithEncryptedEmptyCborMap() 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.
|
||||
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 both StepUpSK leg keys the card now holds.
|
||||
byte[] stepUpSK = java.util.Arrays.copyOfRange(
|
||||
reader.deriveExpeditedKeyMaterial(credentialEphemPubKey), 64, 96);
|
||||
byte[] stepUpSKReader = hkdfStepUp(stepUpSK, "SKReader");
|
||||
byte[] stepUpSKDevice = hkdfStepUp(stepUpSK, "SKDevice");
|
||||
|
||||
// SELECT the Step-Up AID -- arms StepUpApplet's session keys and
|
||||
// initialises both counters 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 plaintext under the reader IV
|
||||
// (00 00 00 00 00 00 00 00 || 00 00 00 01). The applet decrypts and
|
||||
// discards; the actual plaintext is irrelevant for M1.
|
||||
byte[] readerIv = new byte[12];
|
||||
readerIv[11] = 0x01;
|
||||
byte[] plaintext = new byte[] { 0x42, 0x42, 0x42, 0x42 };
|
||||
Cipher gcmEnc = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
gcmEnc.init(Cipher.ENCRYPT_MODE,
|
||||
new SecretKeySpec(stepUpSKReader, "AES"),
|
||||
new GCMParameterSpec(128, readerIv));
|
||||
byte[] envelopeBody = gcmEnc.doFinal(plaintext); // 4 + 16 = 20 bytes
|
||||
assertEquals(20, envelopeBody.length);
|
||||
|
||||
// Send ENVELOPE: CLA=0x00 INS=0xC3 (ISO-class command per Table 8-14).
|
||||
ResponseAPDU envelopeResp = sim.transmitCommand(
|
||||
new CommandAPDU(0x00, 0xC3, 0x00, 0x00, envelopeBody, 256));
|
||||
assertEquals(0x9000, envelopeResp.getSW(),
|
||||
"ENVELOPE with valid GCM tag must return SW=9000");
|
||||
|
||||
byte[] respBody = envelopeResp.getData();
|
||||
assertEquals(17, respBody.length,
|
||||
"M1 ENVELOPE response = 1 ciphertext byte + 16 GCM tag");
|
||||
|
||||
// Device-side decrypt under IV = 00 00 00 00 00 00 00 01 || 00 00 00 01
|
||||
// (device-prefix per §8.3.1.6 + device_counter=1).
|
||||
byte[] deviceIv = new byte[12];
|
||||
deviceIv[7] = 0x01;
|
||||
deviceIv[11] = 0x01;
|
||||
Cipher gcmDec = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
gcmDec.init(Cipher.DECRYPT_MODE,
|
||||
new SecretKeySpec(stepUpSKDevice, "AES"),
|
||||
new GCMParameterSpec(128, deviceIv));
|
||||
byte[] decoded = gcmDec.doFinal(respBody);
|
||||
assertArrayEquals(new byte[] { (byte) 0xA0 }, decoded,
|
||||
"M1 ENVELOPE response plaintext = canonical CBOR empty map (0xA0)");
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
|
||||
Reference in New Issue
Block a user