feat: IssuerAuth verify at AD finalize + issuer pubkey provisioning (M2A.3)

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>
This commit is contained in:
michael
2026-06-17 15:30:56 -07:00
parent 3868678bf9
commit a94e2b498e
11 changed files with 376 additions and 40 deletions

View File

@@ -186,8 +186,10 @@ class AliroAppletAuth1Test {
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));
// Bypass IssuerAuth verify — opaque random bytes here, not a real
// COSE_Sign1. The signaling bitmap path only cares about the
// finalized flag, not the verified flag.
CredentialStore.get().markAccessDocumentFinalizedForTesting((short) ad.length);
sendStandardAuth0();
ResponseAPDU r = sendValidAuth1();

View File

@@ -30,6 +30,7 @@ class CredentialStoreSerializationTest {
private static final byte[] KNOWN_32_BYTES = makeRange(32, 0xA0);
private static final byte[] KNOWN_64_BYTES_B = makeRange(64, 0x80);
private static final byte[] KNOWN_64_BYTES_ISSUER = makeRange(64, 0x40);
private static byte[] makeRange(int len, int start) {
byte[] out = new byte[len];
@@ -51,6 +52,8 @@ class CredentialStoreSerializationTest {
src.setCredentialPrivKey(KNOWN_32_BYTES, (short) 0);
// Intentionally do NOT setCredentialPubKey: leave credentialPubKeySet=false.
src.setReaderPubKey(KNOWN_64_BYTES_B, (short) 0);
// Exercise the new credentialIssuerPubKey slot (M2A.3, FIELD_VERSION=3).
src.setCredentialIssuerPubKey(KNOWN_64_BYTES_ISSUER, (short) 0);
// Intentionally do NOT finalizeAccessDocument: leave accessDocumentFinalized=false
// and accessDocumentLen=0. With len=0, copyAccessDocument() returns an empty array.
src.commit();
@@ -64,6 +67,15 @@ class CredentialStoreSerializationTest {
assertArrayEquals(KNOWN_32_BYTES, restored.copyCredentialPrivKey());
assertArrayEquals(KNOWN_64_BYTES_B, restored.copyReaderPubKey());
// Issuer pubkey round-trips as 0x04 || x || y (uncompressed SEC1).
byte[] expectedIssuerUncomp = new byte[65];
expectedIssuerUncomp[0] = 0x04;
System.arraycopy(KNOWN_64_BYTES_ISSUER, 0, expectedIssuerUncomp, 1, 64);
byte[] gotIssuerUncomp = new byte[65];
restored.copyCredentialIssuerPubKeyUncomp(gotIssuerUncomp, (short) 0);
assertArrayEquals(expectedIssuerUncomp, gotIssuerUncomp);
assertTrue(restored.hasCredentialIssuerPubKey());
// The unset credential pubkey buffer should round-trip as all-zero —
// resetForTesting cleared it and we never wrote to it.
assertArrayEquals(new byte[CredentialStore.CRED_PUBK_LEN],

View File

@@ -25,8 +25,48 @@ class PersonalizationAppletTest {
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 - &lt;&lt;'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
@@ -103,12 +143,14 @@ class PersonalizationAppletTest {
@Test
void accessDocumentChunkedWriteAndFinalizeRoundTrip() {
byte[] ad = new byte[420];
for (int i = 0; i < ad.length; i++) ad[i] = (byte) ((i * 13) ^ 0xA5);
// 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());
// Two chunks: [0..200), [200..420)
byte[] ad = AD_GOOD;
byte[] chunk1 = java.util.Arrays.copyOfRange(ad, 0, 200);
byte[] chunk2 = java.util.Arrays.copyOfRange(ad, 200, 420);
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());
@@ -131,6 +173,9 @@ class PersonalizationAppletTest {
@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;
@@ -185,6 +230,84 @@ class PersonalizationAppletTest {
"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. */