diff --git a/applet/INSTALL.md b/applet/INSTALL.md index 02dc831..e3fbb37 100644 --- a/applet/INSTALL.md +++ b/applet/INSTALL.md @@ -96,22 +96,27 @@ or Access Document — every Aliro flow returns `SW_CONDITIONS_NOT_SATISFIED`. Provision via the PersonalizationApplet (CLA `0x80`): -| INS | P1\|P2 | Data | Description | -| ------ | --------------- | --------------------------------------- | ----------------------------------- | -| `0x20` | `0000` | 32B credential_PrivK | Access Credential long-term privkey | -| `0x21` | `0000` | 64B credential_PubK (x\|\|y) | …matching pubkey | -| `0x22` | `0000` | 64B reader_PubK (x\|\|y) | reader long-term pubkey | -| `0x23` | offset (BE) | up to 255B chunk | Access Document chunk write | -| `0x24` | total_len (BE) | (none, Lc=0) | Finalize Access Document | -| `0x2C` | `0000` | (none) | COMMIT — locks all writes | +| INS | P1\|P2 | Data | Description | +| ------ | --------------- | --------------------------------------- | -------------------------------------------------------------- | +| `0x20` | `0000` | 32B credential_PrivK | Access Credential long-term privkey | +| `0x21` | `0000` | 64B credential_PubK (x\|\|y) | …matching pubkey | +| `0x22` | `0000` | 64B reader_PubK (x\|\|y) | reader long-term pubkey | +| `0x23` | offset (BE) | up to 255B chunk | Access Document chunk write | +| `0x24` | total_len (BE) | (none, Lc=0) | Finalize Access Document — runs IssuerAuth COSE_Sign1 verify | +| `0x25` | `0000` | 64B credential_issuer_PubK (x\|\|y) | Credential Issuer pubkey — trust anchor for IssuerAuth verify | +| `0x2C` | `0000` | (none) | COMMIT — locks all writes | -Required order: SELECT provisioning AID → write all keys + AD chunks → -finalize → COMMIT. After COMMIT, every write returns -`SW_CONDITIONS_NOT_SATISFIED` (no factory-reset mechanism in v1). +Required order: SELECT provisioning AID → write all keys (including +credential_issuer_PubK) + AD chunks → finalize → COMMIT. The issuer pubkey +MUST be set before FINALIZE — without it FINALIZE returns +`SW_CONDITIONS_NOT_SATISFIED`. A signature mismatch at FINALIZE returns +`SW_DATA_INVALID` and leaves the AD un-finalized. After COMMIT, every +write returns `SW_CONDITIONS_NOT_SATISFIED` (no factory-reset mechanism in v1). Source bytes come from `aliro-trustgen init --out-dir ./out`: - `out/access_credential.pem` → derive priv/pub bytes - `out/reader.pem` → derive pub bytes +- `out/issuer.pem` → derive Credential Issuer pubkey - `out/access_document.bin` → chunk into ≤255B writes **One-shot personalization:** the harness ships an `aliro-personalize` diff --git a/applet/src/main/java/com/dangerousthings/aliro/CredentialStore.java b/applet/src/main/java/com/dangerousthings/aliro/CredentialStore.java index d047bcb..1d7a89a 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/CredentialStore.java +++ b/applet/src/main/java/com/dangerousthings/aliro/CredentialStore.java @@ -28,6 +28,7 @@ final class CredentialStore { static final short CRED_PRIV_KEY_LEN = 32; static final short CRED_PUBK_LEN = 64; static final short READER_PUBK_LEN = 64; + static final short CRED_ISSUER_PUBK_LEN = 64; /** Max Access Document blob size (serialized COSE_Sign1 bytes). 1 KB * accommodates a typical Aliro Access Document with room to spare. */ @@ -35,8 +36,12 @@ final class CredentialStore { /** Stable field order for AMD-H Element serialization. Append-only — * never reorder or remove without bumping the package version and - * writing an explicit migration step in the new ELF's onRestore. */ - static final byte FIELD_VERSION = 2; + * writing an explicit migration step in the new ELF's onRestore. + * + *

v3 (M2A.3): adds {@code credentialIssuerPubKey} (64 B x||y) + + * {@code credentialIssuerPubKeySet} flag for IssuerAuth verify at + * finalize. */ + static final byte FIELD_VERSION = 3; /** Publish-point read by AliroApplet/StepUpApplet via {@link #get()}. * PersonalizationApplet owns the actual instance; this is just an alias @@ -54,6 +59,9 @@ final class CredentialStore { private final byte[] readerPubKey; private boolean readerPubKeySet; + private final byte[] credentialIssuerPubKey; + private boolean credentialIssuerPubKeySet; + private final byte[] accessDocument; private short accessDocumentLen; private boolean accessDocumentFinalized; @@ -65,6 +73,7 @@ final class CredentialStore { credentialPrivKey = new byte[CRED_PRIV_KEY_LEN]; credentialPubKey = new byte[CRED_PUBK_LEN]; readerPubKey = new byte[READER_PUBK_LEN]; + credentialIssuerPubKey = new byte[CRED_ISSUER_PUBK_LEN]; accessDocument = new byte[ACCESS_DOC_MAX_LEN]; } @@ -160,6 +169,30 @@ final class CredentialStore { return (short) 32; } + boolean hasCredentialIssuerPubKey() { + return credentialIssuerPubKeySet; + } + + /** Writes the 64-byte x||y issuer public key (no 0x04 prefix). */ + void setCredentialIssuerPubKey(byte[] src, short off) { + javacard.framework.Util.arrayCopyNonAtomic( + src, off, credentialIssuerPubKey, (short) 0, CRED_ISSUER_PUBK_LEN); + credentialIssuerPubKeySet = true; + } + + /** + * Emits the credential issuer public key as 65-byte SEC1 uncompressed + * point (0x04 || X || Y) into {@code dst[dstOff..dstOff+65)}. This is + * the shape {@link CoseVerifier#verifyCoseSign1} expects for its issuer + * pubkey parameter. + */ + short copyCredentialIssuerPubKeyUncomp(byte[] dst, short dstOff) { + dst[dstOff] = (byte) 0x04; + javacard.framework.Util.arrayCopyNonAtomic( + credentialIssuerPubKey, (short) 0, dst, (short) (dstOff + 1), CRED_ISSUER_PUBK_LEN); + return (short) (CRED_ISSUER_PUBK_LEN + 1); + } + /** * Stores an Access Document chunk at the given destination offset. * Returns true iff the write stayed within {@link #ACCESS_DOC_MAX_LEN}; @@ -174,28 +207,60 @@ final class CredentialStore { } /** - * Marks the Access Document as provisioned with {@code totalLen} bytes - * of valid content starting at offset 0. Returns false (and does not - * mutate state) if {@code totalLen} is outside {@code [0, ACCESS_DOC_MAX_LEN]}. + * Finalizes the Access Document: runs the supplied {@link CoseVerifier} + * against the staged AD bytes using the stored Credential Issuer public + * key. On success, atomically sets {@code accessDocumentLen}, + * {@code accessDocumentFinalized}, and {@code accessDocumentVerified} + * inside a {@link javacard.framework.JCSystem#beginTransaction} so + * partial state can never ship. * - *

TODO (Step-Up impl, opt 4a): wrap this in - * {@code JCSystem.beginTransaction()} along with a one-shot IssuerAuth - * COSE_Sign1 verify against the stored Credential Issuer public key, and - * set a persistent {@code accessDocumentVerified} flag. Caching the - * verify result saves ~100 ms per Step-Up transaction at the cost of one - * extra persistent byte + the assumption that the Credential Issuer - * trust anchor is fixed for the card's lifetime (opt 4b — true for DT's - * implantable use case but document the limitation). The verify-flag - * write and the {@code accessDocumentFinalized} flip MUST land in the - * same atomic transaction so partial state can't ship an unverified + *

Pre-conditions enforced by the caller (PersonalizationApplet): + *

+ * + *

This is the M2A.3 expansion of opt 4a from the Step-Up plan: caching + * the verify result saves ~100 ms per Step-Up transaction. The + * verify-flag write and the {@code accessDocumentFinalized} flip land in + * the same atomic transaction so partial state can't ship an unverified * document marked verified. + * + * @param totalLen length of staged AD bytes at {@code accessDocument[0..)} + * @param verifier IssuerAuth verifier (held as a field on + * PersonalizationApplet; never per-call constructed) + * @param scratch65 caller-supplied 65 B scratch into which this method + * writes the uncompressed issuer pubkey before handing + * it to {@code verifier.verifyCoseSign1} + * @return true iff verify succeeded AND state was atomically committed; + * false on bad length OR verify failure (state untouched). */ - boolean finalizeAccessDocument(short totalLen) { + boolean finalizeAccessDocument(short totalLen, CoseVerifier verifier, byte[] scratch65) { if (totalLen < 0 || totalLen > ACCESS_DOC_MAX_LEN) { return false; } - accessDocumentLen = totalLen; - accessDocumentFinalized = true; + // Load the issuer pubkey into the caller-supplied scratch as + // 0x04 || x || y (CoseVerifier needs SEC1 uncompressed). + copyCredentialIssuerPubKeyUncomp(scratch65, (short) 0); + + boolean verified = verifier.verifyCoseSign1( + accessDocument, (short) 0, totalLen, scratch65, (short) 0); + if (!verified) { + return false; + } + + try { + javacard.framework.JCSystem.beginTransaction(); + accessDocumentLen = totalLen; + accessDocumentFinalized = true; + accessDocumentVerified = true; + javacard.framework.JCSystem.commitTransaction(); + } catch (Throwable t) { + if (javacard.framework.JCSystem.getTransactionDepth() != 0) { + javacard.framework.JCSystem.abortTransaction(); + } + return false; + } return true; } @@ -231,12 +296,14 @@ final class CredentialStore { sink.write(credentialPrivKeySet); sink.write(credentialPubKeySet); sink.write(readerPubKeySet); + sink.write(credentialIssuerPubKeySet); sink.write(accessDocumentFinalized); sink.write(accessDocumentVerified); sink.write(accessDocumentLen); sink.write(credentialPrivKey); sink.write(credentialPubKey); sink.write(readerPubKey); + sink.write(credentialIssuerPubKey); sink.write(accessDocument); } @@ -255,6 +322,7 @@ final class CredentialStore { s.credentialPrivKeySet = src.readBoolean(); s.credentialPubKeySet = src.readBoolean(); s.readerPubKeySet = src.readBoolean(); + s.credentialIssuerPubKeySet = src.readBoolean(); s.accessDocumentFinalized = src.readBoolean(); s.accessDocumentVerified = src.readBoolean(); s.accessDocumentLen = src.readShort(); @@ -266,10 +334,23 @@ final class CredentialStore { a = src.readByteArray(); javacard.framework.Util.arrayCopy(a, (short) 0, s.readerPubKey, (short) 0, READER_PUBK_LEN); a = src.readByteArray(); + javacard.framework.Util.arrayCopy(a, (short) 0, s.credentialIssuerPubKey, (short) 0, CRED_ISSUER_PUBK_LEN); + a = src.readByteArray(); javacard.framework.Util.arrayCopy(a, (short) 0, s.accessDocument, (short) 0, ACCESS_DOC_MAX_LEN); return s; } + /** + * Test-only: marks the staged AD bytes as finalized without running the + * IssuerAuth verify. Used by AliroApplet tests that exercise downstream + * behavior (signaling bitmap etc.) with opaque AD bytes that aren't + * signed by a real issuer key. + */ + void markAccessDocumentFinalizedForTesting(short totalLen) { + accessDocumentLen = totalLen; + accessDocumentFinalized = true; + } + /** Test-only: returns a fresh byte[] copy of the credential private key. */ byte[] copyCredentialPrivKey() { byte[] out = new byte[CRED_PRIV_KEY_LEN]; @@ -312,10 +393,12 @@ final class CredentialStore { javacard.framework.Util.arrayFillNonAtomic(credentialPrivKey, (short) 0, CRED_PRIV_KEY_LEN, (byte) 0); javacard.framework.Util.arrayFillNonAtomic(credentialPubKey, (short) 0, CRED_PUBK_LEN, (byte) 0); javacard.framework.Util.arrayFillNonAtomic(readerPubKey, (short) 0, READER_PUBK_LEN, (byte) 0); + javacard.framework.Util.arrayFillNonAtomic(credentialIssuerPubKey, (short) 0, CRED_ISSUER_PUBK_LEN, (byte) 0); javacard.framework.Util.arrayFillNonAtomic(accessDocument, (short) 0, ACCESS_DOC_MAX_LEN, (byte) 0); credentialPrivKeySet = false; credentialPubKeySet = false; readerPubKeySet = false; + credentialIssuerPubKeySet = false; accessDocumentLen = 0; accessDocumentFinalized = false; accessDocumentVerified = false; diff --git a/applet/src/main/java/com/dangerousthings/aliro/PersonalizationApplet.java b/applet/src/main/java/com/dangerousthings/aliro/PersonalizationApplet.java index fa1ba03..b97556a 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/PersonalizationApplet.java +++ b/applet/src/main/java/com/dangerousthings/aliro/PersonalizationApplet.java @@ -24,6 +24,7 @@ public class PersonalizationApplet extends Applet { 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_CREDENTIAL_ISSUER_PUBK = (byte) 0x25; private static final byte INS_COMMIT = (byte) 0x2C; /** The owning reference to the shared CredentialStore. AliroApplet and @@ -34,8 +35,23 @@ public class PersonalizationApplet extends Applet { * state) don't lose enrollment data. */ private final CredentialStore store; + /** One-shot IssuerAuth COSE_Sign1 verifier — constructed at install so + * the 768 B CLEAR_ON_DESELECT transient scratch is allocated once. + * Reconstructing it on every finalize would eventually exhaust the + * transient pool on real hardware. */ + private final CoseVerifier issuerVerifier; + + /** Persistent 65 B scratch the issuer pubkey is rebuilt into before + * every {@link CoseVerifier#verifyCoseSign1} call. Held on the applet + * instance so it survives AMD-H upgrades — we'd rather pay the 65 B + * of EEPROM than risk transient-pool exhaustion or per-finalize + * allocation. */ + private final byte[] issuerPubScratch; + public PersonalizationApplet() { this.store = CredentialStore.bootstrap(); + this.issuerVerifier = new CoseVerifier(); + this.issuerPubScratch = new byte[65]; } public static void install(byte[] bArray, short bOffset, byte bLength) { @@ -76,6 +92,10 @@ public class PersonalizationApplet extends Applet { requireUnlocked(store); setReaderPubKey(apdu, store); return; + case INS_SET_CREDENTIAL_ISSUER_PUBK: + requireUnlocked(store); + setCredentialIssuerPubKey(apdu, store); + return; case INS_WRITE_ACCESS_DOC: requireUnlocked(store); writeAccessDocChunk(apdu, store); @@ -126,6 +146,15 @@ public class PersonalizationApplet extends Applet { store.setReaderPubKey(buf, apdu.getOffsetCdata()); } + private void setCredentialIssuerPubKey(APDU apdu, CredentialStore store) { + short lc = apdu.setIncomingAndReceive(); + if (lc != CredentialStore.CRED_ISSUER_PUBK_LEN) { + ISOException.throwIt(ISO7816.SW_WRONG_DATA); + } + byte[] buf = apdu.getBuffer(); + store.setCredentialIssuerPubKey(buf, apdu.getOffsetCdata()); + } + /** * Writes an Access Document chunk. P1|P2 is the destination offset * within the stored Access Document (big-endian unsigned 16-bit); the @@ -144,19 +173,36 @@ public class PersonalizationApplet extends Applet { } /** - * Marks the Access Document as provisioned. P1|P2 is the total length - * (big-endian unsigned 16-bit). Lc must be 0. + * Finalizes the Access Document: triggers a one-shot IssuerAuth verify + * against the stored Credential Issuer pubkey and atomically flips + * {@code accessDocumentFinalized} + {@code accessDocumentVerified} on + * success. P1|P2 is the total length (big-endian unsigned 16-bit). + * Lc must be 0. + * + *

Pre-conditions: the issuer pubkey must have been set first + * (INS_SET_CREDENTIAL_ISSUER_PUBK = 0x25); without it we have no trust + * anchor and reject with {@code SW_CONDITIONS_NOT_SATISFIED}. A verify + * failure (signature didn't match) returns {@code SW_DATA_INVALID}. */ private void finalizeAccessDoc(APDU apdu, CredentialStore store) { short lc = apdu.setIncomingAndReceive(); if (lc != 0) { ISOException.throwIt(ISO7816.SW_WRONG_DATA); } + if (!store.hasCredentialIssuerPubKey()) { + ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED); + } byte[] buf = apdu.getBuffer(); short totalLen = (short) (((buf[ISO7816.OFFSET_P1] & 0xFF) << 8) | (buf[ISO7816.OFFSET_P2] & 0xFF)); - if (!store.finalizeAccessDocument(totalLen)) { + // Bound-check first so the existing oversize test stays at + // SW_WRONG_DATA. Once length is valid, only IssuerAuth verify can + // fail — and that's the tamper case the brief maps to SW_DATA_INVALID. + if (totalLen < 0 || totalLen > CredentialStore.ACCESS_DOC_MAX_LEN) { ISOException.throwIt(ISO7816.SW_WRONG_DATA); } + if (!store.finalizeAccessDocument(totalLen, issuerVerifier, issuerPubScratch)) { + ISOException.throwIt(ISO7816.SW_DATA_INVALID); + } } } diff --git a/applet/src/test/java/com/dangerousthings/aliro/AliroAppletAuth1Test.java b/applet/src/test/java/com/dangerousthings/aliro/AliroAppletAuth1Test.java index 56d7a0a..837e53c 100644 --- a/applet/src/test/java/com/dangerousthings/aliro/AliroAppletAuth1Test.java +++ b/applet/src/test/java/com/dangerousthings/aliro/AliroAppletAuth1Test.java @@ -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(); diff --git a/applet/src/test/java/com/dangerousthings/aliro/CredentialStoreSerializationTest.java b/applet/src/test/java/com/dangerousthings/aliro/CredentialStoreSerializationTest.java index 39a127a..819f6c3 100644 --- a/applet/src/test/java/com/dangerousthings/aliro/CredentialStoreSerializationTest.java +++ b/applet/src/test/java/com/dangerousthings/aliro/CredentialStoreSerializationTest.java @@ -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], diff --git a/applet/src/test/java/com/dangerousthings/aliro/PersonalizationAppletTest.java b/applet/src/test/java/com/dangerousthings/aliro/PersonalizationAppletTest.java index 3204af7..679c0f6 100644 --- a/applet/src/test/java/com/dangerousthings/aliro/PersonalizationAppletTest.java +++ b/applet/src/test/java/com/dangerousthings/aliro/PersonalizationAppletTest.java @@ -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. + * + *

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 @@ -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. */ diff --git a/harness/src/aliro_harness/personalizer/apdus.py b/harness/src/aliro_harness/personalizer/apdus.py index 0fdf75b..5e65f14 100644 --- a/harness/src/aliro_harness/personalizer/apdus.py +++ b/harness/src/aliro_harness/personalizer/apdus.py @@ -11,6 +11,7 @@ INS_SET_CRED_PUBK = 0x21 INS_SET_READER_PUBK = 0x22 INS_WRITE_ACCESS_DOC = 0x23 INS_FINALIZE_ACCESS_DOC = 0x24 +INS_SET_CRED_ISSUER_PUBK = 0x25 INS_COMMIT = 0x2C # Provisioning AID — must match AliroAids.PROVISIONING in the applet. @@ -21,6 +22,7 @@ PROVISIONING_AID = bytes.fromhex("A000000909ACCE559901") CRED_PRIV_LEN = 32 CRED_PUBK_LEN = 64 READER_PUBK_LEN = 64 +CRED_ISSUER_PUBK_LEN = 64 def select_provisioning_apdu() -> bytes: @@ -46,6 +48,16 @@ def set_reader_pubk_apdu(pubk_xy: bytes) -> bytes: return _short_apdu(INS_SET_READER_PUBK, 0, 0, pubk_xy) +def set_credential_issuer_pubk_apdu(pubk_xy: bytes) -> bytes: + """Push the Credential Issuer public key (P-256 x||y, no 0x04 prefix). + The applet stages it for IssuerAuth COSE_Sign1 verify at FINALIZE_AD.""" + if len(pubk_xy) != CRED_ISSUER_PUBK_LEN: + raise ValueError( + f"credential_issuer_PubK must be {CRED_ISSUER_PUBK_LEN}B (x||y), got {len(pubk_xy)}" + ) + return _short_apdu(INS_SET_CRED_ISSUER_PUBK, 0, 0, pubk_xy) + + def commit_apdu() -> bytes: return bytes([CLA_PROPRIETARY, INS_COMMIT, 0x00, 0x00]) diff --git a/harness/src/aliro_harness/personalizer/cli.py b/harness/src/aliro_harness/personalizer/cli.py index 948d40f..5658e1e 100644 --- a/harness/src/aliro_harness/personalizer/cli.py +++ b/harness/src/aliro_harness/personalizer/cli.py @@ -80,6 +80,7 @@ def main(trust_dir: Path | None, reader_index: int, list_readers: bool) -> None: f"Loaded artifacts: credential_PrivK={len(artifacts.credential_priv)}B, " f"credential_PubK={len(artifacts.credential_pubk_xy)}B, " f"reader_PubK={len(artifacts.reader_pubk_xy)}B, " + f"credential_issuer_PubK={len(artifacts.credential_issuer_pub)}B, " f"Access Document={len(artifacts.access_document)}B" ) diff --git a/harness/src/aliro_harness/personalizer/orchestrator.py b/harness/src/aliro_harness/personalizer/orchestrator.py index edba186..3821908 100644 --- a/harness/src/aliro_harness/personalizer/orchestrator.py +++ b/harness/src/aliro_harness/personalizer/orchestrator.py @@ -13,6 +13,7 @@ from aliro_harness.personalizer.access_document import access_document_apdus from aliro_harness.personalizer.apdus import ( commit_apdu, select_provisioning_apdu, + set_credential_issuer_pubk_apdu, set_credential_priv_apdu, set_credential_pubk_apdu, set_reader_pubk_apdu, @@ -45,21 +46,25 @@ class TrustArtifacts: credential_priv: bytes # 32B credential_pubk_xy: bytes # 64B (x || y, no 0x04 prefix) reader_pubk_xy: bytes # 64B + credential_issuer_pub: bytes # 64B issuer pubkey x||y for IssuerAuth verify access_document: bytes # COSE_Sign1 serialized @classmethod def from_trust_dir(cls, trust_dir: Path) -> "TrustArtifacts": cred_pem = (trust_dir / "access_credential.pem").read_bytes() reader_pem = (trust_dir / "reader.pem").read_bytes() + issuer_pem = (trust_dir / "issuer.pem").read_bytes() ad = (trust_dir / "access_document.bin").read_bytes() cred_key = serialization.load_pem_private_key(cred_pem, password=None) reader_key = serialization.load_pem_private_key(reader_pem, password=None) + issuer_key = serialization.load_pem_private_key(issuer_pem, password=None) return cls( credential_priv=extract_priv_scalar(cred_key), credential_pubk_xy=extract_pub_xy(cred_key.public_key()), reader_pubk_xy=extract_pub_xy(reader_key.public_key()), + credential_issuer_pub=extract_pub_xy(issuer_key.public_key()), access_document=ad, ) @@ -73,6 +78,14 @@ def personalize_card(transmit: Transmit, artifacts: TrustArtifacts) -> None: _send(transmit, "SET credential_PrivK", set_credential_priv_apdu(artifacts.credential_priv)) _send(transmit, "SET credential_PubK", set_credential_pubk_apdu(artifacts.credential_pubk_xy)) _send(transmit, "SET reader_PubK", set_reader_pubk_apdu(artifacts.reader_pubk_xy)) + # Issuer pubkey must land BEFORE the AD chunks so that by the time + # FINALIZE_AD runs and triggers IssuerAuth COSE_Sign1 verify, the trust + # anchor is already staged. + _send( + transmit, + "SET credential_issuer_PubK", + set_credential_issuer_pubk_apdu(artifacts.credential_issuer_pub), + ) for i, apdu in enumerate(access_document_apdus(artifacts.access_document)): _send(transmit, f"Access Document APDU {i}", apdu) diff --git a/harness/tests/test_personalizer_apdus.py b/harness/tests/test_personalizer_apdus.py index b512418..68a7274 100644 --- a/harness/tests/test_personalizer_apdus.py +++ b/harness/tests/test_personalizer_apdus.py @@ -5,12 +5,14 @@ import pytest from aliro_harness.personalizer.apdus import ( CLA_PROPRIETARY, INS_COMMIT, + INS_SET_CRED_ISSUER_PUBK, INS_SET_CRED_PRIV, INS_SET_CRED_PUBK, INS_SET_READER_PUBK, PROVISIONING_AID, commit_apdu, select_provisioning_apdu, + set_credential_issuer_pubk_apdu, set_credential_priv_apdu, set_credential_pubk_apdu, set_reader_pubk_apdu, @@ -55,6 +57,23 @@ def test_set_reader_pubk_apdu(): assert apdu == bytes([CLA_PROPRIETARY, INS_SET_READER_PUBK, 0x00, 0x00, 0x40]) + pub +def test_set_credential_issuer_pubk_apdu(): + pub = bytes(range(64)) + apdu = set_credential_issuer_pubk_apdu(pub) + # CLA INS P1 P2 Lc data — INS 0x25, same 64B x||y shape as cred_pubk + assert apdu == bytes([CLA_PROPRIETARY, INS_SET_CRED_ISSUER_PUBK, 0x00, 0x00, 0x40]) + pub + + +def test_set_credential_issuer_pubk_rejects_wrong_length(): + with pytest.raises(ValueError, match="64B"): + set_credential_issuer_pubk_apdu(bytes(63)) + + +def test_ins_set_cred_issuer_pubk_constant_matches_applet(): + """Lock-down: applet PersonalizationApplet.INS_SET_CREDENTIAL_ISSUER_PUBK = 0x25.""" + assert INS_SET_CRED_ISSUER_PUBK == 0x25 + + def test_commit_apdu_has_no_data_field(): apdu = commit_apdu() assert apdu == bytes([CLA_PROPRIETARY, INS_COMMIT, 0x00, 0x00]) diff --git a/harness/tests/test_personalizer_orchestrator.py b/harness/tests/test_personalizer_orchestrator.py index 988b3ae..753ca22 100644 --- a/harness/tests/test_personalizer_orchestrator.py +++ b/harness/tests/test_personalizer_orchestrator.py @@ -8,6 +8,7 @@ from aliro_harness.issuer.access_document import build_access_document from aliro_harness.personalizer.apdus import ( INS_COMMIT, INS_FINALIZE_ACCESS_DOC, + INS_SET_CRED_ISSUER_PUBK, INS_SET_CRED_PRIV, INS_SET_CRED_PUBK, INS_SET_READER_PUBK, @@ -46,6 +47,7 @@ def artifacts() -> TrustArtifacts: credential_priv=extract_priv_scalar(cred), credential_pubk_xy=extract_pub_xy(cred.public_key()), reader_pubk_xy=extract_pub_xy(reader.public_key()), + credential_issuer_pub=extract_pub_xy(issuer.public_key()), access_document=build_access_document( issuer_private_key=issuer, access_credential_public_key=cred.public_key(), @@ -58,17 +60,35 @@ def test_full_sequence_in_expected_order(artifacts): personalize_card(transport, artifacts) insns = [apdu[1] for apdu in transport.sent] - # SELECT (0xA4) → SET_PRIV → SET_PUBK → SET_READER_PUBK → WRITE_AD+ → FINALIZE_AD → COMMIT + # SELECT (0xA4) → SET_PRIV → SET_PUBK → SET_READER_PUBK → + # SET_CRED_ISSUER_PUBK → WRITE_AD+ → FINALIZE_AD → COMMIT. + # SET_CRED_ISSUER_PUBK must land BEFORE WRITE_AD chunks so that by + # the time FINALIZE runs, the issuer trust anchor is staged. assert insns[0] == 0xA4 assert insns[1] == INS_SET_CRED_PRIV assert insns[2] == INS_SET_CRED_PUBK assert insns[3] == INS_SET_READER_PUBK + assert insns[4] == INS_SET_CRED_ISSUER_PUBK # AD writes (variable count) then a single finalize, then commit. - assert all(i == INS_WRITE_ACCESS_DOC for i in insns[4:-2]) + assert all(i == INS_WRITE_ACCESS_DOC for i in insns[5:-2]) assert insns[-2] == INS_FINALIZE_ACCESS_DOC assert insns[-1] == INS_COMMIT +def test_credential_issuer_pubk_apdu_carries_exact_bytes(artifacts): + transport = FakeTransport() + personalize_card(transport, artifacts) + # Find the INS_SET_CRED_ISSUER_PUBK APDU. + issuer_apdu = next(a for a in transport.sent if a[1] == INS_SET_CRED_ISSUER_PUBK) + # CLA INS P1 P2 Lc data + assert issuer_apdu[0] == 0x80 + assert issuer_apdu[2] == 0x00 + assert issuer_apdu[3] == 0x00 + assert issuer_apdu[4] == 0x40 # Lc = 64 + assert issuer_apdu[5:] == artifacts.credential_issuer_pub + assert len(artifacts.credential_issuer_pub) == 64 + + def test_select_apdu_carries_provisioning_aid(artifacts): transport = FakeTransport() personalize_card(transport, artifacts)