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:
2026-05-02 10:17:46 -07:00
commit 782074f6ae
8786 changed files with 2902373 additions and 0 deletions

View File

@@ -0,0 +1,181 @@
package com.dangerousthings.aliro;
import com.licel.jcardsim.smartcardio.CardSimulator;
import java.security.KeyPair;
import java.security.SecureRandom;
import java.security.interfaces.ECPublicKey;
import javacard.framework.AID;
import javax.smartcardio.CommandAPDU;
import javax.smartcardio.ResponseAPDU;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* AUTH0 happy-path tests for the Aliro expedited-standard phase (spec &sect;8.3.3.2).
*/
class AliroAppletAuth0Test {
private CardSimulator sim;
private SecureRandom rng;
@BeforeEach
void setUp() {
sim = new CardSimulator();
AID expeditedAid = new AID(AliroAids.EXPEDITED, (short) 0, (byte) AliroAids.EXPEDITED.length);
sim.installApplet(expeditedAid, AliroApplet.class);
CommandAPDU select = new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256);
ResponseAPDU selectResp = sim.transmitCommand(select);
assertEquals(0x9000, selectResp.getSW(), "SELECT must succeed before AUTH0");
rng = new SecureRandom();
}
private ResponseAPDU sendStandardAuth0() {
KeyPair readerEphem = Auth0Command.generateEphemeralKeyPair();
byte[] readerPub = Auth0Command.encodeUncompressed((ECPublicKey) readerEphem.getPublic());
byte[] groupId = new byte[16];
byte[] subId = new byte[16];
byte[] txnId = new byte[16];
rng.nextBytes(groupId);
rng.nextBytes(subId);
rng.nextBytes(txnId);
byte[] data = Auth0Command.buildStandardData(readerPub, groupId, subId, txnId);
CommandAPDU auth0 = new CommandAPDU(
Auth0Command.CLA & 0xFF,
Auth0Command.INS & 0xFF,
0x00, 0x00, data, 256);
return sim.transmitCommand(auth0);
}
@Test
void auth0StandardReturns9000() {
assertEquals(0x9000, sendStandardAuth0().getSW(),
"AUTH0 with a well-formed standard-phase command must succeed");
}
@Test
void auth0ResponseContainsCredentialEphemeralPubKeyTag() {
byte[] data = sendStandardAuth0().getData();
byte[] credEphemPub = TlvUtil.findTopLevel(data, 0x86);
assertNotNull(credEphemPub, "AUTH0 response must contain tag 0x86 (credential_ePubK)");
assertEquals(65, credEphemPub.length, "credential_ePubK must be 65 bytes (0x04 || x || y)");
assertEquals(0x04, credEphemPub[0] & 0xFF,
"credential_ePubK must start with 0x04 (uncompressed point)");
}
@Test
void credentialEphemeralKeyIsFreshPerTransaction() {
byte[] first = TlvUtil.findTopLevel(sendStandardAuth0().getData(), 0x86);
byte[] second = TlvUtil.findTopLevel(sendStandardAuth0().getData(), 0x86);
assertNotNull(first);
assertNotNull(second);
assertNotEquals(
bytesToHex(first),
bytesToHex(second),
"each AUTH0 must produce a fresh ephemeral keypair");
}
@Test
void credentialEphemeralKeyIsNotAllZero() {
byte[] credPub = TlvUtil.findTopLevel(sendStandardAuth0().getData(), 0x86);
boolean allZero = true;
for (int i = 1; i < credPub.length; i++) {
if (credPub[i] != 0) { allZero = false; break; }
}
assertFalse(allZero, "credential_ePubK coordinates must not be all-zero");
}
@Test
void auth0WithEmptyDataFieldFails() {
CommandAPDU auth0 = new CommandAPDU(
Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00, new byte[0], 256);
ResponseAPDU resp = sim.transmitCommand(auth0);
assertNotEquals(0x9000, resp.getSW(),
"AUTH0 with empty data must not succeed");
}
@Test
void auth0MissingReaderEphemPubKeyTagFails() {
byte[] data = validAuth0DataWithMutation(bytes -> {
// Null out the 0x87 tag byte so the parser sees a bogus tag where reader_ePubK should be.
int idx = findTagOffset(bytes, 0x87);
bytes[idx] = (byte) 0x00;
return bytes;
});
CommandAPDU auth0 = new CommandAPDU(
Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00, data, 256);
assertNotEquals(0x9000, sim.transmitCommand(auth0).getSW(),
"AUTH0 without tag 0x87 (reader_ePubK) must fail");
}
@Test
void auth0WithUnsupportedProtocolVersionFails() {
byte[] data = validAuth0DataWithMutation(bytes -> {
int idx = findTagOffset(bytes, 0x5C);
bytes[idx + 2] = (byte) 0x00;
bytes[idx + 3] = (byte) 0x99;
return bytes;
});
CommandAPDU auth0 = new CommandAPDU(
Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00, data, 256);
assertNotEquals(0x9000, sim.transmitCommand(auth0).getSW(),
"AUTH0 with protocol version != 0x0100 must fail");
}
@Test
void auth0WithUnknownTrailingTagIsAccepted() {
// Spec §1.4.3: unknown DER-TLVs SHALL be accepted by the receiver.
byte[] base = validAuth0Data();
byte[] withUnknown = new byte[base.length + 3];
System.arraycopy(base, 0, withUnknown, 0, base.length);
withUnknown[base.length] = (byte) 0x7F;
withUnknown[base.length + 1] = (byte) 0x01;
withUnknown[base.length + 2] = (byte) 0xFF;
CommandAPDU auth0 = new CommandAPDU(
Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00, withUnknown, 256);
assertEquals(0x9000, sim.transmitCommand(auth0).getSW(),
"unknown trailing TLVs must be accepted (spec §1.4.3)");
}
private byte[] validAuth0Data() {
KeyPair readerEphem = Auth0Command.generateEphemeralKeyPair();
byte[] readerPub = Auth0Command.encodeUncompressed((ECPublicKey) readerEphem.getPublic());
byte[] groupId = new byte[16];
byte[] subId = new byte[16];
byte[] txnId = new byte[16];
rng.nextBytes(groupId);
rng.nextBytes(subId);
rng.nextBytes(txnId);
return Auth0Command.buildStandardData(readerPub, groupId, subId, txnId);
}
private byte[] validAuth0DataWithMutation(java.util.function.Function<byte[], byte[]> mut) {
return mut.apply(validAuth0Data());
}
private static int findTagOffset(byte[] data, int tag) {
int i = 0;
while (i < data.length) {
int t = data[i] & 0xFF;
int len = data[i + 1] & 0xFF;
if (t == tag) return i;
i += 2 + len;
}
throw new AssertionError("tag 0x" + Integer.toHexString(tag) + " not found");
}
private static String bytesToHex(byte[] b) {
StringBuilder sb = new StringBuilder(b.length * 2);
for (byte x : b) sb.append(String.format("%02x", x));
return sb.toString();
}
}

View File

@@ -0,0 +1,393 @@
package com.dangerousthings.aliro;
import com.licel.jcardsim.smartcardio.CardSimulator;
import java.security.KeyPair;
import java.security.SecureRandom;
import java.security.interfaces.ECPublicKey;
import javacard.framework.AID;
import javax.smartcardio.CommandAPDU;
import javax.smartcardio.ResponseAPDU;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
/**
* AUTH1 tests. Covers state-machine sequencing, input TLV validation, and
* provisioning preconditions. Real cryptographic verification of the
* reader signature and construction of the encrypted response come in
* later iterations.
*/
class AliroAppletAuth1Test {
private static final byte INS_AUTH1 = (byte) 0x81;
private CardSimulator sim;
private KeyPair credentialKeyPair;
private ReaderSide reader;
private byte[] credentialEphemPubKey; // captured from the last AUTH0 response
@BeforeEach
void setUp() {
CredentialStore.get().resetForTesting();
sim = new CardSimulator();
AID expeditedAid = new AID(AliroAids.EXPEDITED, (short) 0, (byte) AliroAids.EXPEDITED.length);
sim.installApplet(expeditedAid, AliroApplet.class);
credentialKeyPair = Auth0Command.generateEphemeralKeyPair();
reader = new ReaderSide();
reader.provision(sim, credentialKeyPair);
selectExpedited();
}
private void selectExpedited() {
CommandAPDU select = new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256);
ResponseAPDU r = sim.transmitCommand(select);
assertEquals(0x9000, r.getSW(), "SELECT must succeed");
}
/** Runs a standard-phase AUTH0 and captures the returned credential_ePubK for later AUTH1 use. */
private void sendStandardAuth0() {
reader.startTransaction();
byte[] data = reader.buildAuth0Data();
CommandAPDU auth0 = new CommandAPDU(
Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00, data, 256);
ResponseAPDU r = sim.transmitCommand(auth0);
assertEquals(0x9000, r.getSW(), "AUTH0 must succeed as a precondition for AUTH1 tests");
credentialEphemPubKey = TlvUtil.findTopLevel(r.getData(), 0x86);
}
/** AUTH1 command with a correctly-signed reader signature. */
private ResponseAPDU sendValidAuth1() {
return sendAuth1(reader.buildAuth1Data(credentialEphemPubKey));
}
/** Syntactically-valid AUTH1 payload with zeroed signature (for TLV-only tests). */
private ResponseAPDU sendStubAuth1() {
byte[] data = new byte[3 + 2 + 64];
int i = 0;
data[i++] = 0x41; data[i++] = 0x01; data[i++] = 0x00;
data[i++] = (byte) 0x9E; data[i++] = 0x40;
return sendAuth1(data);
}
private ResponseAPDU sendAuth1(byte[] data) {
CommandAPDU auth1 = new CommandAPDU(
Auth0Command.CLA & 0xFF, INS_AUTH1 & 0xFF, 0x00, 0x00, data, 256);
return sim.transmitCommand(auth1);
}
@Test
void auth1WithoutPriorAuth0IsRejected() {
ResponseAPDU r = sendStubAuth1();
assertNotEquals(0x9000, r.getSW(),
"AUTH1 must fail unless immediately preceded by a successful AUTH0");
assertEquals(0x6985, r.getSW(),
"expected SW_CONDITIONS_NOT_SATISFIED (0x6985) when AUTH0 not done");
}
@Test
void auth1WithValidReaderSignatureIsAccepted() {
sendStandardAuth0();
ResponseAPDU r = sendValidAuth1();
assertEquals(0x9000, r.getSW(),
"AUTH1 with a correctly-signed reader signature must succeed");
}
@Test
void auth1ReplayWithoutFreshAuth0IsRejected() {
sendStandardAuth0();
ResponseAPDU first = sendValidAuth1();
assertEquals(0x9000, first.getSW());
// Second AUTH1 without a new AUTH0 must fail — otherwise the card would
// re-encrypt with device_counter=1 under the same key, reusing the GCM
// IV and catastrophically breaking confidentiality + integrity.
ResponseAPDU replay = sendValidAuth1();
assertEquals(0x6985, replay.getSW(),
"AUTH1 after a prior successful AUTH1 must require a fresh AUTH0");
}
@Test
void auth1SuccessArmsStepUpContextWithExpectedStepUpSK() throws Exception {
sendStandardAuth0();
ResponseAPDU r = sendValidAuth1();
assertEquals(0x9000, r.getSW());
// Reader derives the full expedited-standard 160B key material and
// extracts StepUpSK (offset 64..96, per spec §8.3.1.13).
byte[] expected = java.util.Arrays.copyOfRange(
reader.deriveExpeditedKeyMaterial(credentialEphemPubKey), 64, 96);
org.junit.jupiter.api.Assertions.assertTrue(SessionContext.isStepUpArmed(),
"successful AUTH1 must arm the step-up context");
byte[] got = new byte[32];
SessionContext.copyStepUpSKForTesting(got, (short) 0);
org.junit.jupiter.api.Assertions.assertArrayEquals(expected, got,
"armed StepUpSK must equal derived_keys_volatile[64..96]");
}
@Test
void freshAuth0DisarmsPreviousStepUpArming() throws Exception {
// First transaction arms the step-up context.
sendStandardAuth0();
assertEquals(0x9000, sendValidAuth1().getSW());
org.junit.jupiter.api.Assertions.assertTrue(SessionContext.isStepUpArmed());
// Starting a new transaction via AUTH0 (without re-SELECT) must
// invalidate the prior arming — otherwise a mid-transaction failure
// could leave stale StepUpSK reachable by a subsequent SELECT STEP_UP.
reader.startTransaction();
byte[] data = reader.buildAuth0Data();
CommandAPDU auth0 = new CommandAPDU(
Auth0Command.CLA & 0xFF, Auth0Command.INS & 0xFF, 0x00, 0x00, data, 256);
assertEquals(0x9000, sim.transmitCommand(auth0).getSW());
org.junit.jupiter.api.Assertions.assertFalse(SessionContext.isStepUpArmed(),
"starting a new AUTH0 must disarm any prior step-up state");
}
@Test
void reSelectExpeditedDisarmsStepUpContext() throws Exception {
sendStandardAuth0();
assertEquals(0x9000, sendValidAuth1().getSW());
org.junit.jupiter.api.Assertions.assertTrue(SessionContext.isStepUpArmed());
selectExpedited();
org.junit.jupiter.api.Assertions.assertFalse(SessionContext.isStepUpArmed(),
"re-SELECT of expedited AID starts a new transaction and must disarm step-up");
}
@Test
void auth1SignalingBitmapIsAllZeroWhenNoAccessDocProvisioned() throws Exception {
// Default setUp provisions keys but no Access Document.
sendStandardAuth0();
ResponseAPDU r = sendValidAuth1();
assertEquals(0x9000, r.getSW());
byte[] pt = ReaderSide.decryptAuth1Response(
reader.deriveExpeditedSKDevice(credentialEphemPubKey), r.getData());
byte[] bitmap = TlvUtil.findTopLevel(pt, 0x5E);
org.junit.jupiter.api.Assertions.assertArrayEquals(
new byte[] { 0x00, 0x00 }, bitmap,
"no Access Document → signaling_bitmap is 0x0000");
}
@Test
void auth1SignalingBitmapHasAccessDocBitsWhenAdProvisioned() throws Exception {
// Provision a non-empty Access Document via CredentialStore (bypasses
// the PersonalizationApplet INS layer — that flow is tested
// separately). Content is opaque here; we only care that the
// signaling_bitmap reflects presence.
byte[] ad = new byte[120];
for (int i = 0; i < ad.length; i++) ad[i] = (byte) (i ^ 0x5A);
org.junit.jupiter.api.Assertions.assertTrue(
CredentialStore.get().writeAccessDocumentChunk(ad, (short) 0, (short) 0, (short) ad.length));
org.junit.jupiter.api.Assertions.assertTrue(
CredentialStore.get().finalizeAccessDocument((short) ad.length));
sendStandardAuth0();
ResponseAPDU r = sendValidAuth1();
assertEquals(0x9000, r.getSW());
byte[] pt = ReaderSide.decryptAuth1Response(
reader.deriveExpeditedSKDevice(credentialEphemPubKey), r.getData());
byte[] bitmap = TlvUtil.findTopLevel(pt, 0x5E);
// bit 0 (access doc retrievable) + bit 2 (requires step-up AID on NFC)
// = 2^0 + 2^2 = 0x0005 (big-endian)
org.junit.jupiter.api.Assertions.assertArrayEquals(
new byte[] { 0x00, 0x05 }, bitmap,
"Access Document provisioned on NFC → bitmap bits 0 and 2 set (0x0005)");
}
@Test
void auth1WithKeySlotRequestReturnsSha1KeyIdentifier() throws Exception {
sendStandardAuth0();
// Build a valid AUTH1 payload, then flip command_parameters bit 0 to 0
// (key_slot path). The reader sig is computed over Table 8-12, which
// doesn't include command_parameters, so the sig stays valid.
byte[] data = reader.buildAuth1Data(credentialEphemPubKey);
data[2] = 0x00;
ResponseAPDU r = sendAuth1(data);
assertEquals(0x9000, r.getSW());
byte[] pt = ReaderSide.decryptAuth1Response(
reader.deriveExpeditedSKDevice(credentialEphemPubKey), r.getData());
byte[] keySlot = TlvUtil.findTopLevel(pt, 0x4E);
byte[] credPubTag = TlvUtil.findTopLevel(pt, 0x5A);
org.junit.jupiter.api.Assertions.assertNotNull(keySlot, "0x4E key_slot must be present when cmd_params bit 0 = 0");
org.junit.jupiter.api.Assertions.assertNull(credPubTag, "0x5A credential_PubK must NOT be present when cmd_params bit 0 = 0");
assertEquals(8, keySlot.length, "key_slot is the first 8 bytes of keyIdentifier");
// Expected: first 8 bytes of SHA-1(uncompressed credential long-term pubkey)
java.security.MessageDigest sha1 = java.security.MessageDigest.getInstance("SHA-1");
byte[] credPubUncomp = new byte[65];
credPubUncomp[0] = 0x04;
byte[] x = toFixed32(credentialKeyPair.getPublic() instanceof java.security.interfaces.ECPublicKey
? ((java.security.interfaces.ECPublicKey) credentialKeyPair.getPublic()).getW().getAffineX().toByteArray()
: new byte[0]);
byte[] y = toFixed32(((java.security.interfaces.ECPublicKey) credentialKeyPair.getPublic())
.getW().getAffineY().toByteArray());
System.arraycopy(x, 0, credPubUncomp, 1, 32);
System.arraycopy(y, 0, credPubUncomp, 33, 32);
byte[] expected = java.util.Arrays.copyOf(sha1.digest(credPubUncomp), 8);
org.junit.jupiter.api.Assertions.assertArrayEquals(expected, keySlot,
"key_slot must be SHA-1(uncompressed credential_PubK)[0:8]");
}
private static byte[] toFixed32(byte[] in) {
if (in.length == 32) return in;
byte[] out = new byte[32];
if (in.length > 32) System.arraycopy(in, in.length - 32, out, 0, 32);
else System.arraycopy(in, 0, out, 32 - in.length, in.length);
return out;
}
@Test
void distinctTransactionsProduceDistinctCiphertexts() throws Exception {
sendStandardAuth0();
ResponseAPDU r1 = sendValidAuth1();
assertEquals(0x9000, r1.getSW());
byte[] ct1 = r1.getData();
byte[] plaintext1 = ReaderSide.decryptAuth1Response(
reader.deriveExpeditedSKDevice(credentialEphemPubKey), ct1);
// Second full transaction: fresh AUTH0 → fresh ephemerals + txn_id.
sendStandardAuth0();
ResponseAPDU r2 = sendValidAuth1();
assertEquals(0x9000, r2.getSW());
byte[] ct2 = r2.getData();
byte[] plaintext2 = ReaderSide.decryptAuth1Response(
reader.deriveExpeditedSKDevice(credentialEphemPubKey), ct2);
org.junit.jupiter.api.Assertions.assertFalse(
java.util.Arrays.equals(ct1, ct2),
"two transactions must produce different ciphertexts — catches hardcoded salt/IV/key bugs");
// Plaintexts also differ (UD sig is over Table 8-13 which includes txn_id).
byte[] sig1 = TlvUtil.findTopLevel(plaintext1, 0x9E);
byte[] sig2 = TlvUtil.findTopLevel(plaintext2, 0x9E);
org.junit.jupiter.api.Assertions.assertFalse(
java.util.Arrays.equals(sig1, sig2),
"UD signatures over Table 8-13 must differ across transactions");
}
@Test
void auth1EncryptedResponseDecryptsAndVerifies() throws Exception {
sendStandardAuth0();
ResponseAPDU r = sendValidAuth1();
assertEquals(0x9000, r.getSW());
byte[] ctAndTag = r.getData();
byte[] skDevice = reader.deriveExpeditedSKDevice(credentialEphemPubKey);
byte[] plaintext = ReaderSide.decryptAuth1Response(skDevice, ctAndTag);
byte[] credPub = TlvUtil.findTopLevel(plaintext, 0x5A);
byte[] udSig = TlvUtil.findTopLevel(plaintext, 0x9E);
byte[] bitmap = TlvUtil.findTopLevel(plaintext, 0x5E);
org.junit.jupiter.api.Assertions.assertNotNull(credPub, "Table 8-11 must include 0x5A (credential_PubK)");
org.junit.jupiter.api.Assertions.assertNotNull(udSig, "Table 8-11 must include 0x9E (UD signature)");
org.junit.jupiter.api.Assertions.assertNotNull(bitmap, "Table 8-11 must include 0x5E (signaling_bitmap)");
assertEquals(65, credPub.length);
assertEquals(64, udSig.length);
assertEquals(2, bitmap.length);
byte[] table813 = reader.buildTable813(credentialEphemPubKey);
org.junit.jupiter.api.Assertions.assertTrue(
ReaderSide.verifyUdSig(credPub, table813, udSig),
"UD signature in the decrypted Table 8-11 must verify under credential_PubK over Table 8-13");
}
@Test
void auth1WithInvalidReaderSignatureFails() {
sendStandardAuth0();
// Valid TLV structure but a random (definitely-not-matching) sig.
byte[] data = new byte[3 + 2 + 64];
int i = 0;
data[i++] = 0x41; data[i++] = 0x01; data[i++] = 0x00;
data[i++] = (byte) 0x9E; data[i++] = 0x40;
new SecureRandom().nextBytes(java.util.Arrays.copyOfRange(data, i, i + 64));
// Force at least one non-zero byte so the sig isn't accidentally valid.
data[i] = 0x11;
data[i + 1] = 0x22;
ResponseAPDU r = sendAuth1(data);
assertNotEquals(0x9000, r.getSW(),
"AUTH1 with a bad reader signature must not return success");
}
@Test
void reSelectClearsAuth0State() {
sendStandardAuth0();
selectExpedited();
ResponseAPDU r = sendStubAuth1();
assertEquals(0x6985, r.getSW(),
"re-SELECT must invalidate previous AUTH0 state");
}
@Test
void auth1MissingCommandParametersFails() {
sendStandardAuth0();
byte[] data = new byte[2 + 64];
data[0] = (byte) 0x9E; data[1] = 0x40;
assertEquals(0x6A80, sendAuth1(data).getSW(),
"missing tag 0x41 must be rejected with SW_WRONG_DATA");
}
@Test
void auth1MissingReaderSignatureFails() {
sendStandardAuth0();
byte[] data = new byte[] { 0x41, 0x01, 0x00 };
assertEquals(0x6A80, sendAuth1(data).getSW(),
"missing tag 0x9E (reader signature) must be rejected");
}
@Test
void auth1WithWrongReaderSignatureLengthFails() {
sendStandardAuth0();
byte[] data = new byte[3 + 2 + 63];
int i = 0;
data[i++] = 0x41; data[i++] = 0x01; data[i++] = 0x00;
data[i++] = (byte) 0x9E; data[i++] = 0x3F;
assertEquals(0x6A80, sendAuth1(data).getSW(),
"reader signature length != 64 must be rejected");
}
@Test
void auth1WithReaderCertIsRejectedInV1() {
sendStandardAuth0();
byte[] data = new byte[3 + 2 + 64 + 2 + 1];
int i = 0;
data[i++] = 0x41; data[i++] = 0x01; data[i++] = 0x00;
data[i++] = (byte) 0x9E; data[i++] = 0x40;
i += 64;
data[i++] = (byte) 0x90; data[i++] = 0x01; data[i++] = (byte) 0xFF;
assertEquals(0x6A80, sendAuth1(data).getSW(),
"reader_Cert (tag 0x90) is unsupported in v1 and must be rejected");
}
@Test
void auth1WithUnknownTrailingTagIsAccepted() {
sendStandardAuth0();
// Build a real-sig AUTH1 payload, then append an unknown TLV.
byte[] valid = reader.buildAuth1Data(credentialEphemPubKey);
byte[] data = new byte[valid.length + 3];
System.arraycopy(valid, 0, data, 0, valid.length);
int i = valid.length;
data[i++] = (byte) 0x7F; data[i++] = 0x01; data[i++] = (byte) 0xAB;
assertEquals(0x9000, sendAuth1(data).getSW(),
"unknown trailing TLVs must be accepted (spec §1.4.3)");
}
@Test
void auth1WithoutProvisionedKeysFails() {
CredentialStore.get().resetForTesting();
selectExpedited();
sendStandardAuth0();
ResponseAPDU r = sendStubAuth1();
assertEquals(0x6985, r.getSW(),
"AUTH1 must fail if CredentialStore was not provisioned (6985)");
}
}

View File

@@ -0,0 +1,95 @@
package com.dangerousthings.aliro;
import com.licel.jcardsim.smartcardio.CardSimulator;
import javacard.framework.AID;
import javax.smartcardio.CommandAPDU;
import javax.smartcardio.ResponseAPDU;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests covering the Aliro Expedited Phase SELECT handler, per spec &sect;10.2.1.
*/
class AliroAppletTest {
private CardSimulator sim;
@BeforeEach
void setUp() {
sim = new CardSimulator();
AID expeditedAid = new AID(AliroAids.EXPEDITED, (short) 0, (byte) AliroAids.EXPEDITED.length);
sim.installApplet(expeditedAid, AliroApplet.class);
}
private ResponseAPDU selectExpedited() {
CommandAPDU select = new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.EXPEDITED, 256);
return sim.transmitCommand(select);
}
@Test
void selectExpeditedAidReturns9000() {
assertEquals(0x9000, selectExpedited().getSW(),
"SELECT expedited AID must return success SW");
}
@Test
void selectExpeditedReturnsNonEmptyFci() {
byte[] data = selectExpedited().getData();
assertTrue(data.length > 0, "SELECT response data must be non-empty (FCI template)");
}
@Test
void selectExpeditedResponseIsFciTemplate() {
byte[] data = selectExpedited().getData();
assertEquals(0x6F, data[0] & 0xFF, "top-level tag must be 0x6F (FCI template)");
}
@Test
void fciContainsAidTagMatchingExpeditedAid() {
byte[] data = selectExpedited().getData();
byte[] fci = TlvUtil.findTopLevel(data, 0x6F);
assertNotNull(fci, "must contain FCI template");
byte[] aid = TlvUtil.findTopLevel(fci, 0x84);
assertNotNull(aid, "FCI must contain AID tag 0x84");
assertArrayEquals(AliroAids.EXPEDITED, aid, "AID tag value must equal expedited AID");
}
@Test
void fciContainsProprietaryInfoTagA5() {
byte[] data = selectExpedited().getData();
byte[] fci = TlvUtil.findTopLevel(data, 0x6F);
byte[] propInfo = TlvUtil.findTopLevel(fci, 0xA5);
assertNotNull(propInfo, "FCI must contain Proprietary Information tag 0xA5");
}
@Test
void proprietaryInfoContainsCsaApplicationType() {
byte[] data = selectExpedited().getData();
byte[] fci = TlvUtil.findTopLevel(data, 0x6F);
byte[] propInfo = TlvUtil.findTopLevel(fci, 0xA5);
byte[] type = TlvUtil.findTopLevel(propInfo, 0x80);
assertNotNull(type, "Proprietary Information must contain Type tag 0x80");
assertArrayEquals(new byte[] { 0x00, 0x00 }, type, "Type must be 0x0000 (CSA application)");
}
@Test
void proprietaryInfoContainsExpeditedProtocolVersion() {
byte[] data = selectExpedited().getData();
byte[] fci = TlvUtil.findTopLevel(data, 0x6F);
byte[] propInfo = TlvUtil.findTopLevel(fci, 0xA5);
byte[] versions = TlvUtil.findTopLevel(propInfo, 0x5C);
assertNotNull(versions, "expedited SELECT must include protocol versions tag 0x5C");
assertTrue(versions.length >= 2 && versions.length % 2 == 0,
"protocol versions must be a non-empty list of 2-byte entries");
assertEquals(0x01, versions[0] & 0xFF, "expected first protocol version high byte 0x01");
assertEquals(0x00, versions[1] & 0xFF, "expected first protocol version low byte 0x00");
}
}

View File

@@ -0,0 +1,470 @@
package com.dangerousthings.aliro;
import javacard.security.AESKey;
import javacard.security.ECPrivateKey;
import javacard.security.ECPublicKey;
import javacard.security.KeyBuilder;
import javacard.security.KeyPair;
import javacard.security.Signature;
import javacardx.crypto.AEADCipher;
import javacardx.crypto.Cipher;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Direct tests for {@link AliroCrypto} primitives, exercising them outside
* the applet's APDU dispatch.
*/
class AliroCryptoTest {
/** Pulled from RFC 5869 Test Case 1 (HKDF-SHA-256). */
private static final byte[] RFC5869_T1_IKM = hex(
"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b");
private static final byte[] RFC5869_T1_SALT = hex(
"000102030405060708090a0b0c");
private static final byte[] RFC5869_T1_INFO = hex(
"f0f1f2f3f4f5f6f7f8f9");
private static final byte[] RFC5869_T1_PRK = hex(
"077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5");
private static final byte[] RFC5869_T1_OKM_42 = hex(
"3cb25f25faacd57a90434f64d0362f2a"
+ "2d2d0a90cf1a5a4c5db02d56ecc4c5bf"
+ "34007208d5b887185865");
/** RFC 5869 Test Case 3 — empty salt, empty info. Guard for the
* Aliro step-up path (§8.4.3) which specifies an empty salt. */
private static final byte[] RFC5869_T3_IKM = hex(
"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b");
private static final byte[] RFC5869_T3_PRK = hex(
"19ef24a32c717b167f33a91d6f648bdf96596776afdb6377ac434c1c293ccb04");
private static final byte[] RFC5869_T3_OKM_42 = hex(
"8da4e775a563c18f715f802a063c5a31"
+ "b8a11f5c5ee1879ec3454e5f3c738d2d"
+ "9d201395faa4b61a96c8");
@Test
void ecdhIsCommutative() {
AliroCrypto crypto = new AliroCrypto();
KeyPair kp1 = freshP256();
KeyPair kp2 = freshP256();
byte[] pub1 = uncompressed((ECPublicKey) kp1.getPublic());
byte[] pub2 = uncompressed((ECPublicKey) kp2.getPublic());
byte[] x1 = new byte[32];
crypto.computeEcdhSharedX(
(ECPrivateKey) kp1.getPrivate(),
pub2, (short) 0,
x1, (short) 0);
byte[] x2 = new byte[32];
crypto.computeEcdhSharedX(
(ECPrivateKey) kp2.getPrivate(),
pub1, (short) 0,
x2, (short) 0);
assertArrayEquals(x1, x2,
"ECDH(priv1, pub2) must equal ECDH(priv2, pub1)");
}
@Test
void hkdfExtractRfc5869TestCase1() {
AliroCrypto crypto = new AliroCrypto();
byte[] prk = new byte[32];
short prkLen = crypto.hkdfExtract(
RFC5869_T1_SALT, (short) 0, (short) RFC5869_T1_SALT.length,
RFC5869_T1_IKM, (short) 0, (short) RFC5869_T1_IKM.length,
prk, (short) 0);
assertEquals(32, prkLen, "HKDF-Extract output is HashLen (32) bytes");
assertArrayEquals(RFC5869_T1_PRK, prk, "matches RFC 5869 Test Case 1 PRK");
}
@Test
void hkdfWithEmptySaltAndInfoMatchesRfc5869TestCase3() {
AliroCrypto crypto = new AliroCrypto();
byte[] prk = new byte[32];
crypto.hkdfExtract(
new byte[0], (short) 0, (short) 0,
RFC5869_T3_IKM, (short) 0, (short) RFC5869_T3_IKM.length,
prk, (short) 0);
assertArrayEquals(RFC5869_T3_PRK, prk,
"HKDF-Extract with empty salt (per RFC 5869 §2.2: key becomes HashLen zeros)");
byte[] okm = new byte[42];
crypto.hkdfExpand(
prk, (short) 0, (short) 32,
new byte[0], (short) 0, (short) 0,
(short) 42,
okm, (short) 0);
assertArrayEquals(RFC5869_T3_OKM_42, okm,
"HKDF-Expand with empty info");
}
/**
* Step-up session keys per Aliro §8.4.3 + mdoc 9.1.1.5: derive
* StepUpSKDevice and StepUpSKReader from StepUpSK via HKDF-SHA-256 with
* empty salt and info = "SKDevice" or "SKReader". This test drives
* {@link AliroCrypto#deriveStepUpSessionKeys} against the equivalent
* manual HKDF chain.
*/
@Test
void deriveStepUpSessionKeysMatchesManualHkdf() {
AliroCrypto crypto = new AliroCrypto();
byte[] stepUpSK = new byte[32];
for (int i = 0; i < 32; i++) stepUpSK[i] = (byte) (0xC0 + i);
byte[] skDevice = new byte[32];
byte[] skReader = new byte[32];
crypto.deriveStepUpSessionKeys(stepUpSK, (short) 0,
skDevice, (short) 0, skReader, (short) 0);
org.junit.jupiter.api.Assertions.assertFalse(
java.util.Arrays.equals(skDevice, skReader),
"SKDevice and SKReader must differ");
org.junit.jupiter.api.Assertions.assertFalse(
java.util.Arrays.equals(skDevice, stepUpSK),
"SKDevice must not equal the IKM");
// Manual: HKDF(ikm=StepUpSK, salt=empty, info="SKDevice"/"SKReader", L=32)
byte[] manualDevice = manualHkdf(stepUpSK, new byte[0], "SKDevice".getBytes(), 32);
byte[] manualReader = manualHkdf(stepUpSK, new byte[0], "SKReader".getBytes(), 32);
assertArrayEquals(manualDevice, skDevice,
"StepUpSKDevice must equal HKDF(ikm=StepUpSK, salt=∅, info=\"SKDevice\", L=32)");
assertArrayEquals(manualReader, skReader,
"StepUpSKReader must equal HKDF(ikm=StepUpSK, salt=∅, info=\"SKReader\", L=32)");
}
private static byte[] manualHkdf(byte[] ikm, byte[] salt, byte[] info, int L) {
try {
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");
byte[] useSalt = salt.length == 0 ? new byte[32] : salt;
mac.init(new javax.crypto.spec.SecretKeySpec(useSalt, "HmacSHA256"));
byte[] prk = mac.doFinal(ikm);
mac.init(new javax.crypto.spec.SecretKeySpec(prk, "HmacSHA256"));
byte[] out = new byte[L];
byte[] t = new byte[0];
int produced = 0;
byte counter = 1;
while (produced < L) {
mac.update(t);
mac.update(info);
mac.update(counter);
t = mac.doFinal();
int copy = Math.min(L - produced, t.length);
System.arraycopy(t, 0, out, produced, copy);
produced += copy;
counter++;
mac.init(new javax.crypto.spec.SecretKeySpec(prk, "HmacSHA256"));
}
return out;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Test
void hkdfExpandRfc5869TestCase1() {
AliroCrypto crypto = new AliroCrypto();
byte[] okm = new byte[42];
short okmLen = crypto.hkdfExpand(
RFC5869_T1_PRK, (short) 0, (short) RFC5869_T1_PRK.length,
RFC5869_T1_INFO, (short) 0, (short) RFC5869_T1_INFO.length,
(short) 42,
okm, (short) 0);
assertEquals(42, okmLen);
assertArrayEquals(RFC5869_T1_OKM_42, okm, "matches RFC 5869 Test Case 1 OKM");
}
/**
* Regression guard for the upstream licel/jcardsim ByteContainer.setBytes
* bug: without the ByteContainer patch in scripts/jcardsim-patches/,
* reusing an HMACKey across setKey calls of different lengths corrupts
* the internal data array and throws AIOOBE on the second arrayCopy.
* This exact shape — setKey(13), sign, setKey(32) — is what HKDF-Extract
* followed by HKDF-Expand does on the same key.
*/
@Test
void hmacKeyReuseAcrossDifferentKeyLengths() {
javacard.security.HMACKey k = (javacard.security.HMACKey)
javacard.security.KeyBuilder.buildKey(
javacard.security.KeyBuilder.TYPE_HMAC, (short) 512, false);
javacard.security.Signature h = javacard.security.Signature.getInstance(
javacard.security.Signature.ALG_HMAC_SHA_256, false);
k.setKey(RFC5869_T1_SALT, (short) 0, (short) RFC5869_T1_SALT.length);
h.init(k, javacard.security.Signature.MODE_SIGN);
byte[] prk = new byte[32];
h.sign(RFC5869_T1_IKM, (short) 0, (short) RFC5869_T1_IKM.length, prk, (short) 0);
byte[] wide = new byte[64];
System.arraycopy(RFC5869_T1_PRK, 0, wide, 32, 32);
k.setKey(wide, (short) 32, (short) 32);
}
/**
* Kdh is the session-key seed from Aliro §8.3.1.4. The spec note says
* the procedure in §8.3.1.5 (HKDF-SHA-256) supersedes the X9.63 KDF in
* §8.3.1.4 — concretely, Kdh = HKDF(IKM=ECDH_x(ePriv, peerEPub),
* salt=transaction_identifier, info=∅, L=32). This test checks that
* deriveKdh produces exactly what the manual HKDF chain produces.
*/
@Test
void deriveKdhMatchesManualHkdfChain() {
AliroCrypto crypto = new AliroCrypto();
KeyPair kp1 = freshP256();
KeyPair kp2 = freshP256();
byte[] pub2 = uncompressed((ECPublicKey) kp2.getPublic());
byte[] txnId = new byte[16];
for (byte i = 0; i < 16; i++) txnId[i] = (byte) (0x10 + i);
byte[] kdh = new byte[32];
crypto.deriveKdh(
(ECPrivateKey) kp1.getPrivate(),
pub2, (short) 0,
txnId, (short) 0, (short) txnId.length,
kdh, (short) 0);
byte[] zab = new byte[32];
crypto.computeEcdhSharedX(
(ECPrivateKey) kp1.getPrivate(),
pub2, (short) 0,
zab, (short) 0);
byte[] prk = new byte[32];
crypto.hkdfExtract(
txnId, (short) 0, (short) txnId.length,
zab, (short) 0, (short) 32,
prk, (short) 0);
byte[] okm = new byte[32];
crypto.hkdfExpand(
prk, (short) 0, (short) 32,
new byte[0], (short) 0, (short) 0,
(short) 32,
okm, (short) 0);
assertArrayEquals(okm, kdh,
"deriveKdh must equal HKDF(IKM=ECDH_x, salt=txnId, info=empty, L=32)");
}
/**
* §8.3.1.13 expedited-standard key material derivation is just
* HKDF(IKM=Kdh, salt=salt_volatile, info=info, L=160). This test checks
* the wrapper produces the same bytes as the explicit Extract→Expand
* chain and that both transaction sides agree.
*/
@Test
void deriveExpeditedStandardKeysMatchesManualHkdf() {
AliroCrypto crypto = new AliroCrypto();
byte[] kdh = new byte[32];
for (int i = 0; i < 32; i++) kdh[i] = (byte) (0xA0 + i);
byte[] salt = new byte[40];
for (int i = 0; i < 40; i++) salt[i] = (byte) i;
byte[] info = new byte[16];
for (int i = 0; i < 16; i++) info[i] = (byte) (i * 3);
byte[] derived = new byte[160];
crypto.deriveExpeditedStandardKeys(
kdh, (short) 0,
salt, (short) 0, (short) salt.length,
info, (short) 0, (short) info.length,
derived, (short) 0);
byte[] prk = new byte[32];
crypto.hkdfExtract(
salt, (short) 0, (short) salt.length,
kdh, (short) 0, (short) 32,
prk, (short) 0);
byte[] manual = new byte[160];
crypto.hkdfExpand(
prk, (short) 0, (short) 32,
info, (short) 0, (short) info.length,
(short) 160,
manual, (short) 0);
assertArrayEquals(manual, derived,
"deriveExpeditedStandardKeys must equal HKDF(Kdh, salt, info, 160)");
}
@Test
void deriveExpeditedStandardKeysAgreesOnBothSides() {
AliroCrypto crypto = new AliroCrypto();
KeyPair kp1 = freshP256();
KeyPair kp2 = freshP256();
byte[] pub1 = uncompressed((ECPublicKey) kp1.getPublic());
byte[] pub2 = uncompressed((ECPublicKey) kp2.getPublic());
byte[] txnId = new byte[16];
for (byte i = 0; i < 16; i++) txnId[i] = (byte) (0x11 * i);
byte[] salt = new byte[80];
for (int i = 0; i < 80; i++) salt[i] = (byte) (i ^ 0x55);
byte[] info = new byte[32];
for (int i = 0; i < 32; i++) info[i] = (byte) (i ^ 0xAA);
byte[] outA = new byte[160];
byte[] outB = new byte[160];
byte[] kdhA = new byte[32];
byte[] kdhB = new byte[32];
crypto.deriveKdh((ECPrivateKey) kp1.getPrivate(), pub2, (short) 0,
txnId, (short) 0, (short) txnId.length, kdhA, (short) 0);
crypto.deriveExpeditedStandardKeys(kdhA, (short) 0,
salt, (short) 0, (short) salt.length,
info, (short) 0, (short) info.length,
outA, (short) 0);
crypto.deriveKdh((ECPrivateKey) kp2.getPrivate(), pub1, (short) 0,
txnId, (short) 0, (short) txnId.length, kdhB, (short) 0);
crypto.deriveExpeditedStandardKeys(kdhB, (short) 0,
salt, (short) 0, (short) salt.length,
info, (short) 0, (short) info.length,
outB, (short) 0);
assertArrayEquals(outA, outB,
"reader and device must derive identical ExpeditedSK material");
}
@Test
void deriveKdhAgreesOnBothSides() {
AliroCrypto crypto = new AliroCrypto();
KeyPair kp1 = freshP256();
KeyPair kp2 = freshP256();
byte[] pub1 = uncompressed((ECPublicKey) kp1.getPublic());
byte[] pub2 = uncompressed((ECPublicKey) kp2.getPublic());
byte[] txnId = new byte[16];
for (byte i = 0; i < 16; i++) txnId[i] = (byte) (0x42 ^ i);
byte[] kdhA = new byte[32];
byte[] kdhB = new byte[32];
crypto.deriveKdh((ECPrivateKey) kp1.getPrivate(), pub2, (short) 0,
txnId, (short) 0, (short) txnId.length, kdhA, (short) 0);
crypto.deriveKdh((ECPrivateKey) kp2.getPrivate(), pub1, (short) 0,
txnId, (short) 0, (short) txnId.length, kdhB, (short) 0);
assertArrayEquals(kdhA, kdhB, "both sides must derive the same Kdh");
}
/**
* Proves the load-a-known-scalar-into-a-JC-ECPrivateKey pattern we need
* for credential_PrivK: create a JC KeyPair to inherit P-256 curve
* parameters, overwrite S with the externally-supplied scalar, then
* sign — the signature must verify under the matching (also externally
* supplied, loaded via setW) public key.
*/
@Test
void ecPrivateKeyLoadedViaKeyPairPatternSignsAndVerifies() throws Exception {
java.security.KeyPairGenerator g = java.security.KeyPairGenerator.getInstance("EC");
g.initialize(new java.security.spec.ECGenParameterSpec("secp256r1"));
java.security.KeyPair jdk = g.generateKeyPair();
byte[] s = toFixed32(
((java.security.interfaces.ECPrivateKey) jdk.getPrivate()).getS().toByteArray());
byte[] w = uncompressedFromJdk((java.security.interfaces.ECPublicKey) jdk.getPublic());
KeyPair privKp = new KeyPair(KeyPair.ALG_EC_FP, KeyBuilder.LENGTH_EC_FP_256);
privKp.genKeyPair();
((ECPrivateKey) privKp.getPrivate()).setS(s, (short) 0, (short) s.length);
KeyPair pubKp = new KeyPair(KeyPair.ALG_EC_FP, KeyBuilder.LENGTH_EC_FP_256);
pubKp.genKeyPair();
((ECPublicKey) pubKp.getPublic()).setW(w, (short) 0, (short) w.length);
byte[] msg = new byte[] { 0x11, 0x22, 0x33, 0x44, 0x55 };
Signature signer = Signature.getInstance(Signature.ALG_ECDSA_SHA_256, false);
signer.init(privKp.getPrivate(), Signature.MODE_SIGN);
byte[] der = new byte[80];
short sigLen = signer.sign(msg, (short) 0, (short) msg.length, der, (short) 0);
Signature verifier = Signature.getInstance(Signature.ALG_ECDSA_SHA_256, false);
verifier.init(pubKp.getPublic(), Signature.MODE_VERIFY);
assertTrue(verifier.verify(msg, (short) 0, (short) msg.length, der, (short) 0, sigLen),
"sig from loaded privkey must verify under loaded pubkey");
}
private static byte[] toFixed32(byte[] in) {
if (in.length == 32) return in;
byte[] out = new byte[32];
if (in.length > 32) System.arraycopy(in, in.length - 32, out, 0, 32);
else System.arraycopy(in, 0, out, 32 - in.length, in.length);
return out;
}
private static byte[] uncompressedFromJdk(java.security.interfaces.ECPublicKey pub) {
byte[] x = toFixed32(pub.getW().getAffineX().toByteArray());
byte[] y = toFixed32(pub.getW().getAffineY().toByteArray());
byte[] out = new byte[65];
out[0] = 0x04;
System.arraycopy(x, 0, out, 1, 32);
System.arraycopy(y, 0, out, 33, 32);
return out;
}
/**
* Probe: can jcardsim actually instantiate AES-256-GCM and round-trip a
* block? Aliro §8.3.1.6 requires AES-GCM with a 32-byte session key;
* if jcardsim doesn't support it, we need userland GCM instead.
*/
@Test
void jcardsimSupportsAesGcm() {
Cipher c = Cipher.getInstance(AEADCipher.ALG_AES_GCM, false);
assertTrue(c instanceof AEADCipher, "expected an AEADCipher instance");
AEADCipher gcm = (AEADCipher) c;
AESKey k = (AESKey) KeyBuilder.buildKey(
KeyBuilder.TYPE_AES, KeyBuilder.LENGTH_AES_256, false);
byte[] keyBytes = new byte[32];
for (int i = 0; i < 32; i++) keyBytes[i] = (byte) i;
k.setKey(keyBytes, (short) 0);
byte[] iv = new byte[12];
iv[11] = 0x01;
byte[] pt = new byte[] { 0x11, 0x22, 0x33, 0x44 };
// jcardsim's AEADCipher impl writes ct||tag into the doFinal output
// buffer; size it accordingly, then also call retrieveTag.
byte[] ctAndTag = new byte[pt.length + 16];
byte[] tag = new byte[16];
gcm.init(k, Cipher.MODE_ENCRYPT, iv, (short) 0, (short) iv.length);
short outLen = gcm.doFinal(pt, (short) 0, (short) pt.length, ctAndTag, (short) 0);
gcm.retrieveTag(tag, (short) 0, (short) tag.length);
assertEquals(pt.length + 16, outLen);
byte[] tagFromOutput = new byte[16];
System.arraycopy(ctAndTag, pt.length, tagFromOutput, 0, 16);
assertArrayEquals(tag, tagFromOutput,
"tag via retrieveTag must match tag appended by doFinal");
byte[] dec = new byte[pt.length + 16];
gcm.init(k, Cipher.MODE_DECRYPT, iv, (short) 0, (short) iv.length);
short decLen = gcm.doFinal(ctAndTag, (short) 0, (short) (pt.length + 16), dec, (short) 0);
assertEquals(pt.length, decLen, "decrypt doFinal returns plaintext length");
byte[] ptOut = new byte[pt.length];
System.arraycopy(dec, 0, ptOut, 0, pt.length);
assertArrayEquals(pt, ptOut);
}
private static KeyPair freshP256() {
KeyPair kp = new KeyPair(KeyPair.ALG_EC_FP, KeyBuilder.LENGTH_EC_FP_256);
kp.genKeyPair();
return kp;
}
private static byte[] uncompressed(ECPublicKey pub) {
byte[] out = new byte[65];
short len = pub.getW(out, (short) 0);
if (len != 65) throw new IllegalStateException("expected 65B uncompressed point");
return out;
}
private static byte[] hex(String s) {
s = s.replaceAll("\\s+", "");
byte[] out = new byte[s.length() / 2];
for (int i = 0; i < out.length; i++) {
out[i] = (byte) Integer.parseInt(s.substring(i * 2, i * 2 + 2), 16);
}
return out;
}
}

View File

@@ -0,0 +1,229 @@
package com.dangerousthings.aliro;
import javacard.security.AESKey;
import javacard.security.KeyBuilder;
import javacardx.crypto.AEADCipher;
import javacardx.crypto.Cipher;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
/**
* Tests for {@link AliroGcm} — our userland AES-256-GCM, built on the JC
* single-block AES-ECB primitive. Needed because the target card (NXP J3R180)
* does not expose {@code AEADCipher.ALG_AES_GCM}. The NIST test vectors come
* from {@code gcmEncryptExtIV256.rsp} (the CMVP AEAD AES-256 suite).
*/
class AliroGcmTest {
@Test
void nistTestCase13_emptyMessage_emptyAad_returnsTagOnly() {
// K=0^256, IV=0^96, P=A=empty, expected T = 530f8afbc74536b9a963b4f1c4cb738b
byte[] key = new byte[32];
byte[] iv = new byte[12];
byte[] pt = new byte[0];
byte[] expectedTag = hex("530f8afbc74536b9a963b4f1c4cb738b");
byte[] out = new byte[16];
AliroGcm gcm = new AliroGcm();
short n = gcm.encrypt(key, (short) 0, iv, (short) 0, pt, (short) 0, (short) 0, out, (short) 0);
assertEquals(16, n, "empty plaintext → 16 bytes output (just the tag)");
assertArrayEquals(expectedTag, out, "NIST GCM AES-256 Test Case 13 tag");
}
@Test
void nistTestCase14_singleZeroBlock_returnsCiphertextAndTag() {
// K=0^256, IV=0^96, P=0^128, A=empty
// C = cea7403d4d606b6e074ec5d3baf39d18
// T = d0d1c8a799996bf0265b98b5d48ab919
byte[] key = new byte[32];
byte[] iv = new byte[12];
byte[] pt = new byte[16];
byte[] expectedCt = hex("cea7403d4d606b6e074ec5d3baf39d18");
byte[] expectedTag = hex("d0d1c8a799996bf0265b98b5d48ab919");
byte[] out = new byte[32];
AliroGcm gcm = new AliroGcm();
short n = gcm.encrypt(key, (short) 0, iv, (short) 0, pt, (short) 0, (short) pt.length, out, (short) 0);
assertEquals(32, n, "16B plaintext + 16B tag");
byte[] ct = new byte[16];
System.arraycopy(out, 0, ct, 0, 16);
byte[] tag = new byte[16];
System.arraycopy(out, 16, tag, 0, 16);
assertArrayEquals(expectedCt, ct, "NIST GCM AES-256 Test Case 14 ciphertext");
assertArrayEquals(expectedTag, tag, "NIST GCM AES-256 Test Case 14 tag");
}
@Test
void nistTestCase15_fourBlocks_realKey() {
// K = feffe9928665731c6d6a8f9467308308 feffe9928665731c6d6a8f9467308308
// IV = cafebabefacedbaddecaf888
// P = 4 blocks, A = empty
byte[] key = hex("feffe9928665731c6d6a8f9467308308"
+ "feffe9928665731c6d6a8f9467308308");
byte[] iv = hex("cafebabefacedbaddecaf888");
byte[] pt = hex("d9313225f88406e5a55909c5aff5269a"
+ "86a7a9531534f7da2e4c303d8a318a72"
+ "1c3c0c95956809532fcf0e2449a6b525"
+ "b16aedf5aa0de657ba637b391aafd255");
byte[] expectedCt = hex("522dc1f099567d07f47f37a32a84427d"
+ "643a8cdcbfe5c0c97598a2bd2555d1aa"
+ "8cb08e48590dbb3da7b08b1056828838"
+ "c5f61e6393ba7a0abcc9f662898015ad");
byte[] expectedTag = hex("b094dac5d93471bdec1a502270e3cc6c");
byte[] out = new byte[pt.length + 16];
AliroGcm gcm = new AliroGcm();
short n = gcm.encrypt(key, (short) 0, iv, (short) 0, pt, (short) 0, (short) pt.length, out, (short) 0);
assertEquals(pt.length + 16, n);
byte[] ct = new byte[pt.length];
System.arraycopy(out, 0, ct, 0, pt.length);
byte[] tag = new byte[16];
System.arraycopy(out, pt.length, tag, 0, 16);
assertArrayEquals(expectedCt, ct, "NIST GCM AES-256 Test Case 15 ciphertext");
assertArrayEquals(expectedTag, tag, "NIST GCM AES-256 Test Case 15 tag");
}
/**
* End-to-end: encrypt with AliroGcm, decrypt with jcardsim's AEADCipher
* (available in tests via the patched jcardsim master in m2 volume).
* Confirms wire-compatibility with the standard implementation.
*/
@Test
void roundTripAgainstJcardsimAEADCipher() {
byte[] key = new byte[32];
for (int i = 0; i < 32; i++) key[i] = (byte) (i * 7 + 3);
byte[] iv = new byte[12];
for (int i = 0; i < 12; i++) iv[i] = (byte) (0xA0 ^ i);
byte[] pt = new byte[137]; // realistic Aliro Table 8-11 size
for (int i = 0; i < pt.length; i++) pt[i] = (byte) (i ^ 0x5C);
byte[] out = new byte[pt.length + 16];
AliroGcm gcm = new AliroGcm();
gcm.encrypt(key, (short) 0, iv, (short) 0,
pt, (short) 0, (short) pt.length, out, (short) 0);
// Decrypt with jcardsim's AEADCipher.
AESKey k = (AESKey) KeyBuilder.buildKey(
KeyBuilder.TYPE_AES, KeyBuilder.LENGTH_AES_256, false);
k.setKey(key, (short) 0);
AEADCipher jcGcm = (AEADCipher) Cipher.getInstance(AEADCipher.ALG_AES_GCM, false);
jcGcm.init(k, Cipher.MODE_DECRYPT, iv, (short) 0, (short) iv.length);
byte[] dec = new byte[pt.length + 16];
short decLen = jcGcm.doFinal(out, (short) 0, (short) out.length, dec, (short) 0);
assertEquals(pt.length, decLen, "jcardsim GCM decrypt returns pt length");
byte[] ptOut = new byte[pt.length];
System.arraycopy(dec, 0, ptOut, 0, pt.length);
assertArrayEquals(pt, ptOut,
"AliroGcm ciphertext must decrypt correctly under jcardsim AEADCipher");
}
/**
* Partial final block: plaintext whose length isn't a multiple of 16.
* GCTR must handle the partial block correctly (XOR only |P| mod 16 bytes
* of the last keystream block), and GHASH must zero-pad.
*/
@Test
void partialFinalBlock() {
byte[] key = new byte[32];
for (int i = 0; i < 32; i++) key[i] = (byte) i;
byte[] iv = new byte[12];
iv[11] = 0x01;
byte[] pt = new byte[17]; // 1 full block + 1 byte
for (int i = 0; i < 17; i++) pt[i] = (byte) (0xAA + i);
byte[] out = new byte[pt.length + 16];
AliroGcm gcm = new AliroGcm();
short n = gcm.encrypt(key, (short) 0, iv, (short) 0,
pt, (short) 0, (short) pt.length, out, (short) 0);
assertEquals(pt.length + 16, n,
"ciphertext length must equal plaintext length (GCM is length-preserving)");
// Round-trip through jcardsim for correctness.
AESKey k = (AESKey) KeyBuilder.buildKey(
KeyBuilder.TYPE_AES, KeyBuilder.LENGTH_AES_256, false);
k.setKey(key, (short) 0);
AEADCipher jcGcm = (AEADCipher) Cipher.getInstance(AEADCipher.ALG_AES_GCM, false);
jcGcm.init(k, Cipher.MODE_DECRYPT, iv, (short) 0, (short) iv.length);
byte[] dec = new byte[pt.length + 16];
short decLen = jcGcm.doFinal(out, (short) 0, (short) out.length, dec, (short) 0);
assertEquals(pt.length, decLen);
byte[] ptOut = new byte[pt.length];
System.arraycopy(dec, 0, ptOut, 0, pt.length);
assertArrayEquals(pt, ptOut, "17-byte plaintext must round-trip");
}
@Test
void differentKeysProduceDifferentTags() {
byte[] iv = new byte[12];
iv[11] = 0x01;
byte[] pt = new byte[] { 0x11, 0x22, 0x33, 0x44 };
byte[] key1 = new byte[32];
byte[] key2 = new byte[32];
key2[0] = 0x01; // differ in one bit
AliroGcm gcm = new AliroGcm();
byte[] out1 = new byte[pt.length + 16];
byte[] out2 = new byte[pt.length + 16];
gcm.encrypt(key1, (short) 0, iv, (short) 0, pt, (short) 0, (short) pt.length, out1, (short) 0);
gcm.encrypt(key2, (short) 0, iv, (short) 0, pt, (short) 0, (short) pt.length, out2, (short) 0);
// Extract tags (last 16 bytes).
byte[] tag1 = new byte[16];
byte[] tag2 = new byte[16];
System.arraycopy(out1, pt.length, tag1, 0, 16);
System.arraycopy(out2, pt.length, tag2, 0, 16);
assertFalse(java.util.Arrays.equals(tag1, tag2),
"different keys must produce different tags");
}
@Test
void differentIvsProduceDifferentCiphertexts() {
byte[] key = new byte[32];
for (int i = 0; i < 32; i++) key[i] = (byte) i;
byte[] pt = new byte[32];
for (int i = 0; i < 32; i++) pt[i] = (byte) (0x10 + i);
byte[] iv1 = new byte[12];
iv1[11] = 0x01;
byte[] iv2 = new byte[12];
iv2[11] = 0x02;
AliroGcm gcm = new AliroGcm();
byte[] out1 = new byte[pt.length + 16];
byte[] out2 = new byte[pt.length + 16];
gcm.encrypt(key, (short) 0, iv1, (short) 0, pt, (short) 0, (short) pt.length, out1, (short) 0);
gcm.encrypt(key, (short) 0, iv2, (short) 0, pt, (short) 0, (short) pt.length, out2, (short) 0);
byte[] ct1 = new byte[pt.length];
byte[] ct2 = new byte[pt.length];
System.arraycopy(out1, 0, ct1, 0, pt.length);
System.arraycopy(out2, 0, ct2, 0, pt.length);
assertFalse(java.util.Arrays.equals(ct1, ct2),
"different IVs must produce different ciphertexts (CTR stream differs)");
}
private static byte[] hex(String s) {
s = s.replaceAll("\\s+", "");
byte[] out = new byte[s.length() / 2];
for (int i = 0; i < out.length; i++) {
out[i] = (byte) Integer.parseInt(s.substring(i * 2, i * 2 + 2), 16);
}
return out;
}
}

View File

@@ -0,0 +1,181 @@
package com.dangerousthings.aliro;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Tests for {@link AliroHmac} — userland HMAC-SHA-256, built on the JC
* {@code MessageDigest.ALG_SHA_256} primitive. Needed because the target card
* (NXP J3R180) does not support {@code KeyBuilder.TYPE_HMAC} or
* {@code Signature.ALG_HMAC_SHA_256} despite advertising JC 3.0.5.
*
* <p>Known-answer vectors come from RFC 4231 (HMAC-SHA-256 test cases).
*/
class AliroHmacTest {
/** RFC 4231 TC1: K = 0x0b × 20, M = "Hi There". */
@Test
void rfc4231TestCase1() {
byte[] key = new byte[20];
for (int i = 0; i < 20; i++) key[i] = 0x0b;
byte[] msg = "Hi There".getBytes();
byte[] expected = hex(
"b0344c61d8db38535ca8afceaf0bf12b"
+ "881dc200c9833da726e9376c2e32cff7");
byte[] out = new byte[32];
AliroHmac hmac = new AliroHmac();
short n = hmac.compute(
key, (short) 0, (short) key.length,
msg, (short) 0, (short) msg.length,
out, (short) 0);
assertEquals(32, n);
assertArrayEquals(expected, out, "RFC 4231 Test Case 1 HMAC-SHA-256");
}
/** RFC 4231 TC2: K = "Jefe", M = "what do ya want for nothing?". */
@Test
void rfc4231TestCase2() {
byte[] key = "Jefe".getBytes();
byte[] msg = "what do ya want for nothing?".getBytes();
byte[] expected = hex(
"5bdcc146bf60754e6a042426089575c7"
+ "5a003f089d2739839dec58b964ec3843");
byte[] out = new byte[32];
AliroHmac hmac = new AliroHmac();
hmac.compute(
key, (short) 0, (short) key.length,
msg, (short) 0, (short) msg.length,
out, (short) 0);
assertArrayEquals(expected, out, "RFC 4231 Test Case 2 HMAC-SHA-256");
}
/** RFC 4231 TC4: K = 0x01..0x19 (25 bytes), M = 0xcd × 50. */
@Test
void rfc4231TestCase4() {
byte[] key = hex("0102030405060708090a0b0c0d0e0f10111213141516171819");
byte[] msg = new byte[50];
for (int i = 0; i < 50; i++) msg[i] = (byte) 0xcd;
byte[] expected = hex(
"82558a389a443c0ea4cc819899f2083a"
+ "85f0faa3e578f8077a2e3ff46729665b");
byte[] out = new byte[32];
AliroHmac hmac = new AliroHmac();
hmac.compute(
key, (short) 0, (short) key.length,
msg, (short) 0, (short) msg.length,
out, (short) 0);
assertArrayEquals(expected, out, "RFC 4231 Test Case 4 HMAC-SHA-256");
}
/** RFC 4231 TC6: K = 0xaa × 131 (> 64B, triggers K' = SHA-256(K) branch). */
@Test
void rfc4231TestCase6_keyLongerThanBlock() {
byte[] key = new byte[131];
for (int i = 0; i < 131; i++) key[i] = (byte) 0xaa;
byte[] msg = "Test Using Larger Than Block-Size Key - Hash Key First".getBytes();
byte[] expected = hex(
"60e431591ee0b67f0d8a26aacbf5b77f"
+ "8e0bc6213728c5140546040f0ee37f54");
byte[] out = new byte[32];
AliroHmac hmac = new AliroHmac();
hmac.compute(
key, (short) 0, (short) key.length,
msg, (short) 0, (short) msg.length,
out, (short) 0);
assertArrayEquals(expected, out,
"RFC 4231 Test Case 6 HMAC-SHA-256 — long key (K > B) must be hashed");
}
/**
* Calling compute twice with the same inputs must produce the same output —
* catches internal-state leaks (e.g. failing to reset the SHA-256 digest,
* or forgetting to zero the pad block on reuse).
*/
@Test
void repeatedComputesMatch() {
byte[] key = new byte[20];
for (int i = 0; i < 20; i++) key[i] = 0x0b;
byte[] msg = "Hi There".getBytes();
AliroHmac hmac = new AliroHmac();
byte[] out1 = new byte[32];
byte[] out2 = new byte[32];
hmac.compute(
key, (short) 0, (short) key.length,
msg, (short) 0, (short) msg.length,
out1, (short) 0);
hmac.compute(
key, (short) 0, (short) key.length,
msg, (short) 0, (short) msg.length,
out2, (short) 0);
assertArrayEquals(out1, out2,
"same key+msg must produce same HMAC on repeat invocation");
}
/**
* Empty message with a non-empty key — must still produce a valid,
* deterministic HMAC (HMAC is defined for zero-length messages).
* Reference value computed via JDK HmacSHA256 with the same key.
*/
@Test
void emptyMessage() {
byte[] key = new byte[20];
for (int i = 0; i < 20; i++) key[i] = 0x0b;
byte[] expected = jdkHmacSha256(key, new byte[0]);
byte[] out = new byte[32];
AliroHmac hmac = new AliroHmac();
hmac.compute(
key, (short) 0, (short) key.length,
new byte[0], (short) 0, (short) 0,
out, (short) 0);
assertArrayEquals(expected, out, "HMAC of empty message matches JDK reference");
}
/**
* Empty key — RFC 2104 allows zero-length keys (K' is zero-padded to B);
* some buggy implementations trip over this. Reference value from the JDK.
*/
@Test
void emptyKey() {
byte[] msg = "Hi There".getBytes();
byte[] expected = jdkHmacSha256(new byte[0], msg);
byte[] out = new byte[32];
AliroHmac hmac = new AliroHmac();
hmac.compute(
new byte[0], (short) 0, (short) 0,
msg, (short) 0, (short) msg.length,
out, (short) 0);
assertArrayEquals(expected, out, "HMAC with empty key matches JDK reference");
}
private static byte[] jdkHmacSha256(byte[] key, byte[] msg) {
try {
// HmacSHA256 rejects zero-length keys in some JDKs; normalize by
// zero-padding to 1 byte when empty — equivalent under HMAC's
// "zero-pad K to B bytes" rule (all-zero key → all-zero K').
byte[] useKey = key.length == 0 ? new byte[1] : key;
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");
mac.init(new javax.crypto.spec.SecretKeySpec(useKey, "HmacSHA256"));
return mac.doFinal(msg);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static byte[] hex(String s) {
s = s.replaceAll("\\s+", "");
byte[] out = new byte[s.length() / 2];
for (int i = 0; i < out.length; i++) {
out[i] = (byte) Integer.parseInt(s.substring(i * 2, i * 2 + 2), 16);
}
return out;
}
}

View File

@@ -0,0 +1,106 @@
package com.dangerousthings.aliro;
import java.io.ByteArrayOutputStream;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.SecureRandom;
import java.security.interfaces.ECPublicKey;
import java.security.spec.ECGenParameterSpec;
/**
* Test-only builder for Aliro AUTH0 command payloads, per spec Table 8-4.
*
* <pre>
* 0x41 01 [command_parameters]
* 0x42 01 [authentication_policy]
* 0x5C 02 [expedited_phase_protocol_version]
* 0x87 41 04 || x || y (reader_ePubK uncompressed, 65 bytes)
* 0x4C 10 [transaction_identifier]
* 0x4D 20 [reader_group_id 16 || reader_group_sub_id 16]
* </pre>
*/
final class Auth0Command {
static final byte CLA = (byte) 0x80;
static final byte INS = (byte) 0x80;
static final short PROTOCOL_V1_0 = (short) 0x0100;
static final byte CMD_PARAM_STANDARD = (byte) 0x00;
static final byte CMD_PARAM_FAST = (byte) 0x01;
static final byte AUTH_POLICY_NONE = (byte) 0x00;
private Auth0Command() {}
/** Generates a fresh P-256 keypair for use by tests simulating the reader. */
static KeyPair generateEphemeralKeyPair() {
try {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
kpg.initialize(new ECGenParameterSpec("secp256r1"), new SecureRandom());
return kpg.generateKeyPair();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/** Encodes a P-256 public key to uncompressed 65-byte form (0x04 || x || y). */
static byte[] encodeUncompressed(ECPublicKey pub) {
byte[] x = toFixed32(pub.getW().getAffineX().toByteArray());
byte[] y = toFixed32(pub.getW().getAffineY().toByteArray());
byte[] out = new byte[65];
out[0] = 0x04;
System.arraycopy(x, 0, out, 1, 32);
System.arraycopy(y, 0, out, 33, 32);
return out;
}
private static byte[] toFixed32(byte[] b) {
if (b.length == 32) return b;
byte[] out = new byte[32];
if (b.length > 32) {
System.arraycopy(b, b.length - 32, out, 0, 32);
} else {
System.arraycopy(b, 0, out, 32 - b.length, b.length);
}
return out;
}
/** Builds a standard-phase AUTH0 command data field with the given parts. */
static byte[] buildStandardData(
byte[] readerEphemPubKeyUncompressed,
byte[] readerGroupId,
byte[] readerGroupSubId,
byte[] transactionId) {
if (readerEphemPubKeyUncompressed.length != 65) {
throw new IllegalArgumentException("reader_ePubK must be 65 bytes (uncompressed)");
}
if (readerGroupId.length != 16 || readerGroupSubId.length != 16) {
throw new IllegalArgumentException("reader_group_{id,sub_id} must be 16 bytes each");
}
if (transactionId.length != 16) {
throw new IllegalArgumentException("transaction_identifier must be 16 bytes");
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
writeTlv(out, 0x41, new byte[] { CMD_PARAM_STANDARD });
writeTlv(out, 0x42, new byte[] { AUTH_POLICY_NONE });
writeTlv(out, 0x5C, new byte[] {
(byte) ((PROTOCOL_V1_0 >> 8) & 0xFF),
(byte) (PROTOCOL_V1_0 & 0xFF)
});
writeTlv(out, 0x87, readerEphemPubKeyUncompressed);
writeTlv(out, 0x4C, transactionId);
byte[] readerIdentifier = new byte[32];
System.arraycopy(readerGroupId, 0, readerIdentifier, 0, 16);
System.arraycopy(readerGroupSubId, 0, readerIdentifier, 16, 16);
writeTlv(out, 0x4D, readerIdentifier);
return out.toByteArray();
}
private static void writeTlv(ByteArrayOutputStream out, int tag, byte[] value) {
out.write(tag);
out.write(value.length);
out.write(value, 0, value.length);
}
}

View File

@@ -0,0 +1,165 @@
package com.dangerousthings.aliro;
import com.licel.jcardsim.smartcardio.CardSimulator;
import javacard.framework.AID;
import javax.smartcardio.CommandAPDU;
import javax.smartcardio.ResponseAPDU;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Tests for the PersonalizationApplet — our proprietary provisioning
* channel that loads the Access Credential long-term private key and
* reader public keys before the Aliro flow runs.
*/
class PersonalizationAppletTest {
private static final byte CLA = (byte) 0x80;
private static final byte INS_SET_CRED_PRIV = (byte) 0x20;
private static final byte INS_SET_CRED_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;
private CardSimulator sim;
@BeforeEach
void setUp() {
CredentialStore.get().resetForTesting();
sim = new CardSimulator();
AID aid = new AID(AliroAids.PROVISIONING, (short) 0, (byte) AliroAids.PROVISIONING.length);
sim.installApplet(aid, PersonalizationApplet.class);
selectProvisioning();
}
private void selectProvisioning() {
CommandAPDU select = new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.PROVISIONING, 256);
ResponseAPDU r = sim.transmitCommand(select);
assertEquals(0x9000, r.getSW(), "SELECT provisioning AID must succeed");
}
private ResponseAPDU send(byte ins, byte[] data) {
CommandAPDU c = new CommandAPDU(CLA & 0xFF, ins & 0xFF, 0x00, 0x00,
data == null ? new byte[0] : data, 256);
return sim.transmitCommand(c);
}
private ResponseAPDU sendWithP1P2(byte ins, int p1p2, byte[] data) {
CommandAPDU c = new CommandAPDU(CLA & 0xFF, ins & 0xFF,
(p1p2 >> 8) & 0xFF, p1p2 & 0xFF,
data == null ? new byte[0] : data, 256);
return sim.transmitCommand(c);
}
@Test
void setCredentialPrivKeyWith32BytesSucceeds() {
byte[] key = new byte[32];
for (int i = 0; i < key.length; i++) key[i] = (byte) i;
assertEquals(0x9000, send(INS_SET_CRED_PRIV, key).getSW());
}
@Test
void setCredentialPrivKeyWithWrongLengthFails() {
byte[] key = new byte[31];
assertEquals(0x6A80, send(INS_SET_CRED_PRIV, key).getSW(),
"credential private key length != 32 must be rejected");
}
@Test
void setCredentialPubKeyWith64BytesSucceeds() {
byte[] pub = new byte[64];
for (int i = 0; i < pub.length; i++) pub[i] = (byte) (i * 3);
assertEquals(0x9000, send(INS_SET_CRED_PUBK, pub).getSW());
}
@Test
void setCredentialPubKeyWithWrongLengthFails() {
byte[] pub = new byte[63];
assertEquals(0x6A80, send(INS_SET_CRED_PUBK, pub).getSW(),
"credential public key length != 64 must be rejected");
}
@Test
void setCredentialPubKeyAfterCommitIsLocked() {
assertEquals(0x9000, send(INS_COMMIT, null).getSW());
byte[] pub = new byte[64];
assertEquals(0x6985, send(INS_SET_CRED_PUBK, pub).getSW(),
"writes after COMMIT must return SW_CONDITIONS_NOT_SATISFIED");
}
@Test
void setReaderPubKeyWith64BytesSucceeds() {
byte[] pub = new byte[64];
for (int i = 0; i < pub.length; i++) pub[i] = (byte) (i * 7);
assertEquals(0x9000, send(INS_SET_READER_PUBK, pub).getSW());
}
@Test
void accessDocumentChunkedWriteAndFinalizeRoundTrip() {
byte[] ad = new byte[420];
for (int i = 0; i < ad.length; i++) ad[i] = (byte) ((i * 13) ^ 0xA5);
// Two chunks: [0..200), [200..420)
byte[] chunk1 = java.util.Arrays.copyOfRange(ad, 0, 200);
byte[] chunk2 = java.util.Arrays.copyOfRange(ad, 200, 420);
assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 0, chunk1).getSW());
assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 200, chunk2).getSW());
assertEquals(0x9000, sendWithP1P2(INS_FINALIZE_ACCESS_DOC, ad.length, null).getSW());
// Verify via the test-only hook: bytes must match.
byte[] stored = new byte[ad.length];
CredentialStore.get().copyAccessDocument(stored, (short) 0, (short) 0, (short) ad.length);
org.junit.jupiter.api.Assertions.assertArrayEquals(ad, stored);
assertEquals(ad.length, CredentialStore.get().getAccessDocumentLen());
org.junit.jupiter.api.Assertions.assertTrue(CredentialStore.get().hasAccessDocument());
}
@Test
void accessDocumentWriteBeyondMaxSizeFails() {
byte[] payload = new byte[50];
short maxOff = (short) (CredentialStore.ACCESS_DOC_MAX_LEN - 10); // 10 bytes short of max
assertEquals(0x6A80, sendWithP1P2(INS_WRITE_ACCESS_DOC, maxOff, payload).getSW(),
"a 50-byte chunk at offset MAX-10 overruns and must be rejected");
}
@Test
void accessDocumentFinalizeBeyondMaxSizeFails() {
byte[] chunk = new byte[10];
assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 0, chunk).getSW());
int tooBig = CredentialStore.ACCESS_DOC_MAX_LEN + 1;
assertEquals(0x6A80, sendWithP1P2(INS_FINALIZE_ACCESS_DOC, tooBig, null).getSW(),
"finalize length > ACCESS_DOC_MAX_LEN must be rejected");
}
@Test
void accessDocumentWriteAfterCommitIsLocked() {
assertEquals(0x9000, send(INS_COMMIT, null).getSW());
byte[] chunk = new byte[8];
assertEquals(0x6985, sendWithP1P2(INS_WRITE_ACCESS_DOC, 0, chunk).getSW());
assertEquals(0x6985, sendWithP1P2(INS_FINALIZE_ACCESS_DOC, 8, null).getSW());
}
@Test
void commitReturns9000() {
assertEquals(0x9000, send(INS_COMMIT, null).getSW());
}
@Test
void setAfterCommitIsLocked() {
assertEquals(0x9000, send(INS_COMMIT, null).getSW());
byte[] key = new byte[32];
assertEquals(0x6985, send(INS_SET_CRED_PRIV, key).getSW(),
"writes after COMMIT must return SW_CONDITIONS_NOT_SATISFIED");
}
@Test
void secondCommitIsLocked() {
assertEquals(0x9000, send(INS_COMMIT, null).getSW());
assertEquals(0x6985, send(INS_COMMIT, null).getSW(),
"a second COMMIT must also be refused");
}
}

View File

@@ -0,0 +1,72 @@
package com.dangerousthings.aliro;
import com.licel.jcardsim.smartcardio.CardSimulator;
import java.security.KeyPair;
import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey;
import javacard.framework.AID;
import javax.smartcardio.CommandAPDU;
import javax.smartcardio.ResponseAPDU;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Test-only helper that installs {@link PersonalizationApplet}, pushes the
* Access Credential long-term key pair and reader public key into the
* shared {@link CredentialStore}, and COMMITs so later Aliro flow tests
* can assume a fully-provisioned card.
*/
final class ProvisioningHelper {
private static final byte CLA = (byte) 0x80;
private static final byte INS_SET_CRED_PRIV = (byte) 0x20;
private static final byte INS_SET_CRED_PUBK = (byte) 0x21;
private static final byte INS_SET_READER_PUBK = (byte) 0x22;
private static final byte INS_COMMIT = (byte) 0x2C;
private ProvisioningHelper() {}
/** Installs PersonalizationApplet, provisions both keys, commits, and deselects. */
static void provision(CardSimulator sim, KeyPair credential, ECPublicKey readerPub) {
AID aid = new AID(AliroAids.PROVISIONING, (short) 0, (byte) AliroAids.PROVISIONING.length);
sim.installApplet(aid, PersonalizationApplet.class);
CommandAPDU select = new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.PROVISIONING, 256);
assertEquals(0x9000, sim.transmitCommand(select).getSW(), "SELECT provisioning AID");
byte[] credPriv = toFixed32(((ECPrivateKey) credential.getPrivate()).getS().toByteArray());
send(sim, INS_SET_CRED_PRIV, credPriv);
byte[] credPubRaw = extractXY((ECPublicKey) credential.getPublic());
send(sim, INS_SET_CRED_PUBK, credPubRaw);
byte[] readerPubRaw = extractXY(readerPub);
send(sim, INS_SET_READER_PUBK, readerPubRaw);
send(sim, INS_COMMIT, new byte[0]);
}
private static void send(CardSimulator sim, byte ins, byte[] data) {
CommandAPDU c = new CommandAPDU(CLA & 0xFF, ins & 0xFF, 0x00, 0x00, data, 256);
ResponseAPDU r = sim.transmitCommand(c);
assertEquals(0x9000, r.getSW(), "provisioning INS 0x" + Integer.toHexString(ins & 0xFF) + " failed");
}
/** Returns the 64-byte x||y representation (no 0x04 prefix). */
static byte[] extractXY(ECPublicKey pub) {
byte[] x = toFixed32(pub.getW().getAffineX().toByteArray());
byte[] y = toFixed32(pub.getW().getAffineY().toByteArray());
byte[] out = new byte[64];
System.arraycopy(x, 0, out, 0, 32);
System.arraycopy(y, 0, out, 32, 32);
return out;
}
static byte[] toFixed32(byte[] b) {
if (b.length == 32) return b;
byte[] out = new byte[32];
if (b.length > 32) System.arraycopy(b, b.length - 32, out, 0, 32);
else System.arraycopy(b, 0, out, 32 - b.length, b.length);
return out;
}
}

View File

@@ -0,0 +1,410 @@
package com.dangerousthings.aliro;
import com.licel.jcardsim.smartcardio.CardSimulator;
import java.io.ByteArrayOutputStream;
import java.math.BigInteger;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.SecureRandom;
import java.security.Signature;
import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey;
import java.security.spec.ECPoint;
import java.security.spec.ECPublicKeySpec;
import javax.crypto.Cipher;
import javax.crypto.Mac;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
/**
* Test-only model of the Reader side of an Aliro transaction.
*
* <p>Holds the reader's long-term keypair (bound to the card's
* {@link CredentialStore}), a per-transaction ephemeral keypair, the
* reader_identifier, and the transaction_identifier. Produces AUTH0 / AUTH1
* command payloads with correctly-computed signatures over the fields
* specified in spec Tables 8-12 (reader auth data).
*/
final class ReaderSide {
/** Fixed usage constant for reader's AUTH1 signature, spec Table 8-12. */
static final byte[] USAGE_READER_AUTH1 = new byte[] {
(byte) 0x41, (byte) 0x5D, (byte) 0x95, (byte) 0x69
};
/** Fixed usage constant for UD's AUTH1 signature, spec Table 8-13. */
static final byte[] USAGE_UD_AUTH1 = new byte[] {
(byte) 0x4E, (byte) 0x88, (byte) 0x7B, (byte) 0x4C
};
/** Proprietary 0xA5 TLV that our applet returns in its SELECT FCI. Must
* match what AliroApplet.PROPRIETARY_A5_TLV emits byte-for-byte. */
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
};
private final KeyPair longTerm;
private final byte[] readerGroupId = new byte[16];
private final byte[] readerGroupSubId = new byte[16];
private KeyPair ephemeral;
private byte[] transactionId;
ReaderSide() {
longTerm = Auth0Command.generateEphemeralKeyPair();
SecureRandom rng = new SecureRandom();
rng.nextBytes(readerGroupId);
rng.nextBytes(readerGroupSubId);
}
ECPublicKey longTermPublic() {
return (ECPublicKey) longTerm.getPublic();
}
/** Provisions this reader's long-term pubkey and a caller-owned credential keypair onto the card. */
void provision(CardSimulator sim, KeyPair credential) {
ProvisioningHelper.provision(sim, credential, longTermPublic());
setCredentialLongTermPublic((ECPublicKey) credential.getPublic());
}
/** Fresh ephemeral keypair + transaction_id for a new transaction. */
void startTransaction() {
ephemeral = Auth0Command.generateEphemeralKeyPair();
transactionId = new byte[16];
new SecureRandom().nextBytes(transactionId);
}
byte[] buildAuth0Data() {
byte[] readerPubUncomp = Auth0Command.encodeUncompressed((ECPublicKey) ephemeral.getPublic());
return Auth0Command.buildStandardData(
readerPubUncomp, readerGroupId, readerGroupSubId, transactionId);
}
/**
* Builds the AUTH1 command data for the current transaction, computing
* a proper ECDSA-SHA-256 signature over Table 8-12 using the reader's
* long-term key. {@code credentialEphemPubKey65} is the 65-byte
* uncompressed credential_ePubK returned by the card in the AUTH0
* response (tag 0x86 value).
*/
byte[] buildAuth1Data(byte[] credentialEphemPubKey65) {
byte[] toSign = buildTable812(credentialEphemPubKey65);
byte[] rawSig = signEcdsaSha256Raw(toSign, (ECPrivateKey) longTerm.getPrivate());
byte[] out = new byte[3 + 2 + 64];
int i = 0;
// command_parameters bit 0 = 1 → AUTH1 response returns credential_PubK
// (tag 0x5A, full 65B uncompressed). Simpler for the test harness than
// the key_slot (0x4E) path, which requires the reader to maintain a
// lookup table keyed by SHA-1(credential_PubK)[0:8].
out[i++] = 0x41; out[i++] = 0x01; out[i++] = 0x01;
out[i++] = (byte) 0x9E; out[i++] = 0x40;
System.arraycopy(rawSig, 0, out, i, 64);
return out;
}
/**
* Constructs the Table 8-12 byte sequence that the reader signs:
* <pre>
* 0x4D 0x20 [reader_identifier 32B]
* 0x86 0x20 [credential_ePubK.x 32B]
* 0x87 0x20 [reader_ePubK.x 32B]
* 0x4C 0x10 [transaction_identifier 16B]
* 0x93 0x04 0x41 0x5D 0x95 0x69
* </pre>
*/
private byte[] buildTable812(byte[] credEphemPubKey65) {
byte[] readerEphemUncomp = Auth0Command.encodeUncompressed(
(ECPublicKey) ephemeral.getPublic());
ByteArrayOutputStream out = new ByteArrayOutputStream();
write(out, 0x4D, concat(readerGroupId, readerGroupSubId));
write(out, 0x86, java.util.Arrays.copyOfRange(credEphemPubKey65, 1, 33));
write(out, 0x87, java.util.Arrays.copyOfRange(readerEphemUncomp, 1, 33));
write(out, 0x4C, transactionId);
write(out, 0x93, USAGE_READER_AUTH1);
return out.toByteArray();
}
private static void write(ByteArrayOutputStream out, int tag, byte[] value) {
out.write(tag);
out.write(value.length);
out.write(value, 0, value.length);
}
private static byte[] concat(byte[] a, byte[] b) {
byte[] out = new byte[a.length + b.length];
System.arraycopy(a, 0, out, 0, a.length);
System.arraycopy(b, 0, out, a.length, b.length);
return out;
}
/**
* Signs with SHA256withECDSA and returns the 64-byte raw (r||s) form.
* JDK returns DER-encoded signatures; we strip the ASN.1 envelope.
*/
static byte[] signEcdsaSha256Raw(byte[] data, ECPrivateKey priv) {
try {
Signature s = Signature.getInstance("SHA256withECDSA");
s.initSign(priv);
s.update(data);
return derToRaw(s.sign(), 32);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/** ASN.1 DER ECDSA sig → fixed-length {@code coordLen} bytes of r || s. */
static byte[] derToRaw(byte[] der, int coordLen) {
int i = 0;
if (der[i++] != 0x30) throw new IllegalArgumentException("expected SEQUENCE");
int seqLen = der[i++] & 0xFF;
if (seqLen >= 0x80) throw new IllegalArgumentException("unexpected long-form length");
if (der[i++] != 0x02) throw new IllegalArgumentException("expected INTEGER for r");
int rLen = der[i++] & 0xFF;
byte[] r = java.util.Arrays.copyOfRange(der, i, i + rLen);
i += rLen;
if (der[i++] != 0x02) throw new IllegalArgumentException("expected INTEGER for s");
int sLen = der[i++] & 0xFF;
byte[] s = java.util.Arrays.copyOfRange(der, i, i + sLen);
byte[] out = new byte[coordLen * 2];
copyFixed(r, out, 0, coordLen);
copyFixed(s, out, coordLen, coordLen);
return out;
}
private static void copyFixed(byte[] src, byte[] dst, int dstOff, int coordLen) {
if (src.length == coordLen) {
System.arraycopy(src, 0, dst, dstOff, coordLen);
} else if (src.length == coordLen + 1 && src[0] == 0) {
System.arraycopy(src, 1, dst, dstOff, coordLen);
} else if (src.length < coordLen) {
System.arraycopy(src, 0, dst, dstOff + (coordLen - src.length), src.length);
} else {
throw new IllegalArgumentException(
"coord length " + src.length + " > " + coordLen);
}
}
/**
* Derives the 32-byte ExpeditedSKDevice on the reader side (mirroring
* AliroApplet.deriveSessionKeys). Used by integration tests to decrypt
* the UD's AUTH1 response.
*
* @param credentialEphemPubKey65 the 0x86 TLV value from the AUTH0 response
*/
byte[] deriveExpeditedSKDevice(byte[] credentialEphemPubKey65) {
return java.util.Arrays.copyOfRange(
deriveExpeditedKeyMaterial(credentialEphemPubKey65), 32, 64);
}
/**
* Derives the full 160B {@code derived_keys_volatile} per spec §8.3.1.13
* — same as the applet computes. Layout: ExpeditedSKReader[0..32),
* ExpeditedSKDevice[32..64), StepUpSK[64..96), BleSK[96..128),
* URSK[128..160).
*/
byte[] deriveExpeditedKeyMaterial(byte[] credentialEphemPubKey65) {
byte[] zab = ecdhSharedX(
(ECPrivateKey) ephemeral.getPrivate(),
credentialEphemPubKey65);
byte[] kdh = hkdf(zab, transactionId, new byte[0], 32);
byte[] salt = buildSaltVolatile(credentialEphemPubKey65);
byte[] info = java.util.Arrays.copyOfRange(credentialEphemPubKey65, 1, 33);
return hkdf(kdh, salt, info, 160);
}
/**
* Decrypts an AUTH1 response (encrypted_payload || auth_tag, 12-byte IV
* = 0x00..00 0x01 || device_counter=1 per §8.3.1.6) with
* ExpeditedSKDevice. Throws on tag-mismatch.
*/
static byte[] decryptAuth1Response(byte[] skDevice, byte[] ctAndTag) {
try {
byte[] iv = new byte[12];
iv[7] = 0x01;
iv[11] = 0x01; // device_counter starts at 1 for the first response
Cipher gcm = Cipher.getInstance("AES/GCM/NoPadding");
gcm.init(Cipher.DECRYPT_MODE, new SecretKeySpec(skDevice, "AES"),
new GCMParameterSpec(128, iv));
return gcm.doFinal(ctAndTag);
} catch (Exception e) {
throw new RuntimeException("GCM decrypt failed", e);
}
}
/**
* Builds Table 8-13 from this reader's transaction state + the UD's
* ephemeral public key. The UD's long-term public key is not needed here
* — this is the bytes the UD signed.
*/
byte[] buildTable813(byte[] credentialEphemPubKey65) {
byte[] readerEphemUncomp = Auth0Command.encodeUncompressed(
(ECPublicKey) ephemeral.getPublic());
ByteArrayOutputStream out = new ByteArrayOutputStream();
writeTlv(out, 0x4D, concat(readerGroupId, readerGroupSubId));
writeTlv(out, 0x86, java.util.Arrays.copyOfRange(credentialEphemPubKey65, 1, 33));
writeTlv(out, 0x87, java.util.Arrays.copyOfRange(readerEphemUncomp, 1, 33));
writeTlv(out, 0x4C, transactionId);
writeTlv(out, 0x93, USAGE_UD_AUTH1);
return out.toByteArray();
}
/**
* Verifies an ECDSA-SHA-256 signature in raw r||s form using a public
* key in uncompressed 0x04||x||y form. Returns true iff valid.
*/
static boolean verifyUdSig(byte[] credPubUncompressed65, byte[] table813, byte[] rawSig64) {
try {
BigInteger x = new BigInteger(1, java.util.Arrays.copyOfRange(credPubUncompressed65, 1, 33));
BigInteger y = new BigInteger(1, java.util.Arrays.copyOfRange(credPubUncompressed65, 33, 65));
java.security.spec.ECParameterSpec params =
((ECPublicKey) Auth0Command.generateEphemeralKeyPair().getPublic()).getParams();
ECPublicKey pub = (ECPublicKey) KeyFactory.getInstance("EC")
.generatePublic(new ECPublicKeySpec(new ECPoint(x, y), params));
Signature v = Signature.getInstance("SHA256withECDSA");
v.initVerify(pub);
v.update(table813);
return v.verify(rawToDer(rawSig64));
} catch (Exception e) {
throw new RuntimeException(e);
}
}
// ---------- helpers ----------
private byte[] buildSaltVolatile(byte[] credentialEphemPubKey65) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
try {
// x(reader_group_identifier_key) = this reader's long-term pubkey.x
ECPoint lt = ((ECPublicKey) longTerm.getPublic()).getW();
out.write(toFixed32(lt.getAffineX().toByteArray()));
out.write(new byte[] { 'V', 'o', 'l', 'a', 't', 'i', 'l', 'e', '*', '*', '*', '*' });
out.write(readerGroupId);
out.write(readerGroupSubId);
out.write((byte) 0x5E); // interface_byte = NFC
out.write((byte) 0x5C);
out.write((byte) 0x02);
out.write((byte) 0x01); out.write((byte) 0x00); // protocol_version 0x0100
ECPoint re = ((ECPublicKey) ephemeral.getPublic()).getW();
out.write(toFixed32(re.getAffineX().toByteArray()));
out.write(transactionId);
out.write((byte) 0x00); // command_parameters = standard
out.write((byte) 0x00); // authentication_policy = none
out.write(PROPRIETARY_A5_TLV);
ECPoint cl = credentialLongTermPubX();
out.write(toFixed32(cl.getAffineX().toByteArray()));
} catch (java.io.IOException e) {
throw new RuntimeException(e);
}
return out.toByteArray();
}
/** Returns the credential's long-term public key as an ECPoint. Set via {@link #setCredentialLongTermPublic}. */
private ECPoint credentialLongTermPubX() {
if (credentialLongTermPub == null) {
throw new IllegalStateException("credentialLongTermPub not set — call setCredentialLongTermPublic()");
}
return credentialLongTermPub.getW();
}
private ECPublicKey credentialLongTermPub;
/** Test wiring: tells this reader what credential_PubK the card holds so salt_volatile can include x(). */
void setCredentialLongTermPublic(ECPublicKey pub) {
this.credentialLongTermPub = pub;
}
private static byte[] ecdhSharedX(ECPrivateKey priv, byte[] peerPubUncomp65) {
try {
BigInteger x = new BigInteger(1, java.util.Arrays.copyOfRange(peerPubUncomp65, 1, 33));
BigInteger y = new BigInteger(1, java.util.Arrays.copyOfRange(peerPubUncomp65, 33, 65));
java.security.spec.ECParameterSpec params = priv.getParams();
ECPublicKey peer = (ECPublicKey) KeyFactory.getInstance("EC")
.generatePublic(new ECPublicKeySpec(new ECPoint(x, y), params));
javax.crypto.KeyAgreement ka = javax.crypto.KeyAgreement.getInstance("ECDH");
ka.init(priv);
ka.doPhase(peer, true);
return ka.generateSecret(); // raw 32B x-coord
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/** HKDF-SHA-256 (RFC 5869). */
private static byte[] hkdf(byte[] ikm, byte[] salt, byte[] info, int L) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(salt.length == 0 ? new byte[32] : salt, "HmacSHA256"));
byte[] prk = mac.doFinal(ikm);
mac.init(new SecretKeySpec(prk, "HmacSHA256"));
byte[] out = new byte[L];
byte[] t = new byte[0];
int produced = 0;
byte counter = 1;
while (produced < L) {
mac.update(t);
mac.update(info);
mac.update(counter);
t = mac.doFinal();
int copy = Math.min(L - produced, t.length);
System.arraycopy(t, 0, out, produced, copy);
produced += copy;
counter++;
mac.init(new SecretKeySpec(prk, "HmacSHA256"));
}
return out;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static byte[] toFixed32(byte[] in) {
if (in.length == 32) return in;
byte[] out = new byte[32];
if (in.length > 32) System.arraycopy(in, in.length - 32, out, 0, 32);
else System.arraycopy(in, 0, out, 32 - in.length, in.length);
return out;
}
private static void writeTlv(ByteArrayOutputStream out, int tag, byte[] value) {
out.write(tag);
out.write(value.length);
out.write(value, 0, value.length);
}
/** Raw r||s → DER SEQUENCE { INTEGER r, INTEGER s } for JDK Signature.verify. */
static byte[] rawToDer(byte[] raw64) {
byte[] r = java.util.Arrays.copyOfRange(raw64, 0, 32);
byte[] s = java.util.Arrays.copyOfRange(raw64, 32, 64);
byte[] rInt = toAsn1Integer(r);
byte[] sInt = toAsn1Integer(s);
int seqLen = 2 + rInt.length + 2 + sInt.length;
byte[] out = new byte[2 + seqLen];
int o = 0;
out[o++] = 0x30;
out[o++] = (byte) seqLen;
out[o++] = 0x02; out[o++] = (byte) rInt.length;
System.arraycopy(rInt, 0, out, o, rInt.length); o += rInt.length;
out[o++] = 0x02; out[o++] = (byte) sInt.length;
System.arraycopy(sInt, 0, out, o, sInt.length);
return out;
}
private static byte[] toAsn1Integer(byte[] in) {
int start = 0;
while (start < in.length - 1 && in[start] == 0 && (in[start + 1] & 0x80) == 0) start++;
boolean needPad = (in[start] & 0x80) != 0;
byte[] out = new byte[(needPad ? 1 : 0) + (in.length - start)];
int o = 0;
if (needPad) out[o++] = 0x00;
System.arraycopy(in, start, out, o, in.length - start);
return out;
}
}

View File

@@ -0,0 +1,77 @@
package com.dangerousthings.aliro;
import com.licel.jcardsim.smartcardio.CardSimulator;
import javacard.framework.AID;
import javax.smartcardio.CommandAPDU;
import javax.smartcardio.ResponseAPDU;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Tests for {@link StepUpApplet} — scaffold for the Aliro step-up phase
* (spec §8.4). This first pass only covers AID selection and
* INS-not-supported handling; the mdoc DeviceRequest/DeviceResponse +
* StepUpSK key derivation pipelines come in follow-up iterations.
*/
class StepUpAppletTest {
private CardSimulator sim;
@BeforeEach
void setUp() {
CredentialStore.get().resetForTesting();
sim = new CardSimulator();
AID aid = new AID(AliroAids.STEP_UP, (short) 0, (byte) AliroAids.STEP_UP.length);
sim.installApplet(aid, StepUpApplet.class);
}
@Test
void selectStepUpAidSucceeds() {
CommandAPDU select = new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256);
ResponseAPDU r = sim.transmitCommand(select);
assertEquals(0x9000, r.getSW(), "SELECT of step-up AID must succeed");
}
@Test
void selectStepUpAidReturnsFciWithAid() {
CommandAPDU select = new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256);
ResponseAPDU r = sim.transmitCommand(select);
byte[] aidFromFci = TlvUtil.findTopLevel(unwrap6F(r.getData()), 0x84);
org.junit.jupiter.api.Assertions.assertArrayEquals(AliroAids.STEP_UP, aidFromFci,
"FCI 84 tag must echo the step-up AID");
}
@Test
void unsupportedInsReturns6D00() {
CommandAPDU select = new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256);
sim.transmitCommand(select);
// Some arbitrary unsupported INS under the proprietary class.
CommandAPDU bogus = new CommandAPDU(0x80, 0xFE, 0x00, 0x00, new byte[0], 256);
assertEquals(0x6D00, sim.transmitCommand(bogus).getSW(),
"unrecognized INS on step-up applet must return SW_INS_NOT_SUPPORTED");
}
@Test
void wrongClaReturns6E00() {
CommandAPDU select = new CommandAPDU(0x00, 0xA4, 0x04, 0x00, AliroAids.STEP_UP, 256);
sim.transmitCommand(select);
CommandAPDU wrongCla = new CommandAPDU(0x81, 0xC3, 0x00, 0x00, new byte[0], 256);
assertEquals(0x6E00, sim.transmitCommand(wrongCla).getSW(),
"wrong CLA must return SW_CLA_NOT_SUPPORTED");
}
/** Unwraps the outer 6F FCI template to expose its nested TLVs. */
private static byte[] unwrap6F(byte[] fci) {
if (fci.length < 2 || fci[0] != 0x6F) {
throw new IllegalArgumentException("expected a 6F-wrapped FCI template");
}
int len = fci[1] & 0xFF;
byte[] inner = new byte[len];
System.arraycopy(fci, 2, inner, 0, len);
return inner;
}
}

View File

@@ -0,0 +1,48 @@
package com.dangerousthings.aliro;
/**
* Minimal TLV reader for tests. Supports single-byte tags, multi-byte tags
* with BER continuation encoding, and short-form lengths (&lt; 128) plus
* 1- and 2-byte long-form lengths.
*/
final class TlvUtil {
private TlvUtil() {}
/**
* Finds the value bytes of the first TLV with the given tag within the
* given data, searching only at the top level (not recursing into nested
* constructed TLVs).
*
* @return the value bytes of the first match, or {@code null} if not found.
*/
static byte[] findTopLevel(byte[] data, int tag) {
int i = 0;
while (i < data.length) {
int tagStart = i;
int currentTag = data[i++] & 0xFF;
if ((currentTag & 0x1F) == 0x1F) {
int next;
do {
next = data[i++] & 0xFF;
currentTag = (currentTag << 8) | next;
} while ((next & 0x80) != 0);
}
int length = data[i++] & 0xFF;
if (length == 0x81) {
length = data[i++] & 0xFF;
} else if (length == 0x82) {
length = ((data[i++] & 0xFF) << 8) | (data[i++] & 0xFF);
}
if (currentTag == tag) {
byte[] value = new byte[length];
System.arraycopy(data, i, value, 0, length);
return value;
}
i += length;
// Silence unused-variable warning
if (tagStart < 0) break;
}
return null;
}
}