Initial snapshot: Aliro applet, harness, Nucleo NFC10A1 reader port
Three components, all bench-validated to varying depths: - applet/: CSA Aliro v1.0 Java Card applet for J3R180. AUTH0 + AUTH1 expedited-standard flow end-to-end green via PC/SC bench reader (aliro-bench-test). Userland AES-256-GCM and HMAC-SHA-256 layered on top of J3R180's primitives because the card lacks both natively. P-256 curve params seeded explicitly per J3R180's quirk. - harness/: Python orchestrator (aliro-trustgen, aliro-personalize, aliro-bench-test) for trust-bundle generation, card personalization via PersonalizationApplet, and PC/SC AUTH0+AUTH1 transactions. 126 pytest cases passing. - reader/STM32CubeExpansion_ALIRO_V1_0_0/: ST X-CUBE-ALIRO V1.0.0 with our NFC10A1 port (NUCLEO-U545RE-Q + X-NUCLEO-NFC10A1, ST25R200 shared with NFC09A1). nfc10-only/ project, NFC10A1 BSP shim, ALIRO_TRUST_OVERRIDE include into vendor's provisioning.c, and an ALIRO_APDU_TRACE wrapper around demoTransceiveBlocking. Boots, detects the J3R180, completes SELECT + AUTH0; AUTH1 currently fails with RFAL ERR_PROTO (0xB) — under investigation, see docs/plans/2026-04-20-nucleo-nfc10a1-port.md and bench-notes/. Excluded: x-cube-aliro.zip vendor archive, harness/.venv, build dirs, generated aliro_trust.h (contains private reader scalar), all PEMs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
/**
|
||||
* CSA Aliro v1.0 AIDs, spec §10.2.1.
|
||||
*/
|
||||
public final class AliroAids {
|
||||
/** Expedited Phase AID: A0 00 00 09 09 AC CE 55 01 */
|
||||
public static final byte[] EXPEDITED = {
|
||||
(byte)0xA0, (byte)0x00, (byte)0x00, (byte)0x09, (byte)0x09,
|
||||
(byte)0xAC, (byte)0xCE, (byte)0x55, (byte)0x01
|
||||
};
|
||||
|
||||
/** Step-up Phase AID: A0 00 00 09 09 AC CE 55 02 */
|
||||
public static final byte[] STEP_UP = {
|
||||
(byte)0xA0, (byte)0x00, (byte)0x00, (byte)0x09, (byte)0x09,
|
||||
(byte)0xAC, (byte)0xCE, (byte)0x55, (byte)0x02
|
||||
};
|
||||
|
||||
/**
|
||||
* DT-internal provisioning AID. Lives under the CSA RID so this applet
|
||||
* can ship in the same CAP as the spec-defined Aliro applets (Java Card
|
||||
* requires all applets in a package share the package's RID). The PIX
|
||||
* tail (ACCE 55 99 01) is non-spec — Aliro v1 uses 0x01/0x02 as the
|
||||
* PIX-low byte; 0x99 stays clear of any future allocation.
|
||||
*
|
||||
* <p>Used by the PersonalizationApplet to receive Access Credential +
|
||||
* reader keys + Access Document before the main Aliro flow runs. The
|
||||
* applet locks itself after COMMIT.
|
||||
*/
|
||||
public static final byte[] PROVISIONING = {
|
||||
(byte)0xA0, (byte)0x00, (byte)0x00, (byte)0x09, (byte)0x09,
|
||||
(byte)0xAC, (byte)0xCE, (byte)0x55, (byte)0x99, (byte)0x01
|
||||
};
|
||||
|
||||
private AliroAids() {}
|
||||
}
|
||||
1048
applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java
Normal file
1048
applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java
Normal file
File diff suppressed because it is too large
Load Diff
265
applet/src/main/java/com/dangerousthings/aliro/AliroCrypto.java
Normal file
265
applet/src/main/java/com/dangerousthings/aliro/AliroCrypto.java
Normal file
@@ -0,0 +1,265 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
import javacard.security.ECPrivateKey;
|
||||
import javacard.security.KeyAgreement;
|
||||
|
||||
/**
|
||||
* Low-level Aliro cryptographic primitives, factored out so they can be
|
||||
* unit-tested independently of the APDU dispatch in {@link AliroApplet}.
|
||||
*
|
||||
* <p>Intended to be instantiated once per applet (or test) and reused —
|
||||
* the bundled {@link KeyAgreement} and {@link AliroHmac} objects are
|
||||
* expensive to allocate.
|
||||
*
|
||||
* <p>HMAC-SHA-256 is supplied by {@link AliroHmac}, our userland
|
||||
* implementation. The target card (NXP J3R180) does not expose
|
||||
* {@code KeyBuilder.TYPE_HMAC} at any tested key size nor
|
||||
* {@code Signature.ALG_HMAC_SHA_256}, so we build HMAC on top of the card's
|
||||
* raw SHA-256 primitive per RFC 2104.
|
||||
*/
|
||||
final class AliroCrypto {
|
||||
|
||||
private static final short HASH_LEN = 32;
|
||||
|
||||
/** Maximum expanded-input buffer size for HKDF-Expand: one T(i-1) block
|
||||
* (32B) + info (up to ~200B, defensively) + one counter byte. Rounded up
|
||||
* to 256B. The largest info we currently pass is the 32-byte salt_volatile
|
||||
* info for expedited-standard derivation; step-up uses 8-byte ASCII info. */
|
||||
private static final short EXPAND_SCRATCH_LEN = 256;
|
||||
|
||||
private KeyAgreement ecdhPlain;
|
||||
private AliroHmac aliroHmac;
|
||||
|
||||
/** Reusable scratch for one HMAC output (T(i)) and one counter byte. */
|
||||
private byte[] hkdfPrevT;
|
||||
|
||||
/** Scratch for assembling the HMAC message for HKDF-Expand: T(i-1) || info || counter. */
|
||||
private byte[] expandScratch;
|
||||
|
||||
/** Scratch for {@link #deriveKdh}: 32B for Z_AB then 32B for PRK. */
|
||||
private byte[] kdfWorkbuf;
|
||||
private static final short KDF_WORKBUF_LEN = 64;
|
||||
|
||||
/** 32 zero bytes — RFC 5869 §2.2 fallback when {@link #hkdfExtract} is
|
||||
* called with an empty salt. Allocated in the constructor; Java Card
|
||||
* bans {@code new} in static initializers, so we can't make this static-final. */
|
||||
private byte[] emptySaltZeros;
|
||||
|
||||
AliroCrypto() {
|
||||
try {
|
||||
ecdhPlain = KeyAgreement.getInstance(KeyAgreement.ALG_EC_SVDP_DH_PLAIN, false);
|
||||
} catch (Throwable t) {
|
||||
javacard.framework.ISOException.throwIt((short) 0x6FC1);
|
||||
}
|
||||
// Userland HMAC-SHA-256 on top of MessageDigest.ALG_SHA_256. Diagnostic
|
||||
// status word 0x6FC7 gives us one regression cycle of visibility on the
|
||||
// real card before we strip it.
|
||||
try {
|
||||
aliroHmac = new AliroHmac();
|
||||
} catch (Throwable t) {
|
||||
javacard.framework.ISOException.throwIt((short) 0x6FC7);
|
||||
}
|
||||
try {
|
||||
hkdfPrevT = javacard.framework.JCSystem.makeTransientByteArray(
|
||||
HASH_LEN, javacard.framework.JCSystem.CLEAR_ON_DESELECT);
|
||||
expandScratch = javacard.framework.JCSystem.makeTransientByteArray(
|
||||
EXPAND_SCRATCH_LEN, javacard.framework.JCSystem.CLEAR_ON_DESELECT);
|
||||
kdfWorkbuf = javacard.framework.JCSystem.makeTransientByteArray(
|
||||
KDF_WORKBUF_LEN, javacard.framework.JCSystem.CLEAR_ON_DESELECT);
|
||||
} catch (Throwable t) {
|
||||
javacard.framework.ISOException.throwIt((short) 0x6FC4);
|
||||
}
|
||||
try {
|
||||
emptySaltZeros = new byte[32]; // persistent, defaults to all zeros
|
||||
} catch (Throwable t) {
|
||||
javacard.framework.ISOException.throwIt((short) 0x6FC5);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the x-coordinate of the ECDH shared point, given a local
|
||||
* P-256 private key and the peer's uncompressed public point
|
||||
* (0x04 || x || y, 65 bytes). Returns 32 (the x-coordinate length).
|
||||
*/
|
||||
short computeEcdhSharedX(
|
||||
ECPrivateKey priv,
|
||||
byte[] peerPubUncomp, short peerPubOff,
|
||||
byte[] out, short outOff) {
|
||||
ecdhPlain.init(priv);
|
||||
return ecdhPlain.generateSecret(peerPubUncomp, peerPubOff, (short) 65, out, outOff);
|
||||
}
|
||||
|
||||
/**
|
||||
* HKDF-Extract per RFC 5869 §2.2 with HMAC-SHA-256:
|
||||
* {@code PRK = HMAC(salt, IKM)}.
|
||||
* Returns 32 (HashLen).
|
||||
*/
|
||||
short hkdfExtract(
|
||||
byte[] salt, short saltOff, short saltLen,
|
||||
byte[] ikm, short ikmOff, short ikmLen,
|
||||
byte[] prk, short prkOff) {
|
||||
// RFC 5869 §2.2: empty salt is treated as HashLen zero bytes.
|
||||
if (saltLen == 0) {
|
||||
return aliroHmac.compute(
|
||||
emptySaltZeros, (short) 0, HASH_LEN,
|
||||
ikm, ikmOff, ikmLen,
|
||||
prk, prkOff);
|
||||
}
|
||||
return aliroHmac.compute(
|
||||
salt, saltOff, saltLen,
|
||||
ikm, ikmOff, ikmLen,
|
||||
prk, prkOff);
|
||||
}
|
||||
|
||||
/**
|
||||
* HKDF-Expand per RFC 5869 §2.3 with HMAC-SHA-256. Generates {@code L}
|
||||
* bytes of output keying material, where {@code L} must be ≤ 255 *
|
||||
* HashLen (8160 bytes — far above any Aliro need). The info region must
|
||||
* be at most {@code EXPAND_SCRATCH_LEN - HASH_LEN - 1} bytes long
|
||||
* (= 223 bytes with the current buffer sizing).
|
||||
*/
|
||||
short hkdfExpand(
|
||||
byte[] prk, short prkOff, short prkLen,
|
||||
byte[] info, short infoOff, short infoLen,
|
||||
short L,
|
||||
byte[] out, short outOff) {
|
||||
|
||||
// T(0) = empty; T(i) = HMAC(PRK, T(i-1) || info || i). Concatenate
|
||||
// T(1)..T(N) and truncate to L bytes.
|
||||
short produced = 0;
|
||||
short prevTLen = 0;
|
||||
byte counter = (byte) 1;
|
||||
|
||||
while (produced < L) {
|
||||
// Assemble the HMAC message as T(i-1) || info || counter in expandScratch.
|
||||
short msgLen = 0;
|
||||
if (prevTLen != 0) {
|
||||
javacard.framework.Util.arrayCopyNonAtomic(
|
||||
hkdfPrevT, (short) 0, expandScratch, msgLen, prevTLen);
|
||||
msgLen += prevTLen;
|
||||
}
|
||||
if (infoLen != 0) {
|
||||
javacard.framework.Util.arrayCopyNonAtomic(
|
||||
info, infoOff, expandScratch, msgLen, infoLen);
|
||||
msgLen += infoLen;
|
||||
}
|
||||
expandScratch[msgLen] = counter;
|
||||
msgLen++;
|
||||
|
||||
aliroHmac.compute(
|
||||
prk, prkOff, prkLen,
|
||||
expandScratch, (short) 0, msgLen,
|
||||
hkdfPrevT, (short) 0);
|
||||
prevTLen = HASH_LEN;
|
||||
|
||||
short remaining = (short) (L - produced);
|
||||
short toCopy = remaining < HASH_LEN ? remaining : HASH_LEN;
|
||||
javacard.framework.Util.arrayCopyNonAtomic(
|
||||
hkdfPrevT, (short) 0, out, (short) (outOff + produced), toCopy);
|
||||
produced += toCopy;
|
||||
counter++;
|
||||
}
|
||||
|
||||
// Wipe the assembled message scratch — it held T(i-1) values (derived
|
||||
// from the HKDF PRK) and the info bytes. CLEAR_ON_DESELECT cleans up
|
||||
// at deselect, but multi-APDU sessions would otherwise see stale T(i).
|
||||
javacard.framework.Util.arrayFillNonAtomic(
|
||||
expandScratch, (short) 0, EXPAND_SCRATCH_LEN, (byte) 0);
|
||||
|
||||
return produced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives Kdh per Aliro §8.3.1.4 (with the §8.3.1.5 HKDF substitution
|
||||
* noted in the spec): {@code Kdh = HKDF(IKM=ECDH_x(priv, peerPub),
|
||||
* salt=txnId, info=∅, L=32)}. Writes 32 bytes to {@code out[outOff..]}
|
||||
* and returns 32.
|
||||
*/
|
||||
short deriveKdh(
|
||||
ECPrivateKey priv,
|
||||
byte[] peerPubUncomp, short peerPubOff,
|
||||
byte[] txnId, short txnIdOff, short txnIdLen,
|
||||
byte[] out, short outOff) {
|
||||
computeEcdhSharedX(priv, peerPubUncomp, peerPubOff, kdfWorkbuf, (short) 0);
|
||||
hkdfExtract(
|
||||
txnId, txnIdOff, txnIdLen,
|
||||
kdfWorkbuf, (short) 0, HASH_LEN,
|
||||
kdfWorkbuf, HASH_LEN);
|
||||
hkdfExpand(
|
||||
kdfWorkbuf, HASH_LEN, HASH_LEN,
|
||||
kdfWorkbuf, (short) 0, (short) 0,
|
||||
HASH_LEN,
|
||||
out, outOff);
|
||||
return HASH_LEN;
|
||||
}
|
||||
|
||||
/** ASCII info strings for step-up session key derivation, per mdoc 9.1.1.5. */
|
||||
private static final byte[] STEP_UP_INFO_SK_DEVICE = {
|
||||
(byte) 'S', (byte) 'K', (byte) 'D', (byte) 'e',
|
||||
(byte) 'v', (byte) 'i', (byte) 'c', (byte) 'e'
|
||||
};
|
||||
private static final byte[] STEP_UP_INFO_SK_READER = {
|
||||
(byte) 'S', (byte) 'K', (byte) 'R', (byte) 'e',
|
||||
(byte) 'a', (byte) 'd', (byte) 'e', (byte) 'r'
|
||||
};
|
||||
|
||||
/**
|
||||
* Derives {@code StepUpSKDevice} and {@code StepUpSKReader} per Aliro
|
||||
* §8.4.3, which points at mdoc [6] clause 9.1.1.5 with two Aliro-specific
|
||||
* changes: IKM is the {@code StepUpSK} computed in §8.3.1.13 and the salt
|
||||
* is empty. Info remains the ASCII bytes of {@code "SKDevice"} and
|
||||
* {@code "SKReader"}.
|
||||
*
|
||||
* <p>Writes 32 bytes each to {@code skDeviceOut[skDeviceOff..]} and
|
||||
* {@code skReaderOut[skReaderOff..]}.
|
||||
*/
|
||||
short deriveStepUpSessionKeys(
|
||||
byte[] stepUpSK, short stepUpSKOff,
|
||||
byte[] skDeviceOut, short skDeviceOff,
|
||||
byte[] skReaderOut, short skReaderOff) {
|
||||
// HKDF-Extract once (shared across the two Expand calls): empty salt.
|
||||
hkdfExtract(
|
||||
kdfWorkbuf, (short) 0, (short) 0, // empty salt — use first 0 bytes of anything
|
||||
stepUpSK, stepUpSKOff, HASH_LEN,
|
||||
kdfWorkbuf, HASH_LEN); // PRK at [32..64)
|
||||
|
||||
hkdfExpand(
|
||||
kdfWorkbuf, HASH_LEN, HASH_LEN,
|
||||
STEP_UP_INFO_SK_DEVICE, (short) 0, (short) STEP_UP_INFO_SK_DEVICE.length,
|
||||
HASH_LEN,
|
||||
skDeviceOut, skDeviceOff);
|
||||
|
||||
hkdfExpand(
|
||||
kdfWorkbuf, HASH_LEN, HASH_LEN,
|
||||
STEP_UP_INFO_SK_READER, (short) 0, (short) STEP_UP_INFO_SK_READER.length,
|
||||
HASH_LEN,
|
||||
skReaderOut, skReaderOff);
|
||||
|
||||
return HASH_LEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the 160-byte {@code derived_keys_volatile} for the
|
||||
* expedited-standard phase per Aliro §8.3.1.13. Output is laid out as
|
||||
* ExpeditedSKReader[0..32), ExpeditedSKDevice[32..64), StepUpSK[64..96),
|
||||
* BleSK[96..128), URSK[128..160). For NFC-only operation only the
|
||||
* first 96 bytes are load-bearing, but the spec mandates generating all
|
||||
* 160 so the caller can discard what it doesn't need.
|
||||
*/
|
||||
short deriveExpeditedStandardKeys(
|
||||
byte[] kdh, short kdhOff,
|
||||
byte[] saltVolatile, short saltOff, short saltLen,
|
||||
byte[] info, short infoOff, short infoLen,
|
||||
byte[] out, short outOff) {
|
||||
hkdfExtract(
|
||||
saltVolatile, saltOff, saltLen,
|
||||
kdh, kdhOff, HASH_LEN,
|
||||
kdfWorkbuf, (short) 0);
|
||||
hkdfExpand(
|
||||
kdfWorkbuf, (short) 0, HASH_LEN,
|
||||
info, infoOff, infoLen,
|
||||
(short) 160,
|
||||
out, outOff);
|
||||
return (short) 160;
|
||||
}
|
||||
}
|
||||
286
applet/src/main/java/com/dangerousthings/aliro/AliroGcm.java
Normal file
286
applet/src/main/java/com/dangerousthings/aliro/AliroGcm.java
Normal file
@@ -0,0 +1,286 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
import javacard.framework.JCSystem;
|
||||
import javacard.framework.Util;
|
||||
import javacard.security.AESKey;
|
||||
import javacard.security.KeyBuilder;
|
||||
import javacardx.crypto.Cipher;
|
||||
|
||||
/**
|
||||
* Userland AES-256-GCM, built on top of the JC 3.0.5 single-block AES-ECB
|
||||
* primitive. Needed because the target card (NXP J3R180) does not expose
|
||||
* {@code AEADCipher.ALG_AES_GCM} despite advertising JC 3.0.5 support, while
|
||||
* {@code Cipher.ALG_AES_BLOCK_128_ECB_NOPAD} and AES-256 keys are both
|
||||
* present and functional.
|
||||
*
|
||||
* <p>Tailored to the Aliro AUTH1 response use case (spec §8.3.1.6):
|
||||
* AES-256 with a 12-byte IV, empty AAD, 16-byte tag, and plaintext on the
|
||||
* order of 137 bytes. The NIST SP 800-38D canonical 96-bit-IV shortcut is
|
||||
* used (IV is taken as {@code IV || 0x00000001} for J<sub>0</sub>, no IV
|
||||
* hashing). This is the only IV length we ever produce — see
|
||||
* {@link AliroApplet#encryptResponseGcm}.
|
||||
*
|
||||
* <p>GHASH follows NIST SP 800-38D §6.4's bit-reflected convention:
|
||||
* byte 0 bit 7 (MSB) is the highest-degree polynomial coefficient; the
|
||||
* reduction polynomial R = x^128 + x^7 + x^2 + x + 1 is represented as the
|
||||
* 16-byte array {0xE1, 0x00, ..., 0x00}; the GHASH shift-right shifts toward
|
||||
* higher byte indices (lower polynomial degrees).
|
||||
*
|
||||
* <p>Not constant-time — the GF(2^128) multiply branches on bits of the
|
||||
* authentication tag partial state. On a secure JC platform this is
|
||||
* acceptable (the key material is smartcard-protected), but callers should
|
||||
* not pass attacker-chosen GHASH state.
|
||||
*/
|
||||
final class AliroGcm {
|
||||
|
||||
private static final short BLOCK_LEN = 16;
|
||||
private static final short TAG_LEN = 16;
|
||||
private static final short IV_LEN = 12; // 96-bit IV shortcut (NIST SP 800-38D §7.1)
|
||||
private static final short AES_256_KEY_BYTES = 32;
|
||||
|
||||
// Scratch layout — all 16-byte slots, single transient allocation.
|
||||
private static final short OFF_H = 0; // hash subkey = AES_K(0^128)
|
||||
private static final short OFF_J0 = 16; // pre-counter block
|
||||
private static final short OFF_CB = 32; // running counter block
|
||||
private static final short OFF_GHASH = 48; // accumulated GHASH state
|
||||
private static final short OFF_ECB_OUT = 64; // AES keystream block (also used for J0 encryption)
|
||||
private static final short OFF_TMP = 80; // misc: GF multiply workspace (V)
|
||||
private static final short OFF_LEN_BLK = 96; // length block for GHASH finalization
|
||||
private static final short SCRATCH_LEN = 112;
|
||||
|
||||
/** Persistent ECB cipher (AES-128/192/256 single-block-NOPAD). AES-256
|
||||
* is driven by the size of the {@link AESKey} passed to {@link Cipher#init}. */
|
||||
private final Cipher aesEcb;
|
||||
|
||||
/** Reusable 256-bit AES key slot. {@link AESKey#setKey} is called per encrypt. */
|
||||
private final AESKey aesKey;
|
||||
|
||||
/** Transient scratch — see OFF_* constants above. */
|
||||
private final byte[] scratch;
|
||||
|
||||
AliroGcm() {
|
||||
aesEcb = Cipher.getInstance(Cipher.ALG_AES_BLOCK_128_ECB_NOPAD, false);
|
||||
aesKey = (AESKey) KeyBuilder.buildKey(
|
||||
KeyBuilder.TYPE_AES, KeyBuilder.LENGTH_AES_256, false);
|
||||
scratch = JCSystem.makeTransientByteArray(SCRATCH_LEN, JCSystem.CLEAR_ON_DESELECT);
|
||||
}
|
||||
|
||||
/**
|
||||
* AES-256-GCM encrypt with the Aliro parameters: 32-byte key, 12-byte IV,
|
||||
* empty AAD, 16-byte tag appended. Writes {@code ptLen} ciphertext bytes
|
||||
* followed by 16 tag bytes to {@code out[outOff..]} and returns
|
||||
* {@code ptLen + 16}.
|
||||
*
|
||||
* <p>The {@code out} buffer may overlap {@code pt} (same buffer, same
|
||||
* offset is supported — we encrypt block-by-block left-to-right, the
|
||||
* GHASH pass reads from {@code out}, and the tag lands past the end of
|
||||
* the ciphertext).
|
||||
*
|
||||
* @param key 32-byte AES-256 key, at {@code key[keyOff..keyOff+32)}
|
||||
* @param iv 12-byte IV, at {@code iv[ivOff..ivOff+12)}
|
||||
* @param pt plaintext bytes
|
||||
* @param ptOff, ptLen plaintext region
|
||||
* @param out output buffer, must have at least {@code ptLen + 16} bytes
|
||||
* available at {@code outOff}
|
||||
* @param outOff output start offset
|
||||
* @return {@code ptLen + 16}
|
||||
*/
|
||||
short encrypt(
|
||||
byte[] key, short keyOff,
|
||||
byte[] iv, short ivOff,
|
||||
byte[] pt, short ptOff, short ptLen,
|
||||
byte[] out, short outOff) {
|
||||
|
||||
aesKey.setKey(key, keyOff);
|
||||
aesEcb.init(aesKey, Cipher.MODE_ENCRYPT);
|
||||
|
||||
// H = AES_K(0^128). Zero scratch[OFF_H..OFF_H+16) and encrypt in place.
|
||||
Util.arrayFillNonAtomic(scratch, OFF_H, BLOCK_LEN, (byte) 0);
|
||||
aesEcb.doFinal(scratch, OFF_H, BLOCK_LEN, scratch, OFF_H);
|
||||
|
||||
// J0 = IV || 0x00000001 (12B IV + 4B counter). 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;
|
||||
|
||||
// cb = INC32(J0) — first counter block for the plaintext stream.
|
||||
Util.arrayCopyNonAtomic(scratch, OFF_J0, scratch, OFF_CB, BLOCK_LEN);
|
||||
inc32(scratch, OFF_CB);
|
||||
|
||||
// GCTR loop: stream plaintext through AES-CTR, writing ciphertext to out.
|
||||
short produced = 0;
|
||||
while (produced < ptLen) {
|
||||
short blockLen = (short) (ptLen - produced);
|
||||
if (blockLen > BLOCK_LEN) blockLen = BLOCK_LEN;
|
||||
|
||||
// keystream = AES_K(cb)
|
||||
aesEcb.doFinal(scratch, OFF_CB, BLOCK_LEN, scratch, OFF_ECB_OUT);
|
||||
|
||||
// ct[i] = pt[i] XOR keystream[i] — partial-block safe.
|
||||
for (short i = 0; i < blockLen; i++) {
|
||||
out[(short) (outOff + produced + i)] =
|
||||
(byte) (pt[(short) (ptOff + produced + i)]
|
||||
^ scratch[(short) (OFF_ECB_OUT + i)]);
|
||||
}
|
||||
|
||||
inc32(scratch, OFF_CB);
|
||||
produced += blockLen;
|
||||
}
|
||||
|
||||
// GHASH over (AAD=empty || ciphertext || len_block).
|
||||
// state = 0^128
|
||||
Util.arrayFillNonAtomic(scratch, OFF_GHASH, BLOCK_LEN, (byte) 0);
|
||||
|
||||
// Fold ciphertext blocks into GHASH, zero-padding the final partial block.
|
||||
short consumed = 0;
|
||||
while (consumed < ptLen) {
|
||||
short chunk = (short) (ptLen - consumed);
|
||||
if (chunk >= BLOCK_LEN) {
|
||||
// XOR full 16-byte block directly from the output buffer.
|
||||
for (short i = 0; i < BLOCK_LEN; i++) {
|
||||
scratch[(short) (OFF_GHASH + i)] ^= out[(short) (outOff + consumed + i)];
|
||||
}
|
||||
consumed += BLOCK_LEN;
|
||||
} else {
|
||||
// Partial block: XOR the available bytes, implicit zero-pad for the rest.
|
||||
for (short i = 0; i < chunk; i++) {
|
||||
scratch[(short) (OFF_GHASH + i)] ^= out[(short) (outOff + consumed + i)];
|
||||
}
|
||||
consumed += chunk;
|
||||
}
|
||||
gfMul(scratch, OFF_GHASH, scratch, OFF_H);
|
||||
}
|
||||
|
||||
// len_block = (8*|AAD|)^64 || (8*|C|)^64, big-endian. AAD is always empty
|
||||
// for this use case; ptLen fits in 16 bits, so only the low 2 bytes of
|
||||
// the 8-byte plaintext-bit-length field can be non-zero.
|
||||
Util.arrayFillNonAtomic(scratch, OFF_LEN_BLK, BLOCK_LEN, (byte) 0);
|
||||
// ptLen·8 in bits, big-endian, into the last 4 bytes of the 16-byte
|
||||
// length block (bits 0-95 are zero — AAD-bits=0 + high bytes of CT-bits).
|
||||
// Java Card 3.0.5 forbids `int` literals, so we compute as `short` and
|
||||
// split the byte-shift into two stages to dodge the implicit `int`
|
||||
// promotion that the converter rejects ("unsupported int type constant").
|
||||
// ptLen ≤ 8191 ⇒ ptLen·8 ≤ 65528, fits in unsigned 16 bits. The top two
|
||||
// bytes of the length field stay zero.
|
||||
short ctBitsLo = (short) (ptLen << 3); // low 16 bits of bit-count
|
||||
short ctBitsHi = (short) (((short)(ptLen >>> 13)) & 0x07); // bits 16-18 of bit-count
|
||||
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 = AES_K(J0) XOR GHASH
|
||||
aesEcb.doFinal(scratch, OFF_J0, BLOCK_LEN, scratch, OFF_ECB_OUT);
|
||||
short tagOut = (short) (outOff + ptLen);
|
||||
for (short i = 0; i < TAG_LEN; i++) {
|
||||
out[(short) (tagOut + i)] =
|
||||
(byte) (scratch[(short) (OFF_ECB_OUT + i)]
|
||||
^ scratch[(short) (OFF_GHASH + i)]);
|
||||
}
|
||||
|
||||
// Wipe scratch: H, J0 (with IV bytes), GHASH state, and the keystream
|
||||
// remnant in OFF_ECB_OUT all touched the AES key directly. CLEAR_ON_DESELECT
|
||||
// would clean up at deselect, but multi-APDU sessions could otherwise
|
||||
// see stale H/keystream until then.
|
||||
Util.arrayFillNonAtomic(scratch, (short) 0, SCRATCH_LEN, (byte) 0);
|
||||
|
||||
return (short) (ptLen + TAG_LEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* INC32 per NIST SP 800-38D §6.2: increments the last 4 bytes of the
|
||||
* 16-byte block, big-endian, modulo 2^32.
|
||||
*/
|
||||
private static void inc32(byte[] buf, short off) {
|
||||
short i = (short) (off + 15);
|
||||
// Carry propagation across 4 bytes.
|
||||
if ((byte) (++buf[i]) != (byte) 0) return;
|
||||
i--;
|
||||
if ((byte) (++buf[i]) != (byte) 0) return;
|
||||
i--;
|
||||
if ((byte) (++buf[i]) != (byte) 0) return;
|
||||
i--;
|
||||
buf[i]++;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-place GF(2^128) multiplication per NIST SP 800-38D §6.3, storing
|
||||
* X ·_GF Y into X. Uses the bit-reflected convention:
|
||||
* <ul>
|
||||
* <li>byte 0 MSB = x^127 (highest-degree polynomial coefficient)
|
||||
* <li>byte 15 LSB = x^0
|
||||
* <li>reduction polynomial R = x^128 + x^7 + x^2 + x + 1 represented
|
||||
* as {0xE1, 0x00, ..., 0x00}
|
||||
* <li>"shift right" means shift toward higher byte indices
|
||||
* </ul>
|
||||
*
|
||||
* <p>Algorithm 1 from NIST SP 800-38D:
|
||||
* <pre>
|
||||
* Z = 0; V = Y
|
||||
* for i = 0..127:
|
||||
* if bit i of X is 1: Z ^= V
|
||||
* if lowest bit of V (byte 15 bit 0) is 1: V = (V >> 1) ^ R
|
||||
* else: V = V >> 1
|
||||
* return Z
|
||||
* </pre>
|
||||
*
|
||||
* <p>This is the simple, portable NIST shift-XOR form. A 4-bit-table
|
||||
* variant would be ~4x faster but adds 32 bytes of per-H precomputation
|
||||
* and a more intricate shift; we encrypt ~137B per AUTH1, so the extra
|
||||
* cycles are acceptable versus the complexity cost.
|
||||
*/
|
||||
private void gfMul(byte[] xBuf, short xOff, byte[] yBuf, short yOff) {
|
||||
// V = Y (copied into OFF_TMP scratch region; gets shifted in place)
|
||||
Util.arrayCopyNonAtomic(yBuf, yOff, scratch, OFF_TMP, BLOCK_LEN);
|
||||
|
||||
// Z = 0 (we'll accumulate in a second 16-byte region — reuse OFF_LEN_BLK,
|
||||
// which is free until after the main GHASH loop finishes). gfMul is
|
||||
// called both during GHASH accumulation AND during final-length mixing;
|
||||
// at final-length mixing OFF_LEN_BLK has already been consumed, so it's
|
||||
// safe to overwrite. But to avoid an aliasing bug if OFF_LEN_BLK is
|
||||
// ever reused differently, we stage Z in OFF_ECB_OUT instead.
|
||||
//
|
||||
// Wait — OFF_ECB_OUT holds the AES keystream we need later for the
|
||||
// tag (T = AES_K(J0) XOR GHASH). However, gfMul is never called
|
||||
// between producing OFF_ECB_OUT (the AES_K(J0) output) and consuming
|
||||
// it for the tag XOR. The last gfMul is for (state ^ len_block) ·_GF H,
|
||||
// and only AFTER that do we compute AES_K(J0) into OFF_ECB_OUT. So
|
||||
// using OFF_ECB_OUT as the Z accumulator is safe.
|
||||
Util.arrayFillNonAtomic(scratch, OFF_ECB_OUT, BLOCK_LEN, (byte) 0);
|
||||
|
||||
for (short byteIdx = 0; byteIdx < BLOCK_LEN; byteIdx++) {
|
||||
byte xByte = xBuf[(short) (xOff + byteIdx)];
|
||||
for (short bit = 7; bit >= 0; bit--) {
|
||||
// bit of X at polynomial position (byteIdx*8 + (7-bit)): byte-high bit first.
|
||||
if (((xByte >> bit) & 0x01) != 0) {
|
||||
for (short k = 0; k < BLOCK_LEN; k++) {
|
||||
scratch[(short) (OFF_ECB_OUT + k)] ^= scratch[(short) (OFF_TMP + k)];
|
||||
}
|
||||
}
|
||||
// V = V >> 1; if the bit that fell off (byte 15 low bit BEFORE shift) is 1,
|
||||
// also XOR the reduction polynomial {0xE1, 0x00, ..., 0x00}.
|
||||
boolean lsb = (scratch[(short) (OFF_TMP + 15)] & 0x01) != 0;
|
||||
// Shift V right by 1 bit (toward higher byte indices).
|
||||
short carry = 0;
|
||||
for (short k = 0; k < BLOCK_LEN; k++) {
|
||||
short cur = (short) (scratch[(short) (OFF_TMP + k)] & 0xFF);
|
||||
short newCur = (short) ((cur >>> 1) | carry);
|
||||
carry = (short) ((cur & 0x01) << 7);
|
||||
scratch[(short) (OFF_TMP + k)] = (byte) newCur;
|
||||
}
|
||||
if (lsb) {
|
||||
scratch[OFF_TMP] ^= (byte) 0xE1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy Z back into X in place.
|
||||
Util.arrayCopyNonAtomic(scratch, OFF_ECB_OUT, xBuf, xOff, BLOCK_LEN);
|
||||
}
|
||||
}
|
||||
110
applet/src/main/java/com/dangerousthings/aliro/AliroHmac.java
Normal file
110
applet/src/main/java/com/dangerousthings/aliro/AliroHmac.java
Normal file
@@ -0,0 +1,110 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
import javacard.framework.JCSystem;
|
||||
import javacard.framework.Util;
|
||||
import javacard.security.MessageDigest;
|
||||
|
||||
/**
|
||||
* Userland HMAC-SHA-256 per RFC 2104, built on top of the JC
|
||||
* {@link MessageDigest#ALG_SHA_256} primitive. Needed because the target card
|
||||
* (NXP J3R180) does not expose {@code KeyBuilder.TYPE_HMAC} at any tested key
|
||||
* size (512/256/160/128 bits) nor {@code Signature.ALG_HMAC_SHA_256}, while
|
||||
* raw SHA-256 is present and functional.
|
||||
*
|
||||
* <p>Each {@link #compute} call reads a key and a message, zero-pads the key
|
||||
* to the SHA-256 block size (64 bytes) — first hashing the key down with
|
||||
* SHA-256 if it exceeds the block size — then computes:
|
||||
* <pre>
|
||||
* HMAC(K, M) = SHA-256((K_pad XOR opad) || SHA-256((K_pad XOR ipad) || M))
|
||||
* </pre>
|
||||
* with {@code ipad = 0x36 × 64} and {@code opad = 0x5C × 64}. Output is
|
||||
* always 32 bytes.
|
||||
*
|
||||
* <p>Intended to be instantiated once per applet (or test) and reused — the
|
||||
* underlying {@link MessageDigest} is expensive to allocate.
|
||||
*/
|
||||
final class AliroHmac {
|
||||
|
||||
private static final short BLOCK_LEN = 64; // SHA-256 block size
|
||||
private static final short HASH_LEN = 32; // SHA-256 output size
|
||||
|
||||
private static final byte IPAD_BYTE = (byte) 0x36;
|
||||
private static final byte OPAD_BYTE = (byte) 0x5C;
|
||||
|
||||
// Scratch layout: 64B pad block || 32B inner digest = 96B total.
|
||||
private static final short OFF_PAD = 0;
|
||||
private static final short OFF_INNER = 64;
|
||||
private static final short SCRATCH_LEN = 96;
|
||||
|
||||
/** Persistent SHA-256 digest. Always reset before reuse — JC
|
||||
* {@code MessageDigest} does not auto-reset after {@link MessageDigest#doFinal}. */
|
||||
private final MessageDigest sha256;
|
||||
|
||||
/** Transient scratch — see OFF_* constants above. */
|
||||
private final byte[] scratch;
|
||||
|
||||
AliroHmac() {
|
||||
sha256 = MessageDigest.getInstance(MessageDigest.ALG_SHA_256, false);
|
||||
scratch = JCSystem.makeTransientByteArray(SCRATCH_LEN, JCSystem.CLEAR_ON_DESELECT);
|
||||
}
|
||||
|
||||
/**
|
||||
* HMAC-SHA-256(key, msg). Writes 32 bytes to {@code out[outOff..outOff+32)}
|
||||
* and returns 32. The key is copied into internal scratch before the first
|
||||
* hash, and the message is consumed during the inner SHA-256 pass before
|
||||
* anything is written to {@code out}, so the {@code key}, {@code msg}, and
|
||||
* {@code out} buffers may alias each other freely.
|
||||
*/
|
||||
short compute(
|
||||
byte[] key, short keyOff, short keyLen,
|
||||
byte[] msg, short msgOff, short msgLen,
|
||||
byte[] out, short outOff) {
|
||||
|
||||
// --- Build K_pad into scratch[OFF_PAD..OFF_PAD+64) ---
|
||||
// Zero the 64-byte pad region first so any unused key-tail bytes stay 0.
|
||||
Util.arrayFillNonAtomic(scratch, OFF_PAD, BLOCK_LEN, (byte) 0);
|
||||
|
||||
if (keyLen > BLOCK_LEN) {
|
||||
// K' = SHA-256(K), 32 bytes; the remaining 32 bytes stay zero.
|
||||
sha256.reset();
|
||||
sha256.doFinal(key, keyOff, keyLen, scratch, OFF_PAD);
|
||||
} else if (keyLen > 0) {
|
||||
Util.arrayCopyNonAtomic(key, keyOff, scratch, OFF_PAD, keyLen);
|
||||
}
|
||||
// else keyLen == 0: scratch[OFF_PAD..OFF_PAD+64) stays all zeros.
|
||||
|
||||
// --- Inner: SHA-256((K_pad XOR ipad) || M) into scratch[OFF_INNER..) ---
|
||||
for (short i = 0; i < BLOCK_LEN; i++) {
|
||||
scratch[(short) (OFF_PAD + i)] ^= IPAD_BYTE;
|
||||
}
|
||||
sha256.reset();
|
||||
sha256.update(scratch, OFF_PAD, BLOCK_LEN);
|
||||
if (msgLen > 0) {
|
||||
sha256.doFinal(msg, msgOff, msgLen, scratch, OFF_INNER);
|
||||
} else {
|
||||
// doFinal with length 0 — pass the same buffer as a harmless source.
|
||||
sha256.doFinal(scratch, OFF_PAD, (short) 0, scratch, OFF_INNER);
|
||||
}
|
||||
|
||||
// --- Recover K_pad from (K_pad XOR ipad), then XOR opad. ---
|
||||
// (K_pad XOR ipad) XOR (ipad XOR opad) = K_pad XOR opad, so one pass
|
||||
// over the block suffices. ipad XOR opad = 0x36 XOR 0x5C = 0x6A.
|
||||
for (short i = 0; i < BLOCK_LEN; i++) {
|
||||
scratch[(short) (OFF_PAD + i)] ^= (byte) 0x6A;
|
||||
}
|
||||
|
||||
// --- Outer: SHA-256((K_pad XOR opad) || inner) into out[outOff..) ---
|
||||
sha256.reset();
|
||||
sha256.update(scratch, OFF_PAD, BLOCK_LEN);
|
||||
sha256.doFinal(scratch, OFF_INNER, HASH_LEN, out, outOff);
|
||||
|
||||
// Wipe scratch — it held K_pad (derived from the HMAC key) and the
|
||||
// inner digest, both of which would leak key-dependent state to a
|
||||
// later HMAC invocation on a different key otherwise. CLEAR_ON_DESELECT
|
||||
// cleans up at deselect, but multi-APDU sessions could see stale
|
||||
// state until then.
|
||||
Util.arrayFillNonAtomic(scratch, (short) 0, SCRATCH_LEN, (byte) 0);
|
||||
|
||||
return HASH_LEN;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
/**
|
||||
* Shared, package-private provisioning store. Holds the Access Credential
|
||||
* private key and (eventually) reader public keys. The
|
||||
* {@link PersonalizationApplet} writes into it; {@link AliroApplet} reads
|
||||
* from it when it needs long-term keys for AUTH1 / step-up.
|
||||
*
|
||||
* <p>Two applets in the same CAP file share this class's static state
|
||||
* directly — no {@code Shareable} interface needed.
|
||||
*
|
||||
* <p>After {@link #commit()} is called, further writes via the
|
||||
* personalization interface are refused. The only way to re-unlock is
|
||||
* a factory reset, which is not yet implemented.
|
||||
*/
|
||||
final class CredentialStore {
|
||||
|
||||
static final short CRED_PRIV_KEY_LEN = 32;
|
||||
static final short CRED_PUBK_LEN = 64;
|
||||
static final short READER_PUBK_LEN = 64;
|
||||
|
||||
/** Max Access Document blob size (serialized COSE_Sign1 bytes). 1 KB
|
||||
* accommodates a typical Aliro Access Document with room to spare. */
|
||||
static final short ACCESS_DOC_MAX_LEN = 1024;
|
||||
|
||||
/** Lazily initialized in {@link #get()}. Java Card bans {@code new} in
|
||||
* static initializers, so we can't declare {@code = new CredentialStore()}. */
|
||||
private static CredentialStore INSTANCE;
|
||||
|
||||
private final byte[] credentialPrivKey;
|
||||
private boolean credentialPrivKeySet;
|
||||
|
||||
private final byte[] credentialPubKey;
|
||||
private boolean credentialPubKeySet;
|
||||
|
||||
private final byte[] readerPubKey;
|
||||
private boolean readerPubKeySet;
|
||||
|
||||
private final byte[] accessDocument;
|
||||
private short accessDocumentLen;
|
||||
private boolean accessDocumentFinalized;
|
||||
|
||||
private boolean committed;
|
||||
|
||||
private CredentialStore() {
|
||||
credentialPrivKey = new byte[CRED_PRIV_KEY_LEN];
|
||||
credentialPubKey = new byte[CRED_PUBK_LEN];
|
||||
readerPubKey = new byte[READER_PUBK_LEN];
|
||||
accessDocument = new byte[ACCESS_DOC_MAX_LEN];
|
||||
}
|
||||
|
||||
static CredentialStore get() {
|
||||
if (INSTANCE == null) {
|
||||
INSTANCE = new CredentialStore();
|
||||
}
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
boolean isCommitted() {
|
||||
return committed;
|
||||
}
|
||||
|
||||
void commit() {
|
||||
committed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true iff a credential private key has been written. Callers
|
||||
* that need the actual bytes should use {@link #copyCredentialPrivKey}.
|
||||
*/
|
||||
boolean hasCredentialPrivKey() {
|
||||
return credentialPrivKeySet;
|
||||
}
|
||||
|
||||
void setCredentialPrivKey(byte[] src, short off) {
|
||||
javacard.framework.Util.arrayCopyNonAtomic(
|
||||
src, off, credentialPrivKey, (short) 0, CRED_PRIV_KEY_LEN);
|
||||
credentialPrivKeySet = true;
|
||||
}
|
||||
|
||||
short copyCredentialPrivKey(byte[] dst, short dstOff) {
|
||||
javacard.framework.Util.arrayCopyNonAtomic(
|
||||
credentialPrivKey, (short) 0, dst, dstOff, CRED_PRIV_KEY_LEN);
|
||||
return CRED_PRIV_KEY_LEN;
|
||||
}
|
||||
|
||||
boolean hasCredentialPubKey() {
|
||||
return credentialPubKeySet;
|
||||
}
|
||||
|
||||
void setCredentialPubKey(byte[] src, short off) {
|
||||
javacard.framework.Util.arrayCopyNonAtomic(
|
||||
src, off, credentialPubKey, (short) 0, CRED_PUBK_LEN);
|
||||
credentialPubKeySet = true;
|
||||
}
|
||||
|
||||
short copyCredentialPubKey(byte[] dst, short dstOff) {
|
||||
javacard.framework.Util.arrayCopyNonAtomic(
|
||||
credentialPubKey, (short) 0, dst, dstOff, CRED_PUBK_LEN);
|
||||
return CRED_PUBK_LEN;
|
||||
}
|
||||
|
||||
/** Copies only the 32-byte x-coordinate of credential_PubK (spec uses x-only in salt_volatile). */
|
||||
short copyCredentialPubKeyX(byte[] dst, short dstOff) {
|
||||
javacard.framework.Util.arrayCopyNonAtomic(
|
||||
credentialPubKey, (short) 0, dst, dstOff, (short) 32);
|
||||
return (short) 32;
|
||||
}
|
||||
|
||||
boolean hasReaderPubKey() {
|
||||
return readerPubKeySet;
|
||||
}
|
||||
|
||||
void setReaderPubKey(byte[] src, short off) {
|
||||
javacard.framework.Util.arrayCopyNonAtomic(
|
||||
src, off, readerPubKey, (short) 0, READER_PUBK_LEN);
|
||||
readerPubKeySet = true;
|
||||
}
|
||||
|
||||
short copyReaderPubKey(byte[] dst, short dstOff) {
|
||||
javacard.framework.Util.arrayCopyNonAtomic(
|
||||
readerPubKey, (short) 0, dst, dstOff, READER_PUBK_LEN);
|
||||
return READER_PUBK_LEN;
|
||||
}
|
||||
|
||||
/** Copies only the 32-byte x-coordinate of the reader_PubK. */
|
||||
short copyReaderPubKeyX(byte[] dst, short dstOff) {
|
||||
javacard.framework.Util.arrayCopyNonAtomic(
|
||||
readerPubKey, (short) 0, dst, dstOff, (short) 32);
|
||||
return (short) 32;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores an Access Document chunk at the given destination offset.
|
||||
* Returns true iff the write stayed within {@link #ACCESS_DOC_MAX_LEN};
|
||||
* otherwise the store is unchanged.
|
||||
*/
|
||||
boolean writeAccessDocumentChunk(byte[] src, short srcOff, short dstOff, short len) {
|
||||
if ((short) (dstOff + len) > ACCESS_DOC_MAX_LEN || dstOff < 0 || len < 0) {
|
||||
return false;
|
||||
}
|
||||
javacard.framework.Util.arrayCopyNonAtomic(src, srcOff, accessDocument, dstOff, len);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the Access Document as provisioned with {@code totalLen} bytes
|
||||
* of valid content starting at offset 0. Returns false (and does not
|
||||
* mutate state) if {@code totalLen} is outside {@code [0, ACCESS_DOC_MAX_LEN]}.
|
||||
*/
|
||||
boolean finalizeAccessDocument(short totalLen) {
|
||||
if (totalLen < 0 || totalLen > ACCESS_DOC_MAX_LEN) {
|
||||
return false;
|
||||
}
|
||||
accessDocumentLen = totalLen;
|
||||
accessDocumentFinalized = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
boolean hasAccessDocument() {
|
||||
return accessDocumentFinalized;
|
||||
}
|
||||
|
||||
short getAccessDocumentLen() {
|
||||
return accessDocumentLen;
|
||||
}
|
||||
|
||||
short copyAccessDocument(byte[] dst, short dstOff, short srcOff, short len) {
|
||||
javacard.framework.Util.arrayCopyNonAtomic(accessDocument, srcOff, dst, dstOff, len);
|
||||
return len;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only hook. The store is a static singleton, which makes unit
|
||||
* tests interfere with each other unless each test starts from a
|
||||
* known-blank slate. Package-private and named for clarity.
|
||||
*/
|
||||
void resetForTesting() {
|
||||
javacard.framework.Util.arrayFillNonAtomic(credentialPrivKey, (short) 0, CRED_PRIV_KEY_LEN, (byte) 0);
|
||||
javacard.framework.Util.arrayFillNonAtomic(credentialPubKey, (short) 0, CRED_PUBK_LEN, (byte) 0);
|
||||
javacard.framework.Util.arrayFillNonAtomic(readerPubKey, (short) 0, READER_PUBK_LEN, (byte) 0);
|
||||
javacard.framework.Util.arrayFillNonAtomic(accessDocument, (short) 0, ACCESS_DOC_MAX_LEN, (byte) 0);
|
||||
credentialPrivKeySet = false;
|
||||
credentialPubKeySet = false;
|
||||
readerPubKeySet = false;
|
||||
accessDocumentLen = 0;
|
||||
accessDocumentFinalized = false;
|
||||
committed = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
import javacard.framework.APDU;
|
||||
import javacard.framework.Applet;
|
||||
import javacard.framework.ISO7816;
|
||||
import javacard.framework.ISOException;
|
||||
|
||||
/**
|
||||
* Receives Access Credential long-term private key, reader public keys,
|
||||
* and (later) the pre-signed Access Document bytes. Accepts writes only
|
||||
* until {@link CredentialStore#commit()}; afterwards every write returns
|
||||
* SW_CONDITIONS_NOT_SATISFIED.
|
||||
*
|
||||
* <p>This applet lives under {@link AliroAids#PROVISIONING}, a
|
||||
* DangerousThings-proprietary AID — it is not part of the Aliro spec
|
||||
* surface.
|
||||
*/
|
||||
public class PersonalizationApplet extends Applet {
|
||||
|
||||
private static final byte CLA = (byte) 0x80;
|
||||
|
||||
private static final byte INS_SET_CREDENTIAL_PRIV_KEY = (byte) 0x20;
|
||||
private static final byte INS_SET_CREDENTIAL_PUBK = (byte) 0x21;
|
||||
private static final byte INS_SET_READER_PUBK = (byte) 0x22;
|
||||
private static final byte INS_WRITE_ACCESS_DOC = (byte) 0x23;
|
||||
private static final byte INS_FINALIZE_ACCESS_DOC = (byte) 0x24;
|
||||
private static final byte INS_COMMIT = (byte) 0x2C;
|
||||
|
||||
public static void install(byte[] bArray, short bOffset, byte bLength) {
|
||||
PersonalizationApplet applet = new PersonalizationApplet();
|
||||
if (bArray == null || bLength == 0) {
|
||||
applet.register();
|
||||
} else {
|
||||
applet.register(bArray, (short) (bOffset + 1), bArray[bOffset]);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void process(APDU apdu) {
|
||||
if (selectingApplet()) {
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] buf = apdu.getBuffer();
|
||||
if (buf[ISO7816.OFFSET_CLA] != CLA) {
|
||||
ISOException.throwIt(ISO7816.SW_CLA_NOT_SUPPORTED);
|
||||
}
|
||||
|
||||
CredentialStore store = CredentialStore.get();
|
||||
byte ins = buf[ISO7816.OFFSET_INS];
|
||||
|
||||
switch (ins) {
|
||||
case INS_SET_CREDENTIAL_PRIV_KEY:
|
||||
requireUnlocked(store);
|
||||
setCredentialPrivKey(apdu, store);
|
||||
return;
|
||||
case INS_SET_CREDENTIAL_PUBK:
|
||||
requireUnlocked(store);
|
||||
setCredentialPubKey(apdu, store);
|
||||
return;
|
||||
case INS_SET_READER_PUBK:
|
||||
requireUnlocked(store);
|
||||
setReaderPubKey(apdu, store);
|
||||
return;
|
||||
case INS_WRITE_ACCESS_DOC:
|
||||
requireUnlocked(store);
|
||||
writeAccessDocChunk(apdu, store);
|
||||
return;
|
||||
case INS_FINALIZE_ACCESS_DOC:
|
||||
requireUnlocked(store);
|
||||
finalizeAccessDoc(apdu, store);
|
||||
return;
|
||||
case INS_COMMIT:
|
||||
requireUnlocked(store);
|
||||
store.commit();
|
||||
return;
|
||||
default:
|
||||
ISOException.throwIt(ISO7816.SW_INS_NOT_SUPPORTED);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireUnlocked(CredentialStore store) {
|
||||
if (store.isCommitted()) {
|
||||
ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED);
|
||||
}
|
||||
}
|
||||
|
||||
private void setCredentialPrivKey(APDU apdu, CredentialStore store) {
|
||||
short lc = apdu.setIncomingAndReceive();
|
||||
if (lc != CredentialStore.CRED_PRIV_KEY_LEN) {
|
||||
ISOException.throwIt(ISO7816.SW_WRONG_DATA);
|
||||
}
|
||||
byte[] buf = apdu.getBuffer();
|
||||
store.setCredentialPrivKey(buf, apdu.getOffsetCdata());
|
||||
}
|
||||
|
||||
private void setCredentialPubKey(APDU apdu, CredentialStore store) {
|
||||
short lc = apdu.setIncomingAndReceive();
|
||||
if (lc != CredentialStore.CRED_PUBK_LEN) {
|
||||
ISOException.throwIt(ISO7816.SW_WRONG_DATA);
|
||||
}
|
||||
byte[] buf = apdu.getBuffer();
|
||||
store.setCredentialPubKey(buf, apdu.getOffsetCdata());
|
||||
}
|
||||
|
||||
private void setReaderPubKey(APDU apdu, CredentialStore store) {
|
||||
short lc = apdu.setIncomingAndReceive();
|
||||
if (lc != CredentialStore.READER_PUBK_LEN) {
|
||||
ISOException.throwIt(ISO7816.SW_WRONG_DATA);
|
||||
}
|
||||
byte[] buf = apdu.getBuffer();
|
||||
store.setReaderPubKey(buf, apdu.getOffsetCdata());
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an Access Document chunk. P1|P2 is the destination offset
|
||||
* within the stored Access Document (big-endian unsigned 16-bit); the
|
||||
* command data field is the chunk bytes.
|
||||
*/
|
||||
private void writeAccessDocChunk(APDU apdu, CredentialStore store) {
|
||||
short lc = apdu.setIncomingAndReceive();
|
||||
byte[] buf = apdu.getBuffer();
|
||||
short dstOff = (short) (((buf[ISO7816.OFFSET_P1] & 0xFF) << 8)
|
||||
| (buf[ISO7816.OFFSET_P2] & 0xFF));
|
||||
boolean ok = store.writeAccessDocumentChunk(
|
||||
buf, apdu.getOffsetCdata(), dstOff, lc);
|
||||
if (!ok) {
|
||||
ISOException.throwIt(ISO7816.SW_WRONG_DATA);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the Access Document as provisioned. P1|P2 is the total length
|
||||
* (big-endian unsigned 16-bit). Lc must be 0.
|
||||
*/
|
||||
private void finalizeAccessDoc(APDU apdu, CredentialStore store) {
|
||||
short lc = apdu.setIncomingAndReceive();
|
||||
if (lc != 0) {
|
||||
ISOException.throwIt(ISO7816.SW_WRONG_DATA);
|
||||
}
|
||||
byte[] buf = apdu.getBuffer();
|
||||
short totalLen = (short) (((buf[ISO7816.OFFSET_P1] & 0xFF) << 8)
|
||||
| (buf[ISO7816.OFFSET_P2] & 0xFF));
|
||||
if (!store.finalizeAccessDocument(totalLen)) {
|
||||
ISOException.throwIt(ISO7816.SW_WRONG_DATA);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
import javacard.framework.JCSystem;
|
||||
import javacard.framework.Util;
|
||||
|
||||
/**
|
||||
* Cross-applet session state that survives an AID change (EXPEDITED →
|
||||
* STEP_UP) but not a card reset. Since Java Card installs distinct
|
||||
* applet instances per AID, {@link AliroApplet} and {@link StepUpApplet}
|
||||
* need a shared holder to hand off {@code StepUpSK}, the session-bound
|
||||
* 32-byte key derived during AUTH1 and consumed in the step-up phase.
|
||||
*
|
||||
* <p>Storage is a {@link JCSystem#CLEAR_ON_RESET} transient array,
|
||||
* lazily allocated on first access so that neither applet's constructor
|
||||
* assumes a particular install order. The reference is a static field;
|
||||
* both applets see the same underlying array.
|
||||
*
|
||||
* <p>State machine:
|
||||
* <ul>
|
||||
* <li>Initial / post-reset: disarmed, {@code StepUpSK} zeroed.
|
||||
* <li>{@link AliroApplet} SELECT or failure path: calls
|
||||
* {@link #disarmStepUp()} to clear any stale state.
|
||||
* <li>{@link AliroApplet} AUTH1 success: calls
|
||||
* {@link #armStepUp(byte[], short)} to load the freshly-derived
|
||||
* StepUpSK and flip the armed flag.
|
||||
* <li>{@link StepUpApplet}: reads via {@link #isStepUpArmed()} + (future)
|
||||
* an internal {@code copyStepUpSK} call to derive session keys.
|
||||
* </ul>
|
||||
*/
|
||||
final class SessionContext {
|
||||
|
||||
static final short STEP_UP_SK_LEN = 32;
|
||||
|
||||
private static byte[] stepUpSK;
|
||||
private static boolean[] armedFlag; // length 1 — boolean transient arrays are per-JC convention
|
||||
|
||||
private SessionContext() {}
|
||||
|
||||
/** Allocates the transient storage on first call. Subsequent calls are no-ops. */
|
||||
static void ensureInitialized() {
|
||||
if (stepUpSK == null) {
|
||||
stepUpSK = JCSystem.makeTransientByteArray(STEP_UP_SK_LEN, JCSystem.CLEAR_ON_RESET);
|
||||
armedFlag = JCSystem.makeTransientBooleanArray((short) 1, JCSystem.CLEAR_ON_RESET);
|
||||
}
|
||||
}
|
||||
|
||||
static boolean isStepUpArmed() {
|
||||
return armedFlag != null && armedFlag[0];
|
||||
}
|
||||
|
||||
/** Loads {@code StepUpSK} from {@code src[srcOff..srcOff+32)} and arms the context. */
|
||||
static void armStepUp(byte[] src, short srcOff) {
|
||||
ensureInitialized();
|
||||
Util.arrayCopyNonAtomic(src, srcOff, stepUpSK, (short) 0, STEP_UP_SK_LEN);
|
||||
armedFlag[0] = true;
|
||||
}
|
||||
|
||||
/** Zeroes {@code StepUpSK} and flips the armed flag off. */
|
||||
static void disarmStepUp() {
|
||||
if (stepUpSK != null) {
|
||||
Util.arrayFillNonAtomic(stepUpSK, (short) 0, STEP_UP_SK_LEN, (byte) 0);
|
||||
}
|
||||
if (armedFlag != null) {
|
||||
armedFlag[0] = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies the armed StepUpSK into {@code dst[dstOff..]}. Only legal if {@link #isStepUpArmed()}. */
|
||||
static short copyStepUpSK(byte[] dst, short dstOff) {
|
||||
Util.arrayCopyNonAtomic(stepUpSK, (short) 0, dst, dstOff, STEP_UP_SK_LEN);
|
||||
return STEP_UP_SK_LEN;
|
||||
}
|
||||
|
||||
/** Test-only accessor that skips the armed-flag precondition. */
|
||||
static short copyStepUpSKForTesting(byte[] dst, short dstOff) {
|
||||
return copyStepUpSK(dst, dstOff);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.dangerousthings.aliro;
|
||||
|
||||
import javacard.framework.APDU;
|
||||
import javacard.framework.Applet;
|
||||
import javacard.framework.ISO7816;
|
||||
import javacard.framework.ISOException;
|
||||
import javacard.framework.Util;
|
||||
|
||||
/**
|
||||
* Scaffold for the Aliro step-up phase (spec §8.4). Lives under
|
||||
* {@link AliroAids#STEP_UP}.
|
||||
*
|
||||
* <p>This first pass only answers SELECT with a minimal FCI and rejects
|
||||
* unknown commands cleanly. The actual step-up protocol — mdoc
|
||||
* SessionData framing (DeviceRequest/DeviceResponse CBOR with
|
||||
* Aliro-remapped integer keys per Tables 8-21/8-22), ENVELOPE/GET
|
||||
* RESPONSE command chaining, and a fresh AES-256-GCM session keyed by
|
||||
* StepUpSKDevice/StepUpSKReader derived per spec §8.4.3 — comes in
|
||||
* follow-up iterations.
|
||||
*
|
||||
* <p>When that work lands, this applet will need access to the
|
||||
* {@code StepUpSK} (a 32-byte session-bound value produced by
|
||||
* {@link AliroApplet}'s derivation in §8.3.1.13). Since Java Card
|
||||
* installs distinct instances per AID, that state will be exchanged via
|
||||
* a shared package-private holder (not yet implemented).
|
||||
*/
|
||||
public class StepUpApplet extends Applet {
|
||||
|
||||
private static final byte CLA_PROPRIETARY = (byte) 0x80;
|
||||
|
||||
public static void install(byte[] bArray, short bOffset, byte bLength) {
|
||||
StepUpApplet applet = new StepUpApplet();
|
||||
if (bArray == null || bLength == 0) {
|
||||
applet.register();
|
||||
} else {
|
||||
applet.register(bArray, (short) (bOffset + 1), bArray[bOffset]);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean select() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void process(APDU apdu) {
|
||||
if (selectingApplet()) {
|
||||
sendStepUpFci(apdu);
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] buf = apdu.getBuffer();
|
||||
if (buf[ISO7816.OFFSET_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.
|
||||
ISOException.throwIt(ISO7816.SW_INS_NOT_SUPPORTED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal FCI for step-up SELECT. The spec (§10.2.1.2) allows the UD to
|
||||
* advertise supported APDU command/response sizes here; those get added
|
||||
* when we implement ENVELOPE/GET RESPONSE.
|
||||
*
|
||||
* <pre>
|
||||
* 6F L
|
||||
* 84 09 [step-up AID]
|
||||
* </pre>
|
||||
*/
|
||||
private void sendStepUpFci(APDU apdu) {
|
||||
byte[] buf = apdu.getBuffer();
|
||||
short off = 0;
|
||||
buf[off++] = 0x6F;
|
||||
short outerLenPos = off++;
|
||||
|
||||
buf[off++] = (byte) 0x84;
|
||||
buf[off++] = (byte) AliroAids.STEP_UP.length;
|
||||
off = Util.arrayCopyNonAtomic(AliroAids.STEP_UP, (short) 0,
|
||||
buf, off, (short) AliroAids.STEP_UP.length);
|
||||
|
||||
buf[outerLenPos] = (byte) (off - 2);
|
||||
apdu.setOutgoingAndSend((short) 0, off);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user