package com.dangerousthings.aliro; import javacard.framework.APDU; import javacard.framework.Applet; import javacard.framework.ISO7816; import javacard.framework.ISOException; import javacard.framework.JCSystem; import javacard.framework.Util; import javacard.security.ECPrivateKey; import javacard.security.ECPublicKey; import javacard.security.KeyBuilder; import javacard.security.KeyPair; import javacard.security.MessageDigest; import javacard.security.Signature; /** * Aliro v1.0 User Device applet. * *

Instantiated under {@link AliroAids#EXPEDITED} (and, once step-up is wired, * also {@link AliroAids#STEP_UP}). A single class handles both phases; state * is tracked internally. * *

Current scope: *

*/ 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; // Diagnostic INSes (CLA=0x80) for profiling AUTH1 sub-operations. // DEV / PERFORMANCE-DEBUG ONLY. Set DIAGNOSTICS_ENABLED = false for any // production CAP -- the JC converter dead-code-eliminates the disabled // branches, so production binaries have zero attack surface from these. // // When enabled, each INS takes Lc=1 byte = iteration count N (1-255), // runs the operation N times against hardcoded test vectors with sizes // that match what AUTH1 actually does, then returns SW=9000. Output is // written to the APDU buffer (per-APDU, transient -- never aliases the // AUTH session scratch). The diagnostic keypair is allocated separately // from the protocol's ephemeral keypair, so calling diag mid-transaction // cannot clobber an in-flight AUTH0/AUTH1. private static final boolean DIAGNOSTICS_ENABLED = true; private static final byte INS_DIAG_HMAC = (byte) 0xD0; private static final byte INS_DIAG_ECDH = (byte) 0xD1; private static final byte INS_DIAG_ECDSA_SIGN = (byte) 0xD2; private static final byte INS_DIAG_GCM = (byte) 0xD3; // Session state layout (transient, CLEAR_ON_DESELECT + reset on SELECT). private static final short OFF_READER_EPUBK = 0; private static final short OFF_READER_GROUP_ID = 65; private static final short OFF_READER_GROUP_SUB_ID = 81; private static final short OFF_TRANSACTION_ID = 97; private static final short OFF_COMMAND_PARAMETERS = 113; private static final short OFF_AUTH_POLICY = 114; private static final short OFF_AUTH1_CMD_PARAMS = 115; private static final short SESSION_STATE_LEN = 116; // Session flags (transient booleans). private static final short FLAG_AUTH0_DONE = 0; private static final short FLAGS_LEN = 1; // AUTH0 command TLV tags, spec Table 8-4. private static final byte TAG_COMMAND_PARAMETERS = (byte) 0x41; private static final byte TAG_AUTHENTICATION_POLICY = (byte) 0x42; private static final byte TAG_PROTOCOL_VERSION = (byte) 0x5C; private static final byte TAG_READER_EPUBK = (byte) 0x87; private static final byte TAG_TRANSACTION_ID = (byte) 0x4C; private static final byte TAG_READER_IDENTIFIER = (byte) 0x4D; // AUTH1 command TLV tags, spec Table 8-10. private static final byte TAG_READER_SIGNATURE = (byte) 0x9E; private static final byte TAG_READER_CERT = (byte) 0x90; private static final short LEN_READER_SIGNATURE = 64; private static final byte TAG_CREDENTIAL_EPUBK = (byte) 0x86; // Bitmask of required AUTH1 tags. private static final byte REQ_AUTH1_CMD_PARAMS = 0x01; private static final byte REQ_AUTH1_READER_SIG = 0x02; private static final byte REQ_ALL_AUTH1 = (byte) (REQ_AUTH1_CMD_PARAMS | REQ_AUTH1_READER_SIG); // Expected lengths of the mandatory AUTH0 TLVs. private static final short LEN_COMMAND_PARAMETERS = 1; private static final short LEN_AUTHENTICATION_POLICY = 1; private static final short LEN_PROTOCOL_VERSION = 2; private static final short LEN_READER_EPUBK = 65; private static final short LEN_TRANSACTION_ID = 16; private static final short LEN_READER_IDENTIFIER = 32; // Bitmask of required AUTH0 tags successfully parsed. private static final byte REQ_CMD_PARAMS = 0x01; private static final byte REQ_AUTH_POLICY = 0x02; private static final byte REQ_PROTO_VER = 0x04; private static final byte REQ_READER_EPUBK = 0x08; private static final byte REQ_TXN_ID = 0x10; private static final byte REQ_READER_ID = 0x20; private static final byte REQ_ALL_AUTH0 = (byte) (REQ_CMD_PARAMS | REQ_AUTH_POLICY | REQ_PROTO_VER | REQ_READER_EPUBK | REQ_TXN_ID | REQ_READER_ID); /** ALIRO spec protocol version 0x0100 (major 1, minor 0). */ private static final short PROTOCOL_VERSION_1_0 = (short) 0x0100; /** CSA application type, spec Table 10-4. */ private static final short APP_TYPE_CSA = (short) 0x0000; /** Reusable ephemeral keypair — {@link KeyPair#genKeyPair()} refreshes it each AUTH0. */ private final KeyPair credentialEphemeralKeyPair; /** Long-term Access Credential signing keypair. Curve params are set once at * construction via {@code genKeyPair()}; the private scalar is overwritten * per AUTH1 via {@link ECPrivateKey#setS} using bytes from {@link CredentialStore}. */ private final KeyPair credentialSigningKeyPair; /** Holder for the provisioned reader long-term public key. Same underlying object across transactions; {@link ECPublicKey#setW} refreshes it per AUTH1. */ private final KeyPair readerPubKeyHolder; /** Reusable ECDSA-SHA-256 verifier (reader signature) and signer (UD signature). * Allocated lazily on first APDU — see {@link #ensureCryptoInitialized()}. * Some real cards (notably J3R180) refuse {@code Cipher.getInstance(ALG_AES_GCM)} * during {@code install()} even though it works at runtime; doing all * crypto handle resolution lazily keeps install() trivial. */ private Signature ecdsaVerifier; private Signature ecdsaSigner; /** 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) is pulled // from CryptoSingletons.getAliroGcm(). The target card (NXP J3R180) does // not expose AEADCipher.ALG_AES_GCM despite advertising JC 3.0.5, so we // implement GCM in userland. Sharing with StepUpApplet via the singleton // saves ~370 B of transient/EEPROM footprint that a per-applet duplicate // would otherwise pay. See encryptResponseGcm(). // Low-level crypto primitives (ECDH, HKDF, Kdh, expedited key derivation) // are pulled from CryptoSingletons.getAliroCrypto() so both AliroApplet // 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; /** Transient buffers used during AUTH1 response construction. */ private final byte[] derivedKeys; // 160B: ExpeditedSKReader/Device, StepUpSK, BleSK, URSK private final byte[] kdhBuf; // 32B private final byte[] saltVolatile; // up to 200B /** Transient session state. Cleared on deselect and on every SELECT. */ private final byte[] sessionState; private final boolean[] sessionFlags; /** * Transient scratch buffer. Per-transaction layout: *
     *   [  0..128) Table 8-12 bytes (what gets signed)
     *   [128..200) DER-encoded reader signature for {@link Signature#verify}
     *   [200..265) uncompressed 65B reader pubkey for {@link ECPublicKey#setW}
     *   [265..329) raw 64B reader signature captured from AUTH1 input
     * 
*/ private final byte[] scratch; private static final short SCRATCH_TABLE_812_OFF = 0; private static final short SCRATCH_TABLE_812_LEN = 128; private static final short SCRATCH_DER_SIG_OFF = 128; private static final short SCRATCH_DER_SIG_CAPACITY = 72; private static final short SCRATCH_READER_PUB_UNCOMP_OFF = 200; private static final short SCRATCH_RAW_SIG_OFF = 265; private static final short SCRATCH_LEN = 329; private static final short DERIVED_KEYS_LEN = 160; private static final short KDH_LEN = 32; private static final short SALT_VOLATILE_CAPACITY = 200; /** Offsets into {@link #derivedKeys} per spec §8.3.1.13. */ private static final short OFF_EXPEDITED_SK_READER = 0; private static final short OFF_EXPEDITED_SK_DEVICE = 32; private static final short OFF_STEP_UP_SK = 64; /** Size of the 12-byte GCM IV (8B fixed prefix || 4B device_counter). */ private static final short GCM_IV_LEN = 12; private static final short GCM_TAG_LEN = 16; /** Aliro spec §8.3.1.13 salt_volatile constant strings. */ private static final byte[] SALT_VOLATILE_TAG = { 'V', 'o', 'l', 'a', 't', 'i', 'l', 'e', '*', '*', '*', '*' }; // secp256r1 / NIST P-256 curve parameters live in {@link Secp256r1Params} // — shared with {@link CoseVerifier}. J3R180 doesn't ship a default P-256 // parameter set on its EC keys, so seedSecp256r1() must run before any // genKeyPair / setS / setW / Signature.init. /** NFC interface byte, spec §8.3.1.13. */ private static final byte INTERFACE_BYTE_NFC = (byte) 0x5E; /** Table 8-13 usage constant for UD signature (spec §8.3.3.4.3). */ private static final byte[] UD_SIGN_USAGE = { (byte) 0x4E, (byte) 0x88, (byte) 0x7B, (byte) 0x4C }; /** Proprietary 0xA5 TLV contributed by the NFC SELECT FCI (see {@link #sendExpeditedFci}). */ private static final byte[] PROPRIETARY_A5_TLV = { (byte) 0xA5, (byte) 0x08, (byte) 0x80, (byte) 0x02, (byte) 0x00, (byte) 0x00, (byte) 0x5C, (byte) 0x02, (byte) 0x01, (byte) 0x00 }; /** Persistent — set to 1 once {@link #credentialSigningKeyPair} has had * its P-256 curve parameters seeded by {@code genKeyPair()}. We defer * this from the constructor to first AUTH1 because EC keypair generation * during applet install fails on some real cards (notably J3R180 returned * 0x6F00 for INSTALL[for install and make selectable]). genKeyPair runs * exactly once over the lifetime of the applet. */ private byte signingKeyParamsInitialized; private AliroApplet() { // Keep install() trivial: only the JC-required transient buffers and // stateless persistent KeyPair holders. Anything that calls // Signature.getInstance / Cipher.getInstance / MessageDigest.getInstance / // KeyBuilder.buildKey / new AliroCrypto() runs in ensureCryptoInitialized() // on the first APDU. This avoids 0x6F00 from cards that resolve // crypto algorithms lazily and refuse them during install(). credentialEphemeralKeyPair = new KeyPair(KeyPair.ALG_EC_FP, KeyBuilder.LENGTH_EC_FP_256); credentialSigningKeyPair = new KeyPair(KeyPair.ALG_EC_FP, KeyBuilder.LENGTH_EC_FP_256); readerPubKeyHolder = new KeyPair(KeyPair.ALG_EC_FP, KeyBuilder.LENGTH_EC_FP_256); sessionState = JCSystem.makeTransientByteArray(SESSION_STATE_LEN, JCSystem.CLEAR_ON_DESELECT); sessionFlags = JCSystem.makeTransientBooleanArray(FLAGS_LEN, JCSystem.CLEAR_ON_DESELECT); scratch = JCSystem.makeTransientByteArray(SCRATCH_LEN, JCSystem.CLEAR_ON_DESELECT); derivedKeys = JCSystem.makeTransientByteArray(DERIVED_KEYS_LEN, JCSystem.CLEAR_ON_DESELECT); kdhBuf = JCSystem.makeTransientByteArray(KDH_LEN, JCSystem.CLEAR_ON_DESELECT); saltVolatile = JCSystem.makeTransientByteArray(SALT_VOLATILE_CAPACITY, JCSystem.CLEAR_ON_DESELECT); } /** First-APDU lazy initialization for crypto handles. Runs exactly once * over the applet's lifetime (gated by persistent {@link #cryptoInitialized}). * *

Everything here has proven loadable at {@code install()} time on * J3R180 except the AES-GCM {@link Cipher#getInstance} call — J3R180 * advertises JC 3.0.5 but does not implement {@code ALG_AES_GCM}. We * substitute {@link AliroGcm}, which builds GCM in userland on top of * {@code ALG_AES_BLOCK_128_ECB_NOPAD}. The try/catches around * {@link AliroGcm} construction throw 0x6FA8 so a future regression * (e.g. AES-ECB-NOPAD or AES-256-key allocation disappearing from a new * ROM revision) surfaces with a specific, greppable status word. */ private void ensureCryptoInitialized() { if (cryptoInitialized != 0) return; ecdsaVerifier = Signature.getInstance(Signature.ALG_ECDSA_SHA_256, false); ecdsaSigner = Signature.getInstance(Signature.ALG_ECDSA_SHA_256, false); sha1 = MessageDigest.getInstance(MessageDigest.ALG_SHA, false); try { // 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 { // 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 / // setW / Signature.init runs. J3R180 ships ECC keys with no default // domain parameters; without this, ILLEGAL_VALUE comes back at first // crypto use (we hit 0x6FE1 from genKeyPair before adding this). try { seedSecp256r1(credentialEphemeralKeyPair); } catch (Throwable t) { ISOException.throwIt((short) 0x6FB0); } try { seedSecp256r1(credentialSigningKeyPair); } catch (Throwable t) { ISOException.throwIt((short) 0x6FB1); } try { seedSecp256r1(readerPubKeyHolder); } catch (Throwable t) { ISOException.throwIt((short) 0x6FB2); } cryptoInitialized = 1; } /** Loads the secp256r1 / NIST P-256 curve parameters into both halves of * {@code kp}. Must run before any {@code genKeyPair}, {@code setS}, * {@code setW}, or {@code Signature.init} on cards (like J3R180) that * don't preset domain parameters on freshly-allocated EC keys. * *

Thin wrapper over {@link Secp256r1Params#seed(KeyPair)} kept for * call-site readability; the byte arrays live in the shared class. */ private static void seedSecp256r1(KeyPair kp) { Secp256r1Params.seed(kp); } // --- Diagnostic test vectors ------------------------------------------- // Hardcoded inputs for the INS_DIAG_* operations. Sizes match what AUTH1 // exercises: 32B HMAC key (matches HKDF PRK), 64B HMAC message (matches // SHA-256 block size), 12B AES-GCM IV, 137B plaintext (matches the actual // Table 8-11 plaintext length when cmd_params bit 0 = 1). private static final byte[] DIAG_KEY_32 = { (byte) 0x00, (byte) 0x01, (byte) 0x02, (byte) 0x03, (byte) 0x04, (byte) 0x05, (byte) 0x06, (byte) 0x07, (byte) 0x08, (byte) 0x09, (byte) 0x0A, (byte) 0x0B, (byte) 0x0C, (byte) 0x0D, (byte) 0x0E, (byte) 0x0F, (byte) 0x10, (byte) 0x11, (byte) 0x12, (byte) 0x13, (byte) 0x14, (byte) 0x15, (byte) 0x16, (byte) 0x17, (byte) 0x18, (byte) 0x19, (byte) 0x1A, (byte) 0x1B, (byte) 0x1C, (byte) 0x1D, (byte) 0x1E, (byte) 0x1F }; private static final byte[] DIAG_MSG_64 = { (byte) 0x20, (byte) 0x21, (byte) 0x22, (byte) 0x23, (byte) 0x24, (byte) 0x25, (byte) 0x26, (byte) 0x27, (byte) 0x28, (byte) 0x29, (byte) 0x2A, (byte) 0x2B, (byte) 0x2C, (byte) 0x2D, (byte) 0x2E, (byte) 0x2F, (byte) 0x30, (byte) 0x31, (byte) 0x32, (byte) 0x33, (byte) 0x34, (byte) 0x35, (byte) 0x36, (byte) 0x37, (byte) 0x38, (byte) 0x39, (byte) 0x3A, (byte) 0x3B, (byte) 0x3C, (byte) 0x3D, (byte) 0x3E, (byte) 0x3F, (byte) 0x40, (byte) 0x41, (byte) 0x42, (byte) 0x43, (byte) 0x44, (byte) 0x45, (byte) 0x46, (byte) 0x47, (byte) 0x48, (byte) 0x49, (byte) 0x4A, (byte) 0x4B, (byte) 0x4C, (byte) 0x4D, (byte) 0x4E, (byte) 0x4F, (byte) 0x50, (byte) 0x51, (byte) 0x52, (byte) 0x53, (byte) 0x54, (byte) 0x55, (byte) 0x56, (byte) 0x57, (byte) 0x58, (byte) 0x59, (byte) 0x5A, (byte) 0x5B, (byte) 0x5C, (byte) 0x5D, (byte) 0x5E, (byte) 0x5F }; private static final byte[] DIAG_IV_12 = { (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x01, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x01 }; private static final short DIAG_GCM_PT_LEN = (short) 137; /** Dedicated diagnostic keypair, allocated lazily on first INS_DIAG_* and * used ONLY by the diag dispatcher. Kept separate from * {@link #credentialEphemeralKeyPair} so a diagnostic call cannot * clobber an in-flight AUTH0/AUTH1 transaction's ephemeral key. */ private javacard.security.KeyPair diagKeyPair; /** Persistent flag: 1 once {@link #diagKeyPair} has been allocated and * seeded with a random P-256 scalar via genKeyPair(). */ private byte diagInitialized; private void ensureDiagInitialized() { if (diagInitialized != 0) return; diagKeyPair = new javacard.security.KeyPair( javacard.security.KeyPair.ALG_EC_FP, javacard.security.KeyBuilder.LENGTH_EC_FP_256); seedSecp256r1(diagKeyPair); diagKeyPair.genKeyPair(); diagInitialized = 1; } /** Dispatch for the four INS_DIAG_* profiling operations. Output is * written into the APDU buffer (which is per-APDU and never aliases the * AUTH session's {@link #scratch}), and all crypto runs against the * dedicated {@link #diagKeyPair}, never the protocol's ephemeral key. * Caller already gated on {@link #DIAGNOSTICS_ENABLED}. */ private void processDiag(APDU apdu, byte ins) { ensureDiagInitialized(); short lc = apdu.setIncomingAndReceive(); byte[] buf = apdu.getBuffer(); if (lc != (short) 1) { ISOException.throwIt(ISO7816.SW_WRONG_LENGTH); } short n = (short) (buf[apdu.getOffsetCdata()] & 0xFF); if (n == (short) 0) { ISOException.throwIt(ISO7816.SW_WRONG_DATA); } // Diagnostic output lands in the APDU buffer past the Lc/data header // (offset 16 leaves space for HMAC/ECDH 32B output, ECDSA DER sigs up // to 72B, and AES-GCM 153B ciphertext+tag -- all fit in the standard // 261-byte APDU buffer). Never reuses scratch -- AUTH session state // stays intact. final short DIAG_OUT_OFF = (short) 16; // AES-GCM: encrypt in place at offset 16. pt/out overlap at the same // offset is supported by AliroGcm.encrypt. Output is 137B ciphertext // + 16B tag = 153B, ending at offset 169 -- well inside the 261B // APDU buffer. Old code tried pt at offset 176 (16 + 160 reserved // for the CT region) but that ran the 137B read off the end of the // buffer at offset 313, throwing ArrayIndexOutOfBoundsException. final short DIAG_GCM_PT_OFF = (short) 16; final short DIAG_GCM_OUT_OFF = (short) 16; switch (ins) { case INS_DIAG_HMAC: { AliroCrypto cryptoHmac = CryptoSingletons.getAliroCrypto(); for (short i = 0; i < n; i++) { 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); } return; } case INS_DIAG_ECDH: { javacard.security.ECPrivateKey priv = (javacard.security.ECPrivateKey) diagKeyPair.getPrivate(); AliroCrypto cryptoEcdh = CryptoSingletons.getAliroCrypto(); for (short i = 0; i < n; i++) { cryptoEcdh.computeEcdhSharedX(priv, Secp256r1Params.SECP256R1_G, (short) 0, buf, DIAG_OUT_OFF); } return; } case INS_DIAG_ECDSA_SIGN: { // Sign with the DEDICATED diag keypair, never the protocol's // credential or ephemeral keys. The signed message is also a // fixed test vector, so this cannot be coerced into signing // attacker-chosen data. ecdsaSigner.init(diagKeyPair.getPrivate(), Signature.MODE_SIGN); for (short i = 0; i < n; i++) { ecdsaSigner.sign( DIAG_MSG_64, (short) 0, (short) DIAG_MSG_64.length, buf, DIAG_OUT_OFF); } return; } 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, DIAG_IV_12, (short) 0, buf, DIAG_GCM_PT_OFF, DIAG_GCM_PT_LEN, buf, DIAG_GCM_OUT_OFF); } return; } default: ISOException.throwIt(ISO7816.SW_INS_NOT_SUPPORTED); } } @Override public boolean select() { resetSession(); return true; } private void resetSession() { Util.arrayFillNonAtomic(sessionState, (short) 0, SESSION_STATE_LEN, (byte) 0); sessionFlags[FLAG_AUTH0_DONE] = false; // A fresh expedited transaction invalidates any prior step-up arming. SessionContext.disarmStepUp(); } public static void install(byte[] bArray, short bOffset, byte bLength) { AliroApplet applet = new AliroApplet(); if (bArray == null || bLength == 0) { applet.register(); } else { applet.register(bArray, (short) (bOffset + 1), bArray[bOffset]); } } @Override public void process(APDU apdu) { if (selectingApplet()) { sendExpeditedFci(apdu); return; } // First non-SELECT APDU: resolve all crypto handles. One-time cost. ensureCryptoInitialized(); byte[] buf = apdu.getBuffer(); byte cla = buf[ISO7816.OFFSET_CLA]; byte ins = buf[ISO7816.OFFSET_INS]; if (cla != CLA_EXPEDITED) { ISOException.throwIt(ISO7816.SW_CLA_NOT_SUPPORTED); } switch (ins) { case INS_AUTH0: processAuth0(apdu); return; case INS_AUTH1: processAuth1(apdu); return; case INS_DIAG_HMAC: case INS_DIAG_ECDH: case INS_DIAG_ECDSA_SIGN: case INS_DIAG_GCM: // Compile-time gate: with DIAGNOSTICS_ENABLED=false the JC // converter dead-code-eliminates the processDiag call, so a // production CAP rejects these INSes the same way it rejects // any other unsupported INS -- zero residual surface. if (DIAGNOSTICS_ENABLED) { processDiag(apdu, ins); return; } ISOException.throwIt(ISO7816.SW_INS_NOT_SUPPORTED); return; default: ISOException.throwIt(ISO7816.SW_INS_NOT_SUPPORTED); } } /** * Handles AUTH1 (spec §8.3.3.4). Verifies the reader signature over * Table 8-12, derives the Aliro session keys (§8.3.1.13), signs Table * 8-13 with credential_PrivK, builds the Table 8-11 plaintext * (key_slot or credential_PubK per command_parameters bit 0, UD sig, * signaling_bitmap), and AES-256-GCM encrypts it with ExpeditedSKDevice. * Response = encrypted_payload || authentication_tag. */ private void processAuth1(APDU apdu) { if (!sessionFlags[FLAG_AUTH0_DONE]) { ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED); } // One-shot: a single AUTH1 consumes the AUTH0 state. Clearing here — // before any crypto — means even a mid-pipeline exception leaves the // state consumed, so a replay cannot reuse the session keys (and // critically cannot reuse device_counter=1, which would produce IV // collisions in AES-GCM). A fresh AUTH0 is required to try again. sessionFlags[FLAG_AUTH0_DONE] = false; CredentialStore store = CredentialStore.get(); if (!store.hasReaderPubKey() || !store.hasCredentialPrivKey() || !store.hasCredentialPubKey()) { ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED); } short lc = apdu.setIncomingAndReceive(); byte[] buf = apdu.getBuffer(); short dataOff = apdu.getOffsetCdata(); validateAuth1Data(buf, dataOff, lc); verifyReaderSignature(store); deriveSessionKeys(store); loadCredentialSigningKey(store); short udSigLen = signTable813AndGetRawSig( scratch, SCRATCH_TABLE_812_OFF, scratch, SCRATCH_DER_SIG_OFF, scratch, SCRATCH_RAW_SIG_OFF); short ptLen = buildTable811Plaintext( sessionState[OFF_AUTH1_CMD_PARAMS], store, scratch, SCRATCH_RAW_SIG_OFF, udSigLen, buf, (short) 0); short respLen = encryptResponseGcm( buf, (short) 0, ptLen, buf, (short) 0); // AUTH1 succeeded; hand off StepUpSK (derived_keys[64..96]) to the // cross-applet context so StepUpApplet can pick up the step-up flow. SessionContext.armStepUp(derivedKeys, OFF_STEP_UP_SK); apdu.setOutgoingAndSend((short) 0, respLen); } /** * Reconstructs Table 8-12, loads the provisioned reader public key, and * verifies the captured raw reader signature against the table. Throws * SW_WRONG_DATA on any failure. */ private void verifyReaderSignature(CredentialStore store) { short tblLen = buildTable812(scratch, SCRATCH_TABLE_812_OFF); scratch[SCRATCH_READER_PUB_UNCOMP_OFF] = (byte) 0x04; store.copyReaderPubKey(scratch, (short) (SCRATCH_READER_PUB_UNCOMP_OFF + 1)); ECPublicKey readerPub = (ECPublicKey) readerPubKeyHolder.getPublic(); readerPub.setW(scratch, SCRATCH_READER_PUB_UNCOMP_OFF, (short) 65); short derLen = rawSigToDer( scratch, SCRATCH_RAW_SIG_OFF, scratch, SCRATCH_DER_SIG_OFF); ecdsaVerifier.init(readerPub, Signature.MODE_VERIFY); boolean ok = ecdsaVerifier.verify( scratch, SCRATCH_TABLE_812_OFF, tblLen, scratch, SCRATCH_DER_SIG_OFF, derLen); if (!ok) { ISOException.throwIt(ISO7816.SW_WRONG_DATA); } } /** * Derives Kdh and {@code derived_keys_volatile} per spec §8.3.1.13 for * the expedited-standard flow. The 32-byte {@code ExpeditedSKDevice} * lands at {@code derivedKeys[OFF_EXPEDITED_SK_DEVICE..+32)}, ready to * be passed into {@link AliroGcm#encrypt} as the AES key. */ private void deriveSessionKeys(CredentialStore store) { 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, sessionState, OFF_TRANSACTION_ID, (short) 16, kdhBuf, (short) 0); crypto.deriveExpeditedStandardKeys( kdhBuf, (short) 0, saltVolatile, (short) 0, saltLen, scratch, SCRATCH_READER_PUB_UNCOMP_OFF, infoLen, derivedKeys, (short) 0); } /** * Builds salt_volatile per spec §8.3.1.13 into {@code out} starting at * {@code outOff}. Returns the total length written. * *

     * x(reader_group_id_key)        32B  (from CredentialStore)
     * "Volatile****"                12B
     * reader_identifier             32B  (group_id || group_sub_id from AUTH0)
     * interface_byte                 1B  (0x5E for NFC)
     * 0x5C 0x02                      2B  (literal per spec)
     * protocol_version              2B  (0x01 0x00)
     * x(reader_ephem_pub_key)       32B
     * transaction_identifier        16B
     * flag                           2B  (auth0 cmd_params || authentication_policy)
     * proprietary_A5_TLV            10B  (from SELECT FCI)
     * 
* *

Spec §8.3.1.13 salt_volatile ends at the 0xA5 proprietary TLV. We * previously appended x(access_credential_pub_key) here too -- that was * an misread of the spec text (it belongs in {@code info}, not * salt_volatile, and {@code info} uses the EPHEMERAL credential key, not * the long-term one). The misread was symmetric between this applet and * our PC/SC reader, so AUTH1 succeeded against our own host-side reader * but failed against ST's X-CUBE-ALIRO with * {@code ACWG_Error_Crypto_EncryptDecrypt}. Removed 2026-06-11. */ private short buildSaltVolatile(CredentialStore store, byte[] out, short outOff) { short p = outOff; // x(reader_group_identifier_key) — first 32 bytes of the 64B reader_PubK store.copyReaderPubKeyX(out, p); p += 32; Util.arrayCopyNonAtomic(SALT_VOLATILE_TAG, (short) 0, out, p, (short) SALT_VOLATILE_TAG.length); p += SALT_VOLATILE_TAG.length; Util.arrayCopyNonAtomic(sessionState, OFF_READER_GROUP_ID, out, p, (short) 16); p += 16; Util.arrayCopyNonAtomic(sessionState, OFF_READER_GROUP_SUB_ID, out, p, (short) 16); p += 16; out[p++] = INTERFACE_BYTE_NFC; out[p++] = (byte) 0x5C; out[p++] = (byte) 0x02; out[p++] = (byte) ((PROTOCOL_VERSION_1_0 >> 8) & 0xFF); out[p++] = (byte) (PROTOCOL_VERSION_1_0 & 0xFF); // reader_ephem_pub_key.x — sessionState holds full uncompressed (0x04 || x || y) at OFF_READER_EPUBK Util.arrayCopyNonAtomic(sessionState, (short) (OFF_READER_EPUBK + 1), out, p, (short) 32); p += 32; Util.arrayCopyNonAtomic(sessionState, OFF_TRANSACTION_ID, out, p, (short) 16); p += 16; // flag = command_parameters || authentication_policy (1 byte each). // command_parameters comes from the AUTH1 command -- "from the command // data field" in §8.3.1.13 refers to the AUTH1 command being processed // when keys are derived (the AUTH0 cmd_params still gets used for the // EXPEDITED-FAST path's salt_persistent per §8.3.1.12, but for the // EXPEDITED-STANDARD §8.3.1.13 path AUTH1 is the active request). // authentication_policy only exists in AUTH0, so it's pulled from // the AUTH0 state we saved earlier. // Empirical confirmation: with AUTH0's cmd_params the X-CUBE-ALIRO // vendor library failed AES-GCM tag verify on AUTH1 response with // ACWG_Error_Crypto_EncryptDecrypt; AUTH1's cmd_params unblocks it. out[p++] = sessionState[OFF_AUTH1_CMD_PARAMS]; out[p++] = sessionState[OFF_AUTH_POLICY]; Util.arrayCopyNonAtomic(PROPRIETARY_A5_TLV, (short) 0, out, p, (short) PROPRIETARY_A5_TLV.length); p += PROPRIETARY_A5_TLV.length; return (short) (p - outOff); } /** * Builds {@code info} per §8.3.1.13: x(credential_ephemeral_public_key). * In v1 we emit no AUTH0 vendor extensions and accept none, so info * degenerates to the 32-byte x-coordinate of the ephemeral pubkey. */ private short buildInfo(byte[] out, short outOff) { ECPublicKey ephemPub = (ECPublicKey) credentialEphemeralKeyPair.getPublic(); // Temp: first 65B of scratch — free at this point (phase 1 is done // and phase 3 hasn't started). short n = ephemPub.getW(scratch, (short) 0); if (n != 65 || scratch[0] != 0x04) { ISOException.throwIt(ISO7816.SW_UNKNOWN); // should be unreachable on P-256 } Util.arrayCopyNonAtomic(scratch, (short) 1, out, outOff, (short) 32); return (short) 32; } /** Overwrites {@link #credentialSigningKeyPair}'s private scalar with credential_PrivK. * On first call, also seeds the keypair's P-256 curve parameters via genKeyPair() * (the random scalar it produces is immediately discarded by setS below). */ private void loadCredentialSigningKey(CredentialStore store) { if (signingKeyParamsInitialized == 0) { credentialSigningKeyPair.genKeyPair(); signingKeyParamsInitialized = 1; } short off = SCRATCH_DER_SIG_OFF; // borrow scratch region; reader sig is no longer needed here store.copyCredentialPrivKey(scratch, off); ((ECPrivateKey) credentialSigningKeyPair.getPrivate()).setS( scratch, off, CredentialStore.CRED_PRIV_KEY_LEN); } /** * Builds Table 8-13 into {@code t813Buf[t813Off..]}, signs it with * {@code credential_PrivK}, and writes the raw 64-byte r||s signature to * {@code rawSigBuf[rawSigOff..]}. Returns the raw sig length (always 64). * The DER work buffer is only used internally. */ private short signTable813AndGetRawSig( byte[] t813Buf, short t813Off, byte[] derSigBuf, short derSigOff, byte[] rawSigBuf, short rawSigOff) { short t813Len = buildTable813(t813Buf, t813Off); ecdsaSigner.init(credentialSigningKeyPair.getPrivate(), Signature.MODE_SIGN); short derLen = ecdsaSigner.sign(t813Buf, t813Off, t813Len, derSigBuf, derSigOff); derSigToRaw(derSigBuf, derSigOff, derLen, rawSigBuf, rawSigOff); return (short) 64; } /** * Builds Table 8-13 (UD-side authentication data, spec §8.3.3.4.3) into * {@code dst[dstOff..]}. Identical to Table 8-12 except the 4-byte * usage constant (0x4E887B4C instead of 0x415D9569). */ private short buildTable813(byte[] dst, short dstOff) { short p = dstOff; dst[p++] = (byte) 0x4D; dst[p++] = (byte) 0x20; Util.arrayCopyNonAtomic(sessionState, OFF_READER_GROUP_ID, dst, p, (short) 32); p += 32; ECPublicKey credPub = (ECPublicKey) credentialEphemeralKeyPair.getPublic(); credPub.getW(scratch, SCRATCH_READER_PUB_UNCOMP_OFF); dst[p++] = (byte) 0x86; dst[p++] = (byte) 0x20; Util.arrayCopyNonAtomic(scratch, (short) (SCRATCH_READER_PUB_UNCOMP_OFF + 1), dst, p, (short) 32); p += 32; dst[p++] = (byte) 0x87; dst[p++] = (byte) 0x20; Util.arrayCopyNonAtomic(sessionState, (short) (OFF_READER_EPUBK + 1), dst, p, (short) 32); p += 32; dst[p++] = (byte) 0x4C; dst[p++] = (byte) 0x10; Util.arrayCopyNonAtomic(sessionState, OFF_TRANSACTION_ID, dst, p, (short) 16); p += 16; dst[p++] = (byte) 0x93; dst[p++] = (byte) 0x04; Util.arrayCopyNonAtomic(UD_SIGN_USAGE, (short) 0, dst, p, (short) UD_SIGN_USAGE.length); p += UD_SIGN_USAGE.length; return (short) (p - dstOff); } /** * Converts a DER-encoded ECDSA P-256 signature into raw r||s (32+32) * form. Strips the leading 0x00 that DER inserts when the high bit of * r or s is set, and left-zero-pads shorter integers to 32 bytes. */ private static void derSigToRaw( byte[] der, short derOff, short derLen, byte[] raw, short rawOff) { Util.arrayFillNonAtomic(raw, rawOff, (short) 64, (byte) 0); short p = (short) (derOff + 2); // skip SEQUENCE tag + short-form length if (der[p++] != 0x02) ISOException.throwIt(ISO7816.SW_UNKNOWN); short rDerLen = (short) (der[p++] & 0xFF); short rValStart = p; short rValLen = rDerLen; if (rValLen > 32 && der[rValStart] == 0x00) { rValStart++; rValLen--; } Util.arrayCopyNonAtomic(der, rValStart, raw, (short) (rawOff + 32 - rValLen), rValLen); p = (short) (p + rDerLen); if (der[p++] != 0x02) ISOException.throwIt(ISO7816.SW_UNKNOWN); short sDerLen = (short) (der[p++] & 0xFF); short sValStart = p; short sValLen = sDerLen; if (sValLen > 32 && der[sValStart] == 0x00) { sValStart++; sValLen--; } Util.arrayCopyNonAtomic(der, sValStart, raw, (short) (rawOff + 64 - sValLen), sValLen); } /** * Builds Table 8-11 plaintext into {@code out[outOff..]}. Emits either * {@code 0x4E key_slot} (first 8 bytes of SHA-1(uncompressed * credential_PubK), per §8.3.3.4.2 + ref [12]) when command_parameters * bit 0 = 0, or {@code 0x5A credential_PubK} (full uncompressed 65B) * when bit 0 = 1. Then {@code 0x9E UD_signature} and a 2-byte * all-zero {@code 0x5E signaling_bitmap}. */ private short buildTable811Plaintext( byte auth1CmdParams, CredentialStore store, byte[] rawSig, short rawSigOff, short rawSigLen, byte[] out, short outOff) { short p = outOff; if ((auth1CmdParams & 0x01) == 0) { // 0x4E 0x08 [key_slot] — first 8 bytes of SHA-1(uncompressed credential_PubK) out[p++] = (byte) 0x4E; out[p++] = (byte) 0x08; p = appendKeySlot(store, out, p); } else { // 0x5A 0x41 [0x04 || x || y] — 65-byte uncompressed credential long-term pubkey out[p++] = (byte) 0x5A; out[p++] = (byte) 0x41; out[p++] = (byte) 0x04; store.copyCredentialPubKey(out, p); p += CredentialStore.CRED_PUBK_LEN; } // 0x9E 0x40 [raw r||s UD signature] out[p++] = (byte) 0x9E; out[p++] = (byte) 0x40; Util.arrayCopyNonAtomic(rawSig, rawSigOff, out, p, rawSigLen); p += rawSigLen; // 0x5E 0x02 [signaling_bitmap] — 16-bit big-endian. Bit 0: Access // Document retrievable. Bit 2: retrieval requires step-up AID SELECT // (applicable on NFC). Other bits unused in v1 (no mailbox/notify). // // We emit 0x0005 (bits 0 + 2) when an Access Document is provisioned. // Honest reading of the spec would say we should leave these off // until Step-up Phase is actually implemented (CBOR + mdoc + ENVELOPE // + GET RESPONSE + AES-GCM over StepUpSK, §8.4), but empirically the // closed-source ACWG_processAUTH1ResponsePayload() in X-CUBE-ALIRO's // 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. 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 bitmap |= 0x0004; // bit 2 — NFC requires step-up AID to fetch AD } out[p++] = (byte) 0x5E; out[p++] = (byte) 0x02; out[p++] = (byte) ((bitmap >> 8) & 0xFF); out[p++] = (byte) (bitmap & 0xFF); return (short) (p - outOff); } /** * Computes key_slot = first 8 bytes of SHA-1(uncompressed credential_PubK) * and writes it to {@code out[p..p+8)}. Returns {@code p + 8}. * *

Uses scratch for both the 65-byte SHA-1 input and the 20-byte SHA-1 * output — the expensive phases (reader-sig verify, key derivation) have * already finished by the time this runs, so scratch can be reused. */ private short appendKeySlot(CredentialStore store, byte[] out, short p) { // Build uncompressed credential_PubK at scratch[0..65). scratch[0] = (byte) 0x04; store.copyCredentialPubKey(scratch, (short) 1); // Hash into scratch[65..85). sha1.reset(); sha1.doFinal(scratch, (short) 0, (short) 65, scratch, (short) 65); Util.arrayCopyNonAtomic(scratch, (short) 65, out, p, (short) 8); return (short) (p + 8); } /** * AES-256-GCM encrypts {@code plaintext} with {@code ExpeditedSKDevice} * (from {@link #derivedKeys}) and IV = * {@code 0x0000000000000001 || expedited_device_counter (4B BE)}, * where the device_counter starts at 1 (spec §8.3.1.13) and is only * incremented once a new phase begins. For the first AUTH1 response the * counter is 1. Writes {@code ciphertext || tag} into {@code out[outOff..]} * and returns {@code plaintextLen + 16}. * *

Uses {@link AliroGcm} — a userland GCM built on AES-ECB — because * the target card (J3R180) does not expose {@code AEADCipher.ALG_AES_GCM}. */ private short encryptResponseGcm( byte[] plaintext, short ptOff, short ptLen, byte[] out, short outOff) { // Build 12-byte IV in a small scratch region. short ivOff = SCRATCH_RAW_SIG_OFF; // borrow scratch — sig already copied into plaintext Util.arrayFillNonAtomic(scratch, ivOff, GCM_IV_LEN, (byte) 0); scratch[(short) (ivOff + 7)] = (byte) 0x01; // fixed prefix low byte // device_counter big-endian in the last 4 bytes; first AUTH1 = 1 scratch[(short) (ivOff + 11)] = (byte) 0x01; return CryptoSingletons.getAliroGcm().encrypt( derivedKeys, OFF_EXPEDITED_SK_DEVICE, scratch, ivOff, plaintext, ptOff, ptLen, out, outOff); } /** * Builds Table 8-12 (the bytes the reader signed) into the given buffer. * Requires AUTH0 state to be captured and the credential ephemeral * keypair to be fresh (both true by the time we get here). * Returns the total number of bytes written. */ private short buildTable812(byte[] dst, short dstOff) { short p = dstOff; // reader_identifier (tag 0x4D, 32 bytes: group_id || group_sub_id) dst[p++] = (byte) 0x4D; dst[p++] = (byte) 0x20; Util.arrayCopyNonAtomic(sessionState, OFF_READER_GROUP_ID, dst, p, (short) 32); p += 32; // credential_ePubK.x (tag 0x86, 32 bytes) // Write full uncompressed point to a later scratch region, copy only the x part. ECPublicKey credPub = (ECPublicKey) credentialEphemeralKeyPair.getPublic(); short credPubOff = SCRATCH_READER_PUB_UNCOMP_OFF; // reuse — we haven't written reader pub yet credPub.getW(scratch, credPubOff); dst[p++] = (byte) 0x86; dst[p++] = (byte) 0x20; Util.arrayCopyNonAtomic(scratch, (short) (credPubOff + 1), dst, p, (short) 32); p += 32; // reader_ePubK.x (tag 0x87, 32 bytes) — sessionState stores full uncompressed at OFF_READER_EPUBK dst[p++] = (byte) 0x87; dst[p++] = (byte) 0x20; Util.arrayCopyNonAtomic(sessionState, (short) (OFF_READER_EPUBK + 1), dst, p, (short) 32); p += 32; // transaction_identifier (tag 0x4C, 16 bytes) dst[p++] = (byte) 0x4C; dst[p++] = (byte) 0x10; Util.arrayCopyNonAtomic(sessionState, OFF_TRANSACTION_ID, dst, p, (short) 16); p += 16; // usage (tag 0x93, 4 bytes: 0x41 5D 95 69) dst[p++] = (byte) 0x93; dst[p++] = (byte) 0x04; dst[p++] = (byte) 0x41; dst[p++] = (byte) 0x5D; dst[p++] = (byte) 0x95; dst[p++] = (byte) 0x69; return (short) (p - dstOff); } /** * Converts a 64-byte raw ECDSA (r||s) signature into ASN.1 DER: * {@code SEQUENCE { INTEGER r, INTEGER s }}. Both r and s are written * without leading-zero stripping, and a 0x00 is prepended if the high * bit of the first byte is set (to keep the INTEGER positive). * Returns the number of DER bytes written. */ private static short rawSigToDer(byte[] raw, short rawOff, byte[] out, short outOff) { boolean rPad = (raw[rawOff] & 0x80) != 0; boolean sPad = (raw[(short) (rawOff + 32)] & 0x80) != 0; short rLen = rPad ? (short) 33 : (short) 32; short sLen = sPad ? (short) 33 : (short) 32; short contentLen = (short) (2 + rLen + 2 + sLen); short p = outOff; out[p++] = (byte) 0x30; out[p++] = (byte) contentLen; out[p++] = (byte) 0x02; out[p++] = (byte) rLen; if (rPad) out[p++] = (byte) 0x00; Util.arrayCopyNonAtomic(raw, rawOff, out, p, (short) 32); p += 32; out[p++] = (byte) 0x02; out[p++] = (byte) sLen; if (sPad) out[p++] = (byte) 0x00; Util.arrayCopyNonAtomic(raw, (short) (rawOff + 32), out, p, (short) 32); p += 32; return (short) (p - outOff); } /** * Walks AUTH1 command data, verifies the two mandatory tags (0x41 and * 0x9E) with their expected lengths, rejects tag 0x90 (reader_Cert, * unsupported in v1), and silently accepts unknown tags per spec * §1.4.3. */ private void validateAuth1Data(byte[] buf, short dataOff, short dataLen) { if (dataLen <= 0) { ISOException.throwIt(ISO7816.SW_WRONG_DATA); } byte seen = 0; short i = dataOff; short end = (short) (dataOff + dataLen); while (i < end) { if ((short) (i + 2) > end) { ISOException.throwIt(ISO7816.SW_WRONG_DATA); } byte tag = buf[i]; short len = (short) (buf[(short) (i + 1)] & 0xFF); short valueOff = (short) (i + 2); if ((short) (valueOff + len) > end) { ISOException.throwIt(ISO7816.SW_WRONG_DATA); } switch (tag) { case TAG_COMMAND_PARAMETERS: requireLen(len, LEN_COMMAND_PARAMETERS); sessionState[OFF_AUTH1_CMD_PARAMS] = buf[valueOff]; seen |= REQ_AUTH1_CMD_PARAMS; break; case TAG_READER_SIGNATURE: requireLen(len, LEN_READER_SIGNATURE); // Capture the sig for later use by verifyReaderSignature(). Util.arrayCopyNonAtomic(buf, valueOff, scratch, SCRATCH_RAW_SIG_OFF, len); seen |= REQ_AUTH1_READER_SIG; break; case TAG_READER_CERT: // v1 scope: certificates are out of scope. Reject explicitly. ISOException.throwIt(ISO7816.SW_WRONG_DATA); break; default: // Unknown TLV — spec §1.4.3 says accept silently. break; } i = (short) (valueOff + len); } if ((byte) (seen & REQ_ALL_AUTH1) != REQ_ALL_AUTH1) { ISOException.throwIt(ISO7816.SW_WRONG_DATA); } } /** * Builds and sends the FCI template for a successful SELECT of the * expedited-phase AID, per spec §10.2.1. * *

     * 6F L1
     *   84 09 [expedited AID]
     *   A5 L2
     *     80 02 00 00            ; type = CSA application
     *     5C 02 01 00            ; expedited protocol versions = {0x0100}
     * 
*/ private void sendExpeditedFci(APDU apdu) { byte[] buf = apdu.getBuffer(); short off = 0; buf[off++] = 0x6F; short outerLenPos = off++; buf[off++] = (byte) 0x84; buf[off++] = (byte) AliroAids.EXPEDITED.length; off = Util.arrayCopyNonAtomic(AliroAids.EXPEDITED, (short) 0, buf, off, (short) AliroAids.EXPEDITED.length); buf[off++] = (byte) 0xA5; short propLenPos = off++; short propStart = off; buf[off++] = (byte) 0x80; buf[off++] = (byte) 0x02; buf[off++] = (byte) ((APP_TYPE_CSA >> 8) & 0xFF); buf[off++] = (byte) (APP_TYPE_CSA & 0xFF); buf[off++] = (byte) 0x5C; buf[off++] = (byte) 0x02; buf[off++] = (byte) ((PROTOCOL_VERSION_1_0 >> 8) & 0xFF); buf[off++] = (byte) (PROTOCOL_VERSION_1_0 & 0xFF); buf[propLenPos] = (byte) (off - propStart); buf[outerLenPos] = (byte) (off - 2); apdu.setOutgoingAndSend((short) 0, off); } /** * Handles AUTH0 (spec §8.3.3.2). Validates mandatory input TLVs, * captures session state for AUTH1 (reader ephemeral pubkey, * reader_group_id, reader_group_sub_id, transaction_identifier, * command_parameters), generates a fresh credential ephemeral P-256 * keypair, and returns the ephemeral public key tagged 0x86. */ private void processAuth0(APDU apdu) { // A new AUTH0 starts a new transaction; disarm any prior step-up // state so a mid-pipeline failure later cannot leave stale StepUpSK // reachable by SELECT STEP_UP. SessionContext.disarmStepUp(); short lc = apdu.setIncomingAndReceive(); byte[] buf = apdu.getBuffer(); short dataOff = apdu.getOffsetCdata(); validateAuth0Data(buf, dataOff, lc); // Diagnostic ladder for J3R180: EC keypair generation has historically // been brittle on this card (see signingKeyParamsInitialized comment). // Surface the CryptoException reason in the low nibble of the SW so we // can tell ILLEGAL_VALUE/UNINITIALIZED_KEY/NO_SUCH_ALGORITHM apart on // the bench. 0x6FEF = non-CryptoException Throwable. try { credentialEphemeralKeyPair.genKeyPair(); } catch (javacard.security.CryptoException e) { ISOException.throwIt((short) (0x6FE0 | (e.getReason() & 0x000F))); } catch (Throwable t) { ISOException.throwIt((short) 0x6FEF); } ECPublicKey pub = (ECPublicKey) credentialEphemeralKeyPair.getPublic(); short off = 0; buf[off++] = TAG_CREDENTIAL_EPUBK; short lenPos = off++; short keyLen = pub.getW(buf, off); buf[lenPos] = (byte) keyLen; off += keyLen; sessionFlags[FLAG_AUTH0_DONE] = true; apdu.setOutgoingAndSend((short) 0, off); } /** * Walks the AUTH0 command data, verifies every mandatory TLV (tags, * lengths, and protocol version value) is present, and rejects the * command with SW=6A80 if anything required is missing or invalid. * Unknown tags are silently skipped (spec §1.4.3). */ private void validateAuth0Data(byte[] buf, short dataOff, short dataLen) { if (dataLen <= 0) { ISOException.throwIt(ISO7816.SW_WRONG_DATA); } byte seen = 0; short i = dataOff; short end = (short) (dataOff + dataLen); while (i < end) { if ((short) (i + 2) > end) { ISOException.throwIt(ISO7816.SW_WRONG_DATA); } byte tag = buf[i]; short len = (short) (buf[(short) (i + 1)] & 0xFF); short valueOff = (short) (i + 2); if ((short) (valueOff + len) > end) { ISOException.throwIt(ISO7816.SW_WRONG_DATA); } switch (tag) { case TAG_COMMAND_PARAMETERS: requireLen(len, LEN_COMMAND_PARAMETERS); sessionState[OFF_COMMAND_PARAMETERS] = buf[valueOff]; seen |= REQ_CMD_PARAMS; break; case TAG_AUTHENTICATION_POLICY: requireLen(len, LEN_AUTHENTICATION_POLICY); sessionState[OFF_AUTH_POLICY] = buf[valueOff]; seen |= REQ_AUTH_POLICY; break; case TAG_PROTOCOL_VERSION: requireLen(len, LEN_PROTOCOL_VERSION); short version = Util.getShort(buf, valueOff); if (version != PROTOCOL_VERSION_1_0) { ISOException.throwIt(ISO7816.SW_WRONG_DATA); } seen |= REQ_PROTO_VER; break; case TAG_READER_EPUBK: requireLen(len, LEN_READER_EPUBK); if (buf[valueOff] != (byte) 0x04) { // Must be uncompressed point per spec Table 8-4. ISOException.throwIt(ISO7816.SW_WRONG_DATA); } Util.arrayCopyNonAtomic(buf, valueOff, sessionState, OFF_READER_EPUBK, len); seen |= REQ_READER_EPUBK; break; case TAG_TRANSACTION_ID: requireLen(len, LEN_TRANSACTION_ID); Util.arrayCopyNonAtomic(buf, valueOff, sessionState, OFF_TRANSACTION_ID, len); seen |= REQ_TXN_ID; break; case TAG_READER_IDENTIFIER: requireLen(len, LEN_READER_IDENTIFIER); Util.arrayCopyNonAtomic(buf, valueOff, sessionState, OFF_READER_GROUP_ID, (short) 16); Util.arrayCopyNonAtomic(buf, (short) (valueOff + 16), sessionState, OFF_READER_GROUP_SUB_ID, (short) 16); seen |= REQ_READER_ID; break; default: // Unknown TLV — spec §1.4.3 says accept silently. break; } i = (short) (valueOff + len); } if ((byte) (seen & REQ_ALL_AUTH0) != REQ_ALL_AUTH0) { ISOException.throwIt(ISO7816.SW_WRONG_DATA); } } private static void requireLen(short actual, short expected) { if (actual != expected) { ISOException.throwIt(ISO7816.SW_WRONG_DATA); } } }