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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user