package com.dangerousthings.aliro; import com.licel.jcardsim.smartcardio.CardSimulator; import javacard.framework.AID; import javacard.framework.Applet; 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.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; /** * 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_SET_CRED_ISSUER_PUBK = (byte) 0x25; private static final byte INS_COMMIT = (byte) 0x2C; /** * Canonical Aliro Access Document COSE_Sign1 bytes (272 B) — generated * by harness/aliro_harness.issuer.cose against the trustgen-issued * issuer.pem. Layout is the bare 4-element COSE_Sign1 array (no wrapper); * CoseVerifier.verifyCoseSign1 verifies these bytes verbatim against the * uncompressed issuer pubkey below. * *

Reproducibility: dump via *

     *   python - <<'EOF'
     *   from pathlib import Path
     *   from cryptography.hazmat.primitives import serialization
     *   ad = Path("/home/work/aliro-trust/access_document.bin").read_bytes()
     *   iss = serialization.load_pem_private_key(
     *       Path("/home/work/aliro-trust/issuer.pem").read_bytes(), password=None)
     *   pub = iss.public_key().public_bytes(
     *       serialization.Encoding.X962,
     *       serialization.PublicFormat.UncompressedPoint)
     *   print(ad.hex()); print(pub[1:].hex())
     *   EOF
     * 
*/ private static final byte[] AD_GOOD = hex( "8443a10126a104488dae9624eed9280c58bca7613163312e306132675348412d3235" + "366133a06134a16131a401022001215820aa3115ead5d1fecca289aef3598790a6" + "dba23edbe9b14e6818ac683e31a5af02225820839dcc32bcd32924a942c3b9999f" + "6cbf46960396e69606fe4295e83a0c7787a2613567616c69726f2d616136a36131" + "c074323032362d30342d31395432303a32393a31365a6132c074323032362d3034" + "2d31395432303a32393a31365a6133c074323032372d30342d31395432303a3239" + "3a31365a6137f458404927c33ec9c475768b269bb4ee2a098be8d64ac43644e92c" + "8106d9c537d6215dc00e131e4ecf00b37ebc6ac8c26210f939d38df6f1c5b1caf6" + "85c365b22a3f2a"); /** Issuer pubkey x||y (64 B) corresponding to ISSUER_PEM at trustgen time. * This is what the applet INS_SET_CRED_ISSUER_PUBK accepts (no 0x04 prefix). */ private static final byte[] ISSUER_PUBK_XY = hex( "ebee35bacdfc585295da337b29b6f5e8b86d4056e22c793e4bf033e19e9ba31d" + "6b8679bb41a64c4f7f8a37972b6315b4fe91248527c1487caf3a6fe31f75bbc4"); private CardSimulator sim; @BeforeEach void setUp() { // installApplet → PersonalizationApplet. → CredentialStore.bootstrap() // gives us a fresh store for every test — no explicit reset needed any more. 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() { // Uses the canonical AD_GOOD vector because finalize now triggers // IssuerAuth verify — random bytes wouldn't verify. The shape under // test here is still the multi-chunk write path + final length. assertEquals(0x9000, send(INS_SET_CRED_ISSUER_PUBK, ISSUER_PUBK_XY).getSW()); byte[] ad = AD_GOOD; byte[] chunk1 = java.util.Arrays.copyOfRange(ad, 0, 200); byte[] chunk2 = java.util.Arrays.copyOfRange(ad, 200, ad.length); 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() { // Set issuer pubkey so the precondition check passes — we want to // test the length bound specifically, not the missing-pubkey path. assertEquals(0x9000, send(INS_SET_CRED_ISSUER_PUBK, ISSUER_PUBK_XY).getSW()); 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"); } @Test void personalizationAppletOwnsCredentialStoreInstance() throws Exception { // setUp() has already installed PersonalizationApplet via jcardsim. // Reach into the runtime and pull out the installed applet instance, // then assert it owns a non-null `store` instance field and that the // static publish-point in CredentialStore aliases the same object. Applet applet = getInstalledApplet(sim, AliroAids.PROVISIONING); org.junit.jupiter.api.Assertions.assertTrue(applet instanceof PersonalizationApplet, "installed applet at PROVISIONING AID must be a PersonalizationApplet"); java.lang.reflect.Field storeField = PersonalizationApplet.class.getDeclaredField("store"); storeField.setAccessible(true); Object instanceStore = storeField.get(applet); assertNotNull(instanceStore, "PersonalizationApplet must own a CredentialStore instance field"); assertSame(instanceStore, CredentialStore.get(), "Static publish-point must alias the instance-owned store"); } @Test void finalizeAccessDocument_validIssuerAuth_setsVerifiedFlag() { // Push the issuer pubkey, write the AD, then finalize with the exact // total length. The applet should run CoseVerifier internally, see // the signature verify, and atomically flip both the finalized and // verified flags. assertEquals(0x9000, send(INS_SET_CRED_ISSUER_PUBK, ISSUER_PUBK_XY).getSW()); // Single chunk fits in a short-form APDU (255B max) — except AD is // 272B. Split into two chunks like the orchestrator does. byte[] chunk1 = java.util.Arrays.copyOfRange(AD_GOOD, 0, 200); byte[] chunk2 = java.util.Arrays.copyOfRange(AD_GOOD, 200, AD_GOOD.length); 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_GOOD.length, null).getSW(), "finalize with valid IssuerAuth must succeed"); CredentialStore s = CredentialStore.get(); org.junit.jupiter.api.Assertions.assertTrue(s.hasAccessDocument(), "finalized flag must be set after successful verify"); org.junit.jupiter.api.Assertions.assertTrue(s.isAccessDocumentVerified(), "verified flag must be set after successful verify"); } @Test void finalizeAccessDocument_tamperedIssuerAuth_returnsErrorAndLeavesUnverified() { assertEquals(0x9000, send(INS_SET_CRED_ISSUER_PUBK, ISSUER_PUBK_XY).getSW()); // Flip one byte in the payload region (around offset 30 — well // inside the issuer-signed payload, away from outer CBOR headers). byte[] tampered = new byte[AD_GOOD.length]; System.arraycopy(AD_GOOD, 0, tampered, 0, AD_GOOD.length); tampered[30] ^= (byte) 0x01; byte[] chunk1 = java.util.Arrays.copyOfRange(tampered, 0, 200); byte[] chunk2 = java.util.Arrays.copyOfRange(tampered, 200, tampered.length); assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 0, chunk1).getSW()); assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 200, chunk2).getSW()); // Spec says verify failure → SW_DATA_INVALID (0x6984). assertEquals(0x6984, sendWithP1P2(INS_FINALIZE_ACCESS_DOC, tampered.length, null).getSW(), "finalize with tampered IssuerAuth must return SW_DATA_INVALID"); CredentialStore s = CredentialStore.get(); org.junit.jupiter.api.Assertions.assertFalse(s.isAccessDocumentVerified(), "verified flag must stay false on tamper"); org.junit.jupiter.api.Assertions.assertFalse(s.hasAccessDocument(), "finalized flag must stay false on tamper — atomic store guards both"); } @Test void finalizeAccessDocument_missingIssuerPubkey_returnsConditionsNotSatisfied() { // Skip the SET_CRED_ISSUER_PUBK step entirely. Even with a valid AD // loaded, finalize cannot run verify without the trust anchor and // must refuse with SW_CONDITIONS_NOT_SATISFIED. byte[] chunk1 = java.util.Arrays.copyOfRange(AD_GOOD, 0, 200); byte[] chunk2 = java.util.Arrays.copyOfRange(AD_GOOD, 200, AD_GOOD.length); assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 0, chunk1).getSW()); assertEquals(0x9000, sendWithP1P2(INS_WRITE_ACCESS_DOC, 200, chunk2).getSW()); assertEquals(0x6985, sendWithP1P2(INS_FINALIZE_ACCESS_DOC, AD_GOOD.length, null).getSW(), "finalize without an issuer pubkey must return SW_CONDITIONS_NOT_SATISFIED"); CredentialStore s = CredentialStore.get(); org.junit.jupiter.api.Assertions.assertFalse(s.hasAccessDocument()); org.junit.jupiter.api.Assertions.assertFalse(s.isAccessDocumentVerified()); } 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; } /** Reaches through jcardsim's protected runtime/applet APIs to fetch the * installed Applet instance for a given AID. Reflection-only — this is * test infrastructure, not production code. */ private static Applet getInstalledApplet(CardSimulator sim, byte[] aidBytes) throws Exception { java.lang.reflect.Field runtimeField = null; for (Class c = sim.getClass(); c != null && runtimeField == null; c = c.getSuperclass()) { try { runtimeField = c.getDeclaredField("runtime"); } catch (NoSuchFieldException ignore) {} } runtimeField.setAccessible(true); Object runtime = runtimeField.get(sim); java.lang.reflect.Method getApplet = runtime.getClass().getDeclaredMethod("getApplet", AID.class); getApplet.setAccessible(true); AID aid = new AID(aidBytes, (short) 0, (byte) aidBytes.length); return (Applet) getApplet.invoke(runtime, aid); } }