Initial snapshot: Aliro applet, harness, Nucleo NFC10A1 reader port
Three components, all bench-validated to varying depths: - applet/: CSA Aliro v1.0 Java Card applet for J3R180. AUTH0 + AUTH1 expedited-standard flow end-to-end green via PC/SC bench reader (aliro-bench-test). Userland AES-256-GCM and HMAC-SHA-256 layered on top of J3R180's primitives because the card lacks both natively. P-256 curve params seeded explicitly per J3R180's quirk. - harness/: Python orchestrator (aliro-trustgen, aliro-personalize, aliro-bench-test) for trust-bundle generation, card personalization via PersonalizationApplet, and PC/SC AUTH0+AUTH1 transactions. 126 pytest cases passing. - reader/STM32CubeExpansion_ALIRO_V1_0_0/: ST X-CUBE-ALIRO V1.0.0 with our NFC10A1 port (NUCLEO-U545RE-Q + X-NUCLEO-NFC10A1, ST25R200 shared with NFC09A1). nfc10-only/ project, NFC10A1 BSP shim, ALIRO_TRUST_OVERRIDE include into vendor's provisioning.c, and an ALIRO_APDU_TRACE wrapper around demoTransceiveBlocking. Boots, detects the J3R180, completes SELECT + AUTH0; AUTH1 currently fails with RFAL ERR_PROTO (0xB) — under investigation, see docs/plans/2026-04-20-nucleo-nfc10a1-port.md and bench-notes/. Excluded: x-cube-aliro.zip vendor archive, harness/.venv, build dirs, generated aliro_trust.h (contains private reader scalar), all PEMs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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 §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();
|
||||
}
|
||||
}
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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 §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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
229
applet/src/test/java/com/dangerousthings/aliro/AliroGcmTest.java
Normal file
229
applet/src/test/java/com/dangerousthings/aliro/AliroGcmTest.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
106
applet/src/test/java/com/dangerousthings/aliro/Auth0Command.java
Normal file
106
applet/src/test/java/com/dangerousthings/aliro/Auth0Command.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
410
applet/src/test/java/com/dangerousthings/aliro/ReaderSide.java
Normal file
410
applet/src/test/java/com/dangerousthings/aliro/ReaderSide.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
48
applet/src/test/java/com/dangerousthings/aliro/TlvUtil.java
Normal file
48
applet/src/test/java/com/dangerousthings/aliro/TlvUtil.java
Normal 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 (< 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user