Expands M2A.3 from the original plan because the credential issuer pubkey had no provisioning path. Adds INS_SET_CREDENTIAL_ISSUER_PUBK = 0x25 to PersonalizationApplet, bumps CredentialStore FIELD_VERSION 2 -> 3 to add a 64 B credentialIssuerPubKey slot + set-flag, and pivots finalizeAccessDocument to run a one-shot CoseVerifier RFC 9052 COSE_Sign1 verify against the staged AD bytes before atomically setting accessDocumentLen + accessDocumentFinalized + accessDocumentVerified inside a JCSystem transaction. PersonalizationApplet holds one CoseVerifier instance constructed at install (the 768 B CLEAR_ON_DESELECT scratch only allocates once) plus a 65 B EEPROM scratch for staging the uncompressed issuer pubkey. Status word mapping: - missing issuer pubkey at finalize -> SW_CONDITIONS_NOT_SATISFIED (0x6985) - IssuerAuth signature mismatch -> SW_DATA_INVALID (0x6984) - length out of range -> SW_WRONG_DATA (0x6A80) On verify failure the store stays untouched -- both accessDocumentFinalized and accessDocumentVerified remain false. Harness side: TrustArtifacts gains credential_issuer_pub (loaded from trust_dir/issuer.pem). The orchestrator sends INS 0x25 after the existing key writes and before the AD chunks so the trust anchor is staged by the time FINALIZE_AD runs. Test vector: AD bytes + matching issuer pubkey x||y are hardcoded into PersonalizationAppletTest from the canonical trustgen artifacts at /home/work/aliro-trust (272 B AD against a known issuer.pem). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
327 lines
15 KiB
Java
327 lines
15 KiB
Java
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.
|
|
*
|
|
* <p>Reproducibility: dump via
|
|
* <pre>
|
|
* 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
|
|
* </pre>
|
|
*/
|
|
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.<init> → 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);
|
|
}
|
|
}
|