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,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)");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user