feat: diagnostic INSes for AUTH1 sub-op profiling (compile-gated)

Adds INS_DIAG_HMAC (0xD0), INS_DIAG_ECDH (0xD1), INS_DIAG_ECDSA_SIGN
(0xD2), and INS_DIAG_GCM (0xD3) to AliroApplet, gated by a
DIAGNOSTICS_ENABLED compile-time flag so they're trivially strippable
for production builds. Each INS runs its primitive N times (N in P3 byte)
against hardcoded card-side test vectors and returns the result. Output
lands in the APDU buffer at offset 16, never in scratch -- the AUTH
session state stays intact even if a diag INS runs mid-session.

The HMAC INS routes through a new AliroCrypto.diagHmac() wrapper that
exposes the internal AliroHmac instance without leaking it through the
class boundary. ECDH/ECDSA use a dedicated diagKeyPair (separate from
the protocol's credentialEphemeralKeyPair) so a diagnostic call can
never disturb a real AUTH flow.

These INSes are what aliro-bench-profile drives over PC/SC -- they let
us measure each AUTH1 primitive's cost in isolation and reconstruct the
AUTH1 budget against the bench-test wall-clock.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
michael
2026-06-06 15:53:10 -07:00
parent 5f614d74a9
commit b03c781b92
2 changed files with 181 additions and 0 deletions

View File

@@ -34,6 +34,25 @@ public class AliroApplet extends Applet {
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;
@@ -310,6 +329,141 @@ public class AliroApplet extends Applet {
priv.setK((short) 1);
}
// --- 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: {
for (short i = 0; i < n; i++) {
crypto.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();
for (short i = 0; i < n; i++) {
crypto.computeEcdhSharedX(priv,
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.
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();
@@ -357,6 +511,20 @@ public class AliroApplet extends Applet {
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);
}

View File

@@ -89,6 +89,19 @@ final class AliroCrypto {
return ecdhPlain.generateSecret(peerPubUncomp, peerPubOff, (short) 65, out, outOff);
}
/**
* Diagnostic-only: exposes the underlying HMAC-SHA-256 primitive so
* AliroApplet's INS_DIAG_HMAC handler can profile per-call cost. Not
* used by production AUTH0/AUTH1 paths (those go through hkdfExtract
* and hkdfExpand).
*/
short diagHmac(
byte[] key, short keyOff, short keyLen,
byte[] msg, short msgOff, short msgLen,
byte[] out, short outOff) {
return aliroHmac.compute(key, keyOff, keyLen, msg, msgOff, msgLen, out, outOff);
}
/**
* HKDF-Extract per RFC 5869 §2.2 with HMAC-SHA-256:
* {@code PRK = HMAC(salt, IKM)}.